feat(story-mode): save as image, a helper's own steps, a way in on phones, and polish

- Save image: the page as one PNG to share (each panel canvas, then every
  caption, balloon and chip redrawn from its computed style and box).
- The helper drawer lists what the helper did, read live from its own
  transcript (GET .../story/helpers/:agentId), led by the task Claude gave it.
- Session Options has a Story button (same setting), the way in on phones.
- A turn a background task or another session started reads as a notice,
  not as your speech balloon.
- Wide panels keep their scene in a centered stage instead of spreading the
  art to both ends; the fix panel's agent stands next to the page.
- An unchanged page (same ETag) is neither parsed nor redrawn.
- Paths inside the session's folder are shown short; clicks keep full paths.
- Tab stays inside the overlay; a missing 2D context skips the art, not the page.
- A rebindable Story Mode shortcut (no default chord).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 09:51:48 +02:00
1 parent d313b868d4
commit 0c45682136
10 files changed
+511 -16

No files matched your search

+1 -1
View File
@@ -2,4 +2,4 @@
'aicodeman': minor
---
Story Mode draws what Claude Code does as a comic. Turn on **Story Mode** in App Settings → Header buttons (per device, off by default) and its button opens the active Claude session as comic pages: one page per prompt, with panels for reading code, planning, calling in helpers, editing, running tests (POW! or KRAK!), asking you and saving, each with a plain caption anyone can follow. The **Director's cut** switch names every tool call instead and lists all of them beside the page. Everything is clickable: a file opens in the file viewer, the code page shows the exact diff of Claude's edits, a test board shows the output with clickable `file:line` links, and a helper shows what it was asked and what it answered. A turn that is still running redraws as Claude works, new panels inking in where blue-pencil roughs stood. The data comes from `GET /api/sessions/:id/story`, an experimental read-only endpoint.
Story Mode draws what Claude Code does as a comic. Turn on **Story Mode** in App Settings → Header buttons (per device, off by default) and its button opens the active Claude session as comic pages: one page per prompt, with panels for reading code, planning, calling in helpers, editing, running tests (POW! or KRAK!), asking you and saving, each with a plain caption anyone can follow. The **Director's cut** switch names every tool call instead and lists all of them beside the page. Everything is clickable: a file opens in the file viewer, the code page shows the exact diff of Claude's edits, a test board shows the output with clickable `file:line` links, and a helper shows what it was asked, what it answered and every step it took. A turn that is still running redraws as Claude works, new panels inking in where blue-pencil roughs stood. **Save image** downloads the page as a PNG to share with someone who never opens a terminal. On phones, open it from the session's Session Options. The data comes from `GET /api/sessions/:id/story` and `GET /api/sessions/:id/story/helpers/:agentId`, experimental read-only endpoints.
+4
View File
@@ -1061,6 +1061,10 @@ Read-only. The session is resolved like every session route (ownership via `find
- `{ supported: false, reason }`: the session's CLI does not write a Claude Code transcript (registry `capabilities.transcript` is not `claude-jsonl`), or the session is remote or Docker, whose transcript is not on this host.
- `{ supported: true, turns, page }`: `turns` lists up to 60 turns, oldest first (`{ id, at, prompt, origin, complete, interrupted, toolCalls }`; `origin` is `human`, `task`, `peer` or `system`). `page` is the newest turn, or the one `turn=<id>` names, or `null` when there is none (an unknown id included).
Responses carry a weak `ETag` with `Cache-Control: no-cache`; a request with a matching `If-None-Match` gets a `304` without the page being rebuilt, which is what keeps a live view's frequent refetches cheap.
`GET /api/sessions/:id/story/helpers/:agentId` draws one helper (an Agent call; its `agentId` is on the main page's event as `helper.agentId`) from the helper's own transcript, `<conversation>/subagents/agent-<agentId>.jsonl` beside the session's. `data` is `{ supported: true, helper: { agentId, type?, description?, finished? }, page }`, the same page shape as above, or `page: null` while the helper has written nothing yet. `agentId` must match `[A-Za-z0-9_-]{1,64}` (`400` otherwise).
A `page` carries the turn's summary fields plus `events` (one per prompt, sentence Claude wrote and tool call, with `intent`, `ok` when the call returned, `edit` hunks in Claude Code's own `structuredPatch` shape, the tail of `output`, `helper`, `tests` counts), `panels` (each `{ kind, events, caption: { story, director }, balloon?, files?, ... }`, at most 12 per page, indices into `events`), `filesChanged`, `added`, `removed`, `helpers`, `partial` (the turn began before the part that was read) and `truncated` (older outputs were dropped to keep the page small). A transcript that cannot be read is a `500` with `OPERATION_FAILED`.
## Git worktrees of a case
+3 -2
View File
@@ -69,8 +69,9 @@ Ultracode Windows, Cron.
draws the active Claude Code session as a comic: one page per prompt, panels for what Claude
did (reading, planning, helpers, edits, tests, saving), and plain captions anyone can follow.
The **Director's cut** switch names every tool call instead. Click a file to open it, the code
page for the exact diff, a test board for the output. An unfinished turn redraws as Claude
works.
page for the exact diff, a test board for the output, a helper for what it was asked and what
it did. An unfinished turn redraws as Claude works, and **Save image** downloads the page as a
PNG to share. On phones, open it from the session's Session Options (the Story button there).
**Bottom bar** (below the chips): **Git status** shows a small indicator at the right of the
bottom bar, off by default and per device. It reads `● N` uncommitted files, `↑ N` commits not
+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. Tests: `test/story-mode.test.ts`, `test/routes/story-routes.test.ts`.
**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. 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)
+9
View File
@@ -538,6 +538,14 @@ const DEFAULT_SHORTCUTS = [
bindings: [{ modifiers: ['alt'], key: 'b', code: 'KeyB' }],
action: 'toggleSessionSidebar',
},
{
id: 'toggle-story-mode',
group: 'Panels',
label: 'Story Mode',
// No default chord: every free one is some CLI's key. Bind one in App Settings → Shortcuts.
bindings: [],
action: 'toggleStoryMode',
},
// Tile grid (tile-grid.js). Dispatched by tileShortcutFor()/runTileShortcut()
// and deliberately absent from SHORTCUT_ACTIONS: each applies only in some
// states (the focus chords only while the grid is open), and outside them the
@@ -10740,6 +10748,7 @@ class CodemanApp {
nextSession: () => this.nextSession(),
previousSession: () => this.prevSession(),
clearTerminal: () => this.clearTerminal(),
toggleStoryMode: () => this.toggleStoryMode?.(),
restoreTerminalSize: () => this.restoreTerminalSize(),
increaseFontSize: () => this.increaseFontSize(),
decreaseFontSize: () => this.decreaseFontSize(),
+1
View File
@@ -1272,6 +1272,7 @@
<h3>Session Options</h3>
<div class="set-head-actions">
<button class="modal-close" onclick="app.closeSessionOptions()" aria-label="Close session options">&times;</button>
<button type="button" class="story-options-btn" id="sessionOptionsStoryBtn" onclick="app.openStoryFromOptions()" title="Draw this session as a comic" hidden>Story</button>
</div>
</div>
<div class="set-body">
+56
View File
@@ -15,6 +15,27 @@
font-display: swap;
}
/* Session Options → Story (story-mode.js): shown while Story Mode is on. Sits in the sheet's
header tray beside the close button, on every device. */
#sessionOptionsModal .set-head-actions .story-options-btn {
display: inline-flex;
align-items: center;
height: 30px;
padding: 0 12px;
font: inherit;
font-size: 0.82rem;
font-weight: 620;
color: var(--text);
background: transparent;
border: 0;
border-radius: 9px;
box-shadow: inset 0 0 0 1px var(--border);
cursor: pointer;
}
#sessionOptionsModal .set-head-actions .story-options-btn[hidden] {
display: none;
}
.story-overlay {
/* Night edition: dark panels, light ink for marks drawn on them, dark line art on objects. */
--story-bg: #0d0d12;
@@ -149,6 +170,11 @@ body.story-open {
border: 2px solid var(--story-ink);
border-radius: 0;
}
.story-save {
font: 600 12px/1 var(--story-mono) !important;
letter-spacing: 0.04em;
padding: 0 10px;
}
.story-btn:disabled {
opacity: 0.35;
cursor: default;
@@ -361,6 +387,27 @@ body.story-open {
background: var(--story-balloon);
clip-path: polygon(100% 0, 100% 100%, 0 76%);
}
/* A turn somebody else started (a background task, another session): a notice, not your speech. */
.story-balloon--note {
left: 16px;
top: 74px;
max-width: 54%;
border-radius: 4px;
border-style: dashed;
}
.story-balloon--note::before {
content: 'Notice';
position: static;
display: block;
margin-bottom: 3px;
font: 600 10px/1 var(--story-mono);
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--story-magenta);
}
.story-balloon--note::after {
display: none;
}
.story-panel[data-kind='investigate'] .story-balloon--claude {
right: auto;
left: 30%;
@@ -828,6 +875,15 @@ body.story-open {
margin: 16px 18px 0;
font: 17px/1.1 var(--story-letter);
}
.story-helper-list {
margin: 4px 0 0;
padding: 0;
list-style: none;
}
.story-helper-list .story-ev {
grid-template-columns: minmax(0, 1fr);
padding: 3px 18px;
}
.story-todos {
margin: 8px 0 0;
padding: 0 18px 0 40px;
+338 -9
View File
@@ -31,6 +31,11 @@
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;
/**
* 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.
*/
const STORY_STAGE_MAX = 680;
/**
* The comic engine: drawing kit, the agent character and one scene per panel kind. Pure drawing;
@@ -920,7 +925,7 @@ const StoryArt = (() => {
const files = p.panel.files || [];
codePage(ctx, x0, y0, pw, ph, f, baseName(first?.file || files[0] || 'file'), lines, files.length - 1);
hit('diff', x0, y0, pw, ph, "See Claude's changes");
const ax = W - 50;
const ax = Math.min(W - 50, x0 + pw + 200);
const tip = [x0 + pw - 14, y0 + ph * 0.5];
const hp = [tip[0] + 20 + Math.sin(f / 2.6) * 3, tip[1] - 28 + Math.cos(f / 3.1) * 2.5];
const a = agent(ctx, ax, 248, { frame: f, mood: 'focus', l: [hp[0] - ax, hp[1] - 248], r: [30, -42] });
@@ -1194,6 +1199,7 @@ const StoryArt = (() => {
},
},
complete: {
fullWidth: true,
bg(ctx, W, H) {
base(ctx, W, H);
rays(ctx, W, H, W > 420 ? W * 0.72 : W / 2, 200, 30, C.cyan, 0.32);
@@ -1331,9 +1337,20 @@ Object.assign(CodemanApp.prototype, {
applyStoryButtonVisibility() {
const on = this.isStoryButtonEnabled();
document.querySelector('.btn-story-header')?.classList.toggle('btn-story-header--hidden', !on);
// Session Options carries a Story button too: the way in on phones, where the header has no room.
const optionsBtn = document.getElementById('sessionOptionsStoryBtn');
if (optionsBtn) optionsBtn.hidden = !on;
if (!on) this.closeStoryMode();
},
/** Session Options → Story: this session's story, whichever tab is active. */
openStoryFromOptions() {
const id = this.editingSessionId;
if (!id) return;
this.closeSessionOptions?.();
this.openStoryMode(id);
},
toggleStoryMode() {
if (this._story?.open) this.closeStoryMode();
else this.openStoryMode();
@@ -1351,6 +1368,8 @@ Object.assign(CodemanApp.prototype, {
st.turnId = null;
st.data = null;
st.error = null;
st.etag = null;
st.helperSteps = new Map();
st.epoch++;
st.first = true;
st.drawer = null;
@@ -1380,6 +1399,8 @@ Object.assign(CodemanApp.prototype, {
st.pageEl.replaceChildren();
st.wireList.replaceChildren();
st.data = null;
st.etag = null;
st.helperSteps = null;
const back = st.returnFocus;
st.returnFocus = null;
if (back && document.contains(back)) back.focus({ preventScroll: true });
@@ -1448,8 +1469,10 @@ Object.assign(CodemanApp.prototype, {
const live = el('span', 'story-live');
live.append(el('i'), el('span', null, 'LIVE'));
live.hidden = true;
const saveBtn = btn('story-btn story-save', 'Save image', 'Save this page as an image');
saveBtn.title = 'Save this page as a PNG to share';
const closeBtn = btn('story-btn story-close', '×', 'Close Story Mode');
bar.append(brand, nav, seg, live, closeBtn);
bar.append(brand, nav, seg, live, saveBtn, closeBtn);
const main = el('div', 'story-main');
const note = el('p', 'story-note');
@@ -1538,6 +1561,7 @@ Object.assign(CodemanApp.prototype, {
this._story = st;
closeBtn.addEventListener('click', () => this.closeStoryMode());
saveBtn.addEventListener('click', () => this.saveStoryImage());
drawerClose.addEventListener('click', () => this._storyCloseDrawer());
storyBtn.addEventListener('click', () => this._storySetDirector(false));
dirBtn.addEventListener('click', () => this._storySetDirector(true));
@@ -1580,6 +1604,7 @@ Object.assign(CodemanApp.prototype, {
const turns = st.data?.turns || [];
st.turnId = id && id !== turns[turns.length - 1]?.id ? id : null;
st.first = true;
st.etag = null;
st.epoch++;
this._storyCloseDrawer({ refocus: false });
this._storyRefresh();
@@ -1616,6 +1641,10 @@ Object.assign(CodemanApp.prototype, {
const qs = st.turnId ? `?turn=${encodeURIComponent(st.turnId)}` : '';
const res = await this._api(`/api/sessions/${encodeURIComponent(st.sessionId)}/story${qs}`);
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;
let body = null;
try {
body = res ? await res.json() : null;
@@ -1629,6 +1658,7 @@ Object.assign(CodemanApp.prototype, {
} else {
st.error = null;
st.data = body.data;
st.etag = etag;
}
this._storyRender();
} finally {
@@ -1682,7 +1712,12 @@ Object.assign(CodemanApp.prototype, {
if (!page || !page.complete) this._storyRefresh();
}, STORY_POLL_MS);
st.keyHandler = (e) => {
if (e.key !== 'Escape' || !st.open) return;
if (!st.open) return;
if (e.key === 'Tab') {
this._storyTrapTab(e);
return;
}
if (e.key !== 'Escape') return;
// A folder grid on top steps back by itself (preview, then grid), and a preview in edit mode
// keeps its keys. A plain file preview has no Escape of its own outside the grid, so Story
// closes it: one Escape per layer, the preview first.
@@ -1710,6 +1745,28 @@ 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
);
if (!items.length) return;
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement;
const inside = st.root.contains(active);
if (e.shiftKey && (active === first || !inside)) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && (active === last || !inside)) {
e.preventDefault();
first.focus();
}
},
_storyStopRuntime() {
const st = this._story;
cancelAnimationFrame(st.raf);
@@ -1901,7 +1958,14 @@ Object.assign(CodemanApp.prototype, {
const balloonText = p.kind === 'ask' && p.question?.text ? p.question.text : p.balloon?.text;
rt.balloon.hidden = !balloonText;
rt.balloon.textContent = balloonText || '';
rt.balloon.className = `story-balloon ${p.kind === 'mission' || p.balloon?.who === 'you' ? 'story-balloon--you' : 'story-balloon--claude'}`;
const notice = p.kind === 'mission' && index === 0 && page.origin && page.origin !== 'human';
rt.balloon.className = `story-balloon ${
notice
? 'story-balloon--note'
: p.kind === 'mission' || p.balloon?.who === 'you'
? 'story-balloon--you'
: 'story-balloon--claude'
}`;
rt.liveTag.hidden = !p.live;
rt.el.classList.toggle('is-live', !!p.live);
rt.el.setAttribute('aria-label', `Panel ${index + 1}: ${p.caption.story}`);
@@ -1977,13 +2041,23 @@ Object.assign(CodemanApp.prototype, {
rt.bgKey = key;
}
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.setTransform(k, 0, 0, k, 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);
const hits = [];
const hit = (id, x, y, w, h, label) => hits.push({ id, x, y, w, h, label });
scene.draw(ctx, W, StoryArt.VH, rt.clock, { panel: rt.panel, page: st.data?.page, director: st.director }, hit);
const hit = (id, x, y, w, h, label) => hits.push({ id, x: x + ox, y, w, h, label });
scene.draw(
ctx,
stageW,
StoryArt.VH,
rt.clock,
{ panel: rt.panel, page: st.data?.page, director: st.director },
hit
);
this._storySyncHits(rt, hits, s);
},
@@ -2021,6 +2095,167 @@ Object.assign(CodemanApp.prototype, {
}
},
// ── Save as image ──
/**
* The page as one PNG to share: each panel's canvas, then its captions, balloons and chips redrawn
* from their computed style and on-screen box (DOM text cannot be rasterized with self-hosted fonts
* reliably). Text is the page's own, so the image says exactly what the screen says.
*/
async saveStoryImage() {
const st = this._story;
const page = st?.data?.page;
if (!page || !st.panels.length) return;
await document.fonts?.ready;
const sheet = st.pageEl.getBoundingClientRect();
const head = 64;
const scale = Math.max(1, Math.min(2, 12000 / (sheet.height + head)));
const out = document.createElement('canvas');
out.width = Math.round(sheet.width * scale);
out.height = Math.round((sheet.height + head) * scale);
const g = out.getContext('2d');
if (!g) return;
g.scale(scale, scale);
const tok = (n) => getComputedStyle(st.root).getPropertyValue(`--story-${n}`).trim();
g.fillStyle = tok('sheet');
g.fillRect(0, 0, sheet.width, sheet.height + head);
// Title strip: the word STORY, the session and the prompt.
g.textBaseline = 'middle';
g.font = `30px 'Codeman Story Display', Impact, sans-serif`;
g.lineWidth = 4;
g.strokeStyle = tok('line');
g.strokeText('STORY', 16, head / 2);
g.fillStyle = tok('yellow');
g.fillText('STORY', 16, head / 2);
const titleW = g.measureText('STORY').width;
g.font = `500 12px 'JetBrains Mono', monospace`;
g.fillStyle = tok('muted');
const when = new Date(page.at);
const meta = `${st.sessionLabel.textContent} · ${Number.isNaN(when.getTime()) ? '' : when.toLocaleString()}`;
g.fillText(this._storyFit(g, meta, sheet.width - titleW - 48), titleW + 32, head / 2 - 9);
g.fillStyle = tok('ink');
g.fillText(this._storyFit(g, page.prompt || 'Earlier work', sheet.width - titleW - 48), titleW + 32, head / 2 + 9);
g.translate(0, head);
for (const rt of st.panels) {
const r = rt.el.getBoundingClientRect();
const x = r.left - sheet.left;
const y = r.top - sheet.top;
const bw = parseFloat(getComputedStyle(rt.el).borderTopWidth) || 3;
g.drawImage(rt.canvas, x + bw, y + bw, r.width - bw * 2, r.height - bw * 2);
g.lineWidth = bw;
g.strokeStyle = getComputedStyle(rt.el).borderTopColor;
g.strokeRect(x + bw / 2, y + bw / 2, r.width - bw, r.height - bw);
g.save();
g.beginPath();
g.rect(x, y, r.width, r.height);
g.clip();
const boxes = rt.el.querySelectorAll(
'.story-splash-title, .story-cap, .story-balloon, .story-chips li > *, .story-answer, .story-live-tag'
);
for (const el of boxes) this._storyPaintBox(g, el, sheet);
g.restore();
}
const blob = await new Promise((resolve) => out.toBlob(resolve, 'image/png'));
if (!blob) return;
const a = document.createElement('a');
const name = (st.sessionLabel.textContent || 'session').replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 40);
a.href = URL.createObjectURL(blob);
a.download = `story-${name}-${(page.at || '').slice(0, 10) || 'turn'}.png`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 10000);
},
_storyFit(g, text, maxW) {
if (g.measureText(text).width <= maxW) return text;
let lo = 0;
let hi = text.length;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
if (g.measureText(`${text.slice(0, mid)}…`).width <= maxW) lo = mid;
else hi = mid - 1;
}
return `${text.slice(0, lo)}…`;
},
/** One caption, balloon or chip, redrawn where and how the page shows it. */
_storyPaintBox(g, el, sheet) {
if (el.hidden || el.closest('[hidden]')) return;
const r = el.getBoundingClientRect();
if (!r.width || !r.height) return;
const cs = getComputedStyle(el);
if (cs.display === 'none' || cs.visibility === 'hidden') return;
const x = r.left - sheet.left;
const y = r.top - sheet.top;
const isTitle = el.classList.contains('story-splash-title');
if (!isTitle) {
const radius = Math.min(parseFloat(cs.borderTopLeftRadius) || 0, r.height / 2);
g.beginPath();
if (g.roundRect) g.roundRect(x, y, r.width, r.height, radius);
else g.rect(x, y, r.width, r.height);
if (cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)') {
g.fillStyle = cs.backgroundColor;
g.fill();
}
const bw = parseFloat(cs.borderTopWidth) || 0;
if (bw) {
g.lineWidth = bw;
g.strokeStyle = cs.borderTopColor;
g.setLineDash(cs.borderTopStyle === 'dashed' ? [6, 4] : []);
g.stroke();
g.setLineDash([]);
}
}
// The text the reader sees (hidden spans, like the other reading level, are left out).
const text = (el.innerText || el.textContent || '').trim();
if (!text) return;
const size = parseFloat(cs.fontSize) || 14;
const lh = parseFloat(cs.lineHeight) || size * 1.15;
g.font = `${cs.fontWeight} ${size}px ${cs.fontFamily}`;
g.textBaseline = 'top';
const padL = parseFloat(cs.paddingLeft) + (parseFloat(cs.borderLeftWidth) || 0);
const padT = parseFloat(cs.paddingTop) + (parseFloat(cs.borderTopWidth) || 0);
const maxW = r.width - padL - parseFloat(cs.paddingRight) - (parseFloat(cs.borderRightWidth) || 0) + 1;
const lines = [];
for (const para of text.split('\n')) {
let line = '';
for (const word of para.split(/\s+/)) {
const next = line ? `${line} ${word}` : word;
if (g.measureText(next).width > maxW && line) {
lines.push(line);
line = word;
} else line = next;
}
lines.push(line);
}
if (isTitle) {
g.save();
// Rotated about its bottom-left corner: draw from there, with the element's own height (the
// bounding box of a rotated element is taller than the text block).
const h0 = el.offsetHeight || r.height;
g.translate(x, y + r.height);
g.rotate((-3 * Math.PI) / 180);
g.lineJoin = 'round';
lines.forEach((l, i) => {
const ly = -h0 + i * lh;
g.fillStyle = getComputedStyle(this._story.root).getPropertyValue('--story-line').trim();
g.fillText(l, 4, ly + 4);
g.lineWidth = 5;
g.strokeStyle = g.fillStyle;
g.strokeText(l, 0, ly);
g.fillStyle = cs.color;
g.fillText(l, 0, ly);
});
g.restore();
return;
}
g.fillStyle = cs.color;
lines.forEach((l, i) => g.fillText(l, x + padL, y + padT + i * lh));
},
// ── The wire ──
_storyRenderWire() {
@@ -2078,7 +2313,8 @@ Object.assign(CodemanApp.prototype, {
} else {
label = document.createElement('span');
}
label.textContent = ev.label;
label.textContent = this._storyShortPath(ev.label);
if (label.textContent !== ev.label) label.title = ev.label;
const tag = document.createElement('i');
tag.className = `story-tag story-tag--${ev.ok === false ? 'bad' : ev.pending ? 'pending' : ev.intent}`;
tag.textContent = ev.ok === false ? 'error' : ev.pending ? 'running' : ev.intent;
@@ -2087,6 +2323,17 @@ Object.assign(CodemanApp.prototype, {
return li;
},
/**
* A label as the reader wants it: paths inside the session's folder lose that prefix. Display only;
* clicks keep the full path.
*/
_storyShortPath(text) {
const dir = this.sessions?.get(this._story?.sessionId)?.workingDir;
if (!dir || dir === '/' || !text) return text;
const prefix = dir.endsWith('/') ? dir : `${dir}/`;
return text.split(prefix).join('');
},
/** What a click on an event opens, as a `data-story-open` target, or null. */
_storyEventTarget(ev, index) {
if (ev.edit) return `diff:event:${index}`;
@@ -2238,10 +2485,11 @@ Object.assign(CodemanApp.prototype, {
} else if (source.view === 'helper') {
const ev = items[0]?.ev;
kind = 'Helper';
title = ev?.helper ? `${ev.helper.name} (${ev.helper.type})` : 'Helper';
title = ev?.helper ? ev.helper.task || ev.helper.name : 'Helper';
const h = ev?.helper;
sub = h
? [
h.name && h.name !== h.type ? `${h.name}, ${h.type}` : h.type,
h.toolUses !== undefined ? `${h.toolUses} tool calls` : '',
h.durationMs !== undefined ? this._storyDuration(h.durationMs) : '',
ev.pending ? 'still working' : '',
@@ -2255,6 +2503,11 @@ Object.assign(CodemanApp.prototype, {
el('h4', 'story-h', 'The helper answered'),
this._storyLinkifiedPre(h.result || (ev.pending ? 'Still working…' : '(no answer)'))
);
if (h.agentId) {
const box = el('div', 'story-helper-steps');
out.push(el('h4', 'story-h', 'What the helper did'), box);
this._storyFillHelperSteps(box, h.agentId);
}
}
} else if (source.view === 'plan') {
const ev = [...items].reverse().find((x) => x.ev.todos)?.ev;
@@ -2295,6 +2548,82 @@ Object.assign(CodemanApp.prototype, {
else body.scrollTop = prevScroll;
},
/** A helper's own steps (GET …/story/helpers/:agentId), cached per helper and revalidated by ETag. */
async _storyFillHelperSteps(box, agentId) {
const st = this._story;
const cached = st.helperSteps?.get(agentId);
if (cached) this._storyRenderHelperSteps(box, cached.data);
else {
const p = document.createElement('p');
p.className = 'story-empty';
p.textContent = 'Reading its steps…';
box.replaceChildren(p);
}
const res = await this._api(
`/api/sessions/${encodeURIComponent(st.sessionId)}/story/helpers/${encodeURIComponent(agentId)}`
);
if (!box.isConnected || !st.open) return;
const etag = res?.ok ? res.headers?.get?.('etag') || null : null;
if (cached && etag && etag === cached.etag) return;
let body = null;
try {
body = res?.ok ? await res.json() : null;
} catch {
body = null;
}
if (!box.isConnected || !st.open) return;
if (!body?.success) {
if (!cached) box.firstChild.textContent = 'Its steps could not be read.';
return;
}
st.helperSteps?.set(agentId, { etag, data: body.data });
this._storyRenderHelperSteps(box, body.data);
},
_storyRenderHelperSteps(box, data) {
const el = (tag, cls, text) => {
const n = document.createElement(tag);
if (cls) n.className = cls;
if (text != null) n.textContent = text;
return n;
};
const page = data?.page;
if (!page) {
box.replaceChildren(el('p', 'story-empty', 'No steps written yet.'));
return;
}
const steps = page.events.filter((e) => e.intent !== 'prompt' && e.intent !== 'say');
const summary = [
`${steps.length} step${steps.length === 1 ? '' : 's'}`,
page.filesChanged ? `${page.filesChanged} file${page.filesChanged === 1 ? '' : 's'} changed` : '',
data.helper?.finished === false ? 'still working' : '',
]
.filter(Boolean)
.join(' · ');
const list = el('ol', 'story-helper-list');
for (const ev of steps) {
const li = el('li', 'story-ev');
li.dataset.actor = 'helper';
const body = el('span', 'story-ev-body');
const shown = this._storyShortPath(ev.label);
const label = ev.file ? el('button', 'story-link', shown) : el('span', null, shown);
if (shown !== ev.label) label.title = ev.label;
if (ev.file) {
label.type = 'button';
label.addEventListener('click', () => this.openFilePreview?.(ev.file, this._story.sessionId));
}
const tag = el(
'i',
`story-tag story-tag--${ev.ok === false ? 'bad' : ev.pending ? 'pending' : ev.intent}`,
ev.ok === false ? 'error' : ev.pending ? 'running' : ev.intent
);
body.append(el('b', null, ev.tool), ' ', label, ' ', tag);
li.append(body);
list.append(li);
}
box.replaceChildren(el('p', 'story-out-desc', summary), list);
},
_storyDiffSection(ev, el) {
const edit = ev.edit;
const section = el('section', 'story-df');
+80 -1
View File
@@ -10,7 +10,7 @@ import { JSDOM } from 'jsdom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildStoryPage, buildStoryTurns, createStoryReducer, summarizeTurns } from '../src/story-mode.js';
import type { StoryPage, StoryPanel } from '../src/types/story.js';
import { bash, demoTurn, end, prompt, read, resetClock, said } from './story-fixtures.js';
import { bash, demoTurn, end, prompt, read, resetClock, result, said, tool } from './story-fixtures.js';
const STORY_JS = readFileSync(new URL('../src/web/public/story-mode.js', import.meta.url), 'utf-8');
// The real path matcher (findFilePathLinks / resolveLinkedFilePath) the terminal uses.
@@ -56,6 +56,7 @@ function boot(initial: unknown) {
dom = new JSDOM(
`<!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="folder-grid-overlay" id="folderGridOverlay"></div>
</body>`,
@@ -334,6 +335,80 @@ describe('story-mode.js overlay', () => {
expect($('.story-note')!.textContent).toBe('Session not found');
});
it('a turn a background task started gets a notice, not your speech balloon', async () => {
boot(
storyOf([
prompt('<task-notification><summary>Agent "Docs" finished</summary></task-notification>', {
turnOrigin: 'task_notification',
}),
said('Nice.'),
end(1000),
])
);
app.openStoryMode(SID);
await settle();
const balloon = $('.story-panel[data-kind="mission"] .story-balloon')!;
expect(balloon.className).toContain('story-balloon--note');
expect(balloon.textContent).toBe('Agent "Docs" finished');
});
it("the helper drawer fetches and lists the helper's own steps", async () => {
const lines = [
prompt('Docs please'),
tool('a1', 'Agent', { description: 'Write README', subagent_type: 'general-purpose', prompt: 'Write it' }),
result('a1', 'ok', {
toolUseResult: { agentId: 'h1', content: [{ type: 'text', text: 'Written.' }], totalToolUseCount: 2 },
}),
end(3000),
];
boot(storyOf(lines));
const helperPage = storyOf([prompt('Write it'), ...read('r1', '/work/demo-shop/a.md')]).page;
const base = app._api;
app._api = async (path: string) => {
if (path.includes('/story/helpers/h1')) {
calls.push(path);
return {
ok: true,
status: 200,
headers: { get: () => null },
json: async () => ({
success: true,
data: { supported: true, helper: { agentId: 'h1', finished: true }, page: helperPage },
}),
};
}
return base(path);
};
app.openStoryMode(SID);
await settle();
sizeAndDraw();
$('.story-panel[data-kind="backup"] .story-hit')!.click();
await settle();
expect($('.story-drawer-title')!.textContent).toBe('Write README');
expect($('.story-drawer-sub')!.textContent).toContain('general-purpose');
expect(calls).toContain(`/api/sessions/${SID}/story/helpers/h1`);
const steps = $('.story-helper-steps')!;
expect(steps.textContent).toContain('1 step');
// Paths inside the session's folder are shown short; the click still opens the full path.
const link = steps.querySelector('.story-link') as HTMLElement;
expect(link.textContent).toBe('a.md');
link.click();
expect(app.openFilePreview).toHaveBeenLastCalledWith('/work/demo-shop/a.md', SID);
});
it('Tab stays inside the overlay', async () => {
boot(storyOf(demoTurn()));
app.openStoryMode(SID);
await settle();
// JSDOM has no layout (offsetParent is always null), so give every control one.
for (const el of $$('#storyOverlay button, #storyOverlay select'))
Object.defineProperty(el, 'offsetParent', { get: () => win.document.body });
const controls = $$('#storyOverlay button, #storyOverlay select').filter((n) => !(n as HTMLButtonElement).disabled);
controls[controls.length - 1].focus();
press('Tab');
expect(win.document.activeElement).toBe(controls[0]);
});
it('turning the setting off hides the button and closes an open story', async () => {
boot(storyOf(demoTurn()));
app.openStoryMode(SID);
@@ -341,7 +416,11 @@ describe('story-mode.js overlay', () => {
app.loadAppSettingsFromStorage = () => ({ showStoryButton: false });
app.applyStoryButtonVisibility();
expect($('.btn-story-header')!.classList.contains('btn-story-header--hidden')).toBe(true);
expect($('#sessionOptionsStoryBtn')!.hidden).toBe(true);
expect($('#storyOverlay')!.hidden).toBe(true);
app.loadAppSettingsFromStorage = () => ({ showStoryButton: true });
app.applyStoryButtonVisibility();
expect($('#sessionOptionsStoryBtn')!.hidden).toBe(false);
});
});
+18 -2
View File
@@ -3,7 +3,7 @@
* synthetic transcript (test/story-fixtures.ts) handed to the session the way Claude's hooks do it,
* then grown while the overlay is open so the live redraw runs off real SSE events.
*/
import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { appendFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
@@ -95,7 +95,9 @@ describe('Story Mode in a real browser', () => {
it('redraws live when the transcript grows and a hook fires', async () => {
appendFileSync(transcript, jsonl(rest));
await hook('stop');
await page.waitForFunction(() => document.querySelector('.story-panel[data-kind="complete"]'), null, { timeout: 15000 });
await page.waitForFunction(() => document.querySelector('.story-panel[data-kind="complete"]'), null, {
timeout: 15000,
});
expect(await kinds()).toEqual(['mission', 'investigate', 'plan', 'backup', 'loop', 'save', 'complete']);
expect(await page.locator('.story-pencils').count()).toBe(0);
expect(await page.locator('.story-panel.story-fresh').count()).toBeGreaterThan(0);
@@ -113,6 +115,20 @@ describe('Story Mode in a real browser', () => {
expect(await page.locator('#storyOverlay').isHidden()).toBe(true);
});
it('Session Options opens the story, and Save image downloads the page as a PNG', async () => {
await page.evaluate((sid) => (window as any).app.openSessionOptions(sid), sessionId);
await page.locator('#sessionOptionsStoryBtn').click();
await page.waitForFunction(() => document.querySelectorAll('.story-panel').length > 0);
expect(await page.locator('#storyOverlay').isVisible()).toBe(true);
const [download] = await Promise.all([page.waitForEvent('download'), page.locator('.story-save').click()]);
expect(download.suggestedFilename()).toMatch(/^story-story-browser-.*\.png$/);
const path = await download.path();
const head = readFileSync(path!).subarray(0, 24);
expect(head.subarray(1, 4).toString()).toBe('PNG');
expect(head.readUInt32BE(16)).toBeGreaterThan(1000); // width, at 2x
await page.keyboard.press('Escape');
});
it('fits a phone-width viewport without sideways scrolling', async () => {
await page.setViewportSize({ width: 400, height: 860 });
await page.evaluate((sid) => (window as any).app.openStoryMode(sid), sessionId);