fix(story-mode): keyboard focus with the file preview, and what round 3 found missing

From review round 3:

- Keyboard: a file opened from a panel or drawer takes focus on the
  preview's close button, Tab cycles inside the preview while it is open,
  and Escape gives focus back to what opened it. Before, Tab walked out of
  the overlay to the session tabs behind it. A panel rebuilt under focus (a
  new turn, a fold into a loop) hands focus to its replacement.
- A commit at the end of a test one-liner is no longer lost: commits and
  pushes are counted whatever intent wins, and the check says "The tests
  pass, and Claude saves the work." (plus "1 commit" in the Director's cut).
- Files written by `printf/echo ... > f`, and files Claude Code says a
  command changed ("This command modified 1 file you've previously read"),
  count as changed files.
- A compaction says what it did ("automatic · 46.9k → 3.6k tokens · 24 s");
  an automatic one reads "Claude's memory is full", and its panel opens.
- ToolSearch is looking around, not a terminal panel; EnterPlanMode reads
  "Claude switches to plan mode"; the plan drawer shows the plan you
  approved ("You approved the plan.").
- A finished newest page is checked every 15 s (a 304 when unchanged): a
  local command like /compact raises no hook.
- Boards draw the real number of dots (5 tests, 5 dots); captions leave
  here-document markers out.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 11:31:39 +02:00
1 parent dae6a52daa
commit 757e14e991
5 files changed
+266 -30

No files matched your search

+117 -10
View File
@@ -137,9 +137,11 @@ export type StoryEntry =
problems?: boolean;
/** The plan an ExitPlanMode call presented. */
plan?: string;
/** Files Claude Code says a Bash call changed ("This command modified 1 file you've previously read: a.py"). */
modified?: string[];
}
| { k: 'end'; at: string; durationMs?: number }
| { k: 'compact'; at: string }
| { k: 'compact'; at: string; trigger?: string; preTokens?: number; postTokens?: number; durationMs?: number }
/** What a local slash command printed (`<local-command-stdout>`): its whole answer. */
| { k: 'local'; at: string; text: string }
| { k: 'interrupt'; at: string };
@@ -376,6 +378,47 @@ function redirectsIntoProject(segment: string): boolean {
return false;
}
/**
* One-line writes: `printf 'a\nb\n' > f`, `echo "x" >> f`. The quoted text is the content (printf
* and `echo -e` turn `\n` into line breaks). Same target rules as here-documents.
*/
function echoWrites(command: string, cwd?: string): StoryEdit[] {
const out: StoryEdit[] = [];
let moved = false;
for (const segment of commandSegments(withoutHeredocBodies(command.slice(0, 20_000)))) {
const head = segmentHead(segment);
if (/^(cd|pushd)\b/.test(head)) moved = true;
const m =
/^(printf|echo)((?:\s+-[neE]+)*)\s+(['"])((?:(?!\3)[\s\S])*)\3\s*(>>?)\s*(["']?)([^\s"'|;&<>()]+)\6\s*$/.exec(
head
);
if (!m || SCRATCH_TARGET.test(m[7])) continue;
const escapes = m[1] === 'printf' || /e/.test(m[2]);
const text = escapes ? m[4].replace(/\\n/g, '\n').replace(/\\t/g, '\t') : m[4];
const lines = text.replace(/\n$/, '').split('\n');
const file =
m[7].startsWith('/') || !cwd || moved ? m[7] : `${cwd.replace(/\/+$/, '')}/${m[7].replace(/^\.\//, '')}`;
out.push({
file,
added: lines.length,
removed: 0,
created: false,
hunks: [
{
oldStart: m[5] === '>>' ? 1 : 0,
oldLines: 0,
newStart: 1,
newLines: lines.length,
lines: lines.slice(0, 40).map((l) => `+${clip(l, STORY_LIMITS.lineChars)}`),
},
],
truncated: lines.length > 40,
});
if (out.length >= 6) break;
}
return out;
}
/** A here-document's body is data, not commands: `cat > a.py <<'EOF' ... EOF` keeps only its first line. */
function withoutHeredocBodies(command: string): string {
const out: string[] = [];
@@ -470,7 +513,7 @@ function judgeBashCommand(command: string): { intent: StoryIntent; segment: stri
* (`python3 - <<EOF`) writes nothing.
*/
export function heredocWrites(command: string, cwd?: string): StoryEdit[] {
const out: StoryEdit[] = [];
const out: StoryEdit[] = [...echoWrites(command, cwd)];
const lines = command.slice(0, 200_000).split('\n');
let moved = false;
for (let i = 0; i < lines.length && out.length < 6; i++) {
@@ -694,12 +737,13 @@ function reduceInput(name: string, input: unknown, cwd?: string): ReducedInput {
const reads = printedFiles(full);
if (reads.length) input.reads = reads;
}
if (intent === 'save') {
{
// Counted whatever wins: a test run that ends in a commit still saves the work.
const heads = commandSegments(withoutHeredocBodies(full.slice(0, 4000))).map(segmentHead);
if (heads.some((h) => /^gh pr create\b/.test(h))) input.ships = 'pr';
else if (heads.some((h) => /^git push\b/.test(h))) input.ships = 'push';
const commits = heads.filter((h) => /^git commit\b/.test(h)).length;
if (commits > 1) input.commits = commits;
if (commits > 1 || (commits && intent !== 'save')) input.commits = commits;
}
return input;
}
@@ -899,6 +943,15 @@ export function createStoryReducer({ sidechain = false }: { sidechain?: boolean
if (tests) result.tests = tests;
const intent = call?.input.intent;
if ((intent === 'build' || intent === 'test') && reportsProblems(tail.text)) result.problems = true;
const note = /This command modified \d+ files? you've previously read: ([^\]\n]+?)\.? Call Read/.exec(text);
if (note) {
const cwd = str(entry.cwd);
result.modified = note[1]
.split(/,\s*/)
.filter((f) => f && !/\s/.test(f))
.slice(0, 8)
.map((f) => (f.startsWith('/') || !cwd ? f : `${cwd.replace(/\/+$/, '')}/${f}`));
}
}
}
out.push(result);
@@ -1036,7 +1089,19 @@ export function createStoryReducer({ sidechain = false }: { sidechain?: boolean
// Some sessions never write turn_duration; the Stop hook's summary marks the same end (unless
// the hook kept Claude going).
if (line.subtype === 'stop_hook_summary' && line.preventedContinuation !== true) return [{ k: 'end', at }];
if (line.subtype === 'compact_boundary') return [{ k: 'compact', at }];
if (line.subtype === 'compact_boundary') {
const meta = isRecord(line.compactMetadata) ? line.compactMetadata : {};
return [
{
k: 'compact',
at,
trigger: str(meta.trigger),
preTokens: num(meta.preTokens),
postTokens: num(meta.postTokens),
durationMs: num(meta.durationMs),
},
];
}
return [];
default:
return [];
@@ -1083,6 +1148,8 @@ function intentOf(name: string, input: ReducedInput): StoryIntent {
case 'TaskUpdate':
case 'EnterPlanMode':
return 'plan';
case 'ToolSearch':
return 'look';
case 'ExitPlanMode':
case 'AskUserQuestion':
return 'ask';
@@ -1127,6 +1194,10 @@ function labelOf(name: string, i: ReducedInput): string {
return i.question ?? '';
case 'ExitPlanMode':
return 'Plan ready for your OK';
case 'EnterPlanMode':
return 'plan mode on';
case 'ToolSearch':
return `tools: ${(i.text ?? '').replace(/^select:/, '')}`;
case 'Write':
case 'Edit':
return i.file && PLAN_FILE.test(i.file) ? `the plan, ${basename(i.file)}` : (i.file ?? '');
@@ -1198,6 +1269,14 @@ function applyResult(ev: StoryEvent, r: Extract<StoryEntry, { k: 'result' }>): v
if (todos) ev.todos = todos;
}
if (r.problems) ev.ok = false;
if (r.modified?.length) {
// Changed, but no diff to show: the file is listed, its lines unknown.
const known = new Set((ev.edits ?? []).map((d) => d.file));
const more = r.modified
.filter((f) => !known.has(f))
.map((file) => ({ file, added: 0, removed: 0, created: false, hunks: [], truncated: true }));
if (more.length) ev.edits = [...(ev.edits ?? []), ...more];
}
if (r.tests) {
ev.tests = r.tests;
// A run that reports failed tests failed, whatever its exit code (`pytest | tail` exits 0).
@@ -1392,6 +1471,16 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] {
label: 'Context compacted',
intent: 'compact',
};
const k = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
const facts = [
e.trigger === 'auto' ? 'automatic' : e.trigger,
e.preTokens !== undefined && e.postTokens !== undefined
? `${k(e.preTokens)} → ${k(e.postTokens)} tokens`
: '',
e.durationMs !== undefined ? exactDuration(e.durationMs) : '',
].filter(Boolean);
if (facts.length) ev.description = facts.join(' · ');
if (e.trigger === 'auto') ev.label = 'Context compacted: memory was full';
// Between turns (`/compact` writes its boundary before the command line itself), it belongs to
// the turn that comes next; mid-turn (an automatic compaction), to the running one.
const t = cur as StoryTurn | null;
@@ -1547,7 +1636,13 @@ function groupPanels(events: StoryEvent[]): { panels: DraftPanel[]; trailingSay?
} else {
const panel: DraftPanel = { kind, events: [idx] };
// A sentence about how the check just before went belongs to that check, not to what comes next.
const talk = last ? REPORT_TALK[last.kind] : undefined;
// A check that also committed reports on both: "All 8 pass." and "Committed."
const saves = last?.kind === 'check' && last.events.some((i) => events[i].commits);
const talk = last
? saves
? new RegExp(`${TALKS_RESULTS.source}|${TALKS_SAVES.source}`, 'i')
: REPORT_TALK[last.kind]
: undefined;
const reportsBack =
pendingSay &&
talk &&
@@ -1649,7 +1744,7 @@ function countBy(events: StoryEvent[], key: (e: StoryEvent) => string): string {
const NOISE_LINE = /^\s*(#.*|set\s+[-+][a-zA-Z]+(\s+\w+)?|cd\s+\S+|[A-Za-z_][A-Za-z0-9_]*=\S*)?\s*$/;
/** What a command does, for a caption: the part that decided it, else its first telling line. */
const shortCommand = (e: StoryEvent) => {
if (e.headline) return clip(e.headline.split('\n')[0] ?? '', 60);
if (e.headline) return clip((e.headline.split('\n')[0] ?? '').replace(/\s*<<-?\s*(['"]?)\w+\1/g, ''), 60);
const lines = e.label.split('\n');
return clip(lines.find((l) => !NOISE_LINE.test(l)) ?? lines[0] ?? '', 60);
};
@@ -1744,7 +1839,11 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
const todos = [...evs].reverse().find((e) => e.todos)?.todos;
if (todos) panel.todos = todos;
else panel.notes = evs.map(shortCommand).slice(0, 4);
story = todos ? `Claude writes a plan: ${plural(todos.length, 'step')}.` : 'Claude thinks it through.';
story = todos
? `Claude writes a plan: ${plural(todos.length, 'step')}.`
: evs.every((e) => e.tool === 'EnterPlanMode')
? 'Claude switches to plan mode to think it through.'
: 'Claude thinks it through.';
// A plan written as text has no progress to report; a plan tool's list does.
if (todos && evs.every((e) => e.tool === 'Text')) director = `written as text · ${plural(todos.length, 'step')}`;
else if (todos && evs.some((e) => e.file && PLAN_FILE.test(e.file)))
@@ -1860,6 +1959,12 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
tests.errors ? `, ${plural(tests.errors, 'error')}` : ''
}`;
if (edits.length) director += ` · writes ${plural(unique(edits.map((d) => d.file)).length, 'file')}`;
const saved = checks.reduce((n, e) => n + (e.commits ?? 0), 0);
if (saved) {
director += ` · ${plural(saved, 'commit')}`;
if (ok)
story = `${story.slice(0, -1)}, and Claude saves the work${checks.some((e) => e.ships === 'push') ? ' and sends it up' : ''}.`;
}
break;
}
case 'run':
@@ -1908,8 +2013,10 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
break;
}
case 'compact':
story = 'Claude packs up its notes to free memory.';
director = 'compact_boundary';
story = evs.some((e) => e.label.includes('memory was full'))
? "Claude's memory is full: it packs up its notes."
: 'Claude packs up its notes to free memory.';
director = evs.map((e) => e.description).find(Boolean) ?? 'compact_boundary';
break;
case 'montage':
panel.notes = notesOf(evs.filter((e) => e.tool === 'Bash'));
+1 -1
View File
@@ -54,7 +54,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Settings surface** (`#appSettingsModal` + `#sessionOptionsModal` + `#createCaseModal`): one `set-*` language shared through a single `:is(...)` id scope in styles.css. App Settings' rail is a table of contents over ONE scrolling document (`switchSettingsTab` scrolls); Session Options and Add Case really switch (`switchOptionsTab` / `switchCaseModalTab`), and their larger per-modal size blocks are the design, not drift. ⚠️ **The load/save contract is `getElementById` by id**: renaming or dropping a control id silently stops it loading or saving. ⚠️ The Session Options "Session" entry still keys off `context` (label-only rename). ⚠️ Add Case keeps its legacy `.form-row` markup via an adapter; every `<details>` there needs `.set-adv-chev` plus both marker suppressions. ⚠️ Model cards and the effort segment are views over hidden `<select>`s, which stay the source of truth. ⚠️ `.modal-tabs*` classes are retired; `admin-ui.js` needs `.set-rail-items` + `.set-doc` to survive any restructure. Guard: `test/app-settings-structure.test.ts`. → [architecture-invariants#settings-surface-app-settings-session-options-add-case](../../docs/architecture-invariants.md#settings-surface-app-settings-session-options-add-case)
**Story Mode** (`story-mode.js` + `story-mode.css`, `showStoryButton`, per-device, default OFF, header button hidden on phones by mobile.css): the active Claude session's turns as comic pages from `GET /api/sessions/:id/story` (server core `src/story-mode.ts`, pure; reader `src/story-reader.ts`). ⚠️ Live refresh is the transcript tool events plus the stop and prompt-submitted hooks (extra `addListener`s in `connectSSE`, so the handler map keeps one method per event) and a 4 s poll while the turn runs, one request in flight, debounced; never the watcher's `transcript:complete`, which interactive transcripts never raise. ⚠️ Every string on the page is the agent's: `textContent` only, containers carry `data-i18n-skip`; a file opens through `openFilePreview()`, never a new route. ⚠️ Canvas click regions become real buttons (`_storySyncHits`), so they are focusable and labelled; a panel keeps its clock across refreshes (only a NEW panel animates in). ⚠️ The overlay owns Escape with a WINDOW capture listener (it runs before app.js's document one): one layer per press: a file preview opened from a panel (it has no Escape of its own outside the folder grid), then the drawer, then the overlay; a folder grid on top and a preview in edit mode keep their own keys. ⚠️ Its comic palette is its own (`--story-*` tokens, `.story-paper` on a light skin), so a skin never repaints it. ⚠️ Wide panels draw their scene in a centered stage (`STORY_STAGE_MAX`); a scene with `fullWidth` (the final splash) uses the whole panel. ⚠️ **Save image** composites the page itself (each panel canvas, then every caption, balloon and chip redrawn from its computed style and box, `_storyPaintBox`); never rasterize the DOM through SVG (self-hosted fonts do not reach it). Session Options carries a Story button too (`#sessionOptionsStoryBtn`, same setting), the way in on phones. ⚠️ Claude's speech balloon is placed by `_storyPlaceBalloon()` in the band above the head `agent()` last drew (cyan body only), clamped (`--story-lines`) to stay inside the panel and off the head; never position it per kind in CSS (that is what covered the POW burst and the agent's head on narrow panels). Drawer prose (a prompt, a helper's answer) links rooted paths only. Tests: `test/story-mode.test.ts`, `test/routes/story-routes.test.ts`, `test/story-mode-ui.test.ts` (JSDOM, every scene drawn in every shape), `test/story-mode.browser.test.ts` (browser suite).
**Story Mode** (`story-mode.js` + `story-mode.css`, `showStoryButton`, per-device, default OFF, header button hidden on phones by mobile.css): the active Claude session's turns as comic pages from `GET /api/sessions/:id/story` (server core `src/story-mode.ts`, pure; reader `src/story-reader.ts`). ⚠️ Live refresh is the transcript tool events plus the stop and prompt-submitted hooks (extra `addListener`s in `connectSSE`, so the handler map keeps one method per event) and a 4 s poll while the turn runs, one request in flight, debounced; never the watcher's `transcript:complete`, which interactive transcripts never raise. ⚠️ Every string on the page is the agent's: `textContent` only, containers carry `data-i18n-skip`; a file opens through `openFilePreview()`, never a new route. ⚠️ Canvas click regions become real buttons (`_storySyncHits`), so they are focusable and labelled; a panel keeps its clock across refreshes (only a NEW panel animates in). ⚠️ The overlay owns Escape with a WINDOW capture listener (it runs before app.js's document one): one layer per press: a file preview opened from a panel (it has no Escape of its own outside the folder grid), then the drawer, then the overlay; a folder grid on top and a preview in edit mode keep their own keys. ⚠️ Its comic palette is its own (`--story-*` tokens, `.story-paper` on a light skin), so a skin never repaints it. ⚠️ Wide panels draw their scene in a centered stage (`STORY_STAGE_MAX`); a scene with `fullWidth` (the final splash) uses the whole panel. ⚠️ **Save image** composites the page itself (each panel canvas, then every caption, balloon and chip redrawn from its computed style and box, `_storyPaintBox`); never rasterize the DOM through SVG (self-hosted fonts do not reach it). Session Options carries a Story button too (`#sessionOptionsStoryBtn`, same setting), the way in on phones. ⚠️ Claude's speech balloon is placed by `_storyPlaceBalloon()` in the band above the head `agent()` last drew (cyan body only), clamped (`--story-lines`) to stay inside the panel and off the head; never position it per kind in CSS (that is what covered the POW burst and the agent's head on narrow panels). Drawer prose (a prompt, a helper's answer) links rooted paths only. ⚠️ Every file a panel or drawer opens goes through `_storyOpenFile()`: keyboard focus moves to the preview's close button, Tab cycles inside the preview while it is open (`_storyTrapTab`), and Escape gives focus back to what opened it. Tests: `test/story-mode.test.ts`, `test/routes/story-routes.test.ts`, `test/story-mode-ui.test.ts` (JSDOM, every scene drawn in every shape), `test/story-mode.browser.test.ts` (browser suite).
**Header button visibility**: most header controls are opt-in and hidden by a marker class (`btn-multimonitor--hidden`, `btn-response-viewer-header--hidden`, `btn-file-viewer--hidden`, `btn-cron--hidden`, and the desktop-gated `btn-split--hidden` / `btn-tile-grid--hidden`, which also need a `@media (max-width: 1179px)` backstop) that `applyHeaderVisibilitySettings()` (settings-ui.js) toggles after settings load; the multi-monitor button is instead stripped at render by `renderIndexHtml`. ⚠️ Hiding must go through the marker class: the base rules are `display:inline-flex !important`, so an inline style cannot override them. Current desktop default is WS/CPU/MEM + File Viewer + Tiles (1180px and wider) + gear, with the token chip and lifecycle-log button OFF. ⚠️ New header controls must not leak onto phones; `test/mobile-header-buttons-policy.test.ts` is the static guard. → [architecture-invariants#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron](../../docs/architecture-invariants.md#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron)
+68 -16
View File
@@ -31,6 +31,8 @@
const STORY_REFRESH_DEBOUNCE_MS = 800;
/** While a turn runs, refresh this often even without events (text lines raise none). */
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;
/**
* 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.
@@ -1048,7 +1050,7 @@ const StoryArt = (() => {
const running = p.panel.ok === undefined;
let b;
if (total) {
const dots = Math.min(42, Math.max(7, total));
const dots = Math.min(42, total);
const fail = bad ? Math.max(1, Math.round((bad / total) * dots)) : 0;
const fill = running ? (f * 0.6) % (dots + 6) : Math.min(dots, (f - 4) * 1.6);
b = board(ctx, 16, 106, `${total} checks`, dots, fail, fill);
@@ -1108,7 +1110,7 @@ const StoryArt = (() => {
const total = t ? t.passed + bad : 0;
let b;
if (total) {
const dots = Math.min(28, Math.max(7, total));
const dots = Math.min(28, total);
const fail = bad ? Math.max(1, Math.round((bad / total) * dots)) : 0;
b = board(ctx, bx, 116, `${total} checks`, dots, fail, Math.min(dots, (f - 4) * 1.6));
} else b = verdict(ctx, bx, 116, 'checks', p.panel.ok, f);
@@ -1264,8 +1266,9 @@ const StoryArt = (() => {
base(ctx, W, H);
halftone(ctx, W, H, C.magenta, 0, 0, { rmax: 3.2, alpha: 0.7 });
},
draw(ctx, W, H, f) {
draw(ctx, W, H, f, p, hit) {
const squeeze = interpolate(f, [10, 50], [1, 0.35], easeOut);
hit('output', 24, 110, 140, 130, 'See what was packed up');
for (let k = 0; k < 6; k++) {
ctx.save();
ctx.translate(40 + k * 4, 200 - k * 14 * squeeze);
@@ -1850,6 +1853,12 @@ Object.assign(CodemanApp.prototype, {
this._storyRefresh();
return;
}
// A finished newest page: look again now and then (a local command like /compact raises no
// hook). An unchanged page is a 304.
if (!st.turnId && Date.now() - (st.lastIdlePoll || 0) > STORY_IDLE_POLL_MS) {
st.lastIdlePoll = Date.now();
this._storyRefresh();
}
if (st.drawer?.view === 'helper') {
const ev = page?.events.find((e) => e.id === st.drawer.event);
if (ev?.pending || ev?.helper?.running) this._storyRenderDrawer(false);
@@ -1870,8 +1879,12 @@ Object.assign(CodemanApp.prototype, {
if (previewOpen && this.filePreviewEdit) return;
e.preventDefault();
e.stopPropagation();
if (previewOpen) this.closeFilePreview?.();
else if (st.drawer) this._storyCloseDrawer();
if (previewOpen) {
this.closeFilePreview?.();
const back = st.previewReturn;
st.previewReturn = null;
if (back && document.contains(back)) back.focus({ preventScroll: true });
} else if (st.drawer) this._storyCloseDrawer();
else this.closeStoryMode();
};
window.addEventListener('keydown', st.keyHandler, true);
@@ -1892,16 +1905,18 @@ Object.assign(CodemanApp.prototype, {
/** The overlay is a modal dialog: Tab and Shift+Tab cycle inside it (not when a preview is on top). */
_storyTrapTab(e) {
const st = this._story;
if (document.getElementById('filePreviewOverlay')?.classList.contains('visible')) return;
if (document.getElementById('folderGridOverlay')?.classList.contains('visible')) return;
const items = [...st.root.querySelectorAll('button, select, a[href], [tabindex]:not([tabindex="-1"])')].filter(
(n) => !n.disabled && n.offsetParent !== null
);
// A file preview opened from a panel sits on top: Tab cycles inside it until it closes.
const preview = document.getElementById('filePreviewOverlay');
const scope = preview?.classList.contains('visible') ? preview : st.root;
const items = [
...scope.querySelectorAll('button, select, textarea, input, a[href], [tabindex]:not([tabindex="-1"])'),
].filter((n) => !n.disabled && n.offsetParent !== null);
if (!items.length) return;
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement;
const inside = st.root.contains(active);
const inside = scope.contains(active);
if (e.shiftKey && (active === first || !inside)) {
e.preventDefault();
last.focus();
@@ -1985,6 +2000,10 @@ Object.assign(CodemanApp.prototype, {
2
);
const fresh = !st.first;
// A panel that holds focus may be rebuilt (a new turn, a fold into a loop): keep the place.
const focused = document.activeElement;
const focusAt = st.panels.findIndex((rt) => rt.el.contains(focused));
const focusHit = focusAt >= 0 ? [...st.panels[focusAt].hitEls].find(([, b]) => b === focused)?.[0] : null;
if (st.shownPageId !== (page?.id ?? null)) {
for (const rt of st.panels.splice(0)) this._storyDropPanel(rt);
st.shownPageId = page?.id ?? null;
@@ -2031,6 +2050,16 @@ Object.assign(CodemanApp.prototype, {
pencils.style.setProperty('--story-span2', String(spans2[want.length]));
st.pageEl.append(pencils);
} else pencils?.remove();
if (focusAt >= 0 && !document.contains(focused)) {
const rt = st.panels[Math.min(focusAt, st.panels.length - 1)];
if (rt && !rt.w) this._storySizePanel(rt);
const target = rt && ((focusHit && rt.hitEls.get(focusHit)) || rt.hitEls.values().next().value);
if (target) target.focus({ preventScroll: true });
else {
st.pageEl.tabIndex = -1;
st.pageEl.focus({ preventScroll: true });
}
}
},
_storyCreatePanel(kind, fresh) {
@@ -2601,7 +2630,7 @@ Object.assign(CodemanApp.prototype, {
if (!panel) return;
if (what.startsWith('file:')) {
const file = panel.files?.[Number(what.slice(5))];
if (file) this.openFilePreview?.(this._storyResolvePath(file), st.sessionId);
if (file) this._storyOpenFile(this._storyResolvePath(file), trigger);
return;
}
const [view, arg] = what.split(':');
@@ -2623,12 +2652,26 @@ Object.assign(CodemanApp.prototype, {
const ev = page.events[Number(arg)];
if (!ev) return;
if (view === 'file') {
this.openFilePreview?.(this._storyResolvePath(ev.file), st.sessionId);
this._storyOpenFile(this._storyResolvePath(ev.file), trigger);
return;
}
this._storyOpenDrawer({ view, event: ev.id }, trigger);
},
/**
* Opens a file in the file preview on top of Story Mode, with keyboard focus on the preview's close
* button; Escape gives it back to what opened it.
*/
_storyOpenFile(path, trigger) {
const st = this._story;
if (!st || !path) return;
st.previewReturn = trigger || document.activeElement;
this.openFilePreview?.(path, st.sessionId);
requestAnimationFrame(() =>
document.querySelector('#filePreviewOverlay.visible button[title="Close"]')?.focus({ preventScroll: true })
);
},
/** A path as a transcript named it, made absolute against the session's folder when relative. */
_storyResolvePath(path) {
const workingDir = this.sessions?.get(this._story?.sessionId)?.workingDir;
@@ -2817,8 +2860,11 @@ Object.assign(CodemanApp.prototype, {
out.push(ul);
} else if (view === 'ask') {
const ev = items.find((x) => x.ev.question)?.ev;
const plan = ev?.tool === 'ExitPlanMode';
kind = 'Your call';
title = ev?.question?.text || 'Question';
if (plan && ev.output)
out.push(el('h4', 'story-h', 'The plan'), this._storyLinkifiedPre(ev.output, { rooted: true }));
if (ev?.question?.options?.length) {
const ul = el('ul', 'story-todos');
for (const o of ev.question.options) ul.append(el('li', o === ev.question.answer ? 'is-chosen' : '', o));
@@ -2828,7 +2874,13 @@ Object.assign(CodemanApp.prototype, {
el(
'p',
'story-answer-line',
ev?.question?.answer ? `You chose: ${ev.question.answer}` : 'Waiting for your answer in the terminal.'
ev?.question?.answer
? plan
? ev.question.answer === 'Approved'
? 'You approved the plan.'
: 'You sent the plan back.'
: `You chose: ${ev.question.answer}`
: 'Waiting for your answer in the terminal.'
)
);
}
@@ -2905,7 +2957,7 @@ Object.assign(CodemanApp.prototype, {
if (shown !== ev.label) label.title = ev.label;
if (ev.file) {
label.type = 'button';
label.addEventListener('click', () => this.openFilePreview?.(ev.file, this._story.sessionId));
label.addEventListener('click', (e) => this._storyOpenFile(ev.file, e.currentTarget));
}
const tag = el(
'i',
@@ -2934,7 +2986,7 @@ Object.assign(CodemanApp.prototype, {
open.type = 'button';
open.className = 'story-btn-s';
open.textContent = 'Open file';
open.addEventListener('click', () => this.openFilePreview?.(edit.file, this._story.sessionId));
open.addEventListener('click', (e) => this._storyOpenFile(edit.file, e.currentTarget));
head.append(name, cnt, open);
section.append(head);
const code = el('div', 'story-code');
@@ -3041,7 +3093,7 @@ Object.assign(CodemanApp.prototype, {
if (header) target = `${header[1].replace(/\/+$/, '')}/${target}`;
}
const resolved = typeof resolveLinkedFilePath === 'function' ? resolveLinkedFilePath(target, workingDir) : target;
b.addEventListener('click', () => this.openFilePreview?.(resolved, this._story.sessionId));
b.addEventListener('click', (e) => this._storyOpenFile(resolved, e.currentTarget));
pre.append(b);
last = link.index + link.path.length;
}
+31 -2
View File
@@ -57,7 +57,7 @@ function boot(initial: unknown) {
`<!doctype html><body>
<button class="btn-story-header btn-story-header--hidden"></button>
<button id="sessionOptionsStoryBtn" hidden></button>
<div class="file-preview-overlay" id="filePreviewOverlay"></div>
<div class="file-preview-overlay" id="filePreviewOverlay"><button id="pvOther">Edit</button><button title="Close">×</button></div>
<div class="folder-grid-overlay" id="folderGridOverlay"></div>
</body>`,
{ url: 'http://localhost/', runScripts: 'outside-only', pretendToBeVisual: true }
@@ -563,7 +563,12 @@ describe('story-mode.js overlay', () => {
boot(
storyOf([
prompt('look'),
...bash('b1', 'ls /work/demo-shop && ls -R /work/demo-shop/clone', 'List the clone', 'clone/src:\nkebab.py\n\nnotes.md'),
...bash(
'b1',
'ls /work/demo-shop && ls -R /work/demo-shop/clone',
'List the clone',
'clone/src:\nkebab.py\n\nnotes.md'
),
end(1000),
])
);
@@ -579,6 +584,30 @@ describe('story-mode.js overlay', () => {
expect(app.openFilePreview).toHaveBeenLastCalledWith('/work/demo-shop/notes.md', SID);
});
it('a file opened from Story Mode takes keyboard focus, keeps Tab inside, and gives focus back on Escape', async () => {
boot(storyOf(demoTurn()));
const overlay = () => $('#filePreviewOverlay')!;
app.openFilePreview = vi.fn(() => overlay().classList.add('visible'));
app.openStoryMode(SID);
await settle();
sizeAndDraw();
$('.story-panel[data-kind="loop"] .story-hit')!.click();
const open = $$('.story-drawer-body .story-btn-s').find((b) => b.textContent === 'Open file')!;
open.focus();
open.click();
await new Promise((r) => win.requestAnimationFrame(() => r(null)));
const close = $('#filePreviewOverlay button[title="Close"]')!;
expect(win.document.activeElement).toBe(close);
for (const n of $$('#filePreviewOverlay button'))
Object.defineProperty(n, 'offsetParent', { get: () => win.document.body });
press('Tab');
expect(overlay().contains(win.document.activeElement)).toBe(true);
press('Escape');
expect(app.closeFilePreview).toHaveBeenCalled();
expect(win.document.activeElement).toBe(open);
expect($('#storyOverlay')!.hidden).toBe(false);
});
it('turning the setting off hides the button and closes an open story', async () => {
boot(storyOf(demoTurn()));
app.openStoryMode(SID);
+49 -1
View File
@@ -740,7 +740,55 @@ describe('buildStoryPage', () => {
expect(ev.headline).toBe('node --test');
expect(page.filesChanged).toBe(1);
const check = page.panels.find((p) => p.kind === 'check')!;
expect(check.caption.director).toMatch(/^node --test → ok · 3 passed · writes 1 file$/);
expect(check.caption.director).toBe('node --test → ok · 3 passed · writes 1 file · 1 commit');
// The commit at the end of the test one-liner is not lost: the check says it saved.
expect(check.caption.story).toBe('The tests pass, and Claude saves the work.');
});
it('one-line printf/echo writes and the files Claude Code says a command changed are edits', () => {
expect(
heredocWrites('printf \'name = "x"\\nversion = 1\\n\' > pyproject.toml && ls', '/w').map((d) => [d.file, d.added])
).toEqual([['/w/pyproject.toml', 2]]);
expect(heredocWrites('echo hi > /tmp/x.txt; echo "a" >> notes.md', '/w').map((d) => d.file)).toEqual([
'/w/notes.md',
]);
const lines = [
prompt('rewrite'),
...bash(
'b1',
"python3 - <<'EOF'\nopen('t.py','w').write('x')\nEOF",
'Rewrite the test file',
"done\n[This command modified 1 file you've previously read: test_a.py. Call Read before editing.]"
),
end(1000),
];
(lines[2] as { cwd?: string }).cwd = '/w';
const page = pageOf(lines);
expect(page.events[1].edits?.map((d) => d.file)).toEqual(['/w/test_a.py']);
expect(page.filesChanged).toBe(1);
});
it('a compaction says what it did; ToolSearch is looking; EnterPlanMode says plan mode', () => {
const page = pageOf([
prompt('go'),
tool('t1', 'ToolSearch', { query: 'select:EnterPlanMode,ExitPlanMode', max_results: 2 }),
result('t1', 'ok'),
tool('p1', 'EnterPlanMode', {}),
result('p1', 'Entered plan mode.'),
{
type: 'system',
subtype: 'compact_boundary',
timestamp: '2026-10-18T09:45:00.000Z',
compactMetadata: { trigger: 'auto', preTokens: 46928, postTokens: 3623, durationMs: 24014 },
},
end(1000),
]);
expect(page.panels.map((p) => p.kind)).toEqual(['mission', 'investigate', 'plan', 'compact', 'complete']);
expect(page.panels[2].caption.story).toBe('Claude switches to plan mode to think it through.');
expect(page.panels[3].caption).toEqual({
story: "Claude's memory is full: it packs up its notes.",
director: 'automatic · 46.9k → 3.6k tokens · 24 s',
});
});
it('heredocWrites skips scratch, stdin bodies and relative paths after a cd', () => {