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:
parththakkar106
2026-08-18 19:14:07 +05:30
committed by Parth
co-authored by Claude Opus 5
parent cf3d52171e
commit 84827f0f37
7 changed files with 538 additions and 80 deletions
+1
View File
@@ -963,6 +963,7 @@ def list_variants(
return [] # never retried: the turn is its own only take
return [
schemas.VariantOut(
id=row.id,
index=i,
text=row.text,
reasoning=row.reasoning,
+4
View File
@@ -193,6 +193,10 @@ class ActionOut(ORMModel):
class VariantOut(BaseModel):
# Since SP4 every attempt is its own node, so each one has an id — and the
# client needs it: forking is addressed by the attempt being taken, not by
# its ordinal in a group that renumbers whenever one is added.
id: int
index: int
text: str
reasoning: str | None = None
+17
View File
@@ -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
View File
@@ -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
View File
@@ -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} />
+62 -2
View File
@@ -694,9 +694,9 @@ synthetic user and adventure into the local `backend/data.db` and printed perfec
numbers, and only the second run tripped over the unique email. Anything importing that
harness must import it first, and the file now says so where the imports are.
### SP7 — Frontend: full tree visualisation
### SP7 — Frontend: the tree becomes reachable
`VariantPager` is removed. A spatial tree view replaces it, plus switch, rename and
`VariantPager` is removed. A branch view replaces it, plus switch, rename and
delete-with-confirm. `api.js` gains the branch endpoints.
**Verify:** this is where the standing open gap gets closed — **drive the 600-action
@@ -704,6 +704,66 @@ delete-with-confirm. `api.js` gains the branch endpoints.
driven and already hid one bug. A vitest + jsdom harness covers the prepend arithmetic;
jsdom has no layout, so scroll position still needs eyes.
**Done, 2026-08-18** (branch `sp7-tree-ui`). **396 tests green**, the 381 SP6 finished
with plus 15 in the new `test_branch_management.py`. Driven by hand against the `--keep`
fixture in Chrome, which is how the one bug below was found.
**Shape chosen: a branch rail, not a spatial node map.** Three mockups were built and
compared before any of it was written, and the deciding argument was not aesthetic. A
node map is a second windowing problem — the fixture this subphase must be verified
against is 600 actions, which is 600 nodes — so building one would have spent SP7 on the
thing that delays the verification SP7 exists to do. The rail ships now; the map is a
later feature and costs nothing extra to add, because both draw from the same
`GET /branches`. The panel sits beside Plot/Memory/Scripts/Insights, which is this app's
existing idiom for a right-hand rail rather than a new one.
**SP7 was not a frontend-only subphase, and the spec above did not say so.** Of the three
operations it names, SP5 had built exactly one. `switch` existed; `rename` had no column
and no route, `delete` had no route at all. So it opens with migration 61
(`branches.name`), a `PATCH` and a `DELETE` — worth remembering for any future subphase
whose one-line spec says "plus the UI for X".
Five things worth not rediscovering:
- **A name is stored; a label is derived.** `branches.name` is NULL until somebody
chooses one, and the client draws an unnamed branch from its fork depth
(`Fork at moment 547`). A generated "branch 4" in the column would be a lie the moment
branch 3 is deleted and the ordinals shift under it; a fork depth is a coordinate, and
nothing can shift it. The v2 bundle carries the name for exactly the reason SP6 gives
for carrying the fork points — it is a decision, not something computed from one.
- **Refusing to delete the head is only half of it.** The other half is refusing any
branch the head was *forked from*: `parent_branch_id` cascades, so deleting an ancestor
takes the head with it and leaves `head_branch_id` pointing at a row that is gone. One
membership test against the head's own `lineage` covers both, because a lineage already
names itself and every branch it borrows from.
- **A deleted branch's cursor has to be cleared, and the reason is SQLite.** On Postgres
a stale branch id simply never resolves. SQLite hands the freed id to the next fork, at
which point the anchor resolves onto a branch it has never seen and reports a stretch
of story as already summarized — losing it from the memories for good. Same class as
the width-mismatch `cosine` returning 0.0: it reports nothing.
- **`VariantOut` had to grow an `id`.** A fork is addressed by the node being taken. The
group renumbers whenever an attempt is added, so an ordinal held across that points at
a different take — the same reason SP4's note called the pager's index match "one line,
and SP7 removes the pager anyway".
- **The panel reloaded on the wrong thing, and only a browser could say so.** Its refresh
key was `actions.length`. A fork taken from the story column swaps one 60-action window
for another 60-action window, so the length never changes, and the panel went on
drawing a one-branch tree while the story was already being read on a second. The
server was correct throughout; nothing in 396 tests could see it. It keys off the
counter `adoptWindow` bumps now.
**The scroll path was driven, and it holds.** Three prepends on the 602-action fixture,
60 actions and ~16,200 px each. The same DOM node stayed at viewport top 792 → 787 — a
**5 px drift** across the prepend — and the view stayed 48,174 px from the bottom, so PR
#2's throw-to-the-end does not reproduce. Console clean. One note for anyone measuring it
again: the fixture's prose repeats, so an anchor found by matching *text* lands on an
older copy of the same sentence and reads as a huge jump. Hold the DOM node.
**Still open, deliberately:** no vitest + jsdom harness. The verify line offers
hand-driving *or* the harness and this took the first. The harness remains the thing that
would catch a prepend regression without a person in the loop, and jsdom's lack of layout
means it would not have settled the 5 px question either way.
### SP8 — Drop the legacy columns
Only once the tree is proven live. Migration drops `index`, `variants`, `variant_index`,
+63 -29
View File
@@ -78,41 +78,43 @@ needed; nothing requires reading a row of anyone's story.
## Pick up here
**`plan/14-phase-story-tree.md`, SP7 — the frontend.** SP0–SP6 are done and green
(**381 tests**); **nothing is deployed yet**. The tree is complete everywhere except the
screen: a retry writes a sibling node, continuing from a discarded attempt forks a branch,
and a backup carries the whole thing (`ai-dnd-adventure-v2`, with the v1 reader kept so
existing bundles still import). What is left is the frontend (SP7) and dropping the legacy
columns (SP8).
**`plan/14-phase-story-tree.md`, SP8 — drop the legacy columns.** SP0–SP7 are done and
green (**396 tests**); **nothing is deployed yet**. The tree is complete and reachable: a
retry writes a sibling node, continuing from a discarded attempt forks a branch, a backup
carries the whole thing (`ai-dnd-adventure-v2`, v1 reader kept), and as of SP7 there is a
Branches panel that switches, renames and deletes. What is left of the phase is SP8.
**SP7 is the release gate, and it is unscoped.** `VariantPager` comes out, a spatial tree
view replaces it, and branch management — switch, rename, delete-with-confirm — is a hard
dependency rather than a nice-to-have, because nothing auto-prunes and storage otherwise
grows without limit. `api.js` gains `GET /branches`, `POST /branches/{id}/switch` and
`POST /actions/{id}/fork`, which SP5 built for exactly this.
**SP8 is gated on the tree being proven live, and it is not.** It drops `index`,
`variants`, `variant_index`, `variant_count`, the two legacy cursors and the two
`*_before` snapshots. `variant_count` / `variant_index` are the ones to watch: SP7's
attempt chips still read both, so SP8 has to move the chips onto the sibling group before
it drops them. Everything else has been unread since SP3/SP4.
**Drive the 600-action `--keep` fixture in a browser by hand.** That is SP7's own verify
line and the standing open gap in this project: the scroll path has never been driven by
hand and has already hidden one bug. A vitest + jsdom harness covers the prepend
arithmetic, but jsdom has no layout, so scroll position still needs eyes.
**Deploy before SP8, not after.** SP7 is a natural release: the phase is usable from the
screen for the first time, and dropping columns is the one step that cannot be rolled
back by redeploying the previous build.
**The schema is live in code but not on production.** When this ships, the deploy needs
one `VACUUM FULL actions;` on the direct (non-`-pooler`) endpoint afterwards — SP1's
migration rewrites every row and SP4's rewrites it three times more, so **two vacuums are
owed and one run settles both**. SP3's, SP5's and SP6's changes need none (SP5 and SP6 add
no migration at all). See the 144 MB lesson at the top of this file.
owed and one run settles both**. SP3's, SP5's, SP6's and SP7's changes need none (SP5 and
SP6 add no migration; SP7's migration 61 touches `branches`, a handful of rows per
adventure). See the 144 MB lesson at the top of this file.
Three things to carry into SP7:
Three things to carry forward:
- **The bundle is the one thing here a migration can never reach.** `app/bundle.py` owns
both formats and nothing else knows either. Its rule — *carry what was chosen, never
what is derived* — is worth borrowing anywhere else state has to leave the database.
- **`variant_count` and `variant_index` die with the pager.** SP4 left them as a
maintained cache of the sibling group's shape because the pager reads both for every
row of a page. They are dead the moment the tree view replaces it, and SP8 drops them.
- **Nothing on the screen has ever seen a second branch.** Forking has no UI, which is
why the v1-export gap could be left open through SP5 — SP7 is the subphase that makes
a fork reachable, so it is also the one that makes every branch-shaped bug reachable.
what is derived* — decided SP7's naming too: `branches.name` is stored because a player
picked it, and an unnamed branch is drawn from its fork depth rather than given a
generated label that would go stale when a branch before it is deleted.
- **A one-line subphase spec can hide a schema change.** SP7 read as "plus the UI for
switch/rename/delete"; two of those three had no backend at all. Check the routes exist
before believing a spec that says "frontend".
- **The spatial node map was deliberately not built.** SP7 shipped a rail instead, on the
grounds that a per-node map is a second windowing problem at 600 nodes. It is a
standalone feature whenever it is wanted, and it needs no new endpoint — the rail and a
map both draw from `GET /branches`.
And one known cost, not a bug: the two memory marks are a single pair on the adventure,
so switching branches makes the mark on the branch being left unreadable from the new one
@@ -121,14 +123,46 @@ the safe direction. Per-branch cursors are the fix if it ever matters.
**After any migration that rewrites `actions`:** one `VACUUM FULL actions;`. That is the
lesson of the 144 MB above — a rewrite doubles the table and only a `VACUUM FULL` gives
it back. Phase 14's migration rewrites every row.
it back. SP8's migration rewrites every row.
**There is a 600-action adventure to test against now** — `--keep`, below. The tree's
frontend work lands on the same scroll path that has still never been driven by hand, so
drive it before rewriting it.
**The scroll gap is closed.** It was driven by hand on the 602-action `--keep` fixture
during SP7 — three prepends, 5 px of drift, no throw-to-the-end. What is still missing is
an automated version; see SP7's entry in `plan/14`.
---
## What happened on 2026-08-18, part four — the tree, SP7
The tree reached the screen. A Branches panel beside Plot/Memory/Scripts/Insights lists
every line the story has taken and switches, renames or deletes one; under a retried turn
the ‹ 2/3 › pager is gone, replaced by attempt chips and a **take this path** that forks
when the story has already moved past. **396 tests green**, 15 new in
`test_branch_management.py`. Branch `sp7-tree-ui`, migration 61, no vacuum owed by it.
**Three mockups were built before a line of it was written**, because the spec was one
paragraph and the choice was expensive: a per-node spatial map is a second windowing
problem at 600 nodes. The rail won on the grounds that it does not delay the verification
SP7 exists to do, and the map stays available as a later feature at no extra cost — both
read the same `GET /branches`.
**Two of the three operations SP7 "just needed UI for" did not exist.** `switch` did.
`rename` had no column and no route; `delete` had no route. Migration 61 adds
`branches.name`, and the two endpoints came with it.
**One bug, and only a browser could have found it.** The panel refreshed on
`actions.length`. Forking from the story column swaps a 60-action window for another
60-action window, so the length never changes — the panel kept drawing a one-branch tree
while the story was already being read on the second branch. The server was right the
whole time and no test could see it. That is now three bugs on this frontend found by
exercising it rather than by testing it, and the second found in a path that had just
shipped.
**The scroll path is finally driven.** 602-action fixture, three prepends of ~16,200 px
each: the same DOM node held viewport top 792 → 787, and the view stayed 48,174 px from
the bottom. PR #2's fix holds. Measuring note worth keeping — the fixture's prose repeats,
so an anchor matched by *text* finds an older copy of the same sentence and reports a
16,000 px jump that never happened. Hold the node.
## What happened on 2026-08-18, part three — the tree, SP6
The backup learned the tree. `ai-dnd-adventure-v2` carries branches, the fork point each