perf(story-mode): no long tasks while stepping through turns; the rest of review round 4

Stepping through 40 turns made 46 long tasks of 51 to 286 ms, enough for
the app's guard to drop the terminal's WebGL renderer for the rest of the
page's life. Drawing switched off made none: the cost was canvas raster at
paint time, which the per-frame JS budget never saw. Now:

- Panel backgrounds are shared between panels and turns (kind × size ×
  palette), and a new one is built in four bands, one per frame (a wide
  halftone at 2x was ~35 ms in one go; a band is ~8 ms).
- At most two panels are drawn per frame, the first one served rotating so
  none starves; clocks still run for all.
- Measured on 20 turns stepped 40 times at 2x: 1 long task (52 ms), against
  39; the settled overlay is still ~2% of a core.

Also from round 4:
- The turn picker says when older turns were not loaded (`older` in the
  response: the transcript is longer than the part that was read).
- A compaction after the last turn closes that turn; a local command's
  output is the final panel's balloon ("Compacted"), without its markdown
  ticks, and a turn under a second reads "Done.".
- Exact durations keep their seconds past an hour, so the two captions never
  disagree on the minute ("1 h 10 min." / "1 h 9 min 39 s").
- A turn that arrives already finished is announced as new too.
- Whole files written by here-documents read "Claude writes 3 files."; the
  advisor's caption reads "a second opinion" instead of repeating its name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 12:11:54 +02:00
1 parent 846749a912
commit 0effbb9ce4
15 files changed
+188 -44

No files matched your search

+27 -7
View File
@@ -278,7 +278,7 @@ export function exactDuration(ms: number): string {
if (s < 60) return `${s} s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m} min ${s % 60} s`;
return `${Math.floor(m / 60)} h ${m % 60} min`;
return `${Math.floor(m / 60)} h ${m % 60} min ${s % 60} s`;
}
const fmtCounts = (added: number, removed: number): string =>
@@ -984,7 +984,10 @@ export function createStoryReducer({ sidechain = false }: { sidechain?: boolean
const local = /^\s*<local-command-(stdout|stderr)>([\s\S]*?)<\/local-command-\1>/.exec(blockText(content));
if (local) {
// eslint-disable-next-line no-control-regex
const text = local[2].replace(/\x1b\[[0-9;]*[A-Za-z]/g, '').trim();
const text = local[2]
.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
.replace(/`([^`\n]+)`/g, '$1')
.trim();
return [{ k: 'local', at, text: clip(text, 2000) }];
}
const prompt = readPrompt(blockText(content));
@@ -1546,6 +1549,8 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] {
}
}
}
// A compaction after the last turn (no turn came after it to take it) closes that turn.
if (pendingCompact && turns.length) turns[turns.length - 1].events.push(pendingCompact);
return turns;
}
@@ -1915,7 +1920,8 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
: `Claude calls in ${plural(helpers.length, 'helper')}.`
: 'Claude sends a message to another session.';
const inside = evs.reduce((n, e) => n + (e.helper?.toolUses ?? 0), 0);
director = `${tools}${helpers.length ? ` · ${unique(helpers.map((h) => h.type)).join(' + ')}` : ''}${inside ? ` · ${plural(inside, 'call')} inside` : ''}`;
const types = unique(helpers.map((h) => h.type).filter((t) => t !== 'advisor'));
director = `${tools}${advisor ? ' · a second opinion' : ''}${types.length ? ` · ${types.join(' + ')}` : ''}${inside ? ` · ${plural(inside, 'call')} inside` : ''}`;
break;
}
case 'fix':
@@ -1930,12 +1936,16 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
panel.removed = edits.reduce((n, e) => n + e.removed, 0);
if (draft.kind === 'fix') {
const created = unique(edits.filter((e) => e.created).map((e) => e.file)).length;
const whole =
edits.length > 0 && edits.every((d) => d.created || d.hunks.every((h) => !h.oldStart && !h.oldLines));
story =
created && created === files.length
? `Claude writes ${plural(created, 'new file')}.`
: files.length
? `Claude changes ${plural(files.length, 'file')}.`
: 'Claude changes a file.';
: whole && files.length
? `Claude writes ${plural(files.length, 'file')}.`
: files.length
? `Claude changes ${plural(files.length, 'file')}.`
: 'Claude changes a file.';
const perFile = files.slice(0, 3).map((f) => {
const mine = edits.filter((e) => e.file === f);
return `${basename(f)} ${fmtCounts(
@@ -2100,8 +2110,18 @@ function completePanel(turn: StoryTurn, page: Omit<StoryPage, 'panels'>, trailin
]
.filter(Boolean)
.join(' · ');
const panel: StoryPanel = { kind: 'complete', events: [], caption: { story, director } };
const panel: StoryPanel = {
kind: 'complete',
events: [],
caption: {
story: turn.durationMs !== undefined && turn.durationMs < 1000 && story === done ? 'Done.' : story,
director,
},
};
// A local command answers itself: its own first line is the last word.
const said = turn.endedBy === 'local command' ? turn.events[0]?.output?.split('\n')[0] : undefined;
if (trailingSay) panel.balloon = { who: 'claude', text: trailingSay };
else if (said) panel.balloon = { who: 'claude', text: clip(said, 160) };
if (page.filesChanged) {
panel.added = page.added;
panel.removed = page.removed;
+7 -1
View File
@@ -203,4 +203,10 @@ export type StoryHelperResponse =
export type StoryResponse =
| { supported: false; reason: string }
| { supported: true; turns: StoryTurnSummary[]; page: StoryPage | null };
| {
supported: true;
turns: StoryTurnSummary[];
page: StoryPage | null;
/** The transcript is longer than the part that was read: older turns are not listed. */
older?: boolean;
};
+96 -18
View File
@@ -45,6 +45,15 @@ const STORY_STAGE_MAX = 680;
* panel at display rate cost ~80% of a core with five panels on a 2x screen).
*/
const STORY_FPS_INTRO = 30;
/** Panel backgrounds kept for reuse (kind × size × palette). */
const STORY_BG_CACHE = 32;
/** Horizontal bands a new background is built in, one per frame. */
const STORY_BG_BANDS = 4;
const storyBgCache = new Map();
/** Milliseconds of drawing per frame; panels past it wait for the next frame (no long tasks). */
const STORY_FRAME_BUDGET_MS = 10;
/** Panels drawn per frame at most: the raster cost lands at paint time, where the JS budget cannot see it. */
const STORY_DRAWS_PER_FRAME = 2;
const STORY_FPS_LIVE = 15;
const STORY_FPS_SETTLED = 6;
/** A settled panel animates this long, then holds still until the pointer wakes it again. */
@@ -123,6 +132,16 @@ const StoryArt = (() => {
ctx.fillStyle = C.panel;
ctx.fillRect(0, 0, W, H);
}
/** While a background is built in bands (one per frame), the band being drawn: dots outside it are skipped. */
let band = null;
function inBand(top, bottom, fn) {
band = { top, bottom };
try {
fn();
} finally {
band = null;
}
}
function halftone(ctx, W, H, color, fx, fy, { cell = 9, rmax = 3.6, reach = 1, alpha = 1 } = {}) {
const diag = Math.hypot(W, H) * reach;
const R = Math.hypot(W, H) / 2 + cell;
@@ -136,6 +155,7 @@ const StoryArt = (() => {
const ux = gx * k - gy * k + W / 2;
const uy = gx * k + gy * k + H / 2;
if (ux < -cell || uy < -cell || ux > W + cell || uy > H + cell) continue;
if (band && (uy < band.top - cell || uy > band.bottom + cell)) continue;
const r = rmax * (1 - Math.hypot(ux - fx, uy - fy) / diag);
if (r < 0.4) continue;
ctx.moveTo(ux + r, uy);
@@ -1418,7 +1438,7 @@ const StoryArt = (() => {
},
};
return { FPS, HERO, VH, C, readColors, spring, SCENES, takeSpeaker, editsOf };
return { FPS, HERO, VH, C, readColors, spring, SCENES, takeSpeaker, editsOf, inBand };
})();
/** Panel kinds as the wire's group headings name them. */
@@ -1846,10 +1866,25 @@ Object.assign(CodemanApp.prototype, {
if (!st.open) return;
const dt = last ? Math.min(100, t - last) : 16;
last = t;
if (!reduced.matches && !document.hidden) {
for (const rt of st.panels) {
if (!document.hidden) {
// Clocks run for every panel; drawing is rationed per frame, because a canvas rasterizes what
// was drawn when the frame is painted (12 panels redrawn in one frame made 50-80 ms tasks that
// the JS time alone never showed). The panel served first rotates, so none starves.
if (!reduced.matches) for (const rt of st.panels) if (rt.visible) rt.clock += (dt / 1000) * StoryArt.FPS;
const start = performance.now();
const n = st.panels.length;
let drawn = 0;
for (let j = 0; j < n; j++) {
if (drawn >= STORY_DRAWS_PER_FRAME || performance.now() - start > STORY_FRAME_BUDGET_MS) break;
const rt = st.panels[((st.drawCursor || 0) + j) % n];
if (!rt.visible) continue;
rt.clock += (dt / 1000) * StoryArt.FPS;
if (rt.pending) {
rt.pending = false;
this._storyDrawPanel(rt);
drawn++;
continue;
}
if (reduced.matches) continue;
const intro = rt.clock < StoryArt.HERO;
const fps = intro ? STORY_FPS_INTRO : rt.panel?.live ? STORY_FPS_LIVE : STORY_FPS_SETTLED;
if (!intro && !rt.panel?.live) {
@@ -1859,7 +1894,9 @@ Object.assign(CodemanApp.prototype, {
if (t < (rt.nextDrawAt || 0)) continue;
rt.nextDrawAt = t + 1000 / fps - 4;
this._storyDrawPanel(rt);
drawn++;
}
st.drawCursor = n ? ((st.drawCursor || 0) + 1) % n : 0;
}
st.raf = requestAnimationFrame(loop);
};
@@ -1982,6 +2019,13 @@ Object.assign(CodemanApp.prototype, {
return o;
})
);
if (data?.older) {
const o = document.createElement('option');
o.disabled = true;
o.value = '';
o.textContent = '… older turns are not loaded';
sel.prepend(o);
}
if (page) sel.value = page.id;
sel.disabled = turns.length < 2;
const idx = page ? turns.findIndex((t) => t.id === page.id) : -1;
@@ -2019,12 +2063,12 @@ Object.assign(CodemanApp.prototype, {
const key = `${page.id}|${done}`;
if (st.announced === key) return;
const first = st.announced === undefined;
const newTurn = !first && !st.announced.startsWith(`${page.id}|`);
st.announced = key;
if (first) return;
const last = page.panels[page.panels.length - 1];
box.textContent = done
? `Turn finished. ${last?.caption.story || ''}`.trim()
: `New turn: ${page.prompt.slice(0, 140)}`;
const finished = `${done ? `Turn finished. ${last?.caption.story || ''}` : ''}`.trim();
box.textContent = newTurn ? `New turn: ${page.prompt.slice(0, 140)}${finished ? `. ${finished}` : ''}` : finished;
},
_storyRenderPanels(page) {
@@ -2067,7 +2111,7 @@ Object.assign(CodemanApp.prototype, {
rt.el.style.setProperty('--story-span6', String(spans6[i]));
rt.el.style.setProperty('--story-span2', String(spans2[i]));
this._storyFillPanelText(rt, page, i);
this._storyDrawPanel(rt);
this._storyQueueDraw(rt);
});
for (const rt of st.panels.splice(want.length)) this._storyDropPanel(rt);
@@ -2093,6 +2137,7 @@ Object.assign(CodemanApp.prototype, {
if (focusAt >= 0 && !document.contains(focused)) {
const rt = st.panels[Math.min(focusAt, st.panels.length - 1)];
if (rt && !rt.w) this._storySizePanel(rt);
if (rt) this._storyQueueDraw(rt, true); // its click regions must exist to take focus
const target = rt && ((focusHit && rt.hitEls.get(focusHit)) || rt.hitEls.values().next().value);
if (target) target.focus({ preventScroll: true });
else {
@@ -2263,7 +2308,7 @@ Object.assign(CodemanApp.prototype, {
const s = Math.round(ms / 1000);
if (s < 60) return `${s} s`;
const m = Math.floor(s / 60);
return m < 60 ? `${m} min ${s % 60} s` : `${Math.floor(m / 60)} h ${m % 60} min`;
return m < 60 ? `${m} min ${s % 60} s` : `${Math.floor(m / 60)} h ${m % 60} min ${s % 60} s`;
},
_storySizePanel(rt) {
@@ -2277,7 +2322,13 @@ Object.assign(CodemanApp.prototype, {
rt.canvas.width = Math.max(1, Math.round(w * dpr));
rt.canvas.height = Math.max(1, Math.round(h * dpr));
rt.bgKey = '';
this._storyDrawPanel(rt);
this._storyQueueDraw(rt);
},
/** Draws on the next frame, within its budget (now, when the frame loop is not running). */
_storyQueueDraw(rt, now = false) {
if (now || !this._story?.raf) this._storyDrawPanel(rt);
else rt.pending = true;
},
_storyDrawPanel(rt) {
@@ -2288,21 +2339,48 @@ Object.assign(CodemanApp.prototype, {
const s = rt.h / StoryArt.VH;
const W = rt.w / s;
const k = rt.dpr * s;
const key = `${rt.canvas.width}x${rt.canvas.height}|${StoryArt.C.key}`;
const key = `${rt.kind}|${rt.canvas.width}x${rt.canvas.height}|${StoryArt.C.key}`;
if (rt.bgKey !== key) {
rt.bg ??= document.createElement('canvas');
rt.bg.width = rt.canvas.width;
rt.bg.height = rt.canvas.height;
const b = rt.bg.getContext('2d');
b.setTransform(k, 0, 0, k, 0, 0);
scene.bg(b, W, StoryArt.VH);
// A background depends only on the kind, the size and the palette: shared between panels and
// turns, so stepping through turns draws no halftone twice. A new one is built in horizontal
// bands, one per frame (a wide halftone at 2x is ~35 ms in one go, a long task that made the
// app drop its WebGL terminal renderer); the panel shows it as it fills in.
let job = storyBgCache.get(key);
if (!job) {
const canvas = document.createElement('canvas');
canvas.width = rt.canvas.width;
canvas.height = rt.canvas.height;
job = { canvas, next: 0, total: STORY_BG_BANDS };
storyBgCache.set(key, job);
if (storyBgCache.size > STORY_BG_CACHE) storyBgCache.delete(storyBgCache.keys().next().value);
}
rt.bg = job;
rt.bgKey = key;
}
const job = rt.bg;
if (job.next < job.total) {
const b = job.canvas.getContext('2d');
// Without a frame loop (tests, a browser that never paints) the whole background at once.
const steps = st?.raf ? 1 : job.total - job.next;
for (let i = 0; i < steps && b; i++, job.next++) {
const top = (StoryArt.VH / job.total) * job.next;
const bottom = top + StoryArt.VH / job.total;
b.save();
b.setTransform(k, 0, 0, k, 0, 0);
b.beginPath();
b.rect(0, top, W, bottom - top);
b.clip();
StoryArt.inBand(top, bottom, () => scene.bg(b, W, StoryArt.VH));
b.restore();
}
if (!b) job.next = job.total;
if (job.next < job.total) rt.pending = true;
}
const ctx = rt.ctx;
if (!ctx) return; // no 2D context (a browser out of canvas memory): the captions still read
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, rt.canvas.width, rt.canvas.height);
ctx.drawImage(rt.bg, 0, 0);
ctx.drawImage(job.canvas, 0, 0);
const stageW = scene.fullWidth ? W : Math.min(W, STORY_STAGE_MAX);
const ox = (W - stageW) / 2;
ctx.setTransform(k, 0, 0, k, ox * k, 0);
+6 -1
View File
@@ -115,7 +115,12 @@ export function registerStoryRoutes(app: FastifyInstance, ctx: SessionPort & Con
const wanted = turnKey ? turns.find((t) => t.id === turnKey) : turns[turns.length - 1];
return {
success: true,
data: { supported: true, turns: summarizeTurns(turns), page: wanted ? buildStoryPage(wanted) : null },
data: {
supported: true,
turns: summarizeTurns(turns),
page: wanted ? buildStoryPage(wanted) : null,
...(read.partial ? { older: true } : {}),
},
};
});
@@ -43,7 +43,7 @@
"kind": "backup",
"ok": null,
"story": "Claude calls in 2 helpers.",
"director": "Advisor · Agent · advisor + general-purpose · 6 calls inside",
"director": "Advisor · Agent · a second opinion · general-purpose · 6 calls inside",
"balloon": null,
"tests": null,
"events": 2
@@ -34,7 +34,7 @@
"kind": "backup",
"ok": null,
"story": "Claude asks the advisor for a second opinion.",
"director": "Advisor · advisor",
"director": "Advisor · a second opinion",
"balloon": null,
"tests": null,
"events": 1
@@ -116,7 +116,7 @@
"ok": null,
"story": "Done in 24 seconds.",
"director": "local command · 24 s · 0 tool calls",
"balloon": null,
"balloon": "Compacted",
"tests": null,
"events": 0
}
@@ -166,7 +166,7 @@
"kind": "backup",
"ok": null,
"story": "Claude asks the advisor for a second opinion.",
"director": "Advisor · advisor",
"director": "Advisor · a second opinion",
"balloon": null,
"tests": null,
"events": 1
@@ -34,7 +34,7 @@
"kind": "backup",
"ok": null,
"story": "Claude asks the advisor for a second opinion.",
"director": "Advisor · advisor",
"director": "Advisor · a second opinion",
"balloon": null,
"tests": null,
"events": 1
@@ -34,7 +34,7 @@
"kind": "backup",
"ok": null,
"story": "Claude asks the advisor for a second opinion.",
"director": "Advisor · advisor",
"director": "Advisor · a second opinion",
"balloon": null,
"tests": null,
"events": 1
@@ -42,7 +42,7 @@
{
"kind": "fix",
"ok": null,
"story": "Claude changes 3 files.",
"story": "Claude writes 3 files.",
"director": "Bash · pyproject.toml +11 · .gitignore +11 · __init__.py +3",
"balloon": null,
"tests": null,
@@ -112,7 +112,7 @@
"kind": "backup",
"ok": null,
"story": "Claude asks the advisor for a second opinion.",
"director": "Advisor · advisor",
"director": "Advisor · a second opinion",
"balloon": null,
"tests": null,
"events": 1
@@ -34,7 +34,7 @@
"kind": "backup",
"ok": null,
"story": "Claude asks the advisor for a second opinion.",
"director": "Advisor · advisor",
"director": "Advisor · a second opinion",
"balloon": null,
"tests": null,
"events": 1
@@ -64,7 +64,7 @@
"kind": "backup",
"ok": null,
"story": "Claude asks the advisor for a second opinion.",
"director": "Advisor · advisor",
"director": "Advisor · a second opinion",
"balloon": null,
"tests": null,
"events": 1
@@ -43,7 +43,7 @@
"kind": "backup",
"ok": null,
"story": "Claude asks the advisor for a second opinion.",
"director": "Advisor · advisor",
"director": "Advisor · a second opinion",
"balloon": null,
"tests": null,
"events": 1
@@ -103,7 +103,7 @@
"kind": "backup",
"ok": null,
"story": "Claude asks the advisor for a second opinion.",
"director": "Advisor · advisor",
"director": "Advisor · a second opinion",
"balloon": null,
"tests": null,
"events": 1
@@ -34,7 +34,7 @@
"kind": "backup",
"ok": null,
"story": "Claude asks the advisor for a second opinion.",
"director": "Advisor · advisor",
"director": "Advisor · a second opinion",
"balloon": null,
"tests": null,
"events": 1
@@ -220,7 +220,7 @@
"kind": "backup",
"ok": null,
"story": "Claude asks the advisor for a second opinion.",
"director": "Advisor · advisor",
"director": "Advisor · a second opinion",
"balloon": null,
"tests": null,
"events": 1
@@ -43,7 +43,7 @@
"kind": "backup",
"ok": null,
"story": "Claude asks the advisor for a second opinion.",
"director": "Advisor · advisor",
"director": "Advisor · a second opinion",
"balloon": null,
"tests": null,
"events": 1
@@ -43,7 +43,7 @@
"kind": "backup",
"ok": null,
"story": "Claude asks the advisor for a second opinion.",
"director": "Advisor · advisor",
"director": "Advisor · a second opinion",
"balloon": null,
"tests": null,
"events": 1
@@ -82,7 +82,7 @@
"kind": "backup",
"ok": null,
"story": "Claude asks the advisor for a second opinion.",
"director": "Advisor · advisor",
"director": "Advisor · a second opinion",
"balloon": null,
"tests": null,
"events": 1
+16
View File
@@ -153,6 +153,22 @@ describe('GET /api/sessions/:id/story', () => {
expect(page.panels[0].kind).toBe('mission');
});
it('says when older turns were not read', async () => {
const big = 'x'.repeat(100_000);
const lines: unknown[] = [prompt('an old turn')];
for (let i = 0; i < 90; i++) lines.push(...bash(`b${i}`, `cat big${i}.txt`, `Show file ${i}`, big));
lines.push(end(60_000), prompt('the newest turn'), ...bash('n1', 'ls', 'List', 'a'), end(1000));
writeFileSync(transcript, jsonl(lines));
const { app } = await setup();
const data = (await app.inject({ method: 'GET', url: URL })).json().data;
expect(data.page.prompt).toBe('the newest turn');
expect(data.older).toBe(true);
writeFileSync(transcript, jsonl([prompt('one'), end(1000)]));
const small = (await app.inject({ method: 'GET', url: URL })).json().data;
expect(small.older).toBeUndefined();
});
it('a live turn grows as the transcript grows', async () => {
writeFileSync(transcript, jsonl([prompt('go'), ...read('r1', '/w/a.ts')]));
const { app } = await setup();
+19
View File
@@ -194,6 +194,9 @@ describe('text helpers', () => {
expect(exactDuration(432_000)).toBe('7 min 12 s');
expect(roughDuration(4_000)).toBe('4 seconds');
expect(roughDuration(3_900_000)).toBe('1 h 5 min');
// Past an hour the exact one keeps its seconds, so the two never disagree on the minute.
expect(roughDuration(4_179_269)).toBe('1 h 10 min');
expect(exactDuration(4_179_269)).toBe('1 h 9 min 39 s');
});
});
@@ -683,6 +686,22 @@ describe('buildStoryTurns', () => {
expect(page.panels.some((p) => /you add \d+ things?/.test(p.caption.story))).toBe(true);
});
it('an automatic compaction after the last turn closes that turn', () => {
const [turn] = buildStoryTurns(
entriesOf([
prompt('one'),
end(1000),
{
type: 'system',
subtype: 'compact_boundary',
timestamp: '2026-10-18T10:00:00.000Z',
compactMetadata: { trigger: 'auto' },
},
])
);
expect(turn.events.map((e) => e.intent)).toEqual(['prompt', 'compact']);
});
it('an interrupt ends the turn', () => {
const turns = buildStoryTurns(
entriesOf([prompt('go'), ...bash('b', 'ls', 'List', 'a'), prompt('[Request interrupted by user]')])