Files
interactive-story/frontend/src/pages/Play/index.jsx
T
JesseMarkowitzandClaude Opus 5 b7005e6fdd M5: genre-neutral authoritative narrative state, with review corrections
Replaces AI-DnD's RPG relative-delta world state with the genre-neutral typed
narrative state of ADR 010: explicit, absolute, allowlisted events proposed by
the model, validated by the application, applied to one authoritative document,
and snapshotted per position so restore stays a row read.

This commit includes the corrective pass that followed the independent review
in planning/reports/M5-IMPLEMENTATION-REPORT.md. The invariant it exists to
hold is:

    visible active transcript position == stored head == authoritative state

Narrator editing (D10, STORY-BRANCH-SEMANTICS §§14-15)

  A narrator edit no longer rewrites a row. It returns to the state before the
  turn, takes the reader's exact text as the accepted narration, re-derives the
  state that text implies, and becomes a new active continuation — while the
  original narration keeps its words, its live flag and its whole future as
  retained history. At the tip the correction is another take; with story below
  it, it forks. No new history machinery: this is the existing fork/take/head
  path with the reader's text in place of a generated reply. The §14A refusal
  is therefore gone for narrator turns, and remains only for player input.

Pre-M5 positions

  Migration 88 backfills the empty narrative document onto every action written
  before M5, and a missing snapshot now restores the empty document instead of
  leaving the previous position's state standing. Restoring to an old Save
  Point no longer leaves a later position's entities and facts on screen.

Narrator context

  Replayed history carries prose only; the machine-readable block is no longer
  reconstructed into past turns, where it contradicted the authoritative state
  in the same prompt. A fact withdrawn by a manual correction is now named as
  no longer true, with the reader's reason, rather than silently dropped.

Also

  - state_changes joins the action-list bulk read, removing one query per row.
  - Extraction takes only the application's own protocol payload: an ordinary
    ```json or ```python block in a story survives, and a mangled proposal
    still does not reach the reader.

Planning: ADR 013 records the authoritative document shape; §§14-15/14A, D10,
C04 and BUILD-MILESTONES are updated to describe what exists. Debt is recorded
against M8 (scenario editor UX) and M9 (export of the audit trail).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
2026-09-05 07:01:50 -04:00

854 lines
38 KiB
React

