fix(story-mode): the rest of the PR review: lookups, polling, frame budget, linear links

From review round 7 (PR review):

- With no hook path (normal after a server restart), the transcript was
  looked up by conversation id only; it now also tries the Codeman id and
  a recovered session's `restored-<fragment>` transcript, as
  last-response does, and keeps what it found briefly (a lookup reads
  every project folder; a miss is kept only 4 s).
- A session Story Mode cannot draw is not polled, and a page with nothing
  drawn yet is polled like a finished one (every 15 s), not every 4 s.
- Helper steps: one request per helper is drawn, the newest; a failed
  refetch never writes over steps already shown.
- The Director's cut toggle, a skin change and the fonts arriving redraw
  within the frame budget, not every panel in one task.
- A bare name under an `ls` header finds its folder in one pass over the
  lines; a search per link over the text before it was quadratic (about
  1.5 s for 700 links on agent output).
- A page of many questions still keeps to 12 panels: questions fold last.
- Opening Story Mode while it is open never starts a second runtime; the
  status announcer carries data-i18n-skip; the turn key in the ETag is
  clipped.
- The crafted-input timing test runs at sizes where the old code took 6
  to 29 s and the new one under 10 ms, with a 1.5 s bound.
- Docs: `older`, `origin` (`scheduled`), which route answers a 500;
  mobile.css's stale comment; an orphaned doc comment.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 14:44:37 +02:00
1 parent e7ce3b8e56
commit 42d1c75a19
10 files changed
+191 -41

No files matched your search

+9 -5
View File
@@ -1860,10 +1860,10 @@ interface DraftPanel {
rounds?: number;
}
/** Keeps the newest outputs, hunks and helper texts within the page budget. */
/** Every edit an event made: an Edit/Write call's one, or the files a Bash call wrote. */
const editsOf = (e: StoryEvent): StoryEdit[] => (e.edit ? [e.edit] : (e.edits ?? []));
/** Keeps the newest outputs, hunks and helper texts within the page budget; true when it cut any. */
function applyTextBudget(events: StoryEvent[]): boolean {
let used = 0;
let cut = false;
@@ -2000,9 +2000,10 @@ function capPanels(panels: DraftPanel[], max: number): DraftPanel[] {
const firstMergeable = out[0]?.kind === 'mission' ? 1 : 0;
while (out.length > max) {
let best = -1;
// Three passes: quiet kinds; then anything but a prompt or a question; then, as a last resort, a
// prompt you added mid-turn (the turn's own prompt and a question to you are never folded).
search: for (const pass of [0, 1, 2]) {
// Four passes: quiet kinds; then anything but a prompt or a question; then a prompt you added
// mid-turn; then, only so the page keeps its 12 panels, a question too (the turn's own prompt
// is never folded).
search: for (const pass of [0, 1, 2, 3]) {
for (const lastStart of [out.length - 3, out.length - 2]) {
let oldest = -1;
for (let k = firstMergeable; k <= lastStart; k++) {
@@ -2013,7 +2014,10 @@ function capPanels(panels: DraftPanel[], max: number): DraftPanel[] {
? MINOR.has(a.kind) && MINOR.has(b.kind)
: pass === 1
? ![a.kind, b.kind].some((kind) => kind === 'mission' || kind === 'ask')
: ![a, b].some((d) => d.kind === 'ask' || (d.kind === 'mission' && d.events[0] === out[0]?.events[0]));
: ![a, b].some(
(d) =>
(pass === 2 && d.kind === 'ask') || (d.kind === 'mission' && d.events[0] === out[0]?.events[0])
);
if (!ok) continue;
if (a.kind === 'montage' || b.kind === 'montage') {
best = k;
+2 -1
View File
@@ -244,7 +244,8 @@ async function loadFrom(
info.size - cached.offset <= STORY_TAIL_BYTES &&
info.size >= cached.size
) {
// The file only grew: read what was appended since the last complete line.
// The file only grew: read what was appended since the last complete line. (A file rewritten to
// a larger size would read as an append; transcripts are append-only, so it never is.)
const buf = await readRange(handle, cached.offset, info.size);
await ingest(cached, buf, false);
cached.size = info.size;
+38
View File
@@ -47,6 +47,44 @@ export function claudeProjectsDir(configDir?: string): string {
return join(configDir || fromEnv || join(homedir(), '.claude'), 'projects');
}
/**
* The one top-level transcript a recovered session (`restored-<first 8+ hex of its uuid>`, how
* reconcileSessions() names a session whose mux record was lost) belongs to, or null when none or
* more than one matches. The same recovery `last-response` makes (session-routes.ts).
*/
export async function findRestoredClaudeTranscriptPath(
codemanSessionId: string,
configDir?: string
): Promise<string | null> {
const restored = /^restored-([a-f0-9]{8,})$/i.exec(codemanSessionId);
if (!restored) return null;
const fragment = restored[1].toLowerCase();
const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i;
const projectsDir = claudeProjectsDir(configDir);
let projectDirs: string[];
try {
projectDirs = await readdir(projectsDir);
} catch {
return null;
}
const found = new Map<string, string>();
for (const projectDir of projectDirs) {
let files: string[];
try {
files = await readdir(join(projectsDir, projectDir));
} catch {
continue;
}
for (const file of files) {
const id = file.endsWith('.jsonl') ? file.slice(0, -'.jsonl'.length) : '';
if (!id.toLowerCase().startsWith(fragment) || !uuid.test(id)) continue;
const path = join(projectsDir, projectDir, file);
if ((await stat(path).catch(() => null))?.isFile()) found.set(id, path);
}
}
return found.size === 1 ? [...found.values()][0] : null;
}
/**
* Path of the transcript for `conversationId` under any project directory, or null. Unlike
* {@link claudeTranscriptExists} it answers with the file (Story Mode reads it), so the id is
+2 -2
View File
@@ -590,8 +590,8 @@ html.mobile-init .file-browser-panel {
(inline approve/deny in mobile-overview.js); the bell would only crowd the
header it was designed to stay out of. */
.btn-icon-header.btn-approvals,
/* Story Mode: opt-in and desktop-first for now; the overlay itself is responsive, a phone
entry point is a follow-up rather than one more icon in this header. */
/* Story Mode: phones open it from Session Options (#sessionOptionsStoryBtn), not one more icon
in this header; the overlay itself is responsive. */
.btn-icon-header.btn-story-header {
display: none !important;
}
+45 -17
View File
@@ -1570,7 +1570,9 @@ Object.assign(CodemanApp.prototype, {
return;
}
const st = this._storyState();
st.returnFocus = document.activeElement;
// Opened again while open (another session): one runtime, never two frame loops and polls.
if (st.open) this._storyStopRuntime();
else st.returnFocus = document.activeElement;
st.open = true;
st.sessionId = sessionId;
st.turnId = null;
@@ -1703,6 +1705,7 @@ Object.assign(CodemanApp.prototype, {
bar.append(brand, nav, seg, live, saveBtn, closeBtn);
// What a screen reader hears as the page changes: a new turn, a finished one.
const announcer = el('p', 'story-sr');
announcer.dataset.i18nSkip = ''; // it reads out the agent's prompts
announcer.setAttribute('role', 'status');
announcer.setAttribute('aria-live', 'polite');
@@ -1846,7 +1849,8 @@ Object.assign(CodemanApp.prototype, {
st.dirBtn.setAttribute('aria-pressed', String(on));
st.wire.hidden = !on;
if (on) this._storyRenderWire();
for (const rt of st.panels) this._storyDrawPanel(rt);
// Within the frame budget, never every panel in one task.
for (const rt of st.panels) this._storyQueueDraw(rt);
},
_storyPickTurn(id) {
@@ -2040,10 +2044,13 @@ Object.assign(CodemanApp.prototype, {
st.pollTimer = setInterval(() => {
const page = st.data?.page;
if (!st.open || document.hidden) return;
if (!st.turnId && (!page || !page.complete)) {
// A session Story Mode cannot draw does not change that by itself: nothing to poll.
if (st.data && st.data.supported === false) return;
if (!st.turnId && page && !page.complete && !page.interrupted) {
this._storyRefresh();
return;
}
// Nothing drawn yet (no transcript), a finished page or one parked on: now and then only.
// A finished newest page: look again now and then (a local command like /compact raises no
// hook). An unchanged page is a 304.
// Parked on an older turn: the turn list still learns about new turns now and then.
@@ -2082,14 +2089,14 @@ Object.assign(CodemanApp.prototype, {
window.addEventListener('keydown', st.keyHandler, true);
st.skinObserver = new MutationObserver(() => {
this._storyApplyTheme();
for (const rt of st.panels) this._storyDrawPanel(rt);
for (const rt of st.panels) this._storyQueueDraw(rt);
});
st.skinObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-skin', 'class'] });
document.fonts?.ready?.then(() => {
if (!st.open) return;
for (const rt of st.panels) {
rt.bgKey = '';
this._storyDrawPanel(rt);
this._storyQueueDraw(rt);
}
});
},
@@ -3114,7 +3121,8 @@ Object.assign(CodemanApp.prototype, {
const m = /^([^@]*)@[^:]*:(\d+)$/.exec(key);
back =
opens.find((n) => n.dataset.storyOpen === key) ||
(m && opens.find((n) => new RegExp(`^${m[1]}@[^:]*:${m[2]}$`).test(n.dataset.storyOpen)));
(m &&
opens.find((n) => n.dataset.storyOpen.startsWith(`${m[1]}@`) && n.dataset.storyOpen.endsWith(`:${m[2]}`)));
}
if (refocus && back && document.contains(back)) back.focus({ preventScroll: true });
},
@@ -3362,10 +3370,14 @@ Object.assign(CodemanApp.prototype, {
p.textContent = 'Reading its steps…';
box.replaceChildren(p);
}
// One request per helper at a time, and only the newest one's answer is drawn.
st.helperSeq ??= new Map();
const seq = (st.helperSeq.get(agentId) || 0) + 1;
st.helperSeq.set(agentId, seq);
const res = await this._api(
`/api/sessions/${encodeURIComponent(st.sessionId)}/story/helpers/${encodeURIComponent(agentId)}`
);
if (!box.isConnected || !st.open) return;
if (!box.isConnected || !st.open || st.helperSeq.get(agentId) !== seq) return;
const etag = res?.ok ? res.headers?.get?.('etag') || null : null;
if (cached && etag && etag === cached.etag) return;
let body = null;
@@ -3376,7 +3388,10 @@ Object.assign(CodemanApp.prototype, {
}
if (!box.isConnected || !st.open) return;
if (!body?.success) {
if (!cached) box.firstChild.textContent = 'Its steps could not be read.';
// Only over the placeholder: never over steps another answer already drew.
const only = box.firstElementChild;
if (box.childElementCount === 1 && only?.classList.contains('story-empty'))
only.textContent = 'Its steps could not be read.';
return;
}
st.helperSteps?.set(agentId, { etag, data: body.data });
@@ -3569,6 +3584,25 @@ Object.assign(CodemanApp.prototype, {
)
: [];
const workingDir = this.sessions?.get(this._story.sessionId)?.workingDir;
// The `ls` header (`some/dir:`) in force on each line: the newest one above it in its paragraph.
// One pass over the lines (a search per link over the text before it was quadratic).
const lineDirs = [];
const lineEnds = [];
{
let dir = null;
let at = 0;
for (const line of value.split('\n')) {
lineDirs.push(dir);
at += line.length + 1;
lineEnds.push(at);
if (!line.trim()) dir = null;
else {
const m = /^([^\s:][^:]*):[ \t]*$/.exec(line);
if (m) dir = m[1];
}
}
}
let lineNo = 0;
let last = 0;
for (const link of links) {
pre.append(document.createTextNode(this._storyShortPath(value.slice(last, link.index))));
@@ -3579,15 +3613,9 @@ Object.assign(CodemanApp.prototype, {
if (b.textContent !== link.path) b.title = link.path;
// A bare name listed under an `ls` header (`some/dir:`) lives in that directory.
let target = link.path;
if (!target.includes('/')) {
const block =
value
.slice(0, link.index)
.split(/\n\s*\n/)
.pop() || '';
const header = /(?:^|\n)([^\s:][^\n:]*):[ \t]*\n(?![\s\S]*\n[^\s:][^\n:]*:[ \t]*\n)/.exec(block);
if (header) target = `${header[1].replace(/\/+$/, '')}/${target}`;
}
while (lineNo < lineEnds.length - 1 && lineEnds[lineNo] <= link.index) lineNo++;
const dir = lineDirs[lineNo];
if (!target.includes('/') && dir) target = `${dir.replace(/\/+$/, '')}/${target}`;
const resolved = typeof resolveLinkedFilePath === 'function' ? resolveLinkedFilePath(target, workingDir) : target;
b.addEventListener('click', (e) => this._storyOpenFile(resolved, e.currentTarget));
pre.append(b);
+42 -6
View File
@@ -32,7 +32,12 @@ import {
import { getCli } from '../../config/cli-registry/registry.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 {
claudeProjectsDir,
findClaudeTranscriptPath,
findRestoredClaudeTranscriptPath,
} from '../../utils/claude-transcript.js';
import { LRUMap } from '../../utils/lru-map.js';
import { findSessionOrFail } from '../route-helpers.js';
import type { ConfigPort, SessionPort } from '../ports/index.js';
@@ -69,10 +74,40 @@ async function insideClaudeProjects(path: string, configDir?: string): Promise<b
return false;
}
/** The hook-reported path when it is a transcript in a projects folder, else a lookup by conversation id. */
/**
* Transcripts found by id, briefly: a lookup reads every project folder, and a view without a hook
* path (normal after a server restart) asks every few seconds. A miss is kept shorter, so a session
* whose first prompt is on its way finds its transcript soon.
*/
const lookups = new LRUMap<string, { path: string | null; at: number }>({ maxSize: 200 });
const LOOKUP_HIT_MS = 30_000;
const LOOKUP_MISS_MS = 4_000;
/** For tests: forget the transcripts found by id. */
export function clearStoryLookupCache(): void {
lookups.clear();
}
/** The transcript by conversation id, then by Codeman id, then a recovered session's, as last-response finds it. */
async function lookUpTranscript(ids: string[], codemanId: string, configDir?: string): Promise<string | null> {
const key = `${ids.join('|')}|${configDir ?? ''}`;
const hit = lookups.get(key);
if (hit && Date.now() - hit.at < (hit.path ? LOOKUP_HIT_MS : LOOKUP_MISS_MS)) return hit.path;
let path: string | null = null;
for (const id of ids) {
path = await findClaudeTranscriptPath(id, configDir);
if (path) break;
}
path ??= await findRestoredClaudeTranscriptPath(codemanId, configDir);
lookups.set(key, { path, at: Date.now() });
return path;
}
/** The hook-reported path when it is a transcript in a projects folder, else a lookup by id. */
async function resolveTranscriptPath(
hookPath: string | null,
conversationId: string,
ids: string[],
codemanId: string,
configDir?: string
): Promise<string | null> {
if (
@@ -82,7 +117,7 @@ async function resolveTranscriptPath(
(await insideClaudeProjects(hookPath, configDir))
)
return hookPath;
return findClaudeTranscriptPath(conversationId, configDir);
return lookUpTranscript(ids, codemanId, configDir);
}
type Source = { unsupported: string } | { path: string | null };
@@ -102,7 +137,8 @@ async function storySource(ctx: SessionPort & ConfigPort, id: string, req: Fasti
return {
path: await resolveTranscriptPath(
ctx.getTranscriptPath(id),
session.claudeSessionId || session.id,
[...new Set([session.claudeSessionId, session.id].filter((v): v is string => !!v))],
session.id,
session.claudeConfigDir
),
};
@@ -145,7 +181,7 @@ export function registerStoryRoutes(app: FastifyInstance, ctx: SessionPort & Con
const source = await storySource(ctx, id, req);
if ('unsupported' in source) return { success: true, data: { supported: false, reason: source.unsupported } };
if (!source.path) return { success: true, data: { supported: true, turns: [], page: null } };
const turnKey = typeof turn === 'string' && turn ? turn : '';
const turnKey = typeof turn === 'string' && turn ? turn.slice(0, 200) : '';
const tagOf = (version: string) => `${version}-${encodeURIComponent(turnKey || 'latest')}`;
// An unchanged file is a 304 from a stat alone: no read, no parse, whatever the cache holds.
const before = await storyFileVersion(source.path);