M3: complete non-destructive history and active-head export
The head-cursor model landed in 903fa7a and stopped there: the backend
moved the head instead of deleting turns, but a bundle still reopened at
its newest row, the browser had no way forward, and five inherited tests
still asserted the contract Undo had just stopped honouring. This is the
rest of the milestone, plus the one unsafe operation the review found.
Export now writes headDepth, and it belongs on the other side of the rule
app/bundle.py states about itself. The head depth used to be derived —
the tip of the head branch, a fact about the nodes that arrived with it —
and that was true while Undo deleted, because the newest row was the only
place a story could be read. It is a decision now: the same tree exports
identically whether the user undid three turns or none, so the file has
to say. An import that ignored it would silently Redo the story to its
newest retained turn, which is the Phase 0B export finding this milestone
exists to close. A file with no headDepth is opened at the tip, which is
not a fallback but the position such a file recorded; a file naming a
depth its own rows do not reach is refused in plan(), before a row is
written, for the reason that module gives about half-written trees.
Which branches the story has left goes into the file for the same reason.
Every row of an abandoned line arrives on an import either way, so the
disposition is the only thing telling it apart from an active one, and a
restored backup that had lost it would have nothing for the later cleanup
and recovery screens to select on. Both keys or neither: a time with no
depth cannot say what was displaced.
The browser gets a Redo button beside Undo, on Ctrl+Shift+Z, and both are
enabled from can_undo/can_redo rather than from the transcript. Neither
is derivable on the client — Undo stops at the campaign opening, which
may be off the top of the loaded window, and Redo depends on the retained
future, which the client is never sent — so the flags now ride on every
window the server hands back, including a scrolled-up page and the
response to an import. Moving the head also refreshes the state panels,
which undo never did: it has rolled the world state back since long
before M3 and the drawer kept showing the old numbers.
An in-place edit is now refused when story descends from the turn and is
not on screen. Editing rewrites one row and re-evaluates nothing, which
is what makes it a correction rather than a continuation, and that is
harmless while everything below the turn is visible — the reader can see
what their change has to agree with. It stops being harmless when the
continuation is undone, or was left behind by a divergence, because the
edit then silently changes the words an invisible stretch of story was
written from. That was the one way M3's retained history could be made to
contradict itself. Refusing is deliberately the whole of the fix: making
such an edit fork is STORY-BRANCH-SEMANTICS.md §14-15, and §15 wants the
state the edited prose implies re-evaluated, which is M5's extraction
pass. The requirement is not weakened, only deferred, and §14A now says
so.
The predicate asks one question rather than two. A descendant is
invisible either because it is past the head on this lineage or because
it is past a fork on a branch the story left, and both are "a live node,
deeper than this one, descending from it, off the path being read". A
first attempt scoped the search to branches other than the active one and
failed the divergence case, correctly: the departed branch is usually an
ancestor of the branch now being read. Only the deepest live node on each
descending branch is examined, because visibility is monotone in depth.
Five inherited tests are rewritten rather than deleted, because what they
were protecting is still worth protecting and only the mechanism changed.
The undo-state pair keeps its state assertions and swaps "the rows are
gone" for "the rows are all here and the story is read from earlier". The
memory test stops asserting that undo prunes memories and starts
asserting the property that replaced it: a memory past the head is
unreachable, still on disk, and retrievable again after Redo, with no
re-embedding. The attempt-group test still proves the group moves as one,
out of the story rather than out of the database. And the fork test
reverses: Undo used to refuse at a fork point because it deleted rows the
parent branch was also reading, and with nothing deleted there is nothing
to protect the parent from, so it now walks into the story the branch
inherits and stops at the campaign opening instead.
tests/test_head_cursor.py is the milestone's acceptance contract, named
by the items it discharges: D01-D10, E01-E04, I01-I03, I07, L01-L02, and
the invariant they all rest on — Undo deletes zero accepted turns,
asserted on row ids over the whole retained tree. E02 has both controls,
because a negative control alone would pass if memory retrieval were
simply broken. L01 records what it does not claim: the head does move by
one on a failed turn, onto the player's retained input, which is A05
rather than a gap. Two of the edit-guard tests exist to prove the guard
stays out of the way — a correction at the tip and a correction mid-story
with everything visible must both still work.
638 backend tests pass. Frontend lint is unchanged at seven pre-existing
warnings, none in the files touched; the bundle builds at 395.85 kB; the
production image builds.
Verified at runtime against the trusted-LAN Ollama over HTTPS: three
turns, two Undos, a Redo, a Retry, an Undo, a divergent continuation,
Redo correctly refused with 400, Undo back to the opening, export, a
process restart that reopened the campaign still undone, and an import
that opened at the same position with its retained future intact. The
browser click-through of that sequence has not been run — no session in
this milestone had a browser to drive — so the Redo control itself is
verified by its endpoint and its lint and build, not by a click.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QF5TcoB86QADgjHz1GZe8u
This commit is contained in:
co-authored by
Claude Opus 5
parent
903fa7a74f
commit
7f082b61d8
@@ -124,6 +124,9 @@ export const api = {
|
||||
exportAdventure: (id) => request(`/adventures/${id}/export`),
|
||||
importAdventure: (bundle) => request('/adventures/import', { method: 'POST', body: JSON.stringify(bundle) }),
|
||||
undo: (advId) => request(`/adventures/${advId}/undo`, { method: 'POST' }),
|
||||
// Undo moves the story back without deleting it, so there is somewhere to
|
||||
// move forward to again (M3). Both answer with the newest window.
|
||||
redo: (advId) => request(`/adventures/${advId}/redo`, { method: 'POST' }),
|
||||
getAdventureContext: (advId) => request(`/adventures/${advId}/context`),
|
||||
// "Update from scenario": GET describes what would change, POST applies it.
|
||||
previewRefresh: (advId) => request(`/adventures/${advId}/refresh`),
|
||||
|
||||
@@ -104,6 +104,12 @@ export default function Play() {
|
||||
const [total, setTotal] = useState(0)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loadingOlder, setLoadingOlder] = useState(false)
|
||||
// Whether Undo and Redo have anywhere to go, as the server last reported it
|
||||
// (M3). Neither is derivable here: Undo stops at the campaign opening, which
|
||||
// may be off the top of the loaded window, and Redo depends on the retained
|
||||
// future, which the transcript is never sent. So the flags ride on every
|
||||
// window the server hands back and this holds the newest answer.
|
||||
const [history, setHistory] = useState({ undo: false, redo: false })
|
||||
const storyEndRef = useRef(null)
|
||||
const abortRef = useRef(null)
|
||||
const pinnedRef = useRef(true) // autoscroll only while the reader is at the bottom
|
||||
@@ -155,10 +161,19 @@ export default function Play() {
|
||||
setActions(adv.actions)
|
||||
setTotal(adv.action_count ?? adv.actions.length)
|
||||
setHasMore(adv.actions.length < (adv.action_count ?? adv.actions.length))
|
||||
// A campaign closed while undone reopens undone, so the controls have
|
||||
// to be told before the reader can press one.
|
||||
setHistory({ undo: !!adv.can_undo, redo: !!adv.can_redo })
|
||||
})
|
||||
.catch(() => navigate('/'))
|
||||
}, [id, navigate])
|
||||
|
||||
// Record what the server says the history controls can do. Every response
|
||||
// that carries a window carries them, so this is called wherever one lands.
|
||||
const noteHistory = useCallback((page) => {
|
||||
setHistory({ undo: !!page.can_undo, redo: !!page.can_redo })
|
||||
}, [])
|
||||
|
||||
// Take the story the server just handed back, whole.
|
||||
//
|
||||
// Switching a branch and forking one both answer with the newest window of
|
||||
@@ -173,11 +188,12 @@ export default function Play() {
|
||||
setActions(page.actions)
|
||||
setTotal(page.total)
|
||||
setHasMore(page.has_more)
|
||||
noteHistory(page)
|
||||
// The world state comes 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)
|
||||
}, [])
|
||||
}, [noteHistory])
|
||||
|
||||
// Fetch the page above the one on screen and prepend it.
|
||||
//
|
||||
@@ -216,6 +232,7 @@ export default function Play() {
|
||||
}
|
||||
setTotal(page.total)
|
||||
setHasMore(page.has_more)
|
||||
noteHistory(page)
|
||||
} catch {
|
||||
// Leave hasMore alone: a failed fetch should let the reader try again
|
||||
// by scrolling, not permanently hide the rest of their story. But hold
|
||||
@@ -227,7 +244,7 @@ export default function Play() {
|
||||
loadingOlderRef.current = false
|
||||
setLoadingOlder(false)
|
||||
}
|
||||
}, [actions, hasMore, id])
|
||||
}, [actions, hasMore, id, noteHistory])
|
||||
|
||||
// Put the viewport back after a prepend. useLayoutEffect, not useEffect:
|
||||
// this has to run before the browser paints, or the reader sees the story
|
||||
@@ -277,6 +294,12 @@ export default function Play() {
|
||||
setReasoningStream(null)
|
||||
setActions((prev) => [...prev, event.action])
|
||||
setTotal((n) => n + 1)
|
||||
// A turn was accepted, so there is something to undo and nothing left to
|
||||
// redo: writing below a moved-back head is the divergence that retires
|
||||
// the old future, and writing at the tip never had a future in front of
|
||||
// it. Set here rather than fetched, because the turn arrives over SSE and
|
||||
// this is the same answer the server would give.
|
||||
setHistory({ undo: true, redo: false })
|
||||
} else if (event.type === 'error') {
|
||||
setStreaming(null)
|
||||
setReasoningStream(null)
|
||||
@@ -363,30 +386,42 @@ export default function Play() {
|
||||
})
|
||||
}
|
||||
|
||||
async function undo() {
|
||||
// Undo and Redo differ only in which way the head moves, so they share
|
||||
// everything else: the same window shape comes back, the same reader state is
|
||||
// dropped, and the same world state has been restored underneath.
|
||||
async function moveHead(call) {
|
||||
setToast(null)
|
||||
setPreview(null)
|
||||
setPinned(null)
|
||||
try {
|
||||
// A window, not the whole story — undo is the action most likely to be
|
||||
// repeated several times running, so it must not re-fetch everything.
|
||||
const page = await api.undo(id)
|
||||
// A window, not the whole story — these are the actions most likely to be
|
||||
// repeated several times running, so they must not re-fetch everything.
|
||||
const page = await call(id)
|
||||
setActions(page.actions)
|
||||
setTotal(page.total)
|
||||
setHasMore(page.has_more)
|
||||
noteHistory(page)
|
||||
// The story is at a different position, so the state panels are showing
|
||||
// the numbers of a turn that is no longer where the story ends.
|
||||
setStateKey((k) => k + 1)
|
||||
} catch (err) {
|
||||
setToast({ text: err.message, isError: true })
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+Z undo / Ctrl+R retry, ignored while typing in a field.
|
||||
const undo = () => moveHead(api.undo)
|
||||
const redo = () => moveHead(api.redo)
|
||||
|
||||
// Ctrl+Z undo / Ctrl+Shift+Z redo / Ctrl+R retry, ignored while typing.
|
||||
useEffect(() => {
|
||||
const lastIsAi = actions.length > 0 && actions[actions.length - 1].type === 'ai'
|
||||
const canUndo = actions.length > 0 && actions[actions.length - 1].type !== 'start'
|
||||
const onKey = (e) => {
|
||||
if (!(e.ctrlKey || e.metaKey) || busy) return
|
||||
if (e.target.closest?.('input, textarea, select, [contenteditable]')) return
|
||||
if (e.key.toLowerCase() === 'z' && canUndo) {
|
||||
if (e.key.toLowerCase() === 'z' && e.shiftKey && history.redo) {
|
||||
e.preventDefault()
|
||||
redo()
|
||||
} else if (e.key.toLowerCase() === 'z' && !e.shiftKey && history.undo) {
|
||||
e.preventDefault()
|
||||
undo()
|
||||
} else if (e.key.toLowerCase() === 'r' && lastIsAi) {
|
||||
@@ -467,6 +502,7 @@ export default function Play() {
|
||||
setActions(adv.actions)
|
||||
setTotal(adv.action_count ?? adv.actions.length)
|
||||
setHasMore(adv.actions.length < (adv.action_count ?? adv.actions.length))
|
||||
setHistory({ undo: !!adv.can_undo, redo: !!adv.can_redo })
|
||||
// The branch and the world state can both have moved.
|
||||
setStateKey((k) => k + 1)
|
||||
} catch { /* stale beats wrong */ }
|
||||
@@ -490,7 +526,6 @@ export default function Play() {
|
||||
if (!adventure) return null
|
||||
|
||||
const lastIsAi = actions.length > 0 && actions[actions.length - 1].type === 'ai'
|
||||
const canUndo = actions.length > 0 && actions[actions.length - 1].type !== 'start'
|
||||
|
||||
return (
|
||||
<div className={`play-layout ${panel ? 'with-panel' : ''}`}>
|
||||
@@ -673,7 +708,12 @@ export default function Play() {
|
||||
<div className="turn-buttons">
|
||||
<button onClick={() => send('continue')} disabled={busy}>Continue ▸</button>
|
||||
<button onClick={retry} disabled={busy || !lastIsAi} title="Ctrl+R">↻ Retry</button>
|
||||
<button onClick={undo} disabled={busy || !canUndo} title="Ctrl+Z">↶ Undo</button>
|
||||
<button onClick={undo} disabled={busy || !history.undo} title="Ctrl+Z">↶ Undo</button>
|
||||
{/* Undo keeps what it steps back over, so this walks the story
|
||||
forward into it again. Disabled once a new turn has been written
|
||||
from here: that future is retained, but it is no longer the
|
||||
continuation this story tells. */}
|
||||
<button onClick={redo} disabled={busy || !history.redo} title="Ctrl+Shift+Z">↷ Redo</button>
|
||||
</div>
|
||||
<div className="input-bar">
|
||||
<div className="mode-select">
|
||||
|
||||
Reference in New Issue
Block a user