/* The story screen. * * `BROWSER-UX-SPEC.md` §98 sets the priority this file is arranged around: * transcript, input, Undo/Redo/Retry, Save Point, current state, then * diagnostics. The transcript and the composer are the page; everything else * lives in a panel that starts closed and can be closed again. * * The session state below is inherited and largely unchanged, because it was * right: takes, previews, the pinned take, the paging window and the * server-reported history flags all work the way M3-M7 established, and M8 is * not a rewrite of them. What changed is what the reader is shown and what they * are allowed to do. The substantive behavioural changes: * * - **Failures are classified rather than toasted** (§12). A failed turn * leaves the typed text in the box, drops any partial prose from the * transcript, and explains which of §71's five failures happened. * - **Editing explains itself** (§16, §17). Changing an earlier turn with * story after it now says what will happen — a different continuation, the * existing one retained — before it happens, rather than silently forking. * - **Branch controls are gone from the reader's view** (§27). The panel that * drew the tree is not reachable from here. The mechanism underneath is * untouched: takes, divergence and retained futures all still work, and the * endpoints are still tested. * - **The RPG stat drawer only opens for a campaign that has stats** (§18), * which a campaign created in M8's setup flow does not. */ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { api } from '../../api' import { AutoTextarea, useToast } from '../../components' import { ConfirmDialog } from '../../Dialog' import { classifyError } from '../../errors' import { ExternalLinkDialog } from '../../ExternalLinkDialog' import { ModelSetupNotice } from '../../ModelSetupNotice' import { ModelStatusBadge } from '../../ModelStatusBadge' import { blocksPlay, useModelStatus } from '../../modelStatus' import { Composer } from './Composer' import { FailureNotice } from './FailureNotice' import { SidePanel, PANELS } from './SidePanel' import { Transcript, displayText } from './Transcript' const PLAYER_TYPES = ['do', 'say', 'story'] export default function Play() { const { id } = useParams() const navigate = useNavigate() const toast = useToast() const { status: modelStatus, refresh: refreshModelStatus } = useModelStatus() const [adventure, setAdventure] = useState(null) const [actions, setActions] = useState([]) const [input, setInput] = useState('') const [direction, setDirection] = useState(false) const [streaming, setStreaming] = useState(null) const [reasoningStream, setReasoningStream] = useState(null) const [busy, setBusy] = useState(false) const [failure, setFailure] = useState(null) // classified, from errors.js const [partial, setPartial] = useState(null) // prose that streamed then failed const [editing, setEditing] = useState(null) const [editWarning, setEditWarning] = useState(null) // pending edit awaiting confirmation const [panel, setPanel] = useState(null) const [stateKey, setStateKey] = useState(0) const [inspectActionId, setInspectActionId] = useState(null) const [preview, setPreview] = useState(null) const [pinned, setPinned] = useState(null) const [takesKey, setTakesKey] = useState(0) const [total, setTotal] = useState(0) const [hasMore, setHasMore] = useState(false) const [loadingOlder, setLoadingOlder] = useState(false) const [history, setHistory] = useState({ undo: false, redo: false }) const [externalLink, setExternalLink] = useState(null) // Set when a retrieved passage in the Context panel is clicked, so the // Knowledge panel opens on that source instead of on the whole list (§57). const [focusSourceId, setFocusSourceId] = useState(null) const storyEndRef = useRef(null) const abortRef = useRef(null) const pinnedRef = useRef(true) const restoreScrollRef = useRef(null) const loadingOlderRef = useRef(false) const retryAfterRef = useRef(0) // What was streaming when a failure arrived, read in the catch. State would // still hold the pre-failure value there, so this is a ref. const streamedRef = useRef('') // The words the reader submitted for the turn in flight, so A05 can give them // back. A ref because the SSE error path runs inside `handleEvent`, which must // not re-created on every keystroke. const keepInputRef = useRef(null) const firstNarrationId = useMemo( () => actions.find((a) => a.type === 'start' || a.type === 'ai')?.id ?? null, [actions], ) const previewCutoff = useMemo( () => (preview ? actions.findIndex((a) => a.id === preview.actionId) : -1), [preview, actions], ) const waitingForFirstToken = streaming === '' && reasoningStream === null const lastIsAi = actions.length > 0 && actions[actions.length - 1].type === 'ai' 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)) setHistory({ undo: !!adv.can_undo, redo: !!adv.can_redo }) }) .catch(() => navigate('/')) }, [id, navigate]) const noteHistory = useCallback((page) => { setHistory({ undo: !!page.can_undo, redo: !!page.can_redo }) }, []) const adoptWindow = useCallback((page) => { pinnedRef.current = true setPreview(null) setPinned(null) setActions(page.actions) setTotal(page.total) setHasMore(page.has_more) noteHistory(page) setStateKey((k) => k + 1) }, [noteHistory]) 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) { pinnedRef.current = false restoreScrollRef.current = { height: document.documentElement.scrollHeight, top: window.scrollY, } setActions((prev) => { 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 so scrolling can try again, but hold off: parked // near the top, momentum scrolling fires this many times a second. retryAfterRef.current = Date.now() + 3000 } finally { loadingOlderRef.current = false setLoadingOlder(false) } }, [actions, hasMore, id, noteHistory]) 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 if (window.scrollY < 400) loadOlder() } window.addEventListener('scroll', onScroll, { passive: true }) return () => window.removeEventListener('scroll', onScroll) }, [loadOlder]) useEffect(() => { if (pinnedRef.current) { window.scrollTo({ top: document.documentElement.scrollHeight }) } }, [actions, streaming, reasoningStream]) /** * One failed turn, however the failure arrived. * * There are two paths and they must behave identically. A transport or HTTP * failure throws, and `runTurn`'s catch sees it. A *generation* failure does * not throw at all: the server reports it as an SSE `error` event and the * request then completes normally, so the catch never runs. * * That difference was a real A05 defect. The failure notice said "what you * typed is still in the box" while the box was empty, and the header went on * claiming the model was connected — because restoring the input and * re-testing the model both lived in the catch alone. Found by driving a real * provider failure in a browser. */ const failTurn = useCallback((detail) => { const classified = classifyError(detail) setStreaming(null) setReasoningStream(null) setFailure(classified) setPartial(streamedRef.current || null) // A05: the player's words come back so the turn can simply be sent again. if (keepInputRef.current !== null) { setInput(keepInputRef.current) keepInputRef.current = null } // A model problem may well have changed since the status was last checked — // re-test, so the header stops claiming Connected while the notice says the // opposite. if (classified.kind === 'model') refreshModelStatus() }, [refreshModelStatus]) const handleEvent = useCallback((event) => { if (event.type === 'player') { setActions((prev) => [...prev, event.action]) setTotal((n) => n + 1) } else if (event.type === 'chunk') { streamedRef.current += event.text 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) setHistory({ undo: true, redo: false }) } else if (event.type === 'error') { failTurn(event.detail) } }, [failTurn]) async function runTurn(run, { keepInput = null } = {}) { const controller = new AbortController() abortRef.current = controller keepInputRef.current = keepInput setBusy(true) setFailure(null) setPartial(null) streamedRef.current = '' setStreaming('') pinnedRef.current = true try { await run(controller.signal) } catch (err) { if (err.name === 'AbortError') { toast('Generation stopped.') setStreaming(null) setReasoningStream(null) } else { // Whatever streamed is not story. It was never added to `actions` — // only a `done` event does that — but the streaming pane has to be // cleared or it reads as accepted text. `failTurn` does that too. failTurn(err.message) } } finally { abortRef.current = null keepInputRef.current = null setStreaming(null) setReasoningStream(null) setBusy(false) } } function stopGeneration() { abortRef.current?.abort() } function send(type) { const text = input.trim() const after_id = preview?.takeId const run = (payload, keep) => runTurn(async (signal) => { try { await api.sendAction(id, payload, handleEvent, signal) } finally { if (after_id) await resync() } }, { keepInput: keep }) if (preview) setPinned({ ...preview, written: true }) setPreview(null) if (type === 'continue') { run({ type: 'continue', text: '', after_id }, null) return } const payload = { type: text ? type : 'continue', text, after_id } setInput('') run(payload, text) } 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) { // The optimistically removed action may still exist server-side, so // resync rather than guess. 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 moveHead(call) { setFailure(null) setPreview(null) setPinned(null) try { const page = await call(id) setActions(page.actions) setTotal(page.total) setHasMore(page.has_more) noteHistory(page) setStateKey((k) => k + 1) } catch (err) { setFailure(classifyError(err.message)) } } const undo = () => moveHead(api.undo) const redo = () => moveHead(api.redo) // A take-edit belongs to the preview that opened it. useEffect(() => { if (editing?.take && preview?.takeId !== editing.id) setEditing(null) }, [editing, preview]) /** * True when accepting this edit will start a different continuation. * * That is the case §16/§17 require an explanation for: story exists after the * turn being changed, so the existing continuation is left behind (retained, * not deleted). Editing the newest turn changes nothing downstream and needs * no warning. */ const hasStoryAfter = useCallback((actionId) => { const at = actions.findIndex((a) => a.id === actionId) return at !== -1 && at < actions.length - 1 }, [actions]) /** * Splits a stored player action into its marker and its words. * * The editor shows the words. The marker goes back on when the edit is saved, * because it is what identifies a player turn in the replayed prompt — an * edit that dropped it would quietly turn the reader's action into narration * as far as the next turn's context is concerned. */ function splitMarker(target) { const raw = target.text ?? '' const shown = displayText({ type: target.kind }, raw) return { marker: raw.slice(0, raw.length - shown.length), text: shown } } function beginEdit(target) { // A take being read is a leaf by construction, so editing it never strands // a continuation — no warning for that case. const prepared = { ...target, ...splitMarker(target) } if (target.take || !hasStoryAfter(target.id)) { setEditing(prepared) return } // Story exists after this turn, so the reader is about to change something // the rest of the story was written from. Which operation that *is* differs // by whose turn it was, and getting it wrong is D09's whole subject. // // A narrator turn goes through `PATCH`, because `_edit_narration` already // forks it per `STORY-BRANCH-SEMANTICS.md` §§14-15: the corrected prose // becomes a new continuation, downstream state is re-evaluated, and the old // narration keeps its own future. // // A player turn must **not**. `PATCH` on a player row rewrites it in place // and re-evaluates nothing — a correction, not a new continuation — and // refuses outright when displaced history hangs off it. Either way D09 is // unmet: no new continuation is created. Replaying the turn with the new // words is what creates one, and that is `addTake` (SP9). // // The confirmation says "this will continue the story differently… what you // have already read is kept". Routing a player edit through `PATCH` made // that sentence false. Found by the browser run: the dialog appeared, the // editor held the new text, and the edited action never reached the story. const fork = target.kind !== 'ai' setEditWarning({ ...prepared, mode: fork ? 'refork' : 'edit' }) } function beginRetake(action, previewing) { if (action.type === 'ai') { if (hasStoryAfter(action.id)) { setEditWarning({ id: action.id, text: '', kind: action.type, mode: 'retake' }) return } addTake(action.id, '') } else { const seed = previewing ? previewing.text : action.text const prepared = splitMarker({ text: seed, kind: action.type }) if (hasStoryAfter(action.id)) { setEditWarning({ id: action.id, ...prepared, kind: action.type, mode: 'refork', }) return } setEditing({ id: action.id, ...prepared, fork: true, kind: action.type }) } } function acceptWarning() { const pending = editWarning setEditWarning(null) if (!pending) return if (pending.mode === 'retake') addTake(pending.id, '') else if (pending.mode === 'refork') setEditing({ ...pending, fork: true }) else setEditing(pending) } async function saveEdit() { const { id: actionId, fork, take, marker = '' } = editing // The marker the action was stored with, restored around the edited words. const text = marker + editing.text setEditing(null) if (fork) { addTake(actionId, text) return } try { const updated = await api.updateAction(id, actionId, text) // D10. Correcting narrator prose is not a row rewrite: the server answers // with a different node, the old narration keeps its future on the line // it was written on, and the state after it is re-evaluated. Only the // server can say what the transcript is now. if (updated.id !== actionId) { await resync() toast('Edited. The story state after it was recalculated.') return } 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))) } setStateKey((k) => k + 1) } catch (err) { setFailure(classifyError(err.message)) } } function addTake(actionId, text) { setPreview(null) runTurn(async (signal) => { try { await api.addTake(id, actionId, text, handleEvent, signal) } finally { await resync() } }) } async function resync() { try { const adv = await api.getAdventure(id) 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 }) setStateKey((k) => k + 1) } catch { /* stale beats wrong */ } } function inspect(actionId) { setInspectActionId(actionId) setPanel('context') } const copy = useCallback((text) => { navigator.clipboard?.writeText(text) .then(() => toast('Copied.')) .catch(() => toast('Could not copy.', 'error')) }, [toast]) if (!adventure) return null const editor = (
What the story believes after this point will be worked out again from your version.
)}Changing this will continue the story differently from here.
Everything you have already read after this point is kept. It stops being the story you are in, and you can come back to it with Undo or from a Save Point.
{editWarning.kind === 'ai' && (What the story believes — where people are, what they know, what they carry — will be worked out again from your version.
)}