/* 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 the frontend test runner M8 adds
* (`planning/BUILD-MILESTONES.md`). 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 { 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 { SavePointPanel } from './panels/SavePointPanel'
import { StatePanel } from './panels/StatePanel'
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
? <b key={match.index}>{match[1]}</b>
: <i key={match.index}>{match[2]}</i>)
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 (
<details className="reasoning" open={streaming || undefined}>
<summary>💭 Reasoning{streaming ? '…' : ''}</summary>
<div className="reasoning-text">{text}</div>
</details>
)
}
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 | 'state' | 'plot' | 'memory' | 'branches' | 'savepoints' | '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)
// Whether Undo and Redo have anywhere to go, as the server last reported it
// (M3). Neither is derivable here: Undo stops at the campaign opening, which
// may be off the top of the loaded window, and Redo depends on the retained
// future, which the transcript is never sent. So the flags ride on every
// window the server hands back and this holds the newest answer.
const [history, setHistory] = useState({ undo: false, redo: 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))
// A campaign closed while undone reopens undone, so the controls have
// to be told before the reader can press one.
setHistory({ undo: !!adv.can_undo, redo: !!adv.can_redo })
})
.catch(() => navigate('/'))
}, [id, navigate])
// Record what the server says the history controls can do. Every response
// that carries a window carries them, so this is called wherever one lands.
const noteHistory = useCallback((page) => {
setHistory({ undo: !!page.can_undo, redo: !!page.can_redo })
}, [])
// 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)
noteHistory(page)
// The world state comes 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)
}, [noteHistory])
// 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)
noteHistory(page)
} 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, noteHistory])
// 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 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)
// A turn was accepted, so there is something to undo and nothing left to
// redo: writing below a moved-back head is the divergence that retires
// the old future, and writing at the tip never had a future in front of
// it. Set here rather than fetched, because the turn arrives over SSE and
// this is the same answer the server would give.
setHistory({ undo: true, redo: false })
} else if (event.type === 'error') {
setStreaming(null)
setReasoningStream(null)
setToast({ text: event.detail, isError: true })
}
}, [])
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
}
})
}
// Undo and Redo differ only in which way the head moves, so they share
// everything else: the same window shape comes back, the same reader state is
// dropped, and the same world state has been restored underneath.
async function moveHead(call) {
setToast(null)
setPreview(null)
setPinned(null)
try {
// A window, not the whole story — these are the actions most likely to be
// repeated several times running, so they must not re-fetch everything.
const page = await call(id)
setActions(page.actions)
setTotal(page.total)
setHasMore(page.has_more)
noteHistory(page)
// The story is at a different position, so the state panels are showing
// the numbers of a turn that is no longer where the story ends.
setStateKey((k) => k + 1)
} catch (err) {
setToast({ text: err.message, isError: true })
}
}
const undo = () => moveHead(api.undo)
const redo = () => moveHead(api.redo)
// Ctrl+Z undo / Ctrl+Shift+Z redo / Ctrl+R retry, ignored while typing.
useEffect(() => {
const lastIsAi = actions.length > 0 && actions[actions.length - 1].type === 'ai'
const onKey = (e) => {
if (!(e.ctrlKey || e.metaKey) || busy) return
if (e.target.closest?.('input, textarea, select, [contenteditable]')) return
if (e.key.toLowerCase() === 'z' && e.shiftKey && history.redo) {
e.preventDefault()
redo()
} else if (e.key.toLowerCase() === 'z' && !e.shiftKey && history.undo) {
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)
// Correcting narrator prose the story is telling is not a rewrite of a
// row: STORY-BRANCH-SEMANTICS §§14-15 make it a new continuation, so the
// server answers with a different node and the old narration keeps its
// future on the line it was written on. The shape of the story changed,
// and only the server can say what the transcript is now.
if (updated.id !== actionId) {
await resync()
return
}
// 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)))
}
// Editing a take the story is not telling changes that take's words and
// nothing else, but the panel is keyed on this and would otherwise keep
// drawing what it last read.
setStateKey((k) => k + 1)
} 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))
setHistory({ undo: !!adv.can_undo, redo: !!adv.can_redo })
// The branch and the world state can both 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'
return (
<div className={`play-layout ${panel ? 'with-panel' : ''}`}>
{/* 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. */}
<WorldStateDrawer advId={id} refreshKey={`${actions.length}:${stateKey}`}
personaName={adventure?.persona_name} />
<div className="page play-page">
<div className="page-header">
<h1>{adventure.title}</h1>
<div className="panel-toggles">
<button className={panel === 'state' ? 'active' : ''}
onClick={() => setPanel(panel === 'state' ? null : 'state')}>Story State</button>
<button className={panel === 'plot' ? 'active' : ''}
onClick={() => setPanel(panel === 'plot' ? null : 'plot')}>Plot</button>
<button className={panel === 'memory' ? 'active' : ''}
onClick={() => setPanel(panel === 'memory' ? null : 'memory')}>Memory</button>
<button className={panel === 'branches' ? 'active' : ''}
onClick={() => setPanel(panel === 'branches' ? null : 'branches')}>Branches</button>
<button className={panel === 'savepoints' ? 'active' : ''}
onClick={() => setPanel(panel === 'savepoints' ? null : 'savepoints')}>Save Points</button>
<button className={panel === 'insights' ? 'active' : ''}
onClick={() => { setInspectActionId(null); setPanel(panel === 'insights' ? null : 'insights') }}>
Insights
</button>
</div>
</div>
<div className="story">
{actions.length === 0 && streaming === null && (
<div className="empty">A blank page. Type something below to begin your story.</div>
)}
{/* 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 && (
<div className="story-earlier">
{loadingOlder ? (
<span className="dim">Turning back the pages…</span>
) : (
<button type="button" onClick={loadOlder}>
{Math.max(total - actions.length, 0)} earlier
{total - actions.length === 1 ? ' moment' : ' moments'}
</button>
)}
</div>
)}
{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 ? (
<div key={action.id} className="action-edit">
<AutoTextarea
autoFocus
value={editing.text}
onChange={(e) => setEditing({ ...editing, text: e.target.value })}
/>
<div className="edit-buttons">
<button className="primary" onClick={saveEdit}>Save</button>
<button onClick={() => setEditing(null)}>Cancel</button>
</div>
</div>
) : (
<Fragment key={action.id}>
{sceneBreak && <div className="scene-break" aria-hidden="true">❖</div>}
<div className={`action ${isPlayer ? 'player' : ''}${opening ? ' opening' : ''}`}>
<ReasoningBlock text={shown ? shown.reasoning : action.reasoning} />
{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 && (
<StateChangeChips changes={action.world_changes} />
)}
{/* 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. */}
<TakePager
advId={id}
action={action}
busy={busy}
// The pinned take counts as the one on screen, so the
// ordinal matches the words above it.
preview={shown}
takesKey={takesKey}
onPreview={setPreview}
onSwitchedBranch={adoptWindow}
onError={(message) => setToast({ text: message, isError: true })}
/>
{!busy && (
<span className="action-tools">
{action.type === 'ai' && (
<button title="View the exact prompt that produced this"
onClick={() => inspect(action.id)}>🔍</button>
)}
{/* 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. */}
<button title="Edit"
onClick={() => setEditing(previewing
? { id: previewing.takeId, text: previewing.text, take: true }
: { id: action.id, text: action.text })}>✎</button>
{/* 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' && (
<button
title={action.type === 'ai'
? 'Another take on this turn'
: 'Say this differently, and keep both'}
onClick={() => (action.type === 'ai'
? addTake(action.id, '')
: setEditing({
id: action.id,
text: previewing ? previewing.text : action.text,
fork: true,
}))}
>⑂</button>
)}
<button title="Delete" onClick={() => removeAction(action.id)}>✕</button>
</span>
)}
</div>
</Fragment>
)
})}
{waitingForFirstToken && (
<div className="thinking" role="status">
<i /><i /><i />
<span>Weaving</span>
</div>
)}
{streaming !== null && !waitingForFirstToken && (
<div className="action streaming">
<ReasoningBlock text={reasoningStream} streaming />
{renderEmphasis(streaming)}
<span className="cursor">▋</span>
</div>
)}
<div ref={storyEndRef} />
</div>
<div className="play-controls">
<div className="turn-buttons">
<button onClick={() => send('continue')} disabled={busy}>Continue ▸</button>
<button onClick={retry} disabled={busy || !lastIsAi} title="Ctrl+R">↻ Retry</button>
<button onClick={undo} disabled={busy || !history.undo} title="Ctrl+Z">↶ Undo</button>
{/* Undo keeps what it steps back over, so this walks the story
forward into it again. Disabled once a new turn has been written
from here: that future is retained, but it is no longer the
continuation this story tells. */}
<button onClick={redo} disabled={busy || !history.redo} title="Ctrl+Shift+Z">↷ Redo</button>
{/* Save Point sits with the history controls because that is what
it is: a name for a position Undo and Redo move between. The
button opens the panel, where the name is typed — the moment it
saves is wherever the story is being read, so there is nothing
to choose first. */}
<button onClick={() => setPanel('savepoints')} disabled={busy}
title="Name this moment so you can come back to it">⚑ Save Point</button>
</div>
<div className="input-bar">
<div className="mode-select">
{MODES.map((m) => (
<button key={m} className={mode === m ? 'active' : ''}
onClick={() => setMode(m)} disabled={busy}>
{m[0].toUpperCase() + m.slice(1)}
</button>
))}
</div>
<textarea
ref={inputRef}
rows={1}
value={input}
disabled={busy}
placeholder={
mode === 'do' ? 'What do you do?'
: mode === 'say' ? 'What do you say?'
: 'What happens next?'
}
onChange={(e) => setInput(e.target.value)}
// Enter sends; Shift+Enter inserts a newline.
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && !busy) { e.preventDefault(); send() }
}}
/>
{busy ? (
<button className="danger" onClick={stopGeneration}>■ Stop</button>
) : (
<button className="primary" onClick={() => send()}>Send</button>
)}
</div>
</div>
</div>
{panel && (
<div className="side-panel">
<div className="side-panel-header">
<h2>{{
state: 'Story State', plot: 'Plot Components', memory: 'Memory Bank',
branches: 'Branches', savepoints: 'Save Points', insights: 'Insights',
}[panel]}</h2>
<button onClick={() => setPanel(null)}>✕</button>
</div>
{panel === 'state' ? (
// Keyed on the same signal the other live panels use, so the state
// follows Undo, Redo and a Save Point restore — what it shows is
// the state at the position being read, not at the newest turn.
<StatePanel
advId={id}
refreshKey={`${actions.length}:${stateKey}`}
onCorrected={() => setStateKey((k) => k + 1)}
onError={(message) => setToast({ text: message, isError: true })}
/>
) : panel === 'plot' ? (
<PlotPanel adventure={adventure} setAdventure={setAdventure}
onWorldStateChanged={() => setStateKey((k) => k + 1)} />
) : panel === 'memory' ? (
<MemoryPanel adventure={adventure} setAdventure={setAdventure}
// The bank is adventure-wide, so a switch does not change it —
// but deleting a branch deletes the memories that hung off it,
// and that happens without a turn being played.
refreshKey={`${actions.length}:${stateKey}`} />
) : panel === 'savepoints' ? (
// Restoring one moves the story exactly as Undo and Redo do, so it
// adopts the returned window the same way a branch switch does —
// the state panels are showing another position's numbers until
// they re-read.
<SavePointPanel
advId={id}
refreshKey={`${actions.length}:${stateKey}`}
onRestored={adoptWindow}
onError={(message) => setToast({ text: message, isError: true })}
/>
) : panel === 'branches' ? (
<BranchPanel
advId={id}
// Not `actions.length` alone. A fork taken from the story column
// replaces one window with another of the same size, so the
// length is unchanged and the panel would go on showing a tree
// with one branch in it while the story is being read on a
// second. `stateKey` is bumped by adoptWindow, which is exactly
// the two operations that move the head.
refreshKey={`${actions.length}:${stateKey}`}
onSwitched={adoptWindow}
onTreeChanged={() => 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.
<InsightsPanel advId={id} inspectActionId={inspectActionId}
onClearInspect={() => setInspectActionId(null)}
refreshKey={`${actions.length}:${stateKey}`} />
)}
</div>
)}
{toast && (
<div className={`play-toast ${toast.isError ? '' : 'ok'}`}>
{toast.text}
{toast.isError && (
<button style={{ marginLeft: 12 }} onClick={retry}>Retry</button>
)}
<button style={{ marginLeft: 8 }} onClick={() => setToast(null)}>✕</button>
</div>
)}
</div>
)
}