Split Play.jsx into a directory of twelve files

`frontend/src/pages/Play.jsx` was 2280 lines holding 27 components. It is now
`frontend/src/pages/Play/`: `index.jsx` with the page component, five panels,
two drawers, the reports, the take pager, the refresh dialog, and the shared
formatting helpers.

Every line moved verbatim. A coverage check confirms every non-blank line of
the original appears exactly once, in order, across the twelve files, and 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 cut left one at the end of the file
before it. All six moved to the section they describe, rewritten in the house
style.

`usePlaySession.js` is not here. The page component still owns all of the
session state. Moving eighteen `useState` calls and seven `useEffect` calls is a
rewrite rather than a move, and no frontend test would catch a mistake in it
today, so it waits for Stage 5.

Four stand-in providers under `backend/tools/` now define `last_usage = None`.
The turn engine reads that attribute after every call, and the fixtures never
defined it, so `tools.tree_fixture` crashed. That break predates this branch.

Verified by 549 passing tests, a clean `npm run lint` and `npm run build`, and
by driving the Play screen: the story view, all five panels, both drawers
including the world-state edit form, the branch map, the refresh dialog, and the
take pager stepping onto a take that lives on another branch. No console errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Dix4oGV3njgWRdu7P9t6r
This commit is contained in:
parththakkar106
2026-08-29 01:33:14 +05:30
co-authored by Claude Opus 5
parent 3a9090c20a
commit 0623dd8b78
19 changed files with 2445 additions and 2282 deletions
@@ -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 <div className="panel-empty">Couldn’t read the branches — {failed}</div>
if (!branches) return <div className="panel-empty">Reading the tree…</div>
const lineage = headLineage(branches)
return (
<div className="branch-panel">
{/* 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. */}
<button type="button" className="branch-map-open" onClick={() => setMapOpen(true)}>
⌗ See the tree
</button>
{branches.length === 1 && (
<p className="branch-intro">
One thread so far. Retry a turn, then take an attempt the story moved
past — that is what makes a second one.
</p>
)}
<div className="branch-list">
{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 (
<div key={branch.id} className={`branch-row ${branch.is_head ? 'here' : ''}`}
style={{ marginLeft: indent * 12 }}>
<div className="branch-head">
<span className="branch-glyph" aria-hidden="true">
{branch.parent_branch_id === null ? '●' : '└'}
</span>
{isRenaming ? (
<input
className="branch-rename"
autoFocus
maxLength={80}
value={renaming.text}
onChange={(e) => setRenaming({ ...renaming, text: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') saveName(branch)
if (e.key === 'Escape') setRenaming(null)
}}
/>
) : (
<span className="branch-name">{branchLabel(branch)}</span>
)}
{branch.is_head && <span className="branch-here">reading</span>}
</div>
<div className="branch-meta">
{branch.own_actions} of its own
{branch.parent_branch_id !== null && ` · forked at moment ${branch.fork_depth + 1}`}
{` · ends at ${branch.depth + 1}`}
</div>
{isConfirming ? (
<div className="branch-confirm">
<span>Delete this branch and everything forked from it?</span>
<button type="button" className="danger" disabled={busy}
onClick={() => remove(branch)}>Delete</button>
<button type="button" onClick={() => setConfirming(null)}>Keep</button>
</div>
) : (
<div className="branch-tools">
{!branch.is_head && (
<button type="button" disabled={busy} onClick={() => switchTo(branch)}>Switch</button>
)}
{isRenaming ? (
<>
<button type="button" disabled={busy} onClick={() => saveName(branch)}>Save</button>
<button type="button" onClick={() => setRenaming(null)}>Cancel</button>
</>
) : (
<button type="button" disabled={busy}
onClick={() => setRenaming({ id: branch.id, text: branch.name || '' })}>
Rename
</button>
)}
{/* The root holds the turns every other branch borrows, and
the server refuses it — so it is not offered. */}
{branch.parent_branch_id !== null && (
<button type="button" className="danger" disabled={busy || loadBearing}
title={loadBearing
? 'The line you are reading is built on this one. Switch away first.'
: undefined}
onClick={() => setConfirming(branch.id)}>Delete</button>
)}
</div>
)}
</div>
)
})}
</div>
{mapOpen && (
<BranchMap
branches={branches}
busyId={busyId}
onSwitch={switchTo}
onRename={renameTo}
onDelete={removeBranch}
onClose={() => setMapOpen(false)}
/>
)}
</div>
)
}
export { BranchPanel }
@@ -0,0 +1,121 @@
// The insights panel: what the last turn sent, and what it cost.
import { useEffect, useState } from 'react'
import { api } from '../../../api'
import { SECTION_LABELS, pctLabel, sectionColor } from '../format'
import { CacheReport, ScriptReport, TokenBreakdown, WorldStateReport } from '../reports'
function InsightsPanel({ advId, inspectActionId, onClearInspect, refreshKey }) {
const [report, setReport] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
let stale = false // a slow earlier request must not clobber a newer one
setError(null)
const load = inspectActionId
? api.getActionContext(advId, inspectActionId)
: api.getAdventureContext(advId)
load
.then((r) => { if (!stale) setReport(r) })
.catch((err) => { if (!stale) { setReport(null); setError(err.message) } })
return () => { stale = true }
}, [advId, inspectActionId, refreshKey])
if (error) return <div className="empty">{error}</div>
if (!report) return <div className="empty">Loading…</div>
const { tokens, cards, history, sections } = report
const overBudget = tokens.total > tokens.budget
// The per-section sum, not tokens.total: the total also counts the separators
// between sections, and percentages have to add up to 100 for the reader.
const sectionTotal = sections.reduce((n, s) => n + s.tokens, 0)
const jumpToSection = (i) => {
document.getElementById(`ctx-sec-${i}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
return (
<div className="insights">
<div className="insights-meta">
{inspectActionId ? (
<div className="insights-mode">
Snapshot of a past turn
<button onClick={onClearInspect} style={{ marginLeft: 8 }}>Back to next turn</button>
</div>
) : (
<div className="insights-mode">What will be sent on the next turn</div>
)}
<div className={`token-total ${overBudget ? 'over' : ''}`}>
{tokens.total} / {tokens.budget} tokens
</div>
<TokenBreakdown
sections={sections}
tokens={tokens}
used={sectionTotal}
onJump={jumpToSection}
/>
<div className="insights-history">
History: {history.included} of {history.total} actions in context
{history.total > history.included && ' (older history trimmed)'}
{history.oldest_truncated && ' — oldest entry cut mid-text'}
</div>
<CacheReport usage={report.usage} />
{cards.length > 0 && (
<div className="insights-cards">
{cards.map((c, i) => (
<div key={i} className={c.included ? '' : 'dropped'}>
▸ <b>{c.name || '(unnamed card)'}</b> triggered on “{c.keyword}”
{!c.included && ' — dropped (over card budget)'}
</div>
))}
</div>
)}
{report.memories && (
<div className="insights-cards">
{report.memories.error && (
<div className="dropped">⚠ Memory bank: {report.memories.error}</div>
)}
{report.memories.used?.map((m, i) => (
<div key={i}>
▸ memory retrieved ({m.pinned ? 'pinned' : `similarity ${m.similarity.toFixed(2)}`}):
{' '}{m.text.length > 90 ? m.text.slice(0, 90) + '…' : m.text}
</div>
))}
{!report.memories.error && report.memories.used?.length === 0 && (
<div className="dim">Memory bank on — no memories retrieved.</div>
)}
</div>
)}
</div>
{sections.map((s, i) => (
<div
key={i}
id={`ctx-sec-${i}`}
className={`ctx-section ctx-${s.label}`}
style={{ borderLeftColor: sectionColor(s.label) }}
>
<div className="ctx-header" style={{ color: sectionColor(s.label) }}>
<span>{SECTION_LABELS[s.label] || s.label}</span>
<span>
{s.tokens} tok
{sectionTotal > 0 && ` · ${pctLabel((s.tokens / sectionTotal) * 100)}`}
</span>
</div>
<pre>{s.text}</pre>
</div>
))}
<ScriptReport script={report.script} />
<WorldStateReport worldState={report.world_state} />
{report.raw_output && (
<div className="ctx-section">
<div className="ctx-header">
<span>Raw AI output (before state block was stripped)</span>
</div>
<pre>{report.raw_output}</pre>
</div>
)}
</div>
)
}
export { InsightsPanel }
@@ -0,0 +1,151 @@
// The memory bank panel: the entries retrieval can pull into a prompt.
import { useCallback, useEffect, useState } from 'react'
import { api } from '../../../api'
import { AutoTextarea } from '../../../components'
function MemoryRow({ memory, onChange, onDelete }) {
const [editText, setEditText] = useState(null)
const save = () => {
const text = editText.trim()
setEditText(null)
if (text && text !== memory.text) onChange({ text })
}
return (
<div className={`memory-row ${memory.forgotten ? 'forgotten' : ''}`}>
{editText !== null ? (
<div className="action-edit" style={{ margin: 0 }}>
<AutoTextarea autoFocus value={editText} rows={3}
onChange={(e) => setEditText(e.target.value)} />
<div className="edit-buttons">
<button className="primary" onClick={save}>Save</button>
<button onClick={() => setEditText(null)}>Cancel</button>
</div>
</div>
) : (
<>
<div className="memory-text">{memory.text}</div>
<div className="memory-meta">
{memory.pinned && <span className="memory-badge">📌 pinned</span>}
{memory.forgotten && <span className="memory-badge">forgotten</span>}
{!memory.embedded && !memory.forgotten && (
<span className="memory-badge" title="Embedded on the next turn">not embedded yet</span>
)}
{memory.source_start != null && (
<span className="dim">actions {memory.source_start}–{memory.source_end}</span>
)}
<span className="dim">used {memory.use_count}×</span>
<span className="action-tools">
<button title={memory.pinned ? 'Unpin' : 'Pin (always included in context)'}
onClick={() => onChange({ pinned: !memory.pinned })}>📌</button>
{memory.forgotten && (
<button title="Restore this memory"
onClick={() => onChange({ forgotten: false })}>↩</button>
)}
<button title="Edit" onClick={() => setEditText(memory.text)}>✎</button>
<button title="Delete" onClick={onDelete}>✕</button>
</span>
</div>
</>
)}
</div>
)
}
function MemoryPanel({ adventure, setAdventure, refreshKey }) {
const [memories, setMemories] = useState(null)
const [newText, setNewText] = useState('')
const load = useCallback(() => {
api.listMemories(adventure.id).then(setMemories).catch(() => setMemories([]))
}, [adventure.id])
// Summarization runs in the background after a turn, so also refresh shortly after.
useEffect(() => {
load()
const timer = setTimeout(load, 4000)
return () => clearTimeout(timer)
}, [load, refreshKey])
const setFlag = async (field, value) => {
setAdventure({ ...adventure, [field]: value })
await api.updateAdventure(adventure.id, { [field]: value })
}
const change = async (memory, data) => {
const updated = await api.updateMemory(adventure.id, memory.id, data)
setMemories((prev) => prev.map((m) => (m.id === memory.id ? updated : m)))
}
const remove = async (memory) => {
await api.deleteMemory(adventure.id, memory.id)
setMemories((prev) => prev.filter((m) => m.id !== memory.id))
}
const add = async () => {
const text = newText.trim()
if (!text) return
setNewText('')
const memory = await api.createMemory(adventure.id, text)
setMemories((prev) => [...prev, memory])
}
const active = memories?.filter((m) => !m.forgotten) ?? []
const forgotten = memories?.filter((m) => m.forgotten) ?? []
return (
<div>
<label className="script-attach">
<input type="checkbox" checked={adventure.auto_summarize}
onChange={(e) => setFlag('auto_summarize', e.target.checked)} />
<span>Auto-summarization</span>
<span className="dim">memories every 6 actions, Story Summary every 15</span>
</label>
<label className="script-attach">
<input type="checkbox" checked={adventure.memory_bank_enabled}
onChange={(e) => setFlag('memory_bank_enabled', e.target.checked)} />
<span>Memory Bank</span>
<span className="dim">retrieve relevant memories into context (needs an embedding model)</span>
</label>
<div className="page-header" style={{ marginTop: 18 }}>
<h3 style={{ margin: 0 }}>Memories {memories && `(${active.length})`}</h3>
<button onClick={load}>Refresh</button>
</div>
{!memories && <div className="empty" style={{ padding: '12px 0' }}>Loading…</div>}
{memories && active.length === 0 && (
<div className="empty" style={{ padding: '12px 0' }}>
No memories yet. They are generated automatically as you play (from action 12
onward), or add one below.
</div>
)}
{active.map((m) => (
<MemoryRow key={m.id} memory={m}
onChange={(data) => change(m, data)} onDelete={() => remove(m)} />
))}
<div className="input-bar" style={{ marginTop: 10 }}>
<input type="text" value={newText} placeholder="Add a memory manually…"
onChange={(e) => setNewText(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') add() }} />
<button onClick={add}>+ Add</button>
</div>
{forgotten.length > 0 && (
<>
<div className="page-header" style={{ marginTop: 18 }}>
<h3 style={{ margin: 0 }}>Forgotten ({forgotten.length})</h3>
</div>
{forgotten.map((m) => (
<MemoryRow key={m.id} memory={m}
onChange={(data) => change(m, data)} onDelete={() => remove(m)} />
))}
</>
)}
</div>
)
}
export { MemoryPanel }
@@ -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 (
<div>
{adventure.scenario_id != null && (
<div className="plot-source">
<span className="dim">
Copied from its scenario when the adventure began; later scenario edits don't
reach it on their own.
</span>
<button className="linklike" onClick={openRefresh} disabled={planning}
title="Replace this adventure's plot text and scenario story cards with the scenario's current content">
{planning ? 'Checking…' : '⟳ Update from scenario'}
</button>
</div>
)}
{plan && (
<RefreshModal plan={plan} onConfirm={applyRefresh} onCancel={() => setPlan(null)} />
)}
<Field label="Plot Essentials (Memory)" value={adventure.memory}
onChange={(v) => setField('memory', v)} textarea
placeholder="Key facts the AI should always remember." />
<Field label="Author's Note" value={adventure.authors_note}
onChange={(v) => setField('authors_note', v)} textarea rows={2}
placeholder="Style/theme guidance, injected near the end of context." />
<Field label="AI Instructions" value={adventure.ai_instructions}
onChange={(v) => setField('ai_instructions', v)} textarea rows={2}
placeholder="Behavioral guidance for the model." />
<Field label="Story Summary" value={adventure.story_summary}
onChange={(v) => 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." />
<div className="page-header" style={{ marginTop: 18 }}>
<h3 style={{ margin: 0 }}>Story Cards</h3>
<div style={{ display: 'flex', gap: 6 }}>
<button onClick={exportCards} disabled={adventure.story_cards.length === 0}>Export</button>
<button onClick={importCards}>Import</button>
<button onClick={addCard}>+ Add</button>
</div>
</div>
{adventure.story_cards.length === 0 && (
<div className="empty" style={{ padding: '12px 0' }}>No story cards yet.</div>
)}
{adventure.story_cards.map((card) => (
<StoryCardRow key={card.id} card={card}
onChange={updateCard} onDelete={() => deleteCard(card.id)} />
))}
</div>
)
}
export { PlotPanel }
@@ -0,0 +1,116 @@
// The scripts panel: this adventure's copies of the library scripts.
import { useEffect, useState } from 'react'
import { api } from '../../../api'
import { downloadJSON } from '../../../components'
const SCRIPT_HOOKS = [
['Shared library', 'library_js'],
['onInput', 'input_js'],
['onModelContext', 'context_js'],
['onOutput', 'output_js'],
]
function ScriptsPanel({ advId }) {
const [scripts, setScripts] = useState(null)
const [openId, setOpenId] = useState(null)
useEffect(() => {
api.listAdventureScripts(advId).then(setScripts).catch(() => setScripts([]))
}, [advId])
const [syncingId, setSyncingId] = useState(null)
const toggle = async (script) => {
const updated = await api.updateAdventureScript(advId, script.id, { enabled: !script.enabled })
setScripts((prev) => prev.map((s) => (s.id === script.id ? updated : s)))
}
// Pull the latest code from the library script this copy was made from.
const sync = async (script) => {
setSyncingId(script.id)
try {
const updated = await api.syncAdventureScript(advId, script.id)
setScripts((prev) => prev.map((s) => (s.id === script.id ? updated : s)))
} finally {
setSyncingId(null)
}
}
// Download as an import-compatible bundle (matches /scripts export), so demo
// scripts can be forked into your own library via the Scripts page's Import.
const download = (s) => {
downloadJSON(
{
name: s.name,
description: s.description,
library: s.library_js,
input: s.input_js,
context: s.context_js,
output: s.output_js,
},
`${(s.name || 'script').replace(/\W+/g, '-')}.json`,
)
}
if (!scripts) return <div className="empty">Loading…</div>
if (scripts.length === 0) {
return (
<div className="empty">
No scripts on this adventure. Attach scripts to a scenario before starting an
adventure from it.
</div>
)
}
return (
<div>
{scripts.map((s) => {
const hooks = SCRIPT_HOOKS.filter(([, f]) => (s[f] || '').trim())
const open = openId === s.id
return (
<div key={s.id} className="adv-script">
<div className="adv-script-head">
<label className="script-attach" style={{ margin: 0 }}>
<input type="checkbox" checked={s.enabled} onChange={() => toggle(s)} />
<span>{s.name}</span>
</label>
<div style={{ display: 'flex', gap: 12, flexShrink: 0 }}>
{s.out_of_date && (
<button
className="linklike"
title="Replace this adventure's copy with the latest from your script library"
disabled={syncingId === s.id}
onClick={() => sync(s)}
>
{syncingId === s.id ? 'Syncing…' : '⟳ Sync from library'}
</button>
)}
<button className="linklike" onClick={() => setOpenId(open ? null : s.id)}>
{open ? 'Hide code' : 'View code'}
</button>
<button className="linklike" onClick={() => download(s)}>Download</button>
</div>
</div>
{s.description && <div className="dim adv-script-desc">{s.description}</div>}
{open && (
<div className="adv-script-code">
{hooks.length === 0 ? (
<div className="empty">This script has no code.</div>
) : (
hooks.map(([label, f]) => (
<div key={f}>
<div className="adv-script-hook">{label}</div>
<pre>{s[f]}</pre>
</div>
))
)}
</div>
)}
</div>
)
})}
</div>
)
}
export { ScriptsPanel }