diff --git a/backend/tools/branch_fixture.py b/backend/tools/branch_fixture.py
index d53b65e..679da92 100644
--- a/backend/tools/branch_fixture.py
+++ b/backend/tools/branch_fixture.py
@@ -67,6 +67,9 @@ modifier(text);
class ScriptedProvider:
replies: list = []
+ # The turn engine records the cost of the call, so a stand-in provider has
+ # to carry this attribute even when it never calls anything.
+ last_usage = None
calls = 0
def __init__(self, *a, **k):
diff --git a/backend/tools/shots_fixture.py b/backend/tools/shots_fixture.py
index 88279f6..2dc0de1 100644
--- a/backend/tools/shots_fixture.py
+++ b/backend/tools/shots_fixture.py
@@ -151,6 +151,9 @@ class ScriptedProvider:
That difference is what the screenshots show.
"""
+ # The turn engine records the cost of the call, so a stand-in provider has
+ # to carry this attribute even when it never calls anything.
+ last_usage = None
next_reply = ("", {})
diff --git a/backend/tools/stress_session.py b/backend/tools/stress_session.py
index 35ab40a..515b9ed 100644
--- a/backend/tools/stress_session.py
+++ b/backend/tools/stress_session.py
@@ -196,6 +196,10 @@ class FakeProvider:
time.
"""
+ # The turn engine records the cost of the call, so a stand-in provider has
+ # to carry this attribute even when it never calls anything.
+ last_usage = None
+
def __init__(self, *a, **k):
pass
diff --git a/backend/tools/tree_fixture.py b/backend/tools/tree_fixture.py
index fb8aa71..184a81d 100644
--- a/backend/tools/tree_fixture.py
+++ b/backend/tools/tree_fixture.py
@@ -57,6 +57,9 @@ PROSE = [
class ScriptedProvider:
calls = 0
+ # The turn engine records the cost of the call, so a stand-in provider has
+ # to carry this attribute even when it never calls anything.
+ last_usage = None
def __init__(self, *a, **k):
pass
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
index bb6f621..1a4311f 100644
--- a/frontend/src/main.jsx
+++ b/frontend/src/main.jsx
@@ -6,7 +6,7 @@ import Home from './pages/Home.jsx'
import Adventures from './pages/Adventures.jsx'
import Scenarios from './pages/Scenarios.jsx'
import ScenarioEditor from './pages/ScenarioEditor.jsx'
-import Play from './pages/Play.jsx'
+import Play from './pages/Play'
import Scripts from './pages/Scripts.jsx'
import ScriptEditor from './pages/ScriptEditor.jsx'
import Settings from './pages/Settings.jsx'
diff --git a/frontend/src/pages/Play.jsx b/frontend/src/pages/Play.jsx
deleted file mode 100644
index 1854b40..0000000
--- a/frontend/src/pages/Play.jsx
+++ /dev/null
@@ -1,2280 +0,0 @@
-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(
-
-
- 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." />
-
-
- )
-}
-
-// 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 (
-
- )
-}
-
-// `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 ?
- )
-}
-
-// 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
- {/* 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 (
-
- {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 && (
-
- )}
-
- )
-}
-
-// 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}
-
- )
- })}
-
- )
-}
-
-// 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 (
-
- )
-}
-
-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 (
- <>
-
- 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. */}
-
-
-
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 ? (
-
-
- {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' && (
-
- )}
-
-
- )}
-
- {panel === 'plot' ? (
- setStateKey((k) => k + 1)} />
- ) : panel === 'memory' ? (
-
- ) : panel === 'scripts' ? (
-
- ) : panel === 'branches' ? (
- setStateKey((k) => k + 1)}
- onError={(message) => setToast({ text: message, isError: true })}
- />
- ) : (
- // Insights is the prompt as it would be sent *now*, which is built
- // from the story on the current path — so of everything on this
- // screen it is the panel a branch switch changes most completely.
- setInspectActionId(null)}
- refreshKey={`${actions.length}:${stateKey}`} />
- )}
-
- )}
-
- {toast && (
-
- {toast.text}
- {toast.isError && (
-
- )}
-
-
- )}
-
- )
-}
diff --git a/frontend/src/pages/Play/RefreshModal.jsx b/frontend/src/pages/Play/RefreshModal.jsx
new file mode 100644
index 0000000..c8258ab
--- /dev/null
+++ b/frontend/src/pages/Play/RefreshModal.jsx
@@ -0,0 +1,105 @@
+// The confirmation dialog for copying a scenario's current content.
+//
+// The dialog shows exactly what the update changes, and collects any
+// `${Placeholder}` answers the adventure has no stored value for. An adventure
+// started before those were saved has none, and so does one whose author added
+// a placeholder since. The update overwrites plot text and scenario-derived
+// cards, so nothing happens until you press Update.
+
+import { useState } from 'react'
+import { createPortal } from 'react-dom'
+import { FIELD_LABELS, clip } from './format'
+
+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(
+
+
+
,
+ document.body,
+ )
+}
+
+export { RefreshModal }
diff --git a/frontend/src/pages/Play/TakePager.jsx b/frontend/src/pages/Play/TakePager.jsx
new file mode 100644
index 0000000..f239d58
--- /dev/null
+++ b/frontend/src/pages/Play/TakePager.jsx
@@ -0,0 +1,88 @@
+// The pager that steps between the attempts at one coordinate: ‹ 2/4 ›, and
+// nothing else.
+//
+// SP7 shipped chips instead, because a chip could also offer "take this path"
+// where a pager can only step. Driving it by hand said otherwise. The chip
+// meant two things depending on where you were standing: a real switch at the
+// tip, or a preview that needed a second button above it. Two meanings in one
+// control is what made the tree unusable.
+//
+// So the pager is back, and stepping is all it does. Stepping tells the server
+// nothing, because reading a take is not a decision. You decide by writing
+// below a take, and that is where the branch is created (SP9, `after_id`).
+//
+// One step does reach 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 it 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 ends there.
+
+import { useEffect, useState } from 'react'
+import { api } from '../../api'
+
+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
+ )}
+
+ )
+}
+
+export { TakePager }
diff --git a/frontend/src/pages/Play/drawers/StatusDrawer.jsx b/frontend/src/pages/Play/drawers/StatusDrawer.jsx
new file mode 100644
index 0000000..4a47cc7
--- /dev/null
+++ b/frontend/src/pages/Play/drawers/StatusDrawer.jsx
@@ -0,0 +1,99 @@
+// The left drawer: the script state an adventure's scripts read and write.
+//
+// `StateTree` and `StateValue` render a value of any shape, because script
+// state is whatever the scripts put there.
+
+import { useCallback, useEffect, useState } from 'react'
+import { api } from '../../../api'
+
+// Renders one script-state value. A primitive renders inline, typed and
+// colored. An object or an array renders as a collapsible indented tree, and
+// the render recurses, so deep state shows its structure rather than one 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}
+
+
+ ))}
+
+ )}
+
+ )}
+
+ )
+}
+
+export { StatusDrawer }
diff --git a/frontend/src/pages/Play/drawers/WorldStateDrawer.jsx b/frontend/src/pages/Play/drawers/WorldStateDrawer.jsx
new file mode 100644
index 0000000..923011e
--- /dev/null
+++ b/frontend/src/pages/Play/drawers/WorldStateDrawer.jsx
@@ -0,0 +1,295 @@
+// The right drawer: the RPG world state, and the form that edits it.
+//
+// Editing builds a flat draft keyed by path, so a nested value can be edited
+// without rebuilding the tree on every keystroke. `buildWorldStateDraft` makes
+// it and `sliceDraft` reads one section back out.
+
+import { useCallback, useEffect, useState } from 'react'
+import { api } from '../../../api'
+import { npcInitials } from '../../../components'
+
+// Returns the word label for a value, read from the stat def's bands. This
+// mirrors `worldstate.band_label` on the server.
+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 (
+
+ )
+}
+
+// `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 ?
+ )
+}
+
+export { WorldStateDrawer }
diff --git a/frontend/src/pages/Play/format.js b/frontend/src/pages/Play/format.js
new file mode 100644
index 0000000..6f8e7a8
--- /dev/null
+++ b/frontend/src/pages/Play/format.js
@@ -0,0 +1,67 @@
+// Labels and formatting shared by the panels that report on a turn.
+//
+// `SECTION_COLORS` and `SECTION_FALLBACK` stay private. Read a color through
+// `sectionColor`, so an unknown section gets the fallback rather than
+// `undefined`.
+
+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)')
+}
+
+export { SECTION_LABELS, sectionColor, pctLabel, FIELD_LABELS, clip }
diff --git a/frontend/src/pages/Play/index.jsx b/frontend/src/pages/Play/index.jsx
new file mode 100644
index 0000000..b734dde
--- /dev/null
+++ b/frontend/src/pages/Play/index.jsx
@@ -0,0 +1,783 @@
+/* The Play screen.
+ *
+ * This file holds the page component and the two helpers only it uses. The
+ * panels, drawers, and reports it renders are separate modules, listed in the
+ * imports below.
+ *
+ * The page component still owns all of the session state. Lifting it into a
+ * `usePlaySession` hook waits for Stage 5 of `plan/17-refactor.md`, which adds
+ * a frontend test runner. Moving eighteen `useState` calls and seven
+ * `useEffect` calls is a rewrite rather than a move, and nothing would catch a
+ * mistake in it today.
+ */
+
+import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import { api } from '../../api'
+import { AutoTextarea } from '../../components'
+import { StateChangeChips } from './reports'
+import { TakePager } from './TakePager'
+import { StatusDrawer } from './drawers/StatusDrawer'
+import { WorldStateDrawer } from './drawers/WorldStateDrawer'
+import { BranchPanel } from './panels/BranchPanel'
+import { InsightsPanel } from './panels/InsightsPanel'
+import { MemoryPanel } from './panels/MemoryPanel'
+import { PlotPanel } from './panels/PlotPanel'
+import { ScriptsPanel } from './panels/ScriptsPanel'
+
+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}
+
+ )
+}
+
+
+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. */}
+
+
+
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 ? (
+
+
+ {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' && (
+
+ )}
+
+
+ )}
+
+ {panel === 'plot' ? (
+ setStateKey((k) => k + 1)} />
+ ) : panel === 'memory' ? (
+
+ ) : panel === 'scripts' ? (
+
+ ) : panel === 'branches' ? (
+ setStateKey((k) => k + 1)}
+ onError={(message) => setToast({ text: message, isError: true })}
+ />
+ ) : (
+ // Insights is the prompt as it would be sent *now*, which is built
+ // from the story on the current path — so of everything on this
+ // screen it is the panel a branch switch changes most completely.
+ setInspectActionId(null)}
+ refreshKey={`${actions.length}:${stateKey}`} />
+ )}
+
+ )}
+
+ {toast && (
+
+ {toast.text}
+ {toast.isError && (
+
+ )}
+
+
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/Play/panels/BranchPanel.jsx b/frontend/src/pages/Play/panels/BranchPanel.jsx
new file mode 100644
index 0000000..d7038d5
--- /dev/null
+++ b/frontend/src/pages/Play/panels/BranchPanel.jsx
@@ -0,0 +1,175 @@
+// The branch panel: every line the story has taken, and the map of them.
+//
+// One request draws the whole panel. `fork_depth` says where a branch leaves
+// its parent and `depth` says where it currently ends, so the shape is two
+// numbers a row rather than a walk.
+//
+// Delete belongs here rather than in a later subphase because nothing prunes
+// the tree on its own. This panel is the first place you can make a fork, so
+// it has to be the first place you can unmake one.
+
+import { useEffect, useState } from 'react'
+import { api } from '../../../api'
+import { BranchMap } from '../../../BranchMap'
+import { branchLabel, headLineage, orderBranches } from '../../../branches'
+
+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
+ {/* 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 (
+
+ {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 && (
+
+ )}
+
+ )
+}
+
+export { MemoryPanel }
diff --git a/frontend/src/pages/Play/panels/PlotPanel.jsx b/frontend/src/pages/Play/panels/PlotPanel.jsx
new file mode 100644
index 0000000..ac8914d
--- /dev/null
+++ b/frontend/src/pages/Play/panels/PlotPanel.jsx
@@ -0,0 +1,142 @@
+// The plot panel: the adventure's own copy of the scenario text and cards.
+
+import { useRef, useState } from 'react'
+import { api } from '../../../api'
+import { Field, StoryCardRow, downloadJSON, pickJSONFile, useToast } from '../../../components'
+import { RefreshModal } from '../RefreshModal'
+
+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." />
+
+
+ )
+}
+
+export { ScriptsPanel }
diff --git a/frontend/src/pages/Play/reports.jsx b/frontend/src/pages/Play/reports.jsx
new file mode 100644
index 0000000..e569d59
--- /dev/null
+++ b/frontend/src/pages/Play/reports.jsx
@@ -0,0 +1,246 @@
+// What a turn did, rendered five ways: world-state chips, the world-state
+// report, the script report, the cache report, and the token breakdown.
+//
+// These read a turn's result and render it. None of them fetch.
+
+import { useState } from 'react'
+import { SECTION_LABELS, pctLabel, sectionColor } from './format'
+
+// 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}
+
+ )
+ })}
+
+ )
+}
+
+// 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 (
+
+ )
+}
+
+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 (
+ <>
+
+ >
+ )
+}
+
+export { StateChangeChips, WorldStateReport, ScriptReport, CacheReport, TokenBreakdown }
diff --git a/plan/17-refactor.md b/plan/17-refactor.md
index f130480..2419538 100644
--- a/plan/17-refactor.md
+++ b/plan/17-refactor.md
@@ -18,7 +18,7 @@ stage before it is green.
| Stage | Work | Status | Landed |
|---|---|---|---|
| 0 | Hygiene: worktrees, branches, undocumented settings | done, except the branch deletion | 2026-08-29 |
-| 1 | Split the four largest files | tests share one setup; the router is a package; three files left | |
+| 1 | Split the four largest files | done: one test setup, and all four files split | 2026-08-29 |
| 2 | Remove duplication | not started | |
| 3 | SP8: drop the legacy columns | not started | |
| 4 | Documentation | not started | |
@@ -317,6 +317,48 @@ two known places, recorded in `plan/STATUS.md` and in the comments at
**Check:** 549 tests pass, `npm run lint` and `npm run build` are clean, and the
Play screen works in a browser at desktop and at 500 px wide.
+### What Stage 1 actually did to the frontend, 2026-08-29
+
+`Play.jsx` is now `frontend/src/pages/Play/`, twelve files. The largest is
+`index.jsx` at 781 lines. `index.css` is now an `@import` list over
+`frontend/src/styles/`, eighteen files.
+
+Two decisions differ from the plan above.
+
+**`usePlaySession.js` does not exist yet.** The page component still owns all of
+the session state. Moving eighteen `useState` calls and seven `useEffect` calls
+is a rewrite, not a move, and no frontend test would catch a mistake in it
+today. It waits for Stage 5.
+
+**The `@media` blocks stayed where they were.** `responsive.css` still holds one
+`max-width: 720px` block, at the end of the import order. Moving a `@media` block
+next to the rules it overrides moves it earlier in the cascade, which changes
+which of two equal-specificity rules wins. Nothing in the test suite would catch
+that. Do this after Stage 5.
+
+Each split is verified by a different proof, because neither one has a test:
+
+- CSS: the parts rebuild `index.css` byte for byte, and the built bundle is
+ identical before and after at 56686 bytes.
+- JSX: every non-blank line of the original appears exactly once, in order, across
+ the twelve files. A name-resolution check confirms every identifier each file
+ references is defined or imported there, with no unused imports.
+
+The line split stranded a comment at six of the boundaries. A leading comment
+sits above the section it describes, so each boundary cut one loose and left it
+at the end of the file before it. All six moved to the section they describe.
+
+`npm run lint` and `npm run build` are clean, and 549 tests pass. Driving the
+Play screen covered the story view, all five panels, both drawers including the
+world-state edit form, the branch map, the refresh dialog, and the take pager,
+which stepped onto a take that lives on another branch and switched to it. The
+console reported no errors. The extension cannot resize the render viewport and
+the app sends `X-Frame-Options: DENY`, so the narrow-width check ran by setting
+the `max-width` media queries to `all` in the live stylesheet. All 71 narrow
+rules found their elements: the nav collapses to one button, the panel tabs move
+onto the title row, a panel fills the screen, the composer stacks, and both
+drawers become edge tabs.
+
## Stage 2: remove duplication
Each item here is small and is covered by tests that already exist.