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