fix(story-mode): live helper steps behind an unchanged page, a clean reopen, no wedged view

From review round 7 (PR review):

- A helper's drawer froze while the helper worked: a background helper
  writes only to its own transcript, so the parent page answered 304 and
  the refresh returned before anything refreshed the drawer. An unchanged
  page now still refreshes an open drawer's working helpers (ETag'd).
- A helper messaged twice read as superseded ("The trail stops here…"):
  its one-page run took its ending from the first turn. It takes the last.
- More than 60 turns in a small file listed the newest 60 with no hint;
  `older` is now set whenever the list leaves turns out.
- One /story request that never settled wedged the view, even across a
  reopen. Requests carry a token and a 30 s timeout, and opening resets
  the request state, with what was announced and shown.
- The background canvases (megabytes each at 2x) are released when Story
  Mode closes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 14:38:13 +02:00
1 parent 91ee063fc2
commit e7ce3b8e56
6 files changed
+132 -9

No files matched your search

+3
View File
@@ -2512,10 +2512,13 @@ export function buildHelperPage(turns: StoryTurn[], finished?: boolean): StoryPa
if (!turns.length) return null;
const first = turns[0];
const last = turns[turns.length - 1];
// One helper's whole run as one page: how it ended is the LAST turn's (the first one "ended" only
// because the helper was messaged again, which would read as superseded).
return buildStoryPage({
...first,
events: turns.flatMap((t) => t.events),
ended: finished ?? last.ended,
endedBy: last.endedBy === 'next prompt' ? undefined : last.endedBy,
interrupted: false,
endedAt: last.endedAt,
durationMs: undefined,
+42 -7
View File
@@ -33,6 +33,8 @@ const STORY_REFRESH_DEBOUNCE_MS = 800;
const STORY_POLL_MS = 4000;
/** How often a finished newest page is checked for a turn no hook announced. */
const STORY_IDLE_POLL_MS = 15000;
/** A /story request that has not settled by then counts as failed, so the view never wedges. */
const STORY_REQUEST_TIMEOUT_MS = 30000;
/**
* Widest stage a scene is drawn on, in virtual units. A panel stretched across a whole row keeps its
* art together in a centered stage instead of spreading it to both ends; the background still fills it.
@@ -1579,6 +1581,15 @@ Object.assign(CodemanApp.prototype, {
st.epoch++;
st.first = true;
st.drawer = null;
st.drawerHolds = false;
// What was said and shown belongs to the last session drawn, not this one.
st.announced = undefined;
st.knownTurns = undefined;
st.shownPageId = undefined;
st.picked = false;
// A request still out from before is ignored (its epoch is old); it must not block this one.
st.inFlight = 0;
st.again = false;
st.root.hidden = false;
document.body.classList.add('story-open');
this._storyApplyTheme();
@@ -1604,6 +1615,8 @@ Object.assign(CodemanApp.prototype, {
this._storyStopRuntime();
this._storyCloseDrawer({ refocus: false });
for (const rt of st.panels) this._storyDropPanel(rt);
// Full-size backing canvases (megabytes each at 2x): not kept once Story Mode is closed.
storyBgCache.clear();
st.panels = [];
st.pageEl.replaceChildren();
st.wireList.replaceChildren();
@@ -1874,16 +1887,28 @@ Object.assign(CodemanApp.prototype, {
st.again = true;
return;
}
st.inFlight = true;
const mine = (st.requestSeq = (st.requestSeq || 0) + 1);
st.inFlight = mine;
const epoch = st.epoch;
try {
const qs = st.turnId ? `?turn=${encodeURIComponent(st.turnId)}` : '';
const res = await this._api(`/api/sessions/${encodeURIComponent(st.sessionId)}/story${qs}`);
// A request that never settles must not wedge the view: after the timeout it counts as failed.
let timer;
const timeout = new Promise((resolve) => (timer = setTimeout(() => resolve(null), STORY_REQUEST_TIMEOUT_MS)));
const res = await Promise.race([
this._api(`/api/sessions/${encodeURIComponent(st.sessionId)}/story${qs}`),
timeout,
]).finally(() => clearTimeout(timer));
if (epoch !== st.epoch || !st.open) return;
// The route sends an ETag and no-cache, so the browser revalidates and an unchanged page comes
// back from its cache with the same tag: nothing to parse, nothing to redraw.
const etag = res?.ok ? res.headers?.get?.('etag') || null : null;
if (etag && etag === st.etag && !st.error) return;
if (etag && etag === st.etag && !st.error) {
// The page holds still, but a helper still working writes only to its own transcript (a
// foreground helper adds nothing to this one until it returns): its open drawer refreshes.
this._storyRefreshWorkingHelpers();
return;
}
let body = null;
try {
body = res ? await res.json() : null;
@@ -1916,14 +1941,24 @@ Object.assign(CodemanApp.prototype, {
}
this._storyRender();
} finally {
st.inFlight = false;
if (st.again) {
st.again = false;
this._storyScheduleRefresh(250);
if (st.inFlight === mine) {
st.inFlight = 0;
if (st.again) {
st.again = false;
this._storyScheduleRefresh(250);
}
}
}
},
/** An open drawer's helpers that are still working fetch their newest steps (each ETag'd). */
_storyRefreshWorkingHelpers() {
const st = this._story;
if (!st?.drawer) return;
for (const box of st.drawerBody.querySelectorAll('.story-helper-steps'))
if (box.dataset.working === '1') this._storyFillHelperSteps(box, box.dataset.agent, true);
},
// ── Runtime: frame loop, observers, poll, Escape ──
_storyStartRuntime() {
+3 -2
View File
@@ -30,7 +30,7 @@ import {
type StoryResponse,
} from '../../types.js';
import { getCli } from '../../config/cli-registry/registry.js';
import { buildHelperPage, buildStoryPage, buildStoryTurns, summarizeTurns } from '../../story-mode.js';
import { STORY_LIMITS, buildHelperPage, buildStoryPage, buildStoryTurns, summarizeTurns } from '../../story-mode.js';
import { readSmallRegularFile, readStoryEntries, storyFileVersion } from '../../story-reader.js';
import { claudeProjectsDir, findClaudeTranscriptPath } from '../../utils/claude-transcript.js';
import { findSessionOrFail } from '../route-helpers.js';
@@ -169,7 +169,8 @@ export function registerStoryRoutes(app: FastifyInstance, ctx: SessionPort & Con
supported: true,
turns: summarizeTurns(turns),
page: wanted ? buildStoryPage(wanted) : null,
...(read.partial ? { older: true } : {}),
// Older turns exist that the list leaves out: before the part read, or past the listed ones.
...(read.partial || turns.length > STORY_LIMITS.turnsListed ? { older: true } : {}),
},
};
});