diff --git a/backend/app/routers/adventures.py b/backend/app/routers/adventures.py
index e6134d1..f5edd9f 100644
--- a/backend/app/routers/adventures.py
+++ b/backend/app/routers/adventures.py
@@ -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,
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index 447d0dd..0412b64 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -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
diff --git a/frontend/src/api.js b/frontend/src/api.js
index 43bb2a8..e3f78de 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -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),
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 204d6a0..cc40de0 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -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.
diff --git a/frontend/src/pages/Play.jsx b/frontend/src/pages/Play.jsx
index f9d6693..1e2cc85 100644
--- a/frontend/src/pages/Play.jsx
+++ b/frontend/src/pages/Play.jsx
@@ -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 (
-
-
- {current + 1}/{count}
-
- {previewIndex !== null && (
-
- earlier attempt — the story continued from {action.variant_index + 1}
+
+ {Array.from({ length: count }, (_, i) => (
+
+ ))}
+ {preview?.attemptId != null && (
+
+ )}
+ {preview && (
+
+ the story continued from take {live + 1}
)}
)
}
+// 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
Couldn’t read the branches — {failed}
+ if (!branches) return
Reading the tree…
+
+ return (
+
+ {branches.length === 1 && (
+
+ One thread so far. Retry a turn, then take an attempt the story moved
+ past — that is what makes a second one.
+
+ {branch.own_actions} of its own
+ {branch.parent_branch_id !== null && ` · forked at moment ${branch.fork_depth + 1}`}
+ {` · ends at ${branch.depth + 1}`}
+
+
+ {isConfirming ? (
+
+ Delete this branch and everything forked from it?
+
+
+
+ ) : (
+
+ {!branch.is_head && (
+
+ )}
+ {isRenaming ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+ {/* The root holds the turns every other branch borrows, and
+ the server refuses it — so it is not offered. */}
+ {branch.parent_branch_id !== null && (
+
+ )}
+
+ )}
+
+ )
+ })}
+
+
+ )
+}
+
// 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
+