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' 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} )}
  • ) })}
)} )}
)}
) } // ChatGPT-style ‹ 2/3 › pager under an AI message that has been retried. // // The last message can actually be switched (the server restores the stats that // attempt produced); earlier ones are read-only, because the turns after them // were written as a continuation of whatever is active now. Browsing an // earlier one is a local preview, so `onPreview` hands the text up to the story // renderer rather than the pager drawing it. function VariantPager({ advId, action, isLast, busy, previewIndex, onPreview, onSwitched, onError }) { const [variants, setVariants] = useState(null) const [loading, setLoading] = useState(false) const count = action.variant_count const current = previewIndex ?? action.variant_index async function go(delta) { const next = current + delta if (next < 0 || next >= count || loading || busy) return setLoading(true) try { if (isLast) { onPreview(null) onSwitched(await api.selectVariant(advId, action.id, next)) } else { // Fetched once per message, then cached — paging back and forth // shouldn't re-hit the server. const list = variants || await api.listVariants(advId, action.id) if (!variants) setVariants(list) onPreview(next === action.variant_index ? null : { actionId: action.id, index: next, text: list[next].text, reasoning: list[next].reasoning }) } } catch (err) { onError(err.message) } finally { setLoading(false) } } return (
{current + 1}/{count} {previewIndex !== null && ( earlier attempt — the story continued from {action.variant_index + 1} )}
) } // Compact chips shown under an AI message summarizing what state changed. 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)} } const d = c.delta const dir = typeof d === 'number' ? (d > 0 ? 'up' : d < 0 ? 'down' : 'flat') : 'flat' const txt = typeof d === 'number' ? (d > 0 ? `+${d}` : `${d}`) : `→ ${c.value}` return {nice(c.label)} {txt} })}
) } // 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}
)}
) } 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) // Read-only browsing of an earlier attempt at a past turn (see VariantPager). // One at a time; null when every message is showing its active version. const [preview, setPreview] = useState(null) // 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) // 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], ) // 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]) // 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 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) { // 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. } 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() if (type === 'continue') { // Continue never consumes typed text — leave it in the box. runTurn((signal) => api.sendAction(id, { type: 'continue', text: '' }, handleEvent, signal)) return } const payload = { type: text ? type : 'continue', text } setInput('') runTurn((signal) => api.sendAction(id, payload, handleEvent, signal)) } 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) 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) }) async function saveEdit() { const { id: actionId, text } = editing setEditing(null) try { const updated = await api.updateAction(id, actionId, text) setActions((prev) => prev.map((a) => (a.id === actionId ? updated : a))) } catch (err) { setToast({ text: err.message, isError: true }) } } 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 (

{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) => { 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 return editing?.id === action.id ? (
setEditing({ ...editing, text: e.target.value })} />
) : ( {sceneBreak && }
{renderEmphasis(previewing ? previewing.text : action.text)} {/* The chips describe the *active* attempt's state changes, which a previewed one didn't make — so they're hidden rather than shown against the wrong text. */} {action.type === 'ai' && !previewing && ( )} {action.type === 'ai' && action.variant_count > 1 && ( setActions((prev) => prev.map((a) => (a.id === updated.id ? updated : a)))} onError={(message) => setToast({ text: message, isError: true })} /> )} {!busy && ( {action.type === 'ai' && ( )} )}
) })} {waitingForFirstToken && (
Weaving
)} {streaming !== null && !waitingForFirstToken && (
{renderEmphasis(streaming)} ▋
)}
{MODES.map((m) => ( ))}