import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { useNavigate, useParams } from 'react-router-dom' import { api } from '../api' import { AutoTextarea, Field, StoryCardRow, downloadJSON, npcInitials, pickJSONFile, useToast } from '../components' import { BranchMap } from '../BranchMap' import { branchLabel, headLineage, orderBranches } from '../branches' const MODES = ['do', 'say', 'story'] const PLAYER_TYPES = ['do', 'say', 'story'] // Models often emit light markdown emphasis; render **bold** / *italic* // instead of showing raw asterisks. Everything else stays plain text. function renderEmphasis(text) { const re = /\*\*([^*\n]+)\*\*|\*([^*\n]+)\*/g const parts = [] let last = 0 let match while ((match = re.exec(text)) !== null) { if (match.index > last) parts.push(text.slice(last, match.index)) parts.push(match[1] !== undefined ? {match[1]} : {match[2]}) last = match.index + match[0].length } if (parts.length === 0) return text if (last < text.length) parts.push(text.slice(last)) return parts } function ReasoningBlock({ text, streaming }) { if (!text) return null return (
💭 Reasoning{streaming ? '…' : ''}
{text}
) } const SECTION_LABELS = { narrator: 'Narrator prompt', script_context: 'Script context', ai_instructions: 'AI Instructions', plot_essentials: 'Plot Essentials', story_summary: 'Story Summary', used_memories: 'Used Memories (memory bank)', world_state_guide: 'World State (stat guide)', world_state: 'World State (RPG)', world_state_rule: 'World State (reporting rule)', world_lore: 'World Lore (story cards)', history: 'Story history', authors_note: "Author's Note", recent_history: 'Recent history', front_memory: 'Front memory', length_hint: 'Length guidance', world_state_reminder: 'World State (emit reminder)', } // One colour per context section, and the single source of truth for it: the // token bar, the legend and each section's own header all read from here, so a // slice of the bar and the text it stands for always carry the same colour. // Related sections share a hue family but never an exact shade — in a stacked // bar two identical colours read as one section. const SECTION_COLORS = { narrator: '#7d8fc9', ai_instructions: '#9c8fd6', plot_essentials: '#c97dc0', script_context: '#d99ad0', story_summary: '#7dc9a2', used_memories: '#5fb8c9', world_lore: '#c9b47d', world_state: '#d79a63', world_state_guide: '#b8834a', world_state_rule: '#9d7a52', world_state_reminder: '#8a6f52', history: '#74748c', recent_history: '#9d9db4', authors_note: '#c97d7d', front_memory: '#d99a9a', length_hint: '#98a06b', } const SECTION_FALLBACK = '#6a6a78' const sectionColor = (label) => SECTION_COLORS[label] || SECTION_FALLBACK // Share of the prompt, rounded for glanceability. Sections too small to round // to a whole percent still say so rather than showing a misleading 0%. const pctLabel = (pct) => (pct > 0 && pct < 1 ? '<1%' : `${Math.round(pct)}%`) const FIELD_LABELS = { memory: 'Plot Essentials (Memory)', authors_note: "Author's Note", ai_instructions: 'AI Instructions', } function clip(text, n = 90) { const one = (text || '').replace(/\s+/g, ' ').trim() return one.length > n ? `${one.slice(0, n)}…` : (one || '(empty)') } // Confirms "Update from scenario" by showing exactly what it would change, and // collects any ${Placeholder} answers the adventure has no stored value for // (adventures started before those were saved, or a placeholder the author // added since). Destructive by design — it overwrites plot text and // scenario-derived cards — so nothing happens until Update is pressed. function RefreshModal({ plan, onConfirm, onCancel }) { const [values, setValues] = useState( Object.fromEntries((plan.placeholders_needed || []).map((n) => [n, ''])), ) const [busy, setBusy] = useState(false) const { added = [], updated = [], removed = [] } = plan.cards || {} const world = plan.world_state || {} const fields = Object.entries(plan.fields || {}) const submit = (e) => { e.preventDefault() setBusy(true) onConfirm(values).finally(() => setBusy(false)) } // Portalled to : this modal is opened from inside .side-panel, whose // panel-in animation (fill mode `both`) makes it the containing block for // position:fixed children — an overlay rendered in place would be trapped in // the 420px panel and clipped by its overflow. Same trap for any future modal // opened from a drawer or panel. return createPortal(
e.stopPropagation()} onSubmit={submit}>

Update from scenario

{!plan.has_changes ? (

This adventure already matches “{plan.scenario_title}”. Nothing to update.

) : ( <>

Pull the current content of “{plan.scenario_title}” down over this adventure. Your story, its title, summary and your own story cards are untouched — but the plot text below is overwritten, including any edits you made here.

{fields.map(([field, diff]) => (
{FIELD_LABELS[field] || field}
− {clip(diff.old)}
+ {clip(diff.new)}
))} {added.length > 0 && (
Story cards added: {added.join(', ')}
)} {updated.length > 0 && (
Story cards overwritten: {updated.join(', ')}
)} {removed.length > 0 && (
Story cards removed: {removed.join(', ')}
)} {world.added?.length > 0 && (
New stats (at their starting value): {world.added.join(', ')}
)} {world.removed?.length > 0 && (
Stats removed: {world.removed.join(', ')}
)}
{(plan.placeholders_needed || []).length > 0 && ( <>

This scenario asks a few questions, and this adventure has no saved answers for them. They'll be remembered from now on.

{plan.placeholders_needed.map((name) => ( ))} )} )}
{plan.has_changes && ( )}
, document.body, ) } function PlotPanel({ adventure, setAdventure, onWorldStateChanged }) { const toast = useToast() const [plan, setPlan] = useState(null) // non-null while the modal is open const [planning, setPlanning] = useState(false) // One timer per field/card: a single shared timer would cancel the pending // save of whatever was edited previously within the debounce window. const saveTimers = useRef(new Map()) const debounceSave = (key, fn) => { clearTimeout(saveTimers.current.get(key)) saveTimers.current.set(key, setTimeout(fn, 600)) } const setField = (field, value) => { setAdventure({ ...adventure, [field]: value }) debounceSave(field, () => api.updateAdventure(adventure.id, { [field]: value })) } const addCard = async () => { const card = await api.createStoryCard({ adventure_id: adventure.id }) setAdventure({ ...adventure, story_cards: [...adventure.story_cards, card] }) } const updateCard = (card) => { setAdventure({ ...adventure, story_cards: adventure.story_cards.map((c) => (c.id === card.id ? card : c)), }) debounceSave(`card-${card.id}`, () => { api.updateStoryCard(card.id, { name: card.name, type: card.type, keys: card.keys, entry: card.entry, notes: card.notes, }) }) } const deleteCard = async (cardId) => { await api.deleteStoryCard(cardId) setAdventure({ ...adventure, story_cards: adventure.story_cards.filter((c) => c.id !== cardId), }) } const exportCards = async () => { const cards = await api.exportStoryCards({ adventure_id: adventure.id }) downloadJSON(cards, `${(adventure.title || 'adventure').replace(/\W+/g, '-')}-cards.json`) } const importCards = async () => { try { const parsed = await pickJSONFile() const cards = Array.isArray(parsed) ? parsed : (parsed.cards || parsed.storyCards) if (!Array.isArray(cards)) return toast('Expected a JSON array of story cards.', 'error') const created = await api.importStoryCards({ adventure_id: adventure.id, cards }) setAdventure({ ...adventure, story_cards: [...adventure.story_cards, ...created] }) } catch (err) { toast(err.message, 'error') } } // Ask the server what a refresh would do, then let the player confirm it. const openRefresh = async () => { setPlanning(true) try { setPlan(await api.previewRefresh(adventure.id)) } catch (err) { // 404 = the scenario was deleted or unshared; there's nothing to sync to. toast(err.message, 'error') } finally { setPlanning(false) } } const applyRefresh = async (placeholders) => { try { const updated = await api.refreshFromScenario(adventure.id, placeholders) setAdventure(updated) setPlan(null) onWorldStateChanged?.() toast('Updated from scenario.') } catch (err) { toast(err.message, 'error') } } return (
{adventure.scenario_id != null && (
Copied from its scenario when the adventure began; later scenario edits don't reach it on their own.
)} {plan && ( setPlan(null)} /> )} setField('memory', v)} textarea placeholder="Key facts the AI should always remember." /> setField('authors_note', v)} textarea rows={2} placeholder="Style/theme guidance, injected near the end of context." /> setField('ai_instructions', v)} textarea rows={2} placeholder="Behavioral guidance for the model." /> setField('story_summary', v)} textarea placeholder="Running summary of events so far. Updated automatically every 15 actions when auto-summarization is on; your edits are kept as the base for the next update." />

Story Cards

{adventure.story_cards.length === 0 && (
No story cards yet.
)} {adventure.story_cards.map((card) => ( deleteCard(card.id)} /> ))}
) } function MemoryRow({ memory, onChange, onDelete }) { const [editText, setEditText] = useState(null) const save = () => { const text = editText.trim() setEditText(null) if (text && text !== memory.text) onChange({ text }) } return (
{editText !== null ? (
setEditText(e.target.value)} />
) : ( <>
{memory.text}
{memory.pinned && 📌 pinned} {memory.forgotten && forgotten} {!memory.embedded && !memory.forgotten && ( not embedded yet )} {memory.source_start != null && ( actions {memory.source_start}–{memory.source_end} )} used {memory.use_count}× {memory.forgotten && ( )}
)}
) } function MemoryPanel({ adventure, setAdventure, refreshKey }) { const [memories, setMemories] = useState(null) const [newText, setNewText] = useState('') const load = useCallback(() => { api.listMemories(adventure.id).then(setMemories).catch(() => setMemories([])) }, [adventure.id]) // Summarization runs in the background after a turn, so also refresh shortly after. useEffect(() => { load() const timer = setTimeout(load, 4000) return () => clearTimeout(timer) }, [load, refreshKey]) const setFlag = async (field, value) => { setAdventure({ ...adventure, [field]: value }) await api.updateAdventure(adventure.id, { [field]: value }) } const change = async (memory, data) => { const updated = await api.updateMemory(adventure.id, memory.id, data) setMemories((prev) => prev.map((m) => (m.id === memory.id ? updated : m))) } const remove = async (memory) => { await api.deleteMemory(adventure.id, memory.id) setMemories((prev) => prev.filter((m) => m.id !== memory.id)) } const add = async () => { const text = newText.trim() if (!text) return setNewText('') const memory = await api.createMemory(adventure.id, text) setMemories((prev) => [...prev, memory]) } const active = memories?.filter((m) => !m.forgotten) ?? [] const forgotten = memories?.filter((m) => m.forgotten) ?? [] return (

Memories {memories && `(${active.length})`}

{!memories &&
Loading…
} {memories && active.length === 0 && (
No memories yet. They are generated automatically as you play (from action 12 onward), or add one below.
)} {active.map((m) => ( change(m, data)} onDelete={() => remove(m)} /> ))}
setNewText(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') add() }} />
{forgotten.length > 0 && ( <>

Forgotten ({forgotten.length})

{forgotten.map((m) => ( change(m, data)} onDelete={() => remove(m)} /> ))} )}
) } const SCRIPT_HOOKS = [ ['Shared library', 'library_js'], ['onInput', 'input_js'], ['onModelContext', 'context_js'], ['onOutput', 'output_js'], ] function ScriptsPanel({ advId }) { const [scripts, setScripts] = useState(null) const [openId, setOpenId] = useState(null) useEffect(() => { api.listAdventureScripts(advId).then(setScripts).catch(() => setScripts([])) }, [advId]) const [syncingId, setSyncingId] = useState(null) const toggle = async (script) => { const updated = await api.updateAdventureScript(advId, script.id, { enabled: !script.enabled }) setScripts((prev) => prev.map((s) => (s.id === script.id ? updated : s))) } // Pull the latest code from the library script this copy was made from. const sync = async (script) => { setSyncingId(script.id) try { const updated = await api.syncAdventureScript(advId, script.id) setScripts((prev) => prev.map((s) => (s.id === script.id ? updated : s))) } finally { setSyncingId(null) } } // Download as an import-compatible bundle (matches /scripts export), so demo // scripts can be forked into your own library via the Scripts page's Import. const download = (s) => { downloadJSON( { name: s.name, description: s.description, library: s.library_js, input: s.input_js, context: s.context_js, output: s.output_js, }, `${(s.name || 'script').replace(/\W+/g, '-')}.json`, ) } if (!scripts) return
Loading…
if (scripts.length === 0) { return (
No scripts on this adventure. Attach scripts to a scenario before starting an adventure from it.
) } return (
{scripts.map((s) => { const hooks = SCRIPT_HOOKS.filter(([, f]) => (s[f] || '').trim()) const open = openId === s.id return (
{s.out_of_date && ( )}
{s.description &&
{s.description}
} {open && (
{hooks.length === 0 ? (
This script has no code.
) : ( hooks.map(([label, f]) => (
{label}
{s[f]}
)) )}
)}
) })}
) } // Renders one script-state value: primitives inline (typed/coloured), and // objects/arrays as a collapsible, indented tree — recursing into nesting so // deep state shows structure instead of a flat JSON blob. function StateValue({ value, depth = 0 }) { if (value === null || value === undefined) return null if (typeof value === 'boolean') return {String(value)} if (typeof value === 'number') return {value} if (typeof value === 'string') return {value} if (Array.isArray(value)) return [i, v])} empty="[ ]" depth={depth} /> if (typeof value === 'object') return return {String(value)} } // Only the first level is expanded by default (depth 0); nested trees start // collapsed and can be opened on demand. function StateTree({ entries, empty, depth = 0 }) { const [open, setOpen] = useState(depth < 1) if (entries.length === 0) return {empty} return (
{open && (
    {entries.map(([k, v]) => (
  • {k}
  • ))}
)}
) } // Collapsible left rail showing the scripting `state` object — every variable // scripts read/write via state.x, refreshed after each turn. function StatusDrawer({ advId, refreshKey }) { const [open, setOpen] = useState(false) const [state, setState] = useState(null) const [failed, setFailed] = useState(false) const load = useCallback(() => { api.getScriptState(advId) .then((r) => { setState(r.state || {}); setFailed(false) }) .catch(() => setFailed(true)) }, [advId]) // Only fetch while open; re-fetch after each turn so values stay live. useEffect(() => { if (open) load() }, [open, refreshKey, load]) const entries = state ? Object.entries(state) : [] return (
{open && (

Script State

{failed ? (
Couldn’t load state.
) : entries.length === 0 ? (
No variables yet. Scripts that use state will appear here after a turn.
) : (
    {entries.map(([k, v]) => (
  • {k}
  • ))}
)}
)}
) } // Word label for a value from a stat def's bands (mirrors worldstate.band_label). function bandLabel(def, value) { const bands = def?.bands if (!Array.isArray(bands) || typeof value !== 'number') return null for (const b of bands) { if (Array.isArray(b) && b.length === 3 && value >= b[0] && value < b[1]) return b[2] } const last = bands[bands.length - 1] if (last && value === last[1]) return last[2] return null } function StatRow({ name, def, value, editing, onChange }) { const isText = def?.type === 'text' if (editing) { return (
{name}
{isText ? ( onChange(e.target.value)} /> ) : ( onChange(e.target.value === '' ? 0 : Number(e.target.value))} /> )}
) } if (isText) { const text = typeof value === 'string' ? value : (def?.initial ?? '') return (
{name} {text || '(unset)'}
) } const val = typeof value === 'number' ? value : (def?.initial ?? 0) const { min, max } = def || {} const hasRange = typeof min === 'number' && typeof max === 'number' && max > min const pct = hasRange ? Math.max(0, Math.min(100, ((val - min) / (max - min)) * 100)) : null const label = bandLabel(def, val) return (
{name} {val}{typeof max === 'number' ? `/${max}` : ''} {label ? · {label} : null}
{pct != null && (
)}
) } // `values`/`draft` are both plain {statName: value} maps — `draft` (edit mode) // is a slice of the drawer's flat path->value map for this group's prefix. // `nested` = the caller already drew a heading (an NPC card), so this drops the // group's own spacing and says "nothing here" rather than vanishing and leaving // that heading dangling over empty space. function StatGroup({ title, defs, values, desc, editing, draft, onEdit, nested }) { const entries = Object.entries(defs || {}).filter(([, d]) => d && typeof d === 'object') if (entries.length === 0) { return nested ?
No tracked stats.
: null } return (
{title &&

{title}

} {entries.map(([name, def]) => ( onEdit(name, v) : undefined} /> ))}
) } // Flatten a schema+state into path->value pairs ("player.hp", "npc.gwen.trust", // "flags.has_key", "milestones.escaped") to seed an edit-mode draft. function buildWorldStateDraft(schema, state) { const draft = {} const addStats = (prefix, defs, values) => { for (const [name, def] of Object.entries(defs || {})) { if (!def || typeof def !== 'object') continue const fallback = def.type === 'text' ? '' : 0 draft[`${prefix}.${name}`] = values?.[name] ?? def.initial ?? fallback } } addStats('world', schema.world, state.world) addStats('player', schema.player, state.player) const npcState = state.npc || {} for (const [id, ndef] of Object.entries(schema.npcs || {})) { addStats(`npc.${id}`, ndef?.stats, npcState[id]) } const flagState = state.flags || {} for (const [name, def] of Object.entries(schema.flags || {})) { draft[`flags.${name}`] = flagState[name] ?? !!def?.initial } const reached = state.milestones || {} for (const mid of Object.keys(schema.milestones || {})) { draft[`milestones.${mid}`] = !!reached[mid]?.reached } return draft } // Slice a flat path->value draft down to one group's {statName: value} map. function sliceDraft(draft, prefix) { const out = {} const p = `${prefix}.` for (const [k, v] of Object.entries(draft || {})) { if (k.startsWith(p)) out[k.slice(p.length)] = v } return out } // Collapsible left rail showing the RPG world state (Phase 12): world/player/NPC // stats with bands + bars, and a milestones checklist. Renders nothing unless // the adventure's scenario defines a stat_schema. An edit mode lets the // player/author directly override the live values (a manual correction, not // a turn) — it never adds new stats, only edits ones the schema already defines. function WorldStateDrawer({ advId, refreshKey }) { const [open, setOpen] = useState(false) const [data, setData] = useState(null) // { state, schema } const [failed, setFailed] = useState(false) const [editing, setEditing] = useState(false) const [draft, setDraft] = useState(null) // flat path->value, only while editing const [saving, setSaving] = useState(false) const load = useCallback(() => { api.getWorldState(advId) .then((r) => { setData(r); setFailed(false) }) .catch(() => setFailed(true)) }, [advId]) // Load once to learn whether there's an RPG layer, then refresh after turns. useEffect(() => { load() }, [load, refreshKey]) const schema = data?.schema if (!schema) return null // no RPG layer for this adventure const state = data?.state || {} const npcState = state.npc || {} const npcs = Object.entries(schema.npcs || {}) // [id, def] — each with its own stats const flags = Object.entries(schema.flags || {}) const flagState = state.flags || {} const milestones = Object.entries(schema.milestones || {}) const reached = state.milestones || {} const startEdit = () => { setDraft(buildWorldStateDraft(schema, state)); setEditing(true) } const cancelEdit = () => { setEditing(false); setDraft(null) } const setPath = (path, value) => setDraft((d) => ({ ...d, [path]: value })) const saveEdit = () => { setSaving(true) api.overrideWorldState(advId, draft) .then(() => { setEditing(false); setDraft(null); load() }) .catch(() => setFailed(true)) .finally(() => setSaving(false)) } return (
{open && (

World State

{editing ? (
) : (
)}
{failed ? (
Couldn’t load world state.
) : ( <> setPath(`world.${name}`, v)} /> setPath(`player.${name}`, v)} /> {npcs.length > 0 && (

Cast

{npcs.map(([id, def]) => (
{def.name || id}
setPath(`npc.${id}.${name}`, v)} nested />
))}
)} {flags.length > 0 && (

Flags

    {flags.map(([fid, def]) => { const on = editing ? !!draft?.[`flags.${fid}`] : (flagState[fid] ?? !!def.initial) return (
  • {editing ? ( ) : ( <> {fid} {on ? 'yes' : 'no'} )}
  • ) })}
)} {milestones.length > 0 && (

Milestones

    {milestones.map(([mid, def]) => { const done = editing ? !!draft?.[`milestones.${mid}`] : !!reached[mid]?.reached return (
  • {editing ? ( ) : ( <> {done ? '☑' : '☐'} {def.desc || mid} )}
  • ) })}
)} )}
)}
) } // The takes of one turn: ‹ 2/4 ›, and nothing else. // // SP7 shipped chips instead, on the grounds that a pager can only step between // takes while a chip could also offer "take this path". Driving it by hand said // otherwise. The chip meant two different things depending on where the reader // was standing — a real switch at the tip, a preview needing a second button // above it — and two meanings in one control is what made the tree unusable. // // So the pager comes back, and stepping is all it does. Stepping is free: it // tells the server nothing, because reading a take is not a decision. The // decision is made by *writing* below one, and that is where the branch is // created (SP9, `after_id`). // // One step still reaches the server, and it is not a fork either. A take that // has a story of its own lives on its own branch, so going there is a branch // switch — the story below has to change, and only the server can say to what. // A take on this branch is a leaf by construction: whatever was played after // this turn was played after the take that is live, so a take that is not live // has nothing under it and the transcript simply ends there. function TakePager({ advId, action, busy, preview, takesKey, onPreview, onSwitchedBranch, onError, }) { const [takes, setTakes] = useState(null) const [loading, setLoading] = useState(false) // The cached list is only as good as the text in it. Editing a take // rewrites one of those rows, so the page says so and the list is fetched // again on the next step. useEffect(() => { setTakes(null) }, [takesKey]) const count = action.take_count const live = action.take_index const current = preview ? preview.index : live async function step(delta) { const next = current + delta if (next < 0 || next >= count || loading || busy) return setLoading(true) try { // Fetched once per message, then cached — walking back and forth through // the takes should not re-hit the server for a list that has not changed. const list = takes || await api.listTakes(advId, action.id) if (!takes) setTakes(list) const target = list[next] if (target.branch_id !== action.branch_id) { // It has a story of its own. Only the server knows what is under it. onPreview(null) onSwitchedBranch(await api.switchBranch(advId, target.branch_id)) } else if (next === live) { onPreview(null) } else { onPreview({ actionId: action.id, index: next, // The take's own node id, never its ordinal: the group renumbers // whenever a take is added, and an ordinal held across that points // at a different one. This is what `after_id` is given if the reader // writes from here. takeId: target.id, text: target.text, reasoning: target.reasoning, }) } } catch (err) { onError(err.message) } finally { setLoading(false) } } if (count < 2) return null return (
{current + 1}/{count} {preview && !preview.written && ( write below to keep this one )}
) } // Every line the story has taken, and the three things you can do to one. // // Drawn from a single request: `fork_depth` says where a branch leaves its // parent and `depth` where it currently ends, so the whole shape is two // numbers a row rather than a walk. // // Delete is here rather than in some later subphase because nothing prunes a // tree on its own — this panel is the first place a fork can be made, so it // has to be the first place one can be unmade. function BranchPanel({ advId, refreshKey, onSwitched, onTreeChanged, onError }) { const [branches, setBranches] = useState(null) const [failed, setFailed] = useState(null) const [busyId, setBusyId] = useState(null) const [renaming, setRenaming] = useState(null) // { id, text } const [confirming, setConfirming] = useState(null) const [mapOpen, setMapOpen] = useState(false) const [tick, setTick] = useState(0) useEffect(() => { let cancelled = false setFailed(null) api.listBranches(advId) .then((list) => { if (!cancelled) setBranches(list) }) .catch((err) => { if (!cancelled) setFailed(err.message) }) return () => { cancelled = true } }, [advId, refreshKey, tick]) // Answers whether it worked. Both callers keep an editor open on a refusal — // a rename the server turned down must not take the typed name with it. async function run(branchId, work) { setBusyId(branchId) try { await work() setTick((t) => t + 1) // Deleting a branch takes its memories with it, and nothing else on the // screen would hear about that — no turn is played, and the story on the // current path does not change by a single action. onTreeChanged() return true } catch (err) { onError(err.message) return false } finally { setBusyId(null) } } // One copy of each operation. The list below and the map overlay both go // through these, so a rule cannot hold in one view and not the other, and a // failure is reported one way wherever it was asked for. const switchTo = (b) => run(b.id, async () => onSwitched(await api.switchBranch(advId, b.id))) const renameTo = (b, name) => run(b.id, () => api.renameBranch(advId, b.id, name)) const removeBranch = (b) => run(b.id, () => api.deleteBranch(advId, b.id)) const saveName = async (b) => { if (await renameTo(b, renaming.text)) setRenaming(null) } const remove = async (b) => { if (await removeBranch(b)) setConfirming(null) } if (failed) return
Couldn’t read the branches — {failed}
if (!branches) return
Reading the tree…
const lineage = headLineage(branches) return (
{/* The list says which lines exist; the map says where they parted and how much story each one is, which is the part a list cannot draw. */} {branches.length === 1 && (

One thread so far. Retry a turn, then take an attempt the story moved past — that is what makes a second one.

)}
{orderBranches(branches).map(({ branch, indent }) => { const isRenaming = renaming?.id === branch.id const isConfirming = confirming === branch.id const busy = busyId === branch.id // The server refuses to delete the line being read or any line it // was forked from. The button said nothing about that and answered // with a toast; it now says so before it is pressed. const loadBearing = lineage.has(branch.id) return (
{isRenaming ? ( setRenaming({ ...renaming, text: e.target.value })} onKeyDown={(e) => { if (e.key === 'Enter') saveName(branch) if (e.key === 'Escape') setRenaming(null) }} /> ) : ( {branchLabel(branch)} )} {branch.is_head && reading}
{branch.own_actions} of its own {branch.parent_branch_id !== null && ` · forked at moment ${branch.fork_depth + 1}`} {` · ends at ${branch.depth + 1}`}
{isConfirming ? (
Delete this branch and everything forked from it?
) : (
{!branch.is_head && ( )} {isRenaming ? ( <> ) : ( )} {/* The root holds the turns every other branch borrows, and the server refuses it — so it is not offered. */} {branch.parent_branch_id !== null && ( )}
)}
) })}
{mapOpen && ( setMapOpen(false)} /> )}
) } // Reasons the engine gives for refusing a change, in the player's words. const REJECT_REASONS = { 'not a number': 'expected a number', 'not a string': 'expected text', 'not a boolean': 'expected on or off', 'not true': 'a milestone can only be set', "counter can't decrease": 'this only counts up', cooldown: 'changed too recently', 'unknown stat': 'no such stat', 'unknown npc': 'no such character', 'unknown npc stat': 'no such stat', 'unknown flag': 'no such flag', 'unknown milestone': 'no such milestone', 'unknown path': 'unrecognized name', } // Compact chips shown under an AI message summarizing what state changed. // // Refused changes appear here too. A clamped stat is marked, and a stat whose // clamp left it exactly where it started reads "no change" rather than "+0", // which looked like an ordinary update. function StateChangeChips({ changes }) { if (!changes?.length) return null const nice = (s) => String(s).replace(/_/g, ' ') return (
{changes.map((c, i) => { if (c.kind === 'flag') { return {nice(c.label)}: {c.on ? 'on' : 'off'} } if (c.kind === 'milestone') { return ✓ {nice(c.label)} } if (c.kind === 'rejected') { const why = REJECT_REASONS[c.reason] || c.reason // The engine's own wording quotes the real limits, so prefer it. return ( {nice(c.label)} refused — {why} ) } const d = c.delta // A clamp that cancels the change entirely is its own outcome. It is // neither an update nor a refusal, and "+0" read as the former. const blocked = c.clamped && d === 0 const dir = blocked ? 'refused' : typeof d === 'number' ? (d > 0 ? 'up' : d < 0 ? 'down' : 'flat') : 'flat' const txt = blocked ? 'no change — at its limit' : typeof d === 'number' ? (d > 0 ? `+${d}` : `${d}`) : `→ ${c.value}` const title = blocked ? c.fix || 'The story asked to change this and it is already at the limit the scenario allows.' : c.clamped ? 'The scenario limits how far this can move in one turn, so the change was reduced.' : undefined return ( {nice(c.label)} {txt} {c.clamped && !blocked ? (limited) : null} ) })}
) } // Per-turn RPG state change, shown when inspecting a past turn's snapshot. function WorldStateReport({ worldState }) { if (!worldState) return null const delta = worldState.delta || {} const report = worldState.report || {} const paths = Object.keys(delta) const rejected = report.rejected || [] const clamped = new Set((report.clamped || []).map((c) => c.path)) if (paths.length === 0 && rejected.length === 0) { return (
World State
No changes this turn.
) } return (
World State changes
    {paths.map((p) => (
  • {p} {String(delta[p])} {clamped.has(p) && clamped}
  • ))} {rejected.map((r, i) => (
  • {r.path} rejected — {r.reason}
  • ))}
) } function ScriptReport({ script }) { if (!script || (!script.logs?.length && !script.errors?.length && !script.context_changed)) { return null } return (
Scripts
{script.errors?.map((e, i) =>
⚠ {e}
)} {script.logs?.length > 0 && (
{script.logs.join('\n')}
)} {script.context_changed && ( <>
Context before script
{script.context_before}
Context after script (sent to AI)
{script.context_after}
)}
) } // What the endpoint charged for the turn, and how much of the prompt it read // back out of its cache instead of billing in full. Only shown on a past turn: // the "next turn" view has not been sent anywhere yet, so it has no usage. A // cached read costs a tenth of a fresh one, which is the whole reason the // prompt is laid out static-first — so this is the number that says whether // that layout is working. function CacheReport({ usage }) { if (!usage) return null const prompt = usage.prompt_tokens || 0 const cached = usage.prompt_tokens_details?.cached_tokens || 0 if (!prompt) return null const pct = Math.round((cached / prompt) * 100) return (
Prompt cache: {cached} of {prompt} prompt tokens read from cache ({pct}%) {usage.cost != null && ` · cost $${Number(usage.cost).toFixed(5)}`}
) } const LEGEND_VISIBLE = 6 // enough to cover what actually moves the budget // Where the prompt's tokens actually went: one stacked bar scaled to the // budget (so leftover width IS the remaining headroom) plus a matching legend. // Bar and legend both run biggest-share-first — the order that answers "what is // eating my context?" — rather than the prompt order the sections below use. function TokenBreakdown({ sections, tokens, used, onJump }) { const [hovered, setHovered] = useState(null) const [expanded, setExpanded] = useState(false) if (used <= 0) return null const budget = tokens.budget || 0 // Over budget there is no headroom to draw, so the bar scales to what the // prompt actually costs and the total line above it carries the warning. const scale = Math.max(budget, used) const free = Math.max(0, budget - used) const ranked = sections .map((s, i) => ({ ...s, i, pct: (s.tokens / used) * 100 })) .filter((s) => s.tokens > 0) .sort((a, b) => b.tokens - a.tokens) // The panel is narrow, so the legend is one column: list the sections that // actually move the budget and fold the long tail behind a count. const shown = expanded ? ranked : ranked.slice(0, LEGEND_VISIBLE) const rest = ranked.slice(shown.length) const restPct = rest.reduce((n, s) => n + s.pct, 0) const describe = (s) => `${SECTION_LABELS[s.label] || s.label} — ${s.tokens} tok · ${pctLabel(s.pct)}` return ( <>
budget ? 'over' : ''}`} role="img" aria-label={`Context breakdown: ${ranked.map(describe).join(', ')}`} > {ranked.map((s) => (
setHovered(s.i)} onMouseLeave={() => setHovered(null)} /> ))} {free > 0 && (
)}
{shown.map((s) => ( ))} {rest.length > 0 && ( )} {expanded && ranked.length > LEGEND_VISIBLE && ( )}
) } function InsightsPanel({ advId, inspectActionId, onClearInspect, refreshKey }) { const [report, setReport] = useState(null) const [error, setError] = useState(null) useEffect(() => { let stale = false // a slow earlier request must not clobber a newer one setError(null) const load = inspectActionId ? api.getActionContext(advId, inspectActionId) : api.getAdventureContext(advId) load .then((r) => { if (!stale) setReport(r) }) .catch((err) => { if (!stale) { setReport(null); setError(err.message) } }) return () => { stale = true } }, [advId, inspectActionId, refreshKey]) if (error) return
{error}
if (!report) return
Loading…
const { tokens, cards, history, sections } = report const overBudget = tokens.total > tokens.budget // The per-section sum, not tokens.total: the total also counts the separators // between sections, and percentages have to add up to 100 for the reader. const sectionTotal = sections.reduce((n, s) => n + s.tokens, 0) const jumpToSection = (i) => { document.getElementById(`ctx-sec-${i}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }) } return (
{inspectActionId ? (
Snapshot of a past turn
) : (
What will be sent on the next turn
)}
{tokens.total} / {tokens.budget} tokens
History: {history.included} of {history.total} actions in context {history.total > history.included && ' (older history trimmed)'} {history.oldest_truncated && ' — oldest entry cut mid-text'}
{cards.length > 0 && (
{cards.map((c, i) => (
▸ {c.name || '(unnamed card)'} triggered on “{c.keyword}” {!c.included && ' — dropped (over card budget)'}
))}
)} {report.memories && (
{report.memories.error && (
⚠ Memory bank: {report.memories.error}
)} {report.memories.used?.map((m, i) => (
▸ memory retrieved ({m.pinned ? 'pinned' : `similarity ${m.similarity.toFixed(2)}`}): {' '}{m.text.length > 90 ? m.text.slice(0, 90) + '…' : m.text}
))} {!report.memories.error && report.memories.used?.length === 0 && (
Memory bank on — no memories retrieved.
)}
)}
{sections.map((s, i) => (
{SECTION_LABELS[s.label] || s.label} {s.tokens} tok {sectionTotal > 0 && ` · ${pctLabel((s.tokens / sectionTotal) * 100)}`}
{s.text}
))} {report.raw_output && (
Raw AI output (before state block was stripped)
{report.raw_output}
)}
) } export default function Play() { const { id } = useParams() const navigate = useNavigate() const [adventure, setAdventure] = useState(null) const [actions, setActions] = useState([]) const [mode, setMode] = useState('do') const [input, setInput] = useState('') const [streaming, setStreaming] = useState(null) const [reasoningStream, setReasoningStream] = useState(null) const [busy, setBusy] = useState(false) const [toast, setToast] = useState(null) const [editing, setEditing] = useState(null) const [panel, setPanel] = useState(null) // null | 'plot' | 'insights' // Bumped when something outside the turn loop changes the drawers' state // (currently "Update from scenario"), which no action count would reflect. const [stateKey, setStateKey] = useState(0) const [inspectActionId, setInspectActionId] = useState(null) // Which take is being read, when it is not the live one (see TakePager). // One at a time; null when every message is showing the take the story tells. // // Purely local: the server is not told, because reading a take is not a // decision. It becomes one when something is written below it, and that is // what `after_id` carries. const [preview, setPreview] = useState(null) // The take a turn was just written below, held from the moment Send is // pressed until the re-read lands. // // Writing below a take is the one moment the server IS told (`after_id`), and // it obeys immediately — the take is made live before a single token is // generated. The transcript only learns that from the resync afterwards, so // dropping the preview at Send time put the *replaced* take back on screen // for the whole length of the turn, and left it there for good if the resync // never landed (a failed turn, a lost connection, a closed tab) — the story // read one way and reloading the page read another. // // So the text stays pinned to what was chosen. Unlike `preview` it does not // truncate the transcript below it: the turn being played goes there. const [pinned, setPinned] = useState(null) // Bumped when a take's stored text changes under the pagers, which cache the // list they fetched. Nothing else invalidates it: a take is added by playing // a turn, and that re-reads the whole window anyway. const [takesKey, setTakesKey] = useState(0) // The transcript is a window on the story, not the whole of it: the page // load brings the newest page and older ones arrive as the reader scrolls // up. `total` is the story's real length, for the "N earlier" line. const [total, setTotal] = useState(0) const [hasMore, setHasMore] = useState(false) const [loadingOlder, setLoadingOlder] = useState(false) const storyEndRef = useRef(null) const abortRef = useRef(null) const pinnedRef = useRef(true) // autoscroll only while the reader is at the bottom const inputRef = useRef(null) // Set just before older actions are prepended, read once afterwards to put // the reader back where they were. See the layout effect below. const restoreScrollRef = useRef(null) const loadingOlderRef = useRef(false) // Earliest time another attempt is allowed after a failure. See the catch // in loadOlder. const retryAfterRef = useRef(0) // The drop cap belongs to the story's first narrated beat. `start` is the // scenario's opening prompt, so it's usually that; an adventure begun blank // has no `start` action and the first AI reply takes it instead. Tracked by // id rather than position so deleting earlier turns moves the cap correctly // instead of stranding it on a removed row. const firstNarrationId = useMemo( () => actions.find((a) => a.type === 'start' || a.type === 'ai')?.id ?? null, [actions], ) // Where the transcript stops while a take that is not the live one is being // read. Such a take is a leaf by construction — whatever was played after // this turn was played after the take that *is* live — so there is nothing // under it, and showing the rest would attach one line's story to another's // text. -1 while nothing is being previewed, which is the ordinary case. const previewCutoff = useMemo( () => (preview ? actions.findIndex((a) => a.id === preview.actionId) : -1), [preview, actions], ) // send() sets streaming to '' before the request goes out; reasoningStream // stays null until reasoning tokens (if any) arrive. Both still at those // values means the request is in flight with nothing to show yet. const waitingForFirstToken = streaming === '' && reasoningStream === null // Grow the action box with its content (CSS caps it at ~4 lines, then // scrolls); shrinks back after send() clears the text. useEffect(() => { const el = inputRef.current if (!el) return el.style.height = 'auto' el.style.height = `${el.scrollHeight}px` }, [input]) useEffect(() => { api.getAdventure(id) .then((adv) => { setAdventure(adv) setActions(adv.actions) setTotal(adv.action_count ?? adv.actions.length) setHasMore(adv.actions.length < (adv.action_count ?? adv.actions.length)) }) .catch(() => navigate('/')) }, [id, navigate]) // Take the story the server just handed back, whole. // // Switching a branch and forking one both answer with the newest window of // the story as it now stands, so there is nothing to merge — the window on // screen belonged to a path that is no longer the one being read. Pinning // back to the bottom is deliberate: a switch lands the reader at the tip of // the line they moved to, which is where the next turn will appear. const adoptWindow = useCallback((page) => { pinnedRef.current = true setPreview(null) setPinned(null) setActions(page.actions) setTotal(page.total) setHasMore(page.has_more) // The script and world state come back to what that branch's tip left // behind, so anything drawn from them is now showing another line's // numbers until it re-reads. setStateKey((k) => k + 1) }, []) // Fetch the page above the one on screen and prepend it. // // Anchored on the oldest action we hold rather than on a count, so a turn // landing while the reader scrolls cannot shift the page. Guarded by a ref // as well as state because scroll fires far faster than React re-renders, // and two in-flight requests would fetch the same page twice. const loadOlder = useCallback(async () => { if (loadingOlderRef.current || !hasMore) return if (Date.now() < retryAfterRef.current) return const oldest = actions[0] if (!oldest) return loadingOlderRef.current = true setLoadingOlder(true) try { const page = await api.getActions(id, { beforeId: oldest.id }) if (page.actions.length) { // Reading backwards is the opposite of following along, so stop // autoscrolling. Without this the bottom-pinning effect below fires on // the same `actions` change and throws the reader to the end of the // story — worst exactly where the button matters, on a window short // enough that it never scrolled and so never un-pinned itself. pinnedRef.current = false // Record the height before the prepend; the layout effect below uses // it to keep the reader looking at the same paragraph. restoreScrollRef.current = { height: document.documentElement.scrollHeight, top: window.scrollY, } setActions((prev) => { // Defensive: never let a page the reader already holds duplicate a // message. Cheap, and the alternative is a visibly doubled turn. const known = new Set(prev.map((a) => a.id)) return [...page.actions.filter((a) => !known.has(a.id)), ...prev] }) } setTotal(page.total) setHasMore(page.has_more) } catch { // Leave hasMore alone: a failed fetch should let the reader try again // by scrolling, not permanently hide the rest of their story. But hold // off briefly first — parked near the top, momentum scrolling fires this // dozens of times a second, and against an endpoint that is failing that // is a retry storm rather than a retry. retryAfterRef.current = Date.now() + 3000 } finally { loadingOlderRef.current = false setLoadingOlder(false) } }, [actions, hasMore, id]) // Put the viewport back after a prepend. useLayoutEffect, not useEffect: // this has to run before the browser paints, or the reader sees the story // jump and then snap back. useLayoutEffect(() => { const mark = restoreScrollRef.current if (!mark) return restoreScrollRef.current = null const grown = document.documentElement.scrollHeight - mark.height if (grown > 0) window.scrollTo({ top: mark.top + grown }) }, [actions]) useEffect(() => { const onScroll = () => { pinnedRef.current = window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 120 // Start the next page before the reader reaches the top, so the story // is usually already there by the time they would have noticed its end. if (window.scrollY < 400) loadOlder() } window.addEventListener('scroll', onScroll, { passive: true }) return () => window.removeEventListener('scroll', onScroll) }, [loadOlder]) useEffect(() => { // Snap to the real document bottom (below the sticky composer), not to // storyEndRef — that ref sits above the composer, so block:'end' would // stop short and fight a reader scrolling down. Instant, not smooth: at // streaming speed a queued smooth animation never settles. if (pinnedRef.current) { window.scrollTo({ top: document.documentElement.scrollHeight }) } }, [actions, streaming, reasoningStream]) const handleScriptReport = useCallback((script) => { if (!script) return if (script.errors?.length) { setToast({ text: `Script error: ${script.errors[0]}`, isError: true }) } else if (script.message) { setToast({ text: script.message, isError: false }) } }, []) const handleEvent = useCallback((event) => { if (event.type === 'player') { setActions((prev) => [...prev, event.action]) // The window grew at the bottom, so the story did too. Kept in step by // hand because nothing re-reads the count between turns. setTotal((n) => n + 1) } else if (event.type === 'chunk') { setStreaming((prev) => (prev ?? '') + event.text) } else if (event.type === 'reasoning') { setReasoningStream((prev) => (prev ?? '') + event.text) } else if (event.type === 'done') { setStreaming(null) setReasoningStream(null) setActions((prev) => [...prev, event.action]) setTotal((n) => n + 1) handleScriptReport(event.script) } else if (event.type === 'stopped') { setStreaming(null) setReasoningStream(null) handleScriptReport(event.script) } else if (event.type === 'error') { setStreaming(null) setReasoningStream(null) setToast({ text: event.detail, isError: true }) } }, [handleScriptReport]) async function runTurn(run) { const controller = new AbortController() abortRef.current = controller setBusy(true) setToast(null) setStreaming('') pinnedRef.current = true try { await run(controller.signal) } catch (err) { if (err.name === 'AbortError') { setToast({ text: 'Generation stopped.', isError: false }) } else { setToast({ text: err.message, isError: true }) } } finally { abortRef.current = null setStreaming(null) setReasoningStream(null) setBusy(false) } } function stopGeneration() { abortRef.current?.abort() } function send(type = mode) { const text = input.trim() // Where the reader is standing. Stepping to a take the story moved past // told the server nothing; this is the moment it has to be told, and it is // the moment the branch is made (SP9). const after_id = preview?.takeId // The window below belongs to the line being left, so it is re-read rather // than appended to — same reasoning as `addTake`. const run = (payload) => runTurn(async (signal) => { try { await api.sendAction(id, payload, handleEvent, signal) } finally { if (after_id) await resync() } }) // Keep the chosen take on screen while the turn runs. The server has // already been told to stand on it, so this is not optimism — it is the // transcript catching up with a decision that is already made. if (preview) setPinned({ ...preview, written: true }) setPreview(null) if (type === 'continue') { // Continue never consumes typed text — leave it in the box. run({ type: 'continue', text: '', after_id }) return } const payload = { type: text ? type : 'continue', text, after_id } setInput('') run(payload) } function retry() { setPreview(null) setActions((prev) => prev.length && prev[prev.length - 1].type === 'ai' ? prev.slice(0, -1) : prev) runTurn(async (signal) => { try { await api.retry(id, handleEvent, signal) } catch (err) { // Failed retry (409, network): the optimistically removed action may // still exist server-side — resync instead of guessing. Resyncing // collapses the transcript back to the newest window, which is the // right call: the reader's place is already lost by the failure. api.getAdventure(id).then((adv) => { setActions(adv.actions) setTotal(adv.action_count ?? adv.actions.length) setHasMore(adv.actions.length < (adv.action_count ?? adv.actions.length)) }).catch(() => {}) throw err } }) } async function undo() { setToast(null) setPreview(null) setPinned(null) try { // A window, not the whole story — undo is the action most likely to be // repeated several times running, so it must not re-fetch everything. const page = await api.undo(id) setActions(page.actions) setTotal(page.total) setHasMore(page.has_more) } catch (err) { setToast({ text: err.message, isError: true }) } } // Ctrl+Z undo / Ctrl+R retry, ignored while typing in a field. useEffect(() => { const lastIsAi = actions.length > 0 && actions[actions.length - 1].type === 'ai' const canUndo = actions.length > 0 && actions[actions.length - 1].type !== 'start' const onKey = (e) => { if (!(e.ctrlKey || e.metaKey) || busy) return if (e.target.closest?.('input, textarea, select, [contenteditable]')) return if (e.key.toLowerCase() === 'z' && canUndo) { e.preventDefault() undo() } else if (e.key.toLowerCase() === 'r' && lastIsAi) { e.preventDefault() retry() } } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }) // A take-edit belongs to the preview that opened it. Anything that leaves // that take — playing a turn, switching branch — takes the box with it, so // the pending edit goes too rather than being saved onto a take nobody is // looking at any more. useEffect(() => { if (editing?.take && preview?.takeId !== editing.id) setEditing(null) }, [editing, preview]) async function saveEdit() { const { id: actionId, text, fork, take } = editing setEditing(null) if (fork) { // Not an edit at all: the turn is played again with this text, and what // the story made of the old text is kept on the line it was written on. addTake(actionId, text) return } try { const updated = await api.updateAction(id, actionId, text) // A take that is only being read is not in `actions` — the row there is // the live one — so the new text goes back into the preview, which is // what that row is drawing. The pager holds the take list it fetched, so // it is told to drop it: stepping away and back would otherwise show the // words before the edit. if (take) { setPreview((p) => (p && p.takeId === actionId ? { ...p, text: updated.text } : p)) setTakesKey((k) => k + 1) } else { setActions((prev) => prev.map((a) => (a.id === actionId ? updated : a))) } } catch (err) { setToast({ text: err.message, isError: true }) } } // Play a turn again, differently. Anywhere in the story, either kind of node. // // The transcript is re-read rather than appended to, which is the difference // from an ordinary turn: a take above the tip leaves the line it was on and // the whole window below it belongs to a story this branch no longer tells. // `handleEvent` appends the new node as it streams; the resync afterwards is // what drops everything that is no longer under it. function addTake(actionId, text) { setPreview(null) runTurn(async (signal) => { try { await api.addTake(id, actionId, text, handleEvent, signal) } finally { await resync() } }) } // Re-read the newest window from the server. // // For a turn that left the line it was on: `handleEvent` appends the new node // as it streams, and everything already on screen below the take belongs to a // story this branch no longer tells. Only the server can say what replaces // it. A failed resync leaves the transcript stale rather than wrong, so it is // swallowed — the next page load settles it. async function resync() { try { const adv = await api.getAdventure(id) // The window now says which take is live, so the pin has done its job. // Left in place if this read fails, which is the whole point of it. setPinned(null) setActions(adv.actions) setTotal(adv.action_count ?? adv.actions.length) setHasMore(adv.actions.length < (adv.action_count ?? adv.actions.length)) // The branch, the script state and the world state can all have moved. setStateKey((k) => k + 1) } catch { /* stale beats wrong */ } } async function removeAction(actionId) { try { await api.deleteAction(id, actionId) setActions((prev) => prev.filter((a) => a.id !== actionId)) setTotal((n) => Math.max(0, n - 1)) } catch (err) { setToast({ text: err.message, isError: true }) } } function inspect(actionId) { setInspectActionId(actionId) setPanel('insights') } if (!adventure) return null const lastIsAi = actions.length > 0 && actions[actions.length - 1].type === 'ai' const canUndo = actions.length > 0 && actions[actions.length - 1].type !== 'start' return (
{/* Both drawers read per-adventure state that a branch switch puts back, so neither can key on the story's length alone: switching between two branches whose windows are both full changes every number in here without changing `actions.length` by one. */}

{adventure.title}

{actions.length === 0 && streaming === null && (
A blank page. Type something below to begin your story.
)} {/* Scrolling up loads the rest. The button is not decoration: on a short viewport the story may not be tall enough to scroll at all, and a reader who cannot scroll must still be able to get back to the beginning. */} {hasMore && (
{loadingOlder ? ( Turning back the pages… ) : ( )}
)} {actions.map((action, i) => { // Below the take being read there is nothing on this line yet. if (previewCutoff !== -1 && i > previewCutoff) return null const isPlayer = PLAYER_TYPES.includes(action.type) // A player action opens a new turn, so that's where the ornamental // break belongs — never above the very first line on the page. const sceneBreak = isPlayer && i > 0 // Drop cap goes on the first narrated beat only. `firstNarrationId` // is derived once above rather than per row. const opening = action.id === firstNarrationId // Non-null while the reader is browsing an older attempt of this // message without making it active (earlier turns only). const previewing = preview?.actionId === action.id ? preview : null // The take this row was written below, still on screen because the // re-read has not landed yet. Same text override as a preview, but // it never truncates the story under it — see `pinned`. const shown = previewing || (pinned?.actionId === action.id ? pinned : null) // The editor stands in for the row it was opened from. That row is // keyed by the live node, so an edit on a take the pager is parked // on carries the take's id instead and is matched through the // preview. const editingHere = editing && (editing.take ? previewing?.takeId === editing.id : editing.id === action.id) return editingHere ? (
setEditing({ ...editing, text: e.target.value })} />
) : ( {sceneBreak && }
{renderEmphasis(shown ? shown.text : action.text)} {/* The chips describe the *active* attempt's state changes, which the take on screen didn't make — so they're hidden rather than shown against the wrong text. */} {action.type === 'ai' && !shown && ( )} {/* On every kind of node, not only the AI's: a player's own turn can be played again too (SP9), so it can have takes to step through. The pager draws nothing for a count of one, which is most turns. */} setToast({ text: message, isError: true })} /> {!busy && ( {action.type === 'ai' && ( )} {/* Edits the take that is *on screen*, which is not the live one while the pager is parked on another. The row is keyed by the live node's id, so seeding from `action` here opened the editor on take 4/4's text while 2/4 was being read — and saved over it. A take is an ordinary row to the edit endpoint, on the path or not, so its own id is all this needs. */} {/* Play this turn again, differently. On the AI's turn that is a regeneration; on your own it opens the text so you can say something else. Either way the story that followed the old take is kept, on the line it was written on. The id stays the live node's even while another take is being read: adding a take branches just above the turn, and the server only accepts a turn that is on the path. Only the seeded text follows the screen, so varying the take you are reading starts from its words. */} {action.type !== 'start' && ( )} )}
) })} {waitingForFirstToken && (
Weaving
)} {streaming !== null && !waitingForFirstToken && (
{renderEmphasis(streaming)} ▋
)}
{MODES.map((m) => ( ))}