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'
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 ?
)
}
// ChatGPT-style ‹ 2/3 › pager under an AI message that has been retried.
//
// The last message can actually be switched (the server restores the stats that
// attempt produced); earlier ones are read-only, because the turns after them
// were written as a continuation of whatever is active now. Browsing an
// earlier one is a local preview, so `onPreview` hands the text up to the story
// renderer rather than the pager drawing it.
function VariantPager({ advId, action, isLast, busy, previewIndex, onPreview, onSwitched, onError }) {
const [variants, setVariants] = useState(null)
const [loading, setLoading] = useState(false)
const count = action.variant_count
const current = previewIndex ?? action.variant_index
async function go(delta) {
const next = current + delta
if (next < 0 || next >= count || loading || busy) return
setLoading(true)
try {
if (isLast) {
onPreview(null)
onSwitched(await api.selectVariant(advId, action.id, next))
} else {
// Fetched once per message, then cached — paging back and forth
// shouldn't re-hit the server.
const list = variants || await api.listVariants(advId, action.id)
if (!variants) setVariants(list)
onPreview(next === action.variant_index
? null
: { actionId: action.id, index: next, text: list[next].text, reasoning: list[next].reasoning })
}
} catch (err) {
onError(err.message)
} finally {
setLoading(false)
}
}
return (
{current + 1}/{count}
{previewIndex !== null && (
earlier attempt — the story continued from {action.variant_index + 1}
)}
)
}
// Compact chips shown under an AI message summarizing what state changed.
function StateChangeChips({ changes }) {
if (!changes?.length) return null
const nice = (s) => String(s).replace(/_/g, ' ')
return (
)
}
const LEGEND_VISIBLE = 6 // enough to cover what actually moves the budget
// Where the prompt's tokens actually went: one stacked bar scaled to the
// budget (so leftover width IS the remaining headroom) plus a matching legend.
// Bar and legend both run biggest-share-first — the order that answers "what is
// eating my context?" — rather than the prompt order the sections below use.
function TokenBreakdown({ sections, tokens, used, onJump }) {
const [hovered, setHovered] = useState(null)
const [expanded, setExpanded] = useState(false)
if (used <= 0) return null
const budget = tokens.budget || 0
// Over budget there is no headroom to draw, so the bar scales to what the
// prompt actually costs and the total line above it carries the warning.
const scale = Math.max(budget, used)
const free = Math.max(0, budget - used)
const ranked = sections
.map((s, i) => ({ ...s, i, pct: (s.tokens / used) * 100 }))
.filter((s) => s.tokens > 0)
.sort((a, b) => b.tokens - a.tokens)
// The panel is narrow, so the legend is one column: list the sections that
// actually move the budget and fold the long tail behind a count.
const shown = expanded ? ranked : ranked.slice(0, LEGEND_VISIBLE)
const rest = ranked.slice(shown.length)
const restPct = rest.reduce((n, s) => n + s.pct, 0)
const describe = (s) =>
`${SECTION_LABELS[s.label] || s.label} — ${s.tokens} tok · ${pctLabel(s.pct)}`
return (
<>
>
)
}
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)
// Read-only browsing of an earlier attempt at a past turn (see VariantPager).
// One at a time; null when every message is showing its active version.
const [preview, setPreview] = useState(null)
// 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)
// 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],
)
// 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])
// 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
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) {
// 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.
} 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()
if (type === 'continue') {
// Continue never consumes typed text — leave it in the box.
runTurn((signal) => api.sendAction(id, { type: 'continue', text: '' }, handleEvent, signal))
return
}
const payload = { type: text ? type : 'continue', text }
setInput('')
runTurn((signal) => api.sendAction(id, payload, handleEvent, signal))
}
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)
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)
})
async function saveEdit() {
const { id: actionId, text } = editing
setEditing(null)
try {
const updated = await api.updateAction(id, actionId, text)
setActions((prev) => prev.map((a) => (a.id === actionId ? updated : a)))
} catch (err) {
setToast({ text: err.message, isError: true })
}
}
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 (
{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) => {
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
return editing?.id === action.id ? (