Put the tree on the screen
The pager could only step between attempts, and stepping has nothing to say about the thing the tree exists for: taking a path the story moved past and keeping both. Every attempt has been its own node since SP4, so a chip is a node now, and "take this path" forks — or simply switches, when the turn is still the tip and its attempts are leaves nobody has built on. Beside it, a Branches panel: every line the story has taken, with where each left its parent and where it ends, and switch, rename and delete-with-confirm. It sits with Plot/Memory/Scripts/Insights rather than inventing a new place to put a rail. An unnamed branch is drawn from its fork depth, never from its position in the list — a position shifts the moment a branch above it goes. A spatial per-node map was considered and deliberately not built. At the size this has to be verified against it is a second windowing problem, and it can be added later without a new endpoint, since the rail and a map read the same GET /branches. VariantOut grows an id because a fork is addressed by the node being taken, not by an ordinal in a group that renumbers. Driven by hand against the 602-action fixture, which found one bug that no test could: the panel refreshed on actions.length, and a fork swaps a 60-action window for another 60-action window, so it went on drawing a one-branch tree while the story was already on the second. It keys off the counter adoptWindow bumps now. The scroll path was driven at the same time — three prepends of ~16,200 px, the same node holding viewport top 792 to 787, never thrown to the end. That closes the standing gap in this project. Console clean. 396 tests, build clean, no new lint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015H5qiyiR7gtFQaoDphHZ3g
This commit is contained in:
committed by
Parth
co-authored by
Claude Opus 5
parent
cf3d52171e
commit
84827f0f37
@@ -110,6 +110,23 @@ export const api = {
|
||||
method: 'POST', body: JSON.stringify({ index }),
|
||||
}),
|
||||
|
||||
// The story tree (Phase 14). One request draws the whole shape however many
|
||||
// forks there are. The three that change it answer with the story as it now
|
||||
// stands, so the caller replaces its window instead of reloading everything.
|
||||
listBranches: (advId) => request(`/adventures/${advId}/branches`),
|
||||
switchBranch: (advId, branchId) =>
|
||||
request(`/adventures/${advId}/branches/${branchId}/switch`, { method: 'POST' }),
|
||||
renameBranch: (advId, branchId, name) =>
|
||||
request(`/adventures/${advId}/branches/${branchId}`, {
|
||||
method: 'PATCH', body: JSON.stringify({ name }),
|
||||
}),
|
||||
deleteBranch: (advId, branchId) =>
|
||||
request(`/adventures/${advId}/branches/${branchId}`, { method: 'DELETE' }),
|
||||
// Take the story down one attempt. A fork only when it has to be: while the
|
||||
// attempts are still at the tip they are leaves, and the server switches.
|
||||
forkFromAttempt: (advId, actionId) =>
|
||||
request(`/adventures/${advId}/actions/${actionId}/fork`, { method: 'POST' }),
|
||||
|
||||
sendAction: (advId, payload, handlers, signal) =>
|
||||
streamSSE(`/adventures/${advId}/actions`, payload, handlers, signal),
|
||||
retry: (advId, handlers, signal) => streamSSE(`/adventures/${advId}/retry`, {}, handlers, signal),
|
||||
|
||||
+114
-20
@@ -426,43 +426,137 @@ button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: n
|
||||
}
|
||||
.story .action-tools button:hover { color: var(--text); }
|
||||
|
||||
/* Retry-history pager under an AI beat. Stays quiet until hovered — it's a
|
||||
footnote on the message, not part of the prose. */
|
||||
.variant-pager {
|
||||
/* The attempts at one AI beat. Stays quiet until hovered — it's a footnote on
|
||||
the message, not part of the prose. */
|
||||
.attempts {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 6px;
|
||||
font-family: var(--font-ui);
|
||||
opacity: 0.45;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.story .action:hover .variant-pager,
|
||||
.variant-pager:focus-within { opacity: 1; }
|
||||
.variant-pager button {
|
||||
padding: 0 7px;
|
||||
font-size: 0.9rem;
|
||||
.story .action:hover .attempts,
|
||||
.attempts:focus-within { opacity: 1; }
|
||||
.attempt-chip {
|
||||
padding: 3px 10px;
|
||||
font-size: 0.73rem;
|
||||
line-height: 1.5;
|
||||
border-radius: 999px;
|
||||
color: var(--text-dim);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.variant-pager button:hover:not(:disabled) { color: var(--accent-bright); border-color: var(--border); }
|
||||
.variant-pager button:disabled { opacity: 0.35; cursor: default; }
|
||||
.variant-count {
|
||||
font-size: 0.74rem;
|
||||
color: var(--text-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 30px;
|
||||
text-align: center;
|
||||
.attempt-chip:hover:not(:disabled) { color: var(--text); border-color: var(--border-bright); }
|
||||
.attempt-chip[aria-pressed="true"] {
|
||||
color: var(--bg);
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.variant-note {
|
||||
margin-left: 4px;
|
||||
.attempt-chip:disabled { opacity: 0.35; cursor: default; }
|
||||
/* Dashed until hovered: taking a path the story moved past creates a branch,
|
||||
so it should not look like the same weight of click as browsing one. */
|
||||
.take-path {
|
||||
padding: 3px 10px;
|
||||
font-size: 0.73rem;
|
||||
line-height: 1.5;
|
||||
color: var(--accent);
|
||||
background: transparent;
|
||||
border: 1px dashed var(--accent-dim);
|
||||
}
|
||||
.take-path:hover:not(:disabled) { color: var(--accent-bright); border-style: solid; }
|
||||
.take-path:disabled { opacity: 0.35; cursor: default; }
|
||||
.attempt-note {
|
||||
margin-left: 2px;
|
||||
font-size: 0.72rem;
|
||||
font-style: italic;
|
||||
color: var(--accent-dim);
|
||||
}
|
||||
|
||||
/* ---------- Branches panel (Phase 14, SP7) ---------- */
|
||||
|
||||
.panel-empty {
|
||||
padding: 18px 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.branch-panel { display: flex; flex-direction: column; gap: 14px; }
|
||||
.branch-intro {
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.branch-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.branch-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
background: var(--bg-input);
|
||||
}
|
||||
.branch-row.here {
|
||||
border-color: var(--accent-dim);
|
||||
background: rgba(212, 169, 78, 0.07);
|
||||
}
|
||||
.branch-head { display: flex; align-items: center; gap: 7px; }
|
||||
.branch-glyph { color: var(--border-bright); font-size: 0.85rem; }
|
||||
.branch-row.here .branch-glyph { color: var(--accent); }
|
||||
.branch-name {
|
||||
font-family: var(--font-story);
|
||||
font-size: 0.98rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.branch-row.here .branch-name { color: var(--accent-bright); }
|
||||
.branch-here {
|
||||
margin-left: auto;
|
||||
font-size: 0.64rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-dim);
|
||||
}
|
||||
.branch-rename {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 3px 7px;
|
||||
font-family: var(--font-story);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.branch-meta {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.branch-tools, .branch-confirm { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; }
|
||||
.branch-tools button, .branch-confirm button {
|
||||
padding: 3px 9px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.branch-tools button:hover:not(:disabled),
|
||||
.branch-confirm button:hover:not(:disabled) {
|
||||
color: var(--accent-bright);
|
||||
border-color: var(--border-bright);
|
||||
}
|
||||
.branch-tools button.danger:hover:not(:disabled),
|
||||
.branch-confirm button.danger:hover:not(:disabled) {
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
.branch-tools button:disabled, .branch-confirm button:disabled { opacity: 0.4; cursor: default; }
|
||||
.branch-confirm span {
|
||||
font-size: 0.74rem;
|
||||
color: var(--danger);
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.action-edit { margin-bottom: 14px; }
|
||||
/* Sized by AutoTextarea to fit the text being edited — an AI beat is usually
|
||||
several paragraphs, and the old fixed 110px turned that into a keyhole.
|
||||
|
||||
+277
-29
@@ -952,35 +952,49 @@ function WorldStateDrawer({ advId, refreshKey }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ChatGPT-style ‹ 2/3 › pager under an AI message that has been retried.
|
||||
// The attempts at one turn, and the way onto one the story left behind.
|
||||
//
|
||||
// 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 }) {
|
||||
// This replaces the ‹ 2/3 › pager, and the reason is not that chips look
|
||||
// better: a pager can only step between attempts, and stepping has nothing to
|
||||
// say about the thing the tree makes possible — taking a path the story moved
|
||||
// past *and keeping both*. Every attempt is its own node now (SP4), so a chip
|
||||
// is a node, and "take this path" is a fork (SP5).
|
||||
//
|
||||
// Two cases behind one control. While the turn is the tip its attempts are
|
||||
// still leaves, so choosing one is a switch and the server restores the state
|
||||
// that attempt produced. Once the story has moved past, choosing one is a
|
||||
// local preview — the turns after it were written as a continuation of
|
||||
// whatever is live — and taking it forks a branch.
|
||||
function AttemptChips({ advId, action, isLast, busy, preview, onPreview, onSwitched, onForked, onError }) {
|
||||
const [variants, setVariants] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const count = action.variant_count
|
||||
const current = previewIndex ?? action.variant_index
|
||||
const live = action.variant_index
|
||||
const current = preview ? preview.index : live
|
||||
|
||||
async function go(delta) {
|
||||
const next = current + delta
|
||||
if (next < 0 || next >= count || loading || busy) return
|
||||
async function show(next) {
|
||||
if (next === current || 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.
|
||||
// Fetched once per message, then cached — moving back and forth
|
||||
// between attempts 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 })
|
||||
onPreview(next === live ? null : {
|
||||
actionId: action.id,
|
||||
index: next,
|
||||
// The attempt's own node id. A fork is addressed by the node being
|
||||
// taken, never by its ordinal — the group renumbers whenever an
|
||||
// attempt is added, and an ordinal held across that points at a
|
||||
// different take.
|
||||
attemptId: list[next].id,
|
||||
text: list[next].text,
|
||||
reasoning: list[next].reasoning,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
onError(err.message)
|
||||
@@ -989,22 +1003,221 @@ function VariantPager({ advId, action, isLast, busy, previewIndex, onPreview, on
|
||||
}
|
||||
}
|
||||
|
||||
async function take(attemptId) {
|
||||
if (loading || busy) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const page = await api.forkFromAttempt(advId, attemptId)
|
||||
onPreview(null)
|
||||
onForked(page)
|
||||
} catch (err) {
|
||||
onError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="variant-pager">
|
||||
<button type="button" onClick={() => go(-1)} disabled={current === 0 || busy || loading}
|
||||
title="Previous attempt">‹</button>
|
||||
<span className="variant-count">{current + 1}/{count}</span>
|
||||
<button type="button" onClick={() => go(1)} disabled={current === count - 1 || busy || loading}
|
||||
title="Next attempt">›</button>
|
||||
{previewIndex !== null && (
|
||||
<span className="variant-note">
|
||||
earlier attempt — the story continued from {action.variant_index + 1}
|
||||
<div className="attempts">
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className="attempt-chip"
|
||||
aria-pressed={i === current}
|
||||
disabled={busy || loading}
|
||||
onClick={() => show(i)}
|
||||
title={i === live ? 'The take the story follows' : `Attempt ${i + 1}`}
|
||||
>
|
||||
take {i + 1}
|
||||
</button>
|
||||
))}
|
||||
{preview?.attemptId != null && (
|
||||
<button type="button" className="take-path" disabled={busy || loading}
|
||||
onClick={() => take(preview.attemptId)}>
|
||||
take this path ↗
|
||||
</button>
|
||||
)}
|
||||
{preview && (
|
||||
<span className="attempt-note">
|
||||
the story continued from take {live + 1}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// What an unnamed branch is called.
|
||||
//
|
||||
// Derived, never stored: a generated name in the column would go stale the
|
||||
// moment a branch before it is deleted. A fork depth is a coordinate, so it
|
||||
// says the same thing whatever else is thrown away.
|
||||
function branchLabel(branch) {
|
||||
if (branch.name) return branch.name
|
||||
if (branch.parent_branch_id === null) return 'The first telling'
|
||||
return `Fork at moment ${branch.fork_depth + 1}`
|
||||
}
|
||||
|
||||
// Parents before children, each child under the branch it left.
|
||||
function orderBranches(branches) {
|
||||
const kids = new Map()
|
||||
for (const b of branches) {
|
||||
const key = b.parent_branch_id
|
||||
if (!kids.has(key)) kids.set(key, [])
|
||||
kids.get(key).push(b)
|
||||
}
|
||||
const out = []
|
||||
const walk = (parentId, indent) => {
|
||||
for (const b of kids.get(parentId) || []) {
|
||||
out.push({ branch: b, indent })
|
||||
walk(b.id, indent + 1)
|
||||
}
|
||||
}
|
||||
// Anything whose parent is missing would otherwise never be walked. That
|
||||
// cannot happen through the API, but a list that silently drops a branch is
|
||||
// the one bug this panel exists to make impossible to have.
|
||||
walk(null, 0)
|
||||
const seen = new Set(out.map((row) => row.branch.id))
|
||||
for (const b of branches) if (!seen.has(b.id)) out.push({ branch: b, indent: 0 })
|
||||
return out
|
||||
}
|
||||
|
||||
// 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, 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 [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])
|
||||
|
||||
async function run(branchId, work) {
|
||||
setBusyId(branchId)
|
||||
try {
|
||||
await work()
|
||||
setTick((t) => t + 1)
|
||||
} catch (err) {
|
||||
onError(err.message)
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const switchTo = (b) => run(b.id, async () => onSwitched(await api.switchBranch(advId, b.id)))
|
||||
|
||||
const saveName = (b) => run(b.id, async () => {
|
||||
await api.renameBranch(advId, b.id, renaming.text)
|
||||
setRenaming(null)
|
||||
})
|
||||
|
||||
const remove = (b) => run(b.id, async () => {
|
||||
await api.deleteBranch(advId, b.id)
|
||||
setConfirming(null)
|
||||
})
|
||||
|
||||
if (failed) return <div className="panel-empty">Couldn’t read the branches — {failed}</div>
|
||||
if (!branches) return <div className="panel-empty">Reading the tree…</div>
|
||||
|
||||
return (
|
||||
<div className="branch-panel">
|
||||
{branches.length === 1 && (
|
||||
<p className="branch-intro">
|
||||
One thread so far. Retry a turn, then take an attempt the story moved
|
||||
past — that is what makes a second one.
|
||||
</p>
|
||||
)}
|
||||
<div className="branch-list">
|
||||
{orderBranches(branches).map(({ branch, indent }) => {
|
||||
const isRenaming = renaming?.id === branch.id
|
||||
const isConfirming = confirming === branch.id
|
||||
const busy = busyId === branch.id
|
||||
return (
|
||||
<div key={branch.id} className={`branch-row ${branch.is_head ? 'here' : ''}`}
|
||||
style={{ marginLeft: indent * 12 }}>
|
||||
<div className="branch-head">
|
||||
<span className="branch-glyph" aria-hidden="true">
|
||||
{branch.parent_branch_id === null ? '●' : '└'}
|
||||
</span>
|
||||
{isRenaming ? (
|
||||
<input
|
||||
className="branch-rename"
|
||||
autoFocus
|
||||
maxLength={80}
|
||||
value={renaming.text}
|
||||
onChange={(e) => setRenaming({ ...renaming, text: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') saveName(branch)
|
||||
if (e.key === 'Escape') setRenaming(null)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="branch-name">{branchLabel(branch)}</span>
|
||||
)}
|
||||
{branch.is_head && <span className="branch-here">reading</span>}
|
||||
</div>
|
||||
|
||||
<div className="branch-meta">
|
||||
{branch.own_actions} of its own
|
||||
{branch.parent_branch_id !== null && ` · forked at moment ${branch.fork_depth + 1}`}
|
||||
{` · ends at ${branch.depth + 1}`}
|
||||
</div>
|
||||
|
||||
{isConfirming ? (
|
||||
<div className="branch-confirm">
|
||||
<span>Delete this branch and everything forked from it?</span>
|
||||
<button type="button" className="danger" disabled={busy}
|
||||
onClick={() => remove(branch)}>Delete</button>
|
||||
<button type="button" onClick={() => setConfirming(null)}>Keep</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="branch-tools">
|
||||
{!branch.is_head && (
|
||||
<button type="button" disabled={busy} onClick={() => switchTo(branch)}>Switch</button>
|
||||
)}
|
||||
{isRenaming ? (
|
||||
<>
|
||||
<button type="button" disabled={busy} onClick={() => saveName(branch)}>Save</button>
|
||||
<button type="button" onClick={() => setRenaming(null)}>Cancel</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" disabled={busy}
|
||||
onClick={() => setRenaming({ id: branch.id, text: branch.name || '' })}>
|
||||
Rename
|
||||
</button>
|
||||
)}
|
||||
{/* The root holds the turns every other branch borrows, and
|
||||
the server refuses it — so it is not offered. */}
|
||||
{branch.parent_branch_id !== null && (
|
||||
<button type="button" className="danger" disabled={busy}
|
||||
onClick={() => setConfirming(branch.id)}>Delete</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Compact chips shown under an AI message summarizing what state changed.
|
||||
function StateChangeChips({ changes }) {
|
||||
if (!changes?.length) return null
|
||||
@@ -1307,7 +1520,7 @@ export default function Play() {
|
||||
// (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).
|
||||
// Read-only browsing of an earlier attempt at a past turn (see AttemptChips).
|
||||
// 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
|
||||
@@ -1362,6 +1575,25 @@ export default function Play() {
|
||||
.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)
|
||||
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
|
||||
@@ -1621,6 +1853,8 @@ export default function Play() {
|
||||
onClick={() => setPanel(panel === 'memory' ? null : 'memory')}>Memory</button>
|
||||
<button className={panel === 'scripts' ? 'active' : ''}
|
||||
onClick={() => setPanel(panel === 'scripts' ? null : 'scripts')}>Scripts</button>
|
||||
<button className={panel === 'branches' ? 'active' : ''}
|
||||
onClick={() => setPanel(panel === 'branches' ? null : 'branches')}>Branches</button>
|
||||
<button className={panel === 'insights' ? 'active' : ''}
|
||||
onClick={() => { setInspectActionId(null); setPanel(panel === 'insights' ? null : 'insights') }}>
|
||||
Insights
|
||||
@@ -1685,13 +1919,14 @@ export default function Play() {
|
||||
<StateChangeChips changes={action.world_changes} />
|
||||
)}
|
||||
{action.type === 'ai' && action.variant_count > 1 && (
|
||||
<VariantPager
|
||||
<AttemptChips
|
||||
advId={id}
|
||||
action={action}
|
||||
isLast={i === actions.length - 1}
|
||||
busy={busy}
|
||||
previewIndex={previewing ? previewing.index : null}
|
||||
preview={previewing}
|
||||
onPreview={setPreview}
|
||||
onForked={adoptWindow}
|
||||
onSwitched={(updated) => {
|
||||
// Matched on the action we asked about, not on the one
|
||||
// that came back. Since the story tree made every
|
||||
@@ -1778,7 +2013,7 @@ export default function Play() {
|
||||
{panel && (
|
||||
<div className="side-panel">
|
||||
<div className="side-panel-header">
|
||||
<h2>{{ plot: 'Plot Components', memory: 'Memory Bank', scripts: 'Scripts', insights: 'Insights' }[panel]}</h2>
|
||||
<h2>{{ plot: 'Plot Components', memory: 'Memory Bank', scripts: 'Scripts', branches: 'Branches', insights: 'Insights' }[panel]}</h2>
|
||||
<button onClick={() => setPanel(null)}>✕</button>
|
||||
</div>
|
||||
{panel === 'plot' ? (
|
||||
@@ -1789,6 +2024,19 @@ export default function Play() {
|
||||
refreshKey={actions.length} />
|
||||
) : panel === 'scripts' ? (
|
||||
<ScriptsPanel advId={id} />
|
||||
) : 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}
|
||||
onError={(message) => setToast({ text: message, isError: true })}
|
||||
/>
|
||||
) : (
|
||||
<InsightsPanel advId={id} inspectActionId={inspectActionId}
|
||||
onClearInspect={() => setInspectActionId(null)} refreshKey={actions.length} />
|
||||
|
||||
Reference in New Issue
Block a user