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
@@ -963,6 +963,7 @@ def list_variants(
|
|||||||
return [] # never retried: the turn is its own only take
|
return [] # never retried: the turn is its own only take
|
||||||
return [
|
return [
|
||||||
schemas.VariantOut(
|
schemas.VariantOut(
|
||||||
|
id=row.id,
|
||||||
index=i,
|
index=i,
|
||||||
text=row.text,
|
text=row.text,
|
||||||
reasoning=row.reasoning,
|
reasoning=row.reasoning,
|
||||||
|
|||||||
@@ -193,6 +193,10 @@ class ActionOut(ORMModel):
|
|||||||
|
|
||||||
|
|
||||||
class VariantOut(BaseModel):
|
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
|
index: int
|
||||||
text: str
|
text: str
|
||||||
reasoning: str | None = None
|
reasoning: str | None = None
|
||||||
|
|||||||
@@ -110,6 +110,23 @@ export const api = {
|
|||||||
method: 'POST', body: JSON.stringify({ index }),
|
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) =>
|
sendAction: (advId, payload, handlers, signal) =>
|
||||||
streamSSE(`/adventures/${advId}/actions`, payload, handlers, signal),
|
streamSSE(`/adventures/${advId}/actions`, payload, handlers, signal),
|
||||||
retry: (advId, handlers, signal) => streamSSE(`/adventures/${advId}/retry`, {}, 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); }
|
.story .action-tools button:hover { color: var(--text); }
|
||||||
|
|
||||||
/* Retry-history pager under an AI beat. Stays quiet until hovered — it's a
|
/* The attempts at one AI beat. Stays quiet until hovered — it's a footnote on
|
||||||
footnote on the message, not part of the prose. */
|
the message, not part of the prose. */
|
||||||
.variant-pager {
|
.attempts {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
transition: opacity 0.15s;
|
transition: opacity 0.15s;
|
||||||
}
|
}
|
||||||
.story .action:hover .variant-pager,
|
.story .action:hover .attempts,
|
||||||
.variant-pager:focus-within { opacity: 1; }
|
.attempts:focus-within { opacity: 1; }
|
||||||
.variant-pager button {
|
.attempt-chip {
|
||||||
padding: 0 7px;
|
padding: 3px 10px;
|
||||||
font-size: 0.9rem;
|
font-size: 0.73rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
border-radius: 999px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
background: transparent;
|
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); }
|
.attempt-chip:hover:not(:disabled) { color: var(--text); border-color: var(--border-bright); }
|
||||||
.variant-pager button:disabled { opacity: 0.35; cursor: default; }
|
.attempt-chip[aria-pressed="true"] {
|
||||||
.variant-count {
|
color: var(--bg);
|
||||||
font-size: 0.74rem;
|
background: var(--accent);
|
||||||
color: var(--text-dim);
|
border-color: var(--accent);
|
||||||
font-variant-numeric: tabular-nums;
|
font-weight: 600;
|
||||||
min-width: 30px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
.variant-note {
|
.attempt-chip:disabled { opacity: 0.35; cursor: default; }
|
||||||
margin-left: 4px;
|
/* 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-size: 0.72rem;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: var(--accent-dim);
|
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; }
|
.action-edit { margin-bottom: 14px; }
|
||||||
/* Sized by AutoTextarea to fit the text being edited — an AI beat is usually
|
/* 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.
|
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
|
// This replaces the ‹ 2/3 › pager, and the reason is not that chips look
|
||||||
// attempt produced); earlier ones are read-only, because the turns after them
|
// better: a pager can only step between attempts, and stepping has nothing to
|
||||||
// were written as a continuation of whatever is active now. Browsing an
|
// say about the thing the tree makes possible — taking a path the story moved
|
||||||
// earlier one is a local preview, so `onPreview` hands the text up to the story
|
// past *and keeping both*. Every attempt is its own node now (SP4), so a chip
|
||||||
// renderer rather than the pager drawing it.
|
// is a node, and "take this path" is a fork (SP5).
|
||||||
function VariantPager({ advId, action, isLast, busy, previewIndex, onPreview, onSwitched, onError }) {
|
//
|
||||||
|
// 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 [variants, setVariants] = useState(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const count = action.variant_count
|
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) {
|
async function show(next) {
|
||||||
const next = current + delta
|
if (next === current || loading || busy) return
|
||||||
if (next < 0 || next >= count || loading || busy) return
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
if (isLast) {
|
if (isLast) {
|
||||||
onPreview(null)
|
onPreview(null)
|
||||||
onSwitched(await api.selectVariant(advId, action.id, next))
|
onSwitched(await api.selectVariant(advId, action.id, next))
|
||||||
} else {
|
} else {
|
||||||
// Fetched once per message, then cached — paging back and forth
|
// Fetched once per message, then cached — moving back and forth
|
||||||
// shouldn't re-hit the server.
|
// between attempts shouldn't re-hit the server.
|
||||||
const list = variants || await api.listVariants(advId, action.id)
|
const list = variants || await api.listVariants(advId, action.id)
|
||||||
if (!variants) setVariants(list)
|
if (!variants) setVariants(list)
|
||||||
onPreview(next === action.variant_index
|
onPreview(next === live ? null : {
|
||||||
? null
|
actionId: action.id,
|
||||||
: { actionId: action.id, index: next, text: list[next].text, reasoning: list[next].reasoning })
|
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) {
|
} catch (err) {
|
||||||
onError(err.message)
|
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 (
|
return (
|
||||||
<div className="variant-pager">
|
<div className="attempts">
|
||||||
<button type="button" onClick={() => go(-1)} disabled={current === 0 || busy || loading}
|
{Array.from({ length: count }, (_, i) => (
|
||||||
title="Previous attempt">‹</button>
|
<button
|
||||||
<span className="variant-count">{current + 1}/{count}</span>
|
key={i}
|
||||||
<button type="button" onClick={() => go(1)} disabled={current === count - 1 || busy || loading}
|
type="button"
|
||||||
title="Next attempt">›</button>
|
className="attempt-chip"
|
||||||
{previewIndex !== null && (
|
aria-pressed={i === current}
|
||||||
<span className="variant-note">
|
disabled={busy || loading}
|
||||||
earlier attempt — the story continued from {action.variant_index + 1}
|
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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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.
|
// Compact chips shown under an AI message summarizing what state changed.
|
||||||
function StateChangeChips({ changes }) {
|
function StateChangeChips({ changes }) {
|
||||||
if (!changes?.length) return null
|
if (!changes?.length) return null
|
||||||
@@ -1307,7 +1520,7 @@ export default function Play() {
|
|||||||
// (currently "Update from scenario"), which no action count would reflect.
|
// (currently "Update from scenario"), which no action count would reflect.
|
||||||
const [stateKey, setStateKey] = useState(0)
|
const [stateKey, setStateKey] = useState(0)
|
||||||
const [inspectActionId, setInspectActionId] = useState(null)
|
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.
|
// One at a time; null when every message is showing its active version.
|
||||||
const [preview, setPreview] = useState(null)
|
const [preview, setPreview] = useState(null)
|
||||||
// The transcript is a window on the story, not the whole of it: the page
|
// 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('/'))
|
.catch(() => navigate('/'))
|
||||||
}, [id, 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.
|
// 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
|
// 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>
|
onClick={() => setPanel(panel === 'memory' ? null : 'memory')}>Memory</button>
|
||||||
<button className={panel === 'scripts' ? 'active' : ''}
|
<button className={panel === 'scripts' ? 'active' : ''}
|
||||||
onClick={() => setPanel(panel === 'scripts' ? null : 'scripts')}>Scripts</button>
|
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' : ''}
|
<button className={panel === 'insights' ? 'active' : ''}
|
||||||
onClick={() => { setInspectActionId(null); setPanel(panel === 'insights' ? null : 'insights') }}>
|
onClick={() => { setInspectActionId(null); setPanel(panel === 'insights' ? null : 'insights') }}>
|
||||||
Insights
|
Insights
|
||||||
@@ -1685,13 +1919,14 @@ export default function Play() {
|
|||||||
<StateChangeChips changes={action.world_changes} />
|
<StateChangeChips changes={action.world_changes} />
|
||||||
)}
|
)}
|
||||||
{action.type === 'ai' && action.variant_count > 1 && (
|
{action.type === 'ai' && action.variant_count > 1 && (
|
||||||
<VariantPager
|
<AttemptChips
|
||||||
advId={id}
|
advId={id}
|
||||||
action={action}
|
action={action}
|
||||||
isLast={i === actions.length - 1}
|
isLast={i === actions.length - 1}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
previewIndex={previewing ? previewing.index : null}
|
preview={previewing}
|
||||||
onPreview={setPreview}
|
onPreview={setPreview}
|
||||||
|
onForked={adoptWindow}
|
||||||
onSwitched={(updated) => {
|
onSwitched={(updated) => {
|
||||||
// Matched on the action we asked about, not on the one
|
// Matched on the action we asked about, not on the one
|
||||||
// that came back. Since the story tree made every
|
// that came back. Since the story tree made every
|
||||||
@@ -1778,7 +2013,7 @@ export default function Play() {
|
|||||||
{panel && (
|
{panel && (
|
||||||
<div className="side-panel">
|
<div className="side-panel">
|
||||||
<div className="side-panel-header">
|
<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>
|
<button onClick={() => setPanel(null)}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
{panel === 'plot' ? (
|
{panel === 'plot' ? (
|
||||||
@@ -1789,6 +2024,19 @@ export default function Play() {
|
|||||||
refreshKey={actions.length} />
|
refreshKey={actions.length} />
|
||||||
) : panel === 'scripts' ? (
|
) : panel === 'scripts' ? (
|
||||||
<ScriptsPanel advId={id} />
|
<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}
|
<InsightsPanel advId={id} inspectActionId={inspectActionId}
|
||||||
onClearInspect={() => setInspectActionId(null)} refreshKey={actions.length} />
|
onClearInspect={() => setInspectActionId(null)} refreshKey={actions.length} />
|
||||||
|
|||||||
@@ -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
|
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.
|
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.
|
delete-with-confirm. `api.js` gains the branch endpoints.
|
||||||
|
|
||||||
**Verify:** this is where the standing open gap gets closed — **drive the 600-action
|
**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;
|
driven and already hid one bug. A vitest + jsdom harness covers the prepend arithmetic;
|
||||||
jsdom has no layout, so scroll position still needs eyes.
|
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
|
### SP8 — Drop the legacy columns
|
||||||
|
|
||||||
Only once the tree is proven live. Migration drops `index`, `variants`, `variant_index`,
|
Only once the tree is proven live. Migration drops `index`, `variants`, `variant_index`,
|
||||||
|
|||||||
+63
-29
@@ -78,41 +78,43 @@ needed; nothing requires reading a row of anyone's story.
|
|||||||
|
|
||||||
## Pick up here
|
## Pick up here
|
||||||
|
|
||||||
**`plan/14-phase-story-tree.md`, SP7 — the frontend.** SP0–SP6 are done and green
|
**`plan/14-phase-story-tree.md`, SP8 — drop the legacy columns.** SP0–SP7 are done and
|
||||||
(**381 tests**); **nothing is deployed yet**. The tree is complete everywhere except the
|
green (**396 tests**); **nothing is deployed yet**. The tree is complete and reachable: a
|
||||||
screen: a retry writes a sibling node, continuing from a discarded attempt forks a branch,
|
retry writes a sibling node, continuing from a discarded attempt forks a branch, a backup
|
||||||
and a backup carries the whole thing (`ai-dnd-adventure-v2`, with the v1 reader kept so
|
carries the whole thing (`ai-dnd-adventure-v2`, v1 reader kept), and as of SP7 there is a
|
||||||
existing bundles still import). What is left is the frontend (SP7) and dropping the legacy
|
Branches panel that switches, renames and deletes. What is left of the phase is SP8.
|
||||||
columns (SP8).
|
|
||||||
|
|
||||||
**SP7 is the release gate, and it is unscoped.** `VariantPager` comes out, a spatial tree
|
**SP8 is gated on the tree being proven live, and it is not.** It drops `index`,
|
||||||
view replaces it, and branch management — switch, rename, delete-with-confirm — is a hard
|
`variants`, `variant_index`, `variant_count`, the two legacy cursors and the two
|
||||||
dependency rather than a nice-to-have, because nothing auto-prunes and storage otherwise
|
`*_before` snapshots. `variant_count` / `variant_index` are the ones to watch: SP7's
|
||||||
grows without limit. `api.js` gains `GET /branches`, `POST /branches/{id}/switch` and
|
attempt chips still read both, so SP8 has to move the chips onto the sibling group before
|
||||||
`POST /actions/{id}/fork`, which SP5 built for exactly this.
|
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
|
**Deploy before SP8, not after.** SP7 is a natural release: the phase is usable from the
|
||||||
line and the standing open gap in this project: the scroll path has never been driven by
|
screen for the first time, and dropping columns is the one step that cannot be rolled
|
||||||
hand and has already hidden one bug. A vitest + jsdom harness covers the prepend
|
back by redeploying the previous build.
|
||||||
arithmetic, but jsdom has no layout, so scroll position still needs eyes.
|
|
||||||
|
|
||||||
**The schema is live in code but not on production.** When this ships, the deploy needs
|
**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
|
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
|
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
|
owed and one run settles both**. SP3's, SP5's, SP6's and SP7's changes need none (SP5 and
|
||||||
no migration at all). See the 144 MB lesson at the top of this file.
|
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
|
- **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
|
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.
|
what is derived* — decided SP7's naming too: `branches.name` is stored because a player
|
||||||
- **`variant_count` and `variant_index` die with the pager.** SP4 left them as a
|
picked it, and an unnamed branch is drawn from its fork depth rather than given a
|
||||||
maintained cache of the sibling group's shape because the pager reads both for every
|
generated label that would go stale when a branch before it is deleted.
|
||||||
row of a page. They are dead the moment the tree view replaces it, and SP8 drops them.
|
- **A one-line subphase spec can hide a schema change.** SP7 read as "plus the UI for
|
||||||
- **Nothing on the screen has ever seen a second branch.** Forking has no UI, which is
|
switch/rename/delete"; two of those three had no backend at all. Check the routes exist
|
||||||
why the v1-export gap could be left open through SP5 — SP7 is the subphase that makes
|
before believing a spec that says "frontend".
|
||||||
a fork reachable, so it is also the one that makes every branch-shaped bug reachable.
|
- **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,
|
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
|
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
|
**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
|
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
|
**The scroll gap is closed.** It was driven by hand on the 602-action `--keep` fixture
|
||||||
frontend work lands on the same scroll path that has still never been driven by hand, so
|
during SP7 — three prepends, 5 px of drift, no throw-to-the-end. What is still missing is
|
||||||
drive it before rewriting it.
|
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
|
## 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
|
The backup learned the tree. `ai-dnd-adventure-v2` carries branches, the fork point each
|
||||||
|
|||||||
Reference in New Issue
Block a user