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

+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;
}