feat(story-mode): draw a Claude session's turns as comic pages, live

Story Mode is an opt-in header button (showStoryButton, per device, default
off, kept off phones) that opens the active Claude Code session as comic pages:
one page per prompt, panels for reading code, planning, helpers, edits, tests
(POW/KRAK), questions and saving, each with a plain story caption and a
Director's cut caption naming the tools. Files open in the existing file
preview, the code page opens the exact diff, a test board opens the output,
and a running turn redraws as Claude works.

Server: GET /api/sessions/:id/story (experimental) reads a bounded tail of the
session's own transcript. A pure core (src/story-mode.ts) reduces lines,
joins tool calls with their results, splits turns at prompts and
turn_duration, and groups events into at most 12 panels; Bash calls are
sorted by what the command does. The reader (src/story-reader.ts) caches
reduced entries and reads only appended bytes while the file grows.

Frontend: story-mode.js (canvas frame engine, live refresh from transcript and
hook SSE events plus a poll while a turn runs, details drawer, the wire) and
story-mode.css, with two self-hosted OFL comic fonts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 09:22:06 +02:00
1 parent 0ab71d61d1
commit 2653db10b5
27 files changed
+5746 -6

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
'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.
+2
View File
@@ -231,6 +231,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode-and-workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization)
**Story Mode** (opt-in header button `showStoryButton`, per-device, default OFF): a Claude session's turns drawn as comic pages, live. `GET /api/sessions/:id/story` (EXPERIMENTAL shape) reads a bounded tail of the session's OWN transcript (hook path, else `<conversation id>.jsonl` via `findClaudeTranscriptPath()`), never a path from the request, and never adopts or persists anything. ⚠️ Gate on `capabilities.transcript === 'claude-jsonl'` and `!remote && !docker`, never the mode id. ⚠️ `createStoryReducer()` (`src/story-mode.ts`, pure) is where size is shed: Read contents, Edit/Write `originalFile` and images never leave it, outputs keep a tail, hunks are capped; the reader caches only reduced entries. ⚠️ Interactive transcripts carry no `result` entry: a turn ends at `system/turn_duration`. Frontend rules in `src/web/CLAUDE.md` (Story Mode).
**Clone a repository as a case** (issue #236, Add Case → **Clone Repo**): `POST /api/cases/clone` clones synchronously into the caller's case space (bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` checks anonymous cloneability and lists refs. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: refuse every `::` form and a leading `-`, spawn only argv arrays with `--` before operands. ⚠️ Stay non-interactive (`gitNonInteractiveEnv()`) or the open request hangs; never collect credentials, refuse `user:password@` URLs. ⚠️ Timeout kills the process GROUP, remove the destination only if this attempt created it, and repo contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo `.claude/settings*` warned about). → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case)
**Git worktrees of a case** (case picker → **Git worktrees…**; `src/git-worktrees.ts`, `routes/worktree-routes.ts`): a local repository case keeps worktrees at `<repo>/.claude/worktrees/<name>` on `worktree-<name>`, Claude Code's own `--worktree` layout. ⚠️ Only that managed path is ever created or removed, every component `lstat`ed and realpath-checked (a symlinked `.claude` is refused). ⚠️ git never runs for a Docker or remote case, nor at or inside a Docker workspace (`worktree add` runs hooks and filters on the host). ⚠️ Remove is `git worktree remove` WITHOUT `--force`, refused while a live session is inside; branch delete is `-d`, never `-D`. ⚠️ Sessions start through the existing create paths (quick-start `worktree`, or `POST /api/sessions` `workingDir`), never a new spawn path. → [architecture-invariants#git-worktrees-of-a-case](docs/architecture-invariants.md#git-worktrees-of-a-case)
+13
View File
@@ -1050,6 +1050,19 @@ Both routes accept two optional query parameters, which the UI sends from its pe
Lists are capped (300 files and 50 commits per repository) while the counts stay exact. A branch with no upstream reports the commits no remote has (`HEAD --not --remotes`); a repository with no remote reports `unpushedCount: 0`, since there is nothing to push to. Concurrent polls of one folder share a single git invocation; `?fresh=1` (what the panel's Refresh button and opening the panel send) skips the short-lived caches, though it still joins a computation already running.
## Story Mode (experimental)
`GET /api/sessions/:id/story[?turn=<id>]` is what Story Mode reads (the opt-in header button, App Settings → Header buttons → Story Mode, per device, off by default). It draws one turn of a Claude Code session as a comic page. **Experimental:** the payload shape may still change in a minor release; it is not part of the stable `/api/v1` contract yet.
Read-only. The session is resolved like every session route (ownership via `findSessionOrFail`; another user's session is `404`), and the file read is the session's own transcript, never one the request names: the path the Claude hook reported for it, else `<conversation id>.jsonl` under the Claude projects directory. Only the tail of the transcript is read (8 MB), and file contents, original files and images never leave the server.
`data` is one of:
- `{ 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).
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
A local case whose folder is the top of a git repository can keep **worktrees** at `<case>/.claude/worktrees/<name>`, each on its own branch, so several agents on one repository never share a checkout. This is the layout Claude Code's own `claude --worktree <name>` uses (branch `worktree-<name>`), so either tool finds the other's worktrees. Remote (SSH) and Docker cases are refused with `400 INVALID_INPUT` before git runs, and so is a local case whose folder is at or inside a Docker case workspace: `git worktree add` runs the repository's post-checkout hook and checkout filters on this machine, and a container can write there. A case folder that is not a repository, is a bare repository, or sits below the repository's top level is `400` too. The case resolves the way quick-start resolves it (a linked case is admin-only in multi-user mode, everything else is the caller's own case space; another user's case is `404`), and a case folder on a mount that does not answer is `422 OPERATION_FAILED`, never `404`.
+8 -1
View File
@@ -60,11 +60,18 @@ supervised by systemd or launchd; npm installs report as non-updatable. See
Chips for every optional header control, with a live preview of the resulting header:
Run, Font Size, System Stats, Redraw Terminal, Response Viewer, Away Digest, Session
Run, Font Size, System Stats, Redraw Terminal, Response Viewer, Story Mode, Away Digest, Session
Manager, Attachments, File Viewer, Multi-monitor, Split, Tiles, Plan Usage, Lifecycle Log, Monitor,
Project Insights, File Browser, Subagents, Approvals Inbox, Read My Mind, Ultracode Agents,
Ultracode Windows, Cron.
**Story Mode** (off by default, per device, desktop and tablet) adds a header button that
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.
**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
pushed, `⚠ N` merge conflicts, `? N` repositories git could not read, or `✓` when everything is
+1186
View File
File diff suppressed because it is too large. Load diff
+159
View File
@@ -0,0 +1,159 @@
/**
* @fileoverview Story Mode transcript reader: the IO half of Story Mode (the pure half is story-mode.ts).
*
* Reads the TAIL of a Claude Code transcript, never the whole file: transcripts reach tens of MB
* (Write/Edit results carry whole files). The first read takes the last `STORY_TAIL_BYTES` and drops
* the partial first line; later reads, while the file only grows, read just the appended bytes. A line
* that is still being written (no newline yet) is left for the next read.
*
* Each transcript's reduced entries are cached (`createStoryReducer()` already dropped file contents,
* images and long outputs), bounded per transcript by entry count and character budget, and across
* transcripts by an LRU, so a live view refetching every second costs one small append read.
* Concurrent reads of one path share a single in-flight promise.
*
* All IO is async (`fs/promises`); nothing here runs on a path the browser supplied. The route decides
* which file is read (story-routes.ts).
*/
import { open, stat } from 'node:fs/promises';
import { LRUMap } from './utils/lru-map.js';
import { createStoryReducer, type StoryEntry, type StoryReducer } from './story-mode.js';
/** How much of the file's end a first read takes. */
export const STORY_TAIL_BYTES = 8 * 1024 * 1024;
/** Entries kept per transcript (the oldest go first). */
const MAX_ENTRIES = 20_000;
/** Approximate characters kept per transcript across all entries. */
const MAX_CHARS = 6_000_000;
/** Transcripts cached at once. */
const MAX_TRANSCRIPTS = 6;
interface CacheEntry {
size: number;
mtimeMs: number;
/** Byte offset up to which complete lines were consumed. */
offset: number;
entries: StoryEntry[];
chars: number;
reducer: StoryReducer;
/** The read began mid-file, so the oldest turn may be cut. */
partial: boolean;
}
export interface StoryEntriesResult {
entries: StoryEntry[];
partial: boolean;
}
const cache = new LRUMap<string, CacheEntry>({ maxSize: MAX_TRANSCRIPTS });
const inFlight = new Map<string, Promise<StoryEntriesResult>>();
/** For tests: forget every cached transcript. */
export function clearStoryReaderCache(): void {
cache.clear();
inFlight.clear();
}
async function readRange(path: string, start: number, end: number): Promise<Buffer> {
const length = Math.max(0, end - start);
const buf = Buffer.alloc(length);
const handle = await open(path, 'r');
try {
let read = 0;
while (read < length) {
const { bytesRead } = await handle.read(buf, read, length - read, start + read);
if (bytesRead === 0) break;
read += bytesRead;
}
return read === length ? buf : buf.subarray(0, read);
} finally {
await handle.close();
}
}
const entryChars = (e: StoryEntry): number => JSON.stringify(e).length;
function ingest(entry: CacheEntry, buf: Buffer, startsMidLine: boolean): void {
let begin = 0;
if (startsMidLine) {
const firstNewline = buf.indexOf(0x0a);
if (firstNewline < 0) return; // one huge partial line: nothing complete yet
begin = firstNewline + 1;
}
const lastNewline = buf.lastIndexOf(0x0a);
if (lastNewline < begin) {
// No complete line in what was read; consume nothing beyond a dropped partial head.
entry.offset += begin;
return;
}
const text = buf.subarray(begin, lastNewline).toString('utf8');
entry.offset += lastNewline + 1;
for (const line of text.split('\n')) {
if (!line.trim()) continue;
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch {
continue; // a malformed line is skipped, never fatal
}
for (const reduced of entry.reducer.reduce(parsed)) {
entry.entries.push(reduced);
entry.chars += entryChars(reduced);
}
}
while (entry.entries.length > MAX_ENTRIES || (entry.chars > MAX_CHARS && entry.entries.length > 1)) {
const dropped = entry.entries.shift();
if (dropped) entry.chars -= entryChars(dropped);
entry.partial = true;
}
}
async function load(path: string): Promise<StoryEntriesResult> {
const info = await stat(path);
if (!info.isFile()) throw new Error('Transcript is not a regular file');
const cached = cache.get(path);
if (cached && cached.size === info.size && cached.mtimeMs === info.mtimeMs) {
return { entries: cached.entries, partial: cached.partial };
}
if (
cached &&
info.size > cached.offset &&
info.size - cached.offset <= STORY_TAIL_BYTES &&
info.size >= cached.size
) {
// The file only grew: read what was appended since the last complete line.
const buf = await readRange(path, cached.offset, info.size);
ingest(cached, buf, false);
cached.size = info.size;
cached.mtimeMs = info.mtimeMs;
cache.set(path, cached);
return { entries: cached.entries, partial: cached.partial };
}
// First read, a rewrite, or more appended than a tail holds: start over from the tail.
const start = Math.max(0, info.size - STORY_TAIL_BYTES);
const fresh: CacheEntry = {
size: info.size,
mtimeMs: info.mtimeMs,
offset: start,
entries: [],
chars: 0,
reducer: createStoryReducer(),
partial: start > 0,
};
const buf = await readRange(path, start, info.size);
ingest(fresh, buf, start > 0);
cache.set(path, fresh);
return { entries: fresh.entries, partial: fresh.partial };
}
/**
* Reduced entries for the transcript at `path`, newest last. Throws when the file cannot be read;
* the caller turns that into an error response.
*/
export function readStoryEntries(path: string): Promise<StoryEntriesResult> {
const running = inFlight.get(path);
if (running) return running;
const promise = load(path).finally(() => inFlight.delete(path));
inFlight.set(path, promise);
return promise;
}
+1
View File
@@ -74,3 +74,4 @@ export * from './user.js';
export * from './webview.js';
export * from './intent.js';
export * from './mcp-sync.js';
export * from './story.js';
+169
View File
@@ -0,0 +1,169 @@
/**
* @fileoverview Story Mode types: one Claude Code turn drawn as a comic page.
*
* Produced by the pure core in `src/story-mode.ts` and served by
* `GET /api/sessions/:id/story` (`src/web/routes/story-routes.ts`). The shape is
* EXPERIMENTAL (docs/api-reference.md), not part of the stable `/api/v1` contract yet.
*
* Every string in here comes from a transcript the agent wrote, so it is untrusted
* text: the browser renders it with `textContent` only, and a file path in here is
* opened through the existing file preview, never fetched directly.
*/
/** What one transcript event means, read from the tool name and, for Bash, the command. */
export type StoryIntent =
| 'prompt'
| 'read'
| 'search'
| 'look'
| 'web'
| 'plan'
| 'say'
| 'helper'
| 'message'
| 'edit'
| 'test'
| 'build'
| 'run'
| 'save'
| 'ask'
| 'compact'
| 'interrupt'
| 'tool';
/** The comic panel a run of events is drawn as. */
export type StoryPanelKind =
| 'mission'
| 'investigate'
| 'research'
| 'plan'
| 'backup'
| 'fix'
| 'check'
| 'loop'
| 'run'
| 'save'
| 'ask'
| 'compact'
| 'montage'
| 'complete';
export type StoryActor = 'you' | 'claude' | 'helper' | 'system';
/** Who started a turn: you, a background task reporting back, another session, or a schedule/SDK. */
export type StoryTurnOrigin = 'human' | 'task' | 'peer' | 'system';
/** One hunk of an edit, in the shape of Claude Code's `structuredPatch` (lines keep their ` `/`+`/`-` prefix). */
export interface StoryHunk {
oldStart: number;
oldLines: number;
newStart: number;
newLines: number;
lines: string[];
}
/** What one Edit/Write call changed. Counts are exact even when `hunks` was cut to size. */
export interface StoryEdit {
file: string;
added: number;
removed: number;
created: boolean;
hunks: StoryHunk[];
truncated: boolean;
}
/** A helper (Agent/Task/Workflow call): what it was asked and what it answered. */
export interface StoryHelper {
name: string;
type: string;
/** Claude's short description of the job ("Write a phone-size test"). */
task?: string;
prompt: string;
result?: string;
toolUses?: number;
durationMs?: number;
}
export interface StoryTests {
passed: number;
failed: number;
}
export interface StoryTodo {
text: string;
done: boolean;
}
/** One row of "the wire": a prompt, a sentence Claude said, or one tool call with its result. */
export interface StoryEvent {
id: string;
at: string;
actor: StoryActor;
tool: string;
label: string;
intent: StoryIntent;
/** False when the tool result was flagged as an error; absent while the call is still running. */
ok?: boolean;
pending?: boolean;
/** The one file this event concerns (Read, Edit, Write), as the transcript gave it. */
file?: string;
line?: number;
/** Claude's own short description of a Bash call ("Run the test suite"). */
description?: string;
output?: string;
outputTruncated?: boolean;
edit?: StoryEdit;
helper?: StoryHelper;
tests?: StoryTests;
todos?: StoryTodo[];
question?: { text: string; options: string[]; answer?: string };
}
export interface StoryPanel {
kind: StoryPanelKind;
/** Indices into the page's `events`. */
events: number[];
caption: { story: string; director: string };
balloon?: { who: 'you' | 'claude'; text: string };
files?: string[];
notes?: string[];
helpers?: Array<{ name: string; type: string; task?: string; event: number }>;
added?: number;
removed?: number;
ok?: boolean;
rounds?: number;
tests?: StoryTests;
todos?: StoryTodo[];
question?: { text: string; answer?: string };
/** The turn is still running and this is its newest panel. */
live?: boolean;
}
export interface StoryTurnSummary {
id: string;
at: string;
prompt: string;
origin: StoryTurnOrigin;
complete: boolean;
interrupted: boolean;
toolCalls: number;
}
export interface StoryPage extends StoryTurnSummary {
endedAt?: string;
durationMs?: number;
/** The turn began before the part of the transcript that was read. */
partial: boolean;
/** Older events, outputs or hunks were dropped to keep the page small. */
truncated: boolean;
panels: StoryPanel[];
events: StoryEvent[];
filesChanged: number;
added: number;
removed: number;
helpers: number;
}
export type StoryResponse =
| { supported: false; reason: string }
| { supported: true; turns: StoryTurnSummary[]; page: StoryPage | null };
+26 -1
View File
@@ -22,7 +22,7 @@
* recoverable pane into the collision this module exists to prevent.
*
* @dependencies none
* @consumedby session (relaunch resume pinning)
* @consumedby session (relaunch resume pinning), web/routes/story-routes (Story Mode transcript lookup)
*
* @module utils/claude-transcript
*/
@@ -47,6 +47,31 @@ export function claudeProjectsDir(configDir?: string): string {
return join(configDir || fromEnv || join(homedir(), '.claude'), 'projects');
}
/**
* 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
* checked against a strict charset before it is joined into a path, and only a regular file counts.
*/
export async function findClaudeTranscriptPath(conversationId: string, configDir?: string): Promise<string | null> {
if (!/^[a-zA-Z0-9._-]+$/.test(conversationId) || conversationId.startsWith('.')) return null;
const projectsDir = claudeProjectsDir(configDir);
let projectDirs: string[];
try {
projectDirs = await readdir(projectsDir);
} catch {
return null;
}
for (const projectDir of projectDirs) {
const candidate = join(projectsDir, projectDir, `${conversationId}.jsonl`);
try {
if ((await stat(candidate)).isFile()) return candidate;
} catch {
// Not in this project directory; keep looking.
}
}
return null;
}
/**
* True when a transcript for `conversationId` exists under any project
* directory. Returns false for a missing projects dir or an unreadable one,
+3 -1
View File
@@ -54,6 +54,8 @@ 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): drawer first, then the overlay, and nothing while the file preview or folder grid is open on top. ⚠️ 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`.
**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)
**Terminal font weight** (`terminalFontWeight` / `terminalFontWeightBold`, per-device, default = xterm's own `normal`/`bold`): Claude Code's markdown bold is a bare `ESC[1m`, so the weight step is its only cue. `CodemanTerminalFont.resolveWeights()` (constants.js, pure) resolves each slot against **its own** xterm default. ⚠️ The `@font-face` for `fonts/jetbrains-mono-variable.woff2` must stay declared `100 800` (the browser synthesizes from the descriptor, not the file); narrowing it silently makes the setting a no-op. ⚠️ A live save must reach both echo overlays (`refreshFont()`) and open Agent Teams panes. ⚠️ Leave `_awaitTerminalFont()` untouched. Test: `test/terminal-font-weight.test.ts`. → [architecture-invariants#terminal-font-weight](../../docs/architecture-invariants.md#terminal-font-weight)
@@ -86,6 +88,6 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` can stop delivering without erroring, so the client forces a reconnect when nothing arrives. ⚠️ The server keepalive must stay the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), never an SSE comment, which `EventSource` cannot observe; its no-op client listener must stay registered. ⚠️ Judge staleness only while `connected` and online (the loop breaker). ⚠️ The liveness stamp lives inside `addListener`. ⚠️ Clear the interval only at the top of `connectSSE()`, or intervals stack. → [architecture-invariants#sse-staleness-watchdog](../../docs/architecture-invariants.md#sse-staleness-watchdog)
**Z-index layers** (keep new overlays consistent with this stack): local echo overlay (7; with local echo on it also draws the iOS IME composition preview, as an underlined tail after its pending text via `setComposition`), iOS IME composition preview span when local echo is off (6 inside `.xterm-helpers`, whose own z-index 5 is its EFFECTIVE layer, so it sits UNDER the overlay and must never be used while the overlay shows text), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu + Tiles count menu (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), folder grid (5090, above the response viewer it opens from, below the file preview its tiles open), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its `[hidden]` must re-assert `display: none` or `dismiss()` leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → [architecture-invariants#z-index-layers](../../docs/architecture-invariants.md#z-index-layers)
**Z-index layers** (keep new overlays consistent with this stack): local echo overlay (7; with local echo on it also draws the iOS IME composition preview, as an underlined tail after its pending text via `setComposition`), iOS IME composition preview span when local echo is off (6 inside `.xterm-helpers`, whose own z-index 5 is its EFFECTIVE layer, so it sits UNDER the overlay and must never be used while the overlay shows text), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu + Tiles count menu (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), Story Mode overlay (2400, below the connection-loss overlay so a lost server still shows), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), folder grid (5090, above the response viewer it opens from, below the file preview its tiles open), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its `[hidden]` must re-assert `display: none` or `dismiss()` leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → [architecture-invariants#z-index-layers](../../docs/architecture-invariants.md#z-index-layers)
**Keyboard shortcuts**: Escape (close), Ctrl+? (shortcut overlay), Ctrl/Cmd/Alt+K (session palette), Ctrl+Tab (next), Alt+[/] (prev/next tab), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter or Ctrl+Enter (newline), Ctrl+C (copy selection, else interrupt) / Ctrl+Shift+C (copy, never interrupts), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font), Shift+Wheel (local scrollback when mouse passthrough is active), Shift+drag (start a selection in a stripped-DECSET pane, where xterm's own Shift branch is unreachable and a Shift+drag used to select nothing; `_installShiftDragSelection`), right-click (copy the selection, the mintty/PuTTY convention, since xterm paints into a canvas and the native menu has no Copy for it; with nothing selected the native menu is left alone). Rebindable via the registry. ⚠️ **Ctrl+W is deliberately NOT bound** (owner decision): it is delete-word in every shell and agent CLI, and as Close Session it killed sessions with no confirm; `close-session` keeps `bindings: []` and stays bindable (`test/ctrl-w-never-closes.test.ts`).
+12
View File
@@ -2197,6 +2197,18 @@ class CodemanApp {
addListener(event, () => this._onSessionListMaybeChanged());
}
// Story Mode (story-mode.js): any sign of transcript activity refreshes an open story for that
// session. Extra listeners like the ones above, so the handler map stays one method per event.
// No transcript `complete` event: interactive transcripts carry no `result` entry to raise it.
for (const event of [
SSE_EVENTS.TRANSCRIPT_TOOL_START,
SSE_EVENTS.TRANSCRIPT_TOOL_END,
SSE_EVENTS.HOOK_STOP,
SSE_EVENTS.HOOK_PROMPT_SUBMITTED,
]) {
addListener(event, (e) => this._storyOnActivity?.(e));
}
// Docker export/import: toast + refresh the Manage-tab exports list on completion.
addListener(SSE_EVENTS.DOCKER_EXPORT_COMPLETE, (e) => {
try {
@@ -0,0 +1,193 @@
Story Mode comic fonts (src/web/public/fonts/bowlby-one-sc-latin.woff2, src/web/public/fonts/patrick-hand-sc-latin.woff2).
Both are the Latin subsets served by Google Fonts, unmodified, under the SIL Open Font License 1.1.
=== Bowlby One SC ===
Copyright (c) 2011 by vernon adams (vern@newtypography.co.uk),
with Reserved Font Names "Bowlby" "Bowlby One" and "Bowlby One SC"
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
=== Patrick Hand SC ===
Copyright 2012 The Patrick Hand Authors (mail@patrickwagesreiter.at)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
+5 -1
View File
@@ -17,6 +17,7 @@
<title>Codeman</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' stop-color='%2360a5fa'/%3E%3Cstop offset='100%25' stop-color='%233b82f6'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='6' fill='%230a0a0a'/%3E%3Cpath d='M18 4L8 18h6l-2 10 10-14h-6z' fill='url(%23g)'/%3E%3C/svg%3E">
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="story-mode.css">
<link rel="stylesheet" href="mobile.css" media="(max-width: 1023px)">
<!-- xterm.css loaded async — terminal won't display until xterm.js runs anyway -->
<link rel="preload" href="vendor/xterm.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
@@ -181,6 +182,7 @@
</div>
<button class="btn-icon-header btn-redraw-terminal btn-redraw-terminal--hidden" onclick="app.restoreTerminalSize()" title="Redraw terminal to fit current screen (Ctrl+Shift+R)" aria-label="Redraw terminal"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg></button>
<button class="btn-icon-header btn-response-viewer-header btn-response-viewer-header--hidden" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
<button class="btn-icon-header btn-story-header btn-story-header--hidden" onclick="app.toggleStoryMode()" title="Story Mode: this session as a comic" aria-label="Story Mode"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="3" width="8" height="8" rx="1"/><rect x="3" y="13" width="18" height="8" rx="1"/></svg></button>
<button class="btn-icon-header btn-away-digest btn-away-digest--hidden" onclick="app.openAwayDigest()" title="Away Digest" aria-label="Open away digest"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg></button>
<button class="btn-icon-header btn-session-manager btn-session-manager--hidden" onclick="app.openSessionManager()" title="Session Manager" aria-label="Open session manager"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg></button>
<button class="btn-icon-header btn-approvals btn-approvals--hidden" id="approvalsBtn" onclick="app.toggleApprovalsInbox()" title="Approvals waiting on you" aria-label="Open approvals inbox" aria-expanded="false">
@@ -2067,11 +2069,12 @@
<div class="set-group-head"><h4>Header buttons</h4><span class="set-scope">device</span></div>
<p class="set-group-hint">Tap to show a control in the header. Multi-monitor is the one entry here that syncs across devices.</p>
<div class="set-group-body">
<div class="set-chips" data-search="header buttons plan usage font stats lifecycle response file viewer attachments monitor session away cron redraw split tiles">
<div class="set-chips" data-search="header buttons plan usage font stats lifecycle response story file viewer attachments monitor session away cron redraw split tiles">
<label class="set-chip" data-preview="header" data-preview-order="1" data-preview-text="A+"><input type="checkbox" id="appSettingsShowFontControls"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 20 10 5l6 15"/><path d="M6.5 15h7"/><path d="M18 12h4M20 10v4"/></svg><span>Font Size</span></label>
<label class="set-chip" data-preview="header" data-preview-order="2" data-preview-text="CPU 12%"><input type="checkbox" id="appSettingsShowSystemStats" checked><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h4l2.5-7 4 14L16 12h5"/></svg><span>System Stats</span></label>
<label class="set-chip" data-preview="header" data-preview-order="3"><input type="checkbox" id="appSettingsShowRedrawButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg><span>Redraw Terminal</span></label>
<label class="set-chip" data-preview="header" data-preview-order="4"><input type="checkbox" id="appSettingsShowResponseViewer"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg><span>Response Viewer</span></label>
<label class="set-chip" data-search="story mode comic panels" data-preview="header" data-preview-order="4.5"><input type="checkbox" id="appSettingsShowStoryButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="3" width="8" height="8" rx="1"/><rect x="3" y="13" width="18" height="8" rx="1"/></svg><span>Story Mode</span></label>
<label class="set-chip" data-preview="header" data-preview-order="5"><input type="checkbox" id="appSettingsShowAwayDigestButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg><span>Away Digest</span></label>
<label class="set-chip" data-preview="header" data-preview-order="6"><input type="checkbox" id="appSettingsShowSessionButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg><span>Session Manager</span></label>
<label class="set-chip" data-preview="header" data-preview-order="9"><input type="checkbox" id="appSettingsShowAttachmentsButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg><span>Attachments</span></label>
@@ -4272,6 +4275,7 @@
<script defer src="mobile-overview.js"></script>
<script defer src="home-sessions.js"></script>
<script defer src="git-status-ui.js"></script>
<script defer src="story-mode.js"></script>
<script defer src="entrance-animations.js"></script>
<script defer src="ralph-wizard.js"></script>
<script defer src="api-client.js"></script>
+4 -1
View File
@@ -589,7 +589,10 @@ html.mobile-init .file-browser-panel {
/* Approvals bell: phones answer from the overview's NEEDS YOU rows instead
(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 {
.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. */
.btn-icon-header.btn-story-header {
display: none !important;
}
/* Split-pane is hidden here too, but the AUTHORITATIVE hard gate is the
+6 -1
View File
@@ -422,6 +422,7 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsHeaderStatsStyle').value = this.resolveHeaderStatsStyle(settings);
document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? false;
document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
document.getElementById('appSettingsShowStoryButton').checked = settings.showStoryButton ?? defaults.showStoryButton ?? false;
document.getElementById('appSettingsShowFileViewerButton').checked = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? true;
document.getElementById('appSettingsShowAttachmentsButton').checked = settings.showAttachmentsButton ?? defaults.showAttachmentsButton ?? false;
document.getElementById('appSettingsSkin').value = settings.skin ?? defaults.skin ?? 'daylight-blue';
@@ -2634,6 +2635,7 @@ Object.assign(CodemanApp.prototype, {
headerStatsStyle: document.getElementById('appSettingsHeaderStatsStyle').value,
showLifecycleLog: document.getElementById('appSettingsShowLifecycleLog').checked,
showResponseViewer: document.getElementById('appSettingsShowResponseViewer').checked,
showStoryButton: document.getElementById('appSettingsShowStoryButton').checked,
showFileViewerButton: document.getElementById('appSettingsShowFileViewerButton').checked,
showAttachmentsButton: document.getElementById('appSettingsShowAttachmentsButton').checked,
showMonitor: document.getElementById('appSettingsShowMonitor').checked,
@@ -4137,6 +4139,9 @@ Object.assign(CodemanApp.prototype, {
responseViewerBtn.classList.toggle('btn-response-viewer-header--hidden', !showResponseViewer);
}
// Story Mode (story-mode.js): opt-in, default OFF; turning it off also closes an open story.
this.applyStoryButtonVisibility?.();
// Hide the attachments (history) button when disabled. Opt-in, default OFF —
// marker class, base is display:inline-flex !important.
const showAttachmentsButton = settings.showAttachmentsButton ?? defaults.showAttachmentsButton ?? false;
@@ -4600,7 +4605,7 @@ Object.assign(CodemanApp.prototype, {
_displaySettingKeySet() {
const displayKeys = new Set([
'showFontControls', 'showSystemStats', 'headerStatsStyle', 'showTokenCount', 'showCost',
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
'showLifecycleLog', 'showResponseViewer', 'showStoryButton', 'showRedrawButton',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'showTabCliLogos', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
+848
View File
@@ -0,0 +1,848 @@
/* Story Mode (story-mode.js): a session's turns as comic pages.
Layout: a full-screen overlay (bar on top, the comic sheet below, "the wire" beside it in the
Director's cut) with a details drawer on the right. The comic keeps its own palette, the four
process inks, so it reads the same on every Codeman skin; a light skin gets the paper variant.
Fonts are self-hosted (CSP font-src 'self'); licenses in fonts/LICENSE-story-fonts.txt. */
@font-face {
font-family: 'Codeman Story Display';
src: url('fonts/bowlby-one-sc-latin.woff2') format('woff2');
font-display: swap;
}
@font-face {
font-family: 'Codeman Story Letter';
src: url('fonts/patrick-hand-sc-latin.woff2') format('woff2');
font-display: swap;
}
.story-overlay {
/* Night edition: dark panels, light ink for marks drawn on them, dark line art on objects. */
--story-bg: #0d0d12;
--story-sheet: #15151c;
--story-panel: #1c1c25;
--story-ink: #edeae4;
--story-line: #121016;
--story-muted: #a49fae;
--story-rule: #2e2d38;
--story-cyan: #33b7ea;
--story-magenta: #ff4e97;
--story-yellow: #ffd43d;
--story-go: #3ec27c;
--story-pencil: #4f86b8;
--story-balloon: #f3f1ec;
--story-balloon-ink: #121016;
--story-cap: #ffd43d;
--story-cap-ink: #121016;
--story-display: 'Codeman Story Display', Impact, 'Arial Black', sans-serif;
--story-letter: 'Codeman Story Letter', 'Comic Sans MS', 'Chalkboard SE', cursive;
--story-body: 'Manrope', system-ui, sans-serif;
--story-mono: 'JetBrains Mono', ui-monospace, Menlo, monospace;
--story-drawer-w: min(560px, 44vw);
position: fixed;
inset: 0;
z-index: 2400;
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr);
background: var(--story-bg);
color: var(--story-ink);
font-family: var(--story-body);
color-scheme: dark;
padding-top: env(safe-area-inset-top, 0px);
padding-bottom: env(safe-area-inset-bottom, 0px);
}
.story-overlay.story-paper {
--story-bg: #ecede9;
--story-sheet: #fbfbf9;
--story-panel: #ffffff;
--story-ink: #18151d;
--story-line: #18151d;
--story-muted: #5f5a68;
--story-rule: #d5d3da;
--story-cyan: #0e9fd6;
--story-magenta: #e0237a;
--story-yellow: #ffd019;
--story-go: #1a9a55;
--story-pencil: #5fa6df;
--story-balloon: #ffffff;
--story-balloon-ink: #18151d;
--story-cap: #ffd019;
--story-cap-ink: #18151d;
color-scheme: light;
}
.story-overlay[hidden] {
display: none;
}
body.story-open {
overflow: hidden;
}
.story-overlay button {
font: inherit;
color: inherit;
cursor: pointer;
}
.story-overlay :focus-visible {
outline: 3px solid var(--story-cyan);
outline-offset: 2px;
}
/* Bar */
.story-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 16px;
padding: 10px max(16px, env(safe-area-inset-right, 0px)) 10px max(16px, env(safe-area-inset-left, 0px));
border-bottom: 2px solid var(--story-ink);
background: var(--story-sheet);
}
.story-brand {
display: flex;
align-items: baseline;
gap: 12px;
min-width: 0;
flex: 1 1 180px;
}
.story-title {
font: 28px/1 var(--story-display);
color: var(--story-yellow);
-webkit-text-stroke: 1.5px var(--story-line);
paint-order: stroke fill;
text-shadow: 3px 3px 0 var(--story-magenta);
}
.story-session {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: 500 12.5px/1.2 var(--story-mono);
color: var(--story-muted);
}
.story-nav {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex: 1 1 200px;
}
.story-turns {
min-width: 0;
width: 100%;
flex: 1;
max-width: 420px;
padding: 6px 8px;
font: 500 12.5px/1.2 var(--story-mono);
color: var(--story-ink);
background: var(--story-panel);
border: 2px solid var(--story-ink);
border-radius: 0;
}
.story-btn {
display: inline-grid;
place-items: center;
min-width: 34px;
height: 34px;
padding: 0 8px;
font: 20px/1 var(--story-body) !important;
background: var(--story-panel);
border: 2px solid var(--story-ink);
border-radius: 0;
}
.story-btn:disabled {
opacity: 0.35;
cursor: default;
}
.story-seg {
display: inline-flex;
border: 2.5px solid var(--story-ink);
}
.story-seg-btn {
padding: 6px 12px 5px;
font: 18px/1 var(--story-letter) !important;
background: transparent;
border: 0;
}
.story-seg-btn[aria-pressed='true'] {
background: var(--story-ink);
color: var(--story-sheet) !important;
}
.story-live {
display: inline-flex;
align-items: center;
gap: 7px;
font: 600 12px/1 var(--story-mono);
letter-spacing: 0.1em;
color: var(--story-magenta);
}
.story-live[hidden] {
display: none;
}
.story-live i {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--story-magenta);
animation: story-blink 1s steps(2, start) infinite;
}
@keyframes story-blink {
to {
visibility: hidden;
}
}
/* Main: the sheet and the wire */
.story-main {
overflow: auto;
padding: 16px max(16px, env(safe-area-inset-right, 0px)) 40px max(16px, env(safe-area-inset-left, 0px));
}
.story-overlay.has-drawer .story-main {
padding-right: calc(var(--story-drawer-w) + 16px);
}
.story-note {
max-width: 1240px;
margin: 0 auto 14px;
padding: 8px 12px;
font-size: 14px;
line-height: 1.45;
color: var(--story-ink);
border: 2px dashed var(--story-pencil);
}
.story-note[hidden] {
display: none;
}
.story-stage {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 16px;
align-items: start;
max-width: 1240px;
margin: 0 auto;
}
.story-overlay.is-director .story-stage {
grid-template-columns: minmax(0, 1fr) 320px;
}
@media (max-width: 1100px) {
.story-overlay.is-director .story-stage {
grid-template-columns: minmax(0, 1fr);
}
}
.story-sheet {
container: story-sheet / inline-size;
background: var(--story-sheet);
}
.story-page {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
grid-auto-rows: 270px;
gap: 14px;
padding: 14px;
}
/* Panels */
.story-panel {
position: relative;
margin: 0;
overflow: hidden;
grid-column: span var(--story-span6, 2);
background: var(--story-panel);
border: 3px solid var(--story-ink);
}
.story-panel canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.story-panel.is-live {
border-color: var(--story-magenta);
}
.story-hit {
position: absolute;
z-index: 1;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
}
.story-hit:hover,
.story-hit:focus-visible {
outline: 3px dashed var(--story-magenta) !important;
outline-offset: 2px;
}
.story-cap {
position: absolute;
z-index: 2;
left: 10px;
top: 10px;
margin: 0;
max-width: min(64%, 34ch);
padding: 5px 9px 6px;
background: var(--story-cap);
color: var(--story-cap-ink);
border: 2.5px solid var(--story-cap-ink);
font: 17px/1.13 var(--story-letter);
text-wrap: pretty;
}
.story-cap-dir {
display: none;
}
.story-overlay.is-director .story-cap {
max-width: min(74%, 46ch);
font: 500 11px/1.45 var(--story-mono);
}
.story-overlay.is-director .story-cap-story {
display: none;
}
.story-overlay.is-director .story-cap-dir {
display: inline;
}
.story-balloon {
position: absolute;
z-index: 3;
margin: 0;
max-width: 42%;
padding: 9px 15px 10px;
border-radius: 22px;
background: var(--story-balloon);
color: var(--story-balloon-ink);
border: 2.5px solid var(--story-balloon-ink);
font: 17px/1.12 var(--story-letter);
text-wrap: balance;
overflow-wrap: anywhere;
}
.story-balloon[hidden] {
display: none;
}
.story-balloon::before,
.story-balloon::after {
content: '';
position: absolute;
}
.story-balloon--claude {
right: 18%;
top: 28%;
}
.story-balloon--claude::before {
right: 22px;
bottom: -19px;
width: 22px;
height: 19px;
background: var(--story-balloon-ink);
clip-path: polygon(0 0, 100% 0, 85% 100%);
}
.story-balloon--claude::after {
right: 25.5px;
bottom: -14px;
width: 15px;
height: 16.5px;
background: var(--story-balloon);
clip-path: polygon(0 0, 100% 0, 84% 100%);
}
.story-balloon--you {
left: 16px;
top: 74px;
max-width: 54%;
}
.story-balloon--you::before {
left: -22px;
top: 34%;
width: 22px;
height: 22px;
background: var(--story-balloon-ink);
clip-path: polygon(100% 0, 100% 100%, 0 75%);
}
.story-balloon--you::after {
left: -15px;
top: calc(34% + 3.5px);
width: 17.5px;
height: 15px;
background: var(--story-balloon);
clip-path: polygon(100% 0, 100% 100%, 0 76%);
}
.story-panel[data-kind='investigate'] .story-balloon--claude {
right: auto;
left: 30%;
top: 30%;
max-width: 38%;
}
.story-panel[data-kind='backup'] .story-balloon--claude {
right: auto;
left: 56%;
top: 25%;
max-width: 40%;
}
.story-panel[data-kind='ask'] .story-balloon--claude {
right: auto;
left: 14px;
top: 30%;
max-width: 46%;
}
.story-answer {
position: absolute;
z-index: 3;
left: 14px;
bottom: 14px;
margin: 0;
max-width: 50%;
padding: 5px 9px 4px;
font: 13px/1.2 var(--story-display);
letter-spacing: 0.03em;
color: var(--story-go);
background: var(--story-panel);
border: 3px solid var(--story-go);
transform: rotate(-4deg);
overflow-wrap: anywhere;
}
.story-answer[hidden],
.story-live-tag[hidden] {
display: none;
}
.story-live-tag {
position: absolute;
z-index: 4;
right: 8px;
top: 8px;
padding: 3px 7px 2px;
font: 600 10.5px/1 var(--story-mono);
letter-spacing: 0.1em;
color: #fff;
background: var(--story-magenta);
}
.story-splash {
position: absolute;
z-index: 2;
left: 12px;
top: 12px;
bottom: 12px;
width: 46%;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 8px;
}
.story-splash .story-cap,
.story-splash .story-balloon {
position: static;
align-self: flex-start;
max-width: 100%;
}
.story-splash .story-balloon::before,
.story-splash .story-balloon::after {
display: none;
}
.story-splash-title {
margin: 0;
font: 400 clamp(28px, 4.4cqi, 44px) / 0.92 var(--story-display);
color: var(--story-yellow);
-webkit-text-stroke: 2.5px var(--story-line);
paint-order: stroke fill;
text-shadow: 4px 4px 0 var(--story-line);
transform: rotate(-3deg);
transform-origin: left bottom;
}
.story-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.story-chips li > * {
display: block;
padding: 4px 8px;
font: 500 12px/1.2 var(--story-mono) !important;
background: var(--story-balloon);
color: var(--story-balloon-ink) !important;
border: 2px solid var(--story-line);
}
.story-chips button {
text-decoration: underline dotted;
text-underline-offset: 3px;
}
.story-chips button:hover {
background: var(--story-yellow);
}
.story-pencils {
grid-column: span var(--story-span6, 2);
display: grid;
place-content: center;
gap: 4px;
text-align: center;
color: var(--story-pencil);
border: 2.5px dashed var(--story-pencil);
}
.story-pencils span {
font: 26px/1 var(--story-letter);
letter-spacing: 0.08em;
}
.story-pencils small {
font: 12px/1.3 var(--story-mono);
}
.story-fresh .story-cap,
.story-fresh .story-splash-title {
animation: story-pop 0.45s cubic-bezier(0.3, 1.6, 0.5, 1) both;
}
.story-fresh .story-balloon,
.story-fresh .story-chips {
animation: story-pop 0.45s 0.35s cubic-bezier(0.3, 1.6, 0.5, 1) both;
}
@keyframes story-pop {
from {
opacity: 0;
transform: scale(0.6);
}
to {
opacity: 1;
}
}
@container story-sheet (max-width: 1000px) {
.story-page {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.story-panel {
grid-column: span var(--story-span2, 1);
}
.story-pencils {
grid-column: span var(--story-span2, 1);
}
}
@container story-sheet (max-width: 600px) {
.story-page {
grid-template-columns: minmax(0, 1fr);
grid-auto-rows: auto;
gap: 10px;
padding: 10px;
}
.story-panel,
.story-pencils {
grid-column: auto;
height: 300px;
}
.story-panel[data-kind='complete'] {
height: 430px;
}
.story-splash {
right: 12px;
bottom: auto;
width: auto;
}
.story-splash-title {
font-size: 36px;
}
.story-balloon {
max-width: 52%;
}
}
/* The wire */
.story-wire {
display: flex;
flex-direction: column;
min-height: 0;
max-height: calc(100vh - 120px);
position: sticky;
top: 0;
background: var(--story-sheet);
border: 2.5px solid var(--story-ink);
}
.story-wire[hidden] {
display: none;
}
@media (max-width: 1100px) {
.story-wire {
position: static;
max-height: 460px;
}
}
.story-wire-head {
padding: 11px 13px 10px;
border-bottom: 2px solid var(--story-ink);
}
.story-wire-title {
margin: 0;
font: 22px/1 var(--story-letter);
}
.story-wire-sub {
margin: 4px 0 0;
font-size: 13px;
line-height: 1.4;
color: var(--story-muted);
}
.story-wire-list {
flex: 1;
min-height: 0;
margin: 0;
padding: 4px 0 10px;
list-style: none;
overflow: auto;
}
.story-wire-group {
padding: 12px 13px 3px;
font: 16px/1.1 var(--story-letter);
color: var(--story-muted);
}
.story-ev {
display: grid;
grid-template-columns: 62px minmax(0, 1fr);
gap: 8px;
padding: 4px 13px;
font: 11.5px/1.45 var(--story-mono);
font-variant-numeric: tabular-nums;
}
.story-ev-time {
color: var(--story-muted);
}
.story-ev-body {
overflow-wrap: anywhere;
}
.story-ev-who {
color: var(--story-muted);
}
.story-link {
padding: 0;
border: 0;
background: none;
text-align: left;
font: inherit !important;
text-decoration: underline dotted;
text-decoration-thickness: 1.5px;
text-underline-offset: 3px;
overflow-wrap: anywhere;
}
.story-link:hover {
color: var(--story-yellow) !important;
}
.story-tag {
display: inline-block;
padding: 0 5px;
font-style: normal;
font-size: 10.5px;
color: var(--story-muted);
border: 1.5px solid currentColor;
border-radius: 3px;
}
.story-tag--bad {
color: var(--story-magenta);
}
.story-tag--ok {
color: var(--story-go);
}
.story-tag--pending {
color: var(--story-yellow);
}
/* Drawer */
.story-drawer {
position: absolute;
z-index: 5;
top: 0;
right: 0;
bottom: 0;
width: var(--story-drawer-w);
display: flex;
flex-direction: column;
padding-top: env(safe-area-inset-top, 0px);
padding-right: env(safe-area-inset-right, 0px);
background: var(--story-sheet);
border-left: 3px solid var(--story-ink);
}
.story-drawer[hidden] {
display: none;
}
@media (max-width: 900px) {
.story-overlay {
--story-drawer-w: 100vw;
}
.story-overlay.has-drawer .story-main {
padding-right: max(16px, env(safe-area-inset-right, 0px));
}
.story-drawer {
top: auto;
left: 0;
width: auto;
height: 82vh;
border-left: 0;
border-top: 3px solid var(--story-ink);
padding-left: env(safe-area-inset-left, 0px);
padding-bottom: env(safe-area-inset-bottom, 0px);
}
}
.story-drawer-head {
position: relative;
display: grid;
gap: 6px;
padding: 14px 60px 12px 18px;
border-bottom: 2px solid var(--story-ink);
}
.story-drawer-meta {
display: flex;
align-items: center;
gap: 10px;
}
.story-drawer-kind {
padding: 3px 8px 2px;
font: 16px/1 var(--story-letter);
background: var(--story-cap);
color: var(--story-cap-ink);
border: 2px solid var(--story-cap-ink);
}
.story-drawer-title {
margin: 0;
font: 500 13.5px/1.35 var(--story-mono);
overflow-wrap: anywhere;
}
.story-drawer-sub {
margin: 0;
font-size: 13.5px;
line-height: 1.4;
color: var(--story-muted);
}
.story-drawer-close {
position: absolute;
right: 12px;
top: 12px;
}
.story-drawer-body {
position: relative;
flex: 1;
min-height: 0;
overflow: auto;
padding: 4px 0 28px;
}
.story-btn-s {
padding: 4px 10px 3px;
font: 15px/1 var(--story-letter) !important;
background: var(--story-panel);
border: 2px solid var(--story-ink);
}
.story-df-head,
.story-out-head {
position: sticky;
top: -4px;
z-index: 1;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 10px;
padding: 12px 18px 8px;
background: var(--story-sheet);
border-bottom: 1.5px solid var(--story-rule);
}
.story-df + .story-df .story-df-head,
.story-out + .story-out .story-out-head {
border-top: 2px solid var(--story-ink);
}
.story-df-path,
.story-out-cmd {
flex: 1 1 220px;
font: 500 12.5px/1.35 var(--story-mono);
overflow-wrap: anywhere;
}
.story-cnt {
font: 500 12.5px/1 var(--story-mono);
}
.story-cnt-a {
color: var(--story-go);
}
.story-cnt-d {
color: var(--story-magenta);
}
.story-code {
font: 12px/1.65 var(--story-mono);
}
.story-ln {
display: grid;
grid-template-columns: 3.2em 3.2em 1.6em minmax(0, 1fr);
padding-right: 14px;
}
.story-ln-no {
padding-right: 8px;
text-align: right;
color: var(--story-muted);
user-select: none;
font-variant-numeric: tabular-nums;
}
.story-ln-mk {
text-align: center;
color: var(--story-muted);
user-select: none;
}
.story-ln-tx {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.story-ln--add {
background: color-mix(in srgb, var(--story-go) 17%, transparent);
}
.story-ln--add .story-ln-mk {
color: var(--story-go);
}
.story-ln--del {
background: color-mix(in srgb, var(--story-magenta) 15%, transparent);
}
.story-ln--del .story-ln-mk {
color: var(--story-magenta);
}
.story-ln--hunk {
display: block;
padding: 2px 18px;
color: var(--story-muted);
background: color-mix(in srgb, var(--story-cyan) 12%, transparent);
}
.story-arrive {
animation: story-arrive 1.1s ease-out both;
}
@keyframes story-arrive {
0% {
clip-path: inset(0 100% 0 0);
background: color-mix(in srgb, var(--story-yellow) 45%, transparent);
}
45% {
clip-path: inset(0 0 0 0);
background: color-mix(in srgb, var(--story-yellow) 45%, transparent);
}
}
.story-out-desc,
.story-empty,
.story-answer-line {
margin: 0;
padding: 8px 18px 0;
font-size: 13.5px;
color: var(--story-muted);
}
.story-pre {
margin: 0;
padding: 8px 18px;
font: 12px/1.6 var(--story-mono);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.story-h {
margin: 16px 18px 0;
font: 17px/1.1 var(--story-letter);
}
.story-todos {
margin: 8px 0 0;
padding: 0 18px 0 40px;
font-size: 14.5px;
line-height: 1.6;
}
.story-todos .is-done {
color: var(--story-go);
text-decoration: line-through;
}
@media (prefers-reduced-motion: reduce) {
.story-fresh *,
.story-arrive,
.story-live i {
animation: none !important;
}
}
File diff suppressed because it is too large. Load diff
+9
View File
@@ -13257,6 +13257,15 @@ kbd {
display: none !important;
}
/* "Story Mode" header toggle (story-mode.js, App Settings → Header buttons), opt-in. Same marker
pattern as the Response Viewer; mobile.css keeps it off phones. */
.btn-story-header {
display: inline-flex !important;
}
.btn-story-header.btn-story-header--hidden {
display: none !important;
}
/* "Approvals" header bell: appears ONLY while prompts are pending (JS toggles
the marker class on count changes), so it ships hidden and stays out of the
default header. Same marker pattern as the attachments button. */
+1
View File
@@ -15,6 +15,7 @@ export { registerApprovalRoutes } from './approval-routes.js';
export { registerRebootRestoreRoutes } from './reboot-restore-routes.js';
export { registerReadMyMindRoutes } from './readmymind-routes.js';
export { registerGitStatusRoutes } from './git-status-routes.js';
export { registerStoryRoutes } from './story-routes.js';
export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js';
export { registerCaseRoutes } from './case-routes.js';
export { registerWorktreeRoutes } from './worktree-routes.js';
+76
View File
@@ -0,0 +1,76 @@
/**
* @fileoverview `GET /api/sessions/:id/story`: one turn of a Claude Code session as a comic page
* (Story Mode, `story-mode.js` in the browser). EXPERIMENTAL: the payload shape is not frozen.
*
* Query: `turn=<id>` picks a turn from the `turns` list; without it the newest turn is drawn.
* Answers `{ supported: false, reason }` for a session it cannot draw, decided from the CLI's
* registry capability (`capabilities.transcript === 'claude-jsonl'`, never the mode id) and from
* where the transcript lives: a remote or Docker session's transcript is not on this host.
*
* Read-only: unlike `last-response` it never adopts a conversation id or persists anything. The file
* read is the session's own transcript, chosen here and never named by the request: the path the
* Claude hook reported for this session, else `<conversation id>.jsonl` under the projects directory.
* Reading is bounded and async (story-reader.ts); what comes back is untrusted text the browser
* renders with `textContent`, and file paths in it open through the existing file preview.
*/
import type { FastifyInstance } from 'fastify';
import { ApiErrorCode, createErrorResponse, type ApiResponse, type StoryResponse } from '../../types.js';
import { getCli } from '../../config/cli-registry/registry.js';
import { buildStoryPage, buildStoryTurns, summarizeTurns } from '../../story-mode.js';
import { readStoryEntries } from '../../story-reader.js';
import { findClaudeTranscriptPath } from '../../utils/claude-transcript.js';
import { findSessionOrFail } from '../route-helpers.js';
import type { ConfigPort, SessionPort } from '../ports/index.js';
/** The hook-reported path when it looks like a transcript, else a lookup by conversation id. */
async function resolveTranscriptPath(hookPath: string | null, conversationId: string): Promise<string | null> {
if (hookPath && hookPath.startsWith('/') && hookPath.endsWith('.jsonl')) return hookPath;
return findClaudeTranscriptPath(conversationId);
}
export function registerStoryRoutes(app: FastifyInstance, ctx: SessionPort & ConfigPort): void {
app.get('/api/sessions/:id/story', async (req, reply): Promise<ApiResponse<StoryResponse>> => {
const { id } = req.params as { id: string };
const { turn } = req.query as { turn?: unknown };
const session = findSessionOrFail(ctx, id, req);
if (getCli(session.mode)?.capabilities.transcript !== 'claude-jsonl') {
return {
success: true,
data: { supported: false, reason: 'Story Mode draws Claude Code sessions. This session runs a different CLI.' },
};
}
if (session.remote || session.docker) {
return {
success: true,
data: {
supported: false,
reason:
'Story Mode reads transcripts on this machine. This session keeps its transcript on another host or inside a container.',
},
};
}
const transcriptPath = await resolveTranscriptPath(
ctx.getTranscriptPath(id),
session.claudeSessionId || session.id
);
if (!transcriptPath) return { success: true, data: { supported: true, turns: [], page: null } };
let entries;
try {
({ entries } = await readStoryEntries(transcriptPath));
} catch {
reply.code(500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Could not read the session transcript');
}
const turns = buildStoryTurns(entries);
const wanted = typeof turn === 'string' && turn ? turns.find((t) => t.id === turn) : turns[turns.length - 1];
return {
success: true,
data: { supported: true, turns: summarizeTurns(turns), page: wanted ? buildStoryPage(wanted) : null },
};
});
}
+1
View File
@@ -1584,6 +1584,7 @@ export const SettingsUpdateSchema = z
showCost: z.boolean().optional(),
showLifecycleLog: z.boolean().optional(),
showResponseViewer: z.boolean().optional(),
showStoryButton: z.boolean().optional(),
showMonitor: z.boolean().optional(),
showProjectInsights: z.boolean().optional(),
showFileBrowser: z.boolean().optional(),
+2
View File
@@ -189,6 +189,7 @@ import {
registerRebootRestoreRoutes,
registerReadMyMindRoutes,
registerGitStatusRoutes,
registerStoryRoutes,
registerStatusTelemetryRoutes,
registerSystemRoutes,
registerCaseRoutes,
@@ -1152,6 +1153,7 @@ export class WebServer extends EventEmitter {
registerRebootRestoreRoutes(this.app, ctx);
registerReadMyMindRoutes(this.app, ctx);
registerGitStatusRoutes(this.app, ctx);
registerStoryRoutes(this.app, ctx);
registerStatusTelemetryRoutes(this.app, ctx);
registerSystemRoutes(this.app, ctx);
registerCaseRoutes(this.app, ctx);
+157
View File
@@ -0,0 +1,157 @@
/**
* @fileoverview GET /api/sessions/:id/story (Story Mode) against invented transcripts in a temp dir,
* plus the reader's incremental tail read. Port: N/A (app.inject()).
*/
import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createRouteTestHarness } from './_route-test-utils.js';
import { registerStoryRoutes } from '../../src/web/routes/story-routes.js';
import { clearStoryReaderCache, readStoryEntries } from '../../src/story-reader.js';
import { bash, demoTurn, end, prompt, read, resetClock, said } from '../story-fixtures.js';
const URL = '/api/sessions/test-session-1/story';
const jsonl = (lines: unknown[]) => lines.map((l) => JSON.stringify(l)).join('\n') + '\n';
let dir: string;
let transcript: string;
async function setup(opts: { hookPath?: string | null; mode?: string; remote?: unknown; docker?: unknown } = {}) {
const h = await createRouteTestHarness((app, ctx) => registerStoryRoutes(app, ctx));
const session = h.ctx._session as unknown as Record<string, unknown>;
if (opts.mode) session.mode = opts.mode;
if (opts.remote) session.remote = opts.remote;
if (opts.docker) session.docker = opts.docker;
h.ctx.getTranscriptPath.mockReturnValue(opts.hookPath === undefined ? transcript : opts.hookPath);
return h;
}
beforeEach(() => {
resetClock();
clearStoryReaderCache();
dir = mkdtempSync(join(tmpdir(), 'story-route-'));
transcript = join(dir, 'conversation.jsonl');
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
describe('GET /api/sessions/:id/story', () => {
it('draws the newest turn of the hook-reported transcript', async () => {
writeFileSync(transcript, jsonl([prompt('first job'), said('Done.'), end(2000), ...demoTurn()]));
const { app } = await setup();
const res = await app.inject({ method: 'GET', url: URL });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.supported).toBe(true);
expect(body.data.turns.map((t: { prompt: string }) => t.prompt)).toEqual([
'first job',
'The checkout button does nothing on phones. Can you fix it?',
]);
expect(body.data.page.panels.map((p: { kind: string }) => p.kind)).toEqual([
'mission',
'investigate',
'plan',
'backup',
'loop',
'save',
'complete',
]);
// Nothing a Read or an Edit result carried in full ever leaves the server.
expect(res.body).not.toContain('FILE CONTENT');
expect(res.body).not.toContain('WHOLE ORIGINAL FILE');
});
it('draws an older turn by id and answers page null for an unknown one', async () => {
writeFileSync(transcript, jsonl([prompt('first job'), said('Done.'), end(2000), ...demoTurn()]));
const { app } = await setup();
const list = (await app.inject({ method: 'GET', url: URL })).json().data.turns;
const older = await app.inject({ method: 'GET', url: `${URL}?turn=${encodeURIComponent(list[0].id)}` });
expect(older.json().data.page).toMatchObject({ prompt: 'first job', complete: true });
const gone = await app.inject({ method: 'GET', url: `${URL}?turn=nope` });
expect(gone.json().data.page).toBeNull();
});
it('is supported with no turns when there is no transcript yet', async () => {
const { app } = await setup({ hookPath: null });
const res = await app.inject({ method: 'GET', url: URL });
expect(res.json()).toEqual({ success: true, data: { supported: true, turns: [], page: null } });
});
it('answers unsupported for a CLI that writes no Claude transcript', async () => {
const { app } = await setup({ mode: 'codex' });
const data = (await app.inject({ method: 'GET', url: URL })).json().data;
expect(data.supported).toBe(false);
expect(data.reason).toMatch(/different CLI/);
});
it('answers unsupported for remote and Docker sessions, whose transcript is not on this host', async () => {
for (const where of [{ remote: { hostId: 'h1' } }, { docker: { containerName: 'c1' } }]) {
clearStoryReaderCache();
const { app } = await setup(where);
const data = (await app.inject({ method: 'GET', url: URL })).json().data;
expect(data).toMatchObject({ supported: false });
}
});
it('never reads a hook path that is not a transcript', async () => {
writeFileSync(join(dir, 'secret.txt'), jsonl(demoTurn()));
const { app } = await setup({ hookPath: join(dir, 'secret.txt') });
const res = await app.inject({ method: 'GET', url: URL });
expect(res.json().data).toEqual({ supported: true, turns: [], page: null });
});
it('fails with OPERATION_FAILED when the transcript cannot be read', async () => {
const { app } = await setup({ hookPath: join(dir, 'missing.jsonl') });
const res = await app.inject({ method: 'GET', url: URL });
expect(res.statusCode).toBe(500);
expect(res.json()).toMatchObject({ success: false, errorCode: 'OPERATION_FAILED' });
});
it('404s an unknown session', async () => {
const { app } = await setup();
const res = await app.inject({ method: 'GET', url: '/api/sessions/nope/story' });
expect(res.statusCode).toBe(404);
});
it('a live turn grows as the transcript grows', async () => {
writeFileSync(transcript, jsonl([prompt('go'), ...read('r1', '/w/a.ts')]));
const { app } = await setup();
const before = (await app.inject({ method: 'GET', url: URL })).json().data.page;
expect(before.panels.map((p: { kind: string }) => p.kind)).toEqual(['mission', 'investigate']);
expect(before.panels[1].live).toBe(true);
appendFileSync(transcript, jsonl([...bash('b1', 'npm test', 'Run tests', ' Tests 4 passed (4)'), end(9000)]));
const after = (await app.inject({ method: 'GET', url: URL })).json().data.page;
expect(after.panels.map((p: { kind: string }) => p.kind)).toEqual(['mission', 'investigate', 'check', 'complete']);
expect(after.complete).toBe(true);
});
});
describe('readStoryEntries', () => {
it('leaves a line that is still being written for the next read', async () => {
const full = JSON.stringify(prompt('half written'));
writeFileSync(transcript, jsonl([prompt('one')]) + full.slice(0, 20));
const first = await readStoryEntries(transcript);
expect(first.entries.map((e) => e.k)).toEqual(['prompt']);
appendFileSync(transcript, full.slice(20) + '\n');
const second = await readStoryEntries(transcript);
expect(second.entries.filter((e) => e.k === 'prompt')).toHaveLength(2);
});
it('starts over when the file was replaced by a shorter one', async () => {
writeFileSync(transcript, jsonl([prompt('one'), prompt('two'), prompt('three')]));
await readStoryEntries(transcript);
writeFileSync(transcript, jsonl([prompt('new')]));
const again = await readStoryEntries(transcript);
expect(again.entries.map((e) => (e.k === 'prompt' ? e.text : e.k))).toEqual(['new']);
});
it('shares one read between concurrent callers', async () => {
writeFileSync(transcript, jsonl(demoTurn()));
const [a, b] = await Promise.all([readStoryEntries(transcript), readStoryEntries(transcript)]);
expect(a.entries).toBe(b.entries);
});
});
+142
View File
@@ -0,0 +1,142 @@
/**
* @fileoverview Builders for Story Mode test transcripts. Every line is INVENTED content shaped like a
* real Claude Code transcript entry (field names as measured on local transcripts, 2026-10-18), so no
* real prompt, path or output ever lands in the repo.
*/
let clock = 0;
const at = () => new Date(Date.UTC(2026, 9, 18, 9, 41, 0) + (clock += 1000)).toISOString();
export function resetClock(): void {
clock = 0;
}
export const prompt = (text: string, extra: Record<string, unknown> = {}) => ({
type: 'user',
uuid: `u-${clock + 1}`,
timestamp: at(),
isSidechain: false,
message: { role: 'user', content: text },
turnOrigin: 'human',
promptSource: 'typed',
...extra,
});
export const said = (text: string) => ({
type: 'assistant',
timestamp: at(),
message: { role: 'assistant', content: [{ type: 'text', text }] },
});
export const tool = (id: string, name: string, input: Record<string, unknown>) => ({
type: 'assistant',
timestamp: at(),
message: { role: 'assistant', content: [{ type: 'tool_use', id, name, input }] },
});
export const result = (
id: string,
content: string,
opts: { isError?: boolean; toolUseResult?: Record<string, unknown> } = {}
) => ({
type: 'user',
timestamp: at(),
message: {
role: 'user',
content: [{ type: 'tool_result', tool_use_id: id, content, is_error: opts.isError ?? false }],
},
toolUseResult: opts.toolUseResult,
});
export const end = (durationMs: number) => ({
type: 'system',
subtype: 'turn_duration',
durationMs,
isMeta: true,
timestamp: at(),
});
/** An Edit call and its result, with a structuredPatch like Claude Code writes. */
export function edit(id: string, file: string, lines: string[], oldStart = 10) {
const added = lines.filter((l) => l.startsWith('+')).length;
const removed = lines.filter((l) => l.startsWith('-')).length;
const context = lines.length - added - removed;
return [
tool(id, 'Edit', { file_path: file, old_string: 'old', new_string: 'new' }),
result(id, 'The file has been updated successfully.', {
toolUseResult: {
filePath: file,
oldString: 'old',
newString: 'new',
originalFile: 'WHOLE ORIGINAL FILE THAT MUST NEVER REACH THE PAGE',
structuredPatch: [
{ oldStart, oldLines: removed + context, newStart: oldStart, newLines: added + context, lines },
],
userModified: false,
replaceAll: false,
},
}),
];
}
export const bash = (id: string, command: string, description: string, output: string, isError = false) => [
tool(id, 'Bash', { command, description }),
result(id, output, { isError, toolUseResult: { stdout: output, stderr: '', interrupted: false } }),
];
export const read = (id: string, file: string) => [
tool(id, 'Read', { file_path: file }),
result(id, 'FILE CONTENT THAT MUST NEVER REACH THE PAGE', {
toolUseResult: {
type: 'text',
file: { filePath: file, content: 'FILE CONTENT', numLines: 3, startLine: 1, totalLines: 3 },
},
}),
];
/** A complete demo turn: read, plan, helper, fix, failing test, fix, passing test, commit. */
export function demoTurn(): unknown[] {
return [
prompt('The checkout button does nothing on phones. Can you fix it?'),
said('Let me see how checkout works. I will start with the button.'),
...read('r1', '/work/demo-shop/src/checkout/CheckoutButton.tsx'),
...read('r2', '/work/demo-shop/src/styles/banner.css'),
...bash(
'b1',
'grep -rn "onClick" src/checkout',
'Find click handlers',
'src/checkout/CheckoutButton.tsx:7: onClick'
),
tool('t1', 'TodoWrite', {
todos: [
{ content: 'Fix the banner', status: 'in_progress' },
{ content: 'Test at phone size', status: 'pending' },
],
}),
result('t1', 'Todos have been modified successfully.'),
tool('a1', 'Agent', {
description: 'Write a phone-size test',
subagent_type: 'general-purpose',
prompt: 'Write a test.',
}),
result('a1', 'Test written.', {
toolUseResult: {
status: 'completed',
agentId: 'ag1',
content: [{ type: 'text', text: 'Test written. It fails today.' }],
totalToolUseCount: 3,
totalDurationMs: 30000,
},
}),
...edit('e1', '/work/demo-shop/src/styles/banner.css', [' z-index: 50;', '+ pointer-events: none;', ' }']),
...bash('b2', 'npm test', 'Run the test suite', ' Tests 1 failed | 41 passed (42)\nExit code 1', true),
...edit('e2', '/work/demo-shop/src/styles/banner.css', [
'-.promo-banner__inner { pointer-events: auto; }',
'+.promo-banner__cta { pointer-events: auto; }',
]),
...bash('b3', 'npm test', 'Run the test suite', ' Tests 42 passed (42)'),
...bash('b4', 'git commit -m "fix: banner"', 'Commit the fix', '[main 4be19d2] fix: banner'),
said('Fixed. The banner sat on top of the button. The same fix covers the cart page.'),
end(432_000),
];
}
+329
View File
@@ -0,0 +1,329 @@
/**
* @fileoverview Story Mode core (src/story-mode.ts): transcript lines → entries → turns → comic page.
* Pure functions on invented transcripts (test/story-fixtures.ts). Port: N/A.
*/
import { beforeEach, describe, expect, it } from 'vitest';
import {
STORY_LIMITS,
buildStoryPage,
buildStoryTurns,
classifyBashCommand,
createStoryReducer,
exactDuration,
firstSentence,
parseTestCounts,
roughDuration,
summarizeTurns,
type StoryEntry,
} from '../src/story-mode.js';
import { bash, demoTurn, edit, end, prompt, read, resetClock, result, said, tool } from './story-fixtures.js';
function entriesOf(lines: unknown[]): StoryEntry[] {
const reducer = createStoryReducer();
return lines.flatMap((l) => reducer.reduce(l));
}
const pageOf = (lines: unknown[]) => {
const turns = buildStoryTurns(entriesOf(lines));
return buildStoryPage(turns[turns.length - 1]);
};
beforeEach(() => resetClock());
describe('classifyBashCommand', () => {
it.each([
['npm test', 'test'],
['npm test > out.log 2>&1', 'test'],
['cd app && npx vitest run src/a.test.ts', 'test'],
['python3 -m pytest -q', 'test'],
['npm run typecheck', 'build'],
['npx eslint src', 'build'],
['git commit -m "fix"', 'save'],
['git push origin HEAD', 'save'],
['gh pr create --title x', 'save'],
["sed -i 's/a/b/' file.ts", 'edit'],
['echo hi > notes.txt', 'edit'],
['cat > config.json <<EOF', 'edit'],
['ls -la', 'look'],
['cd src && grep -rn foo .', 'look'],
['git status --short', 'look'],
['ls 2>/dev/null', 'look'],
['curl -s localhost:3000/api/status', 'run'],
['node scripts/build.mjs', 'run'],
])('%s → %s', (command, intent) => {
expect(classifyBashCommand(command)).toBe(intent);
});
});
describe('parseTestCounts', () => {
it.each([
[' Tests 1 failed | 41 passed (42)', { passed: 41, failed: 1 }],
['Tests: 2 failed, 40 passed, 42 total', { passed: 40, failed: 2 }],
['==== 1 failed, 41 passed in 6.31s ====', { passed: 41, failed: 1 }],
[' 41 passing (2s)\n 1 failing\n', { passed: 41, failed: 1 }],
['test result: ok. 42 passed; 0 failed; 0 ignored', { passed: 42, failed: 0 }],
])('reads %j', (output, expected) => {
expect(parseTestCounts(output)).toEqual(expected);
});
it('returns null without a summary line', () => {
expect(parseTestCounts('compiled successfully')).toBeNull();
});
});
describe('text helpers', () => {
it('firstSentence strips markdown and stops at the first sentence', () => {
expect(firstSentence('**Found it.** The `banner` covers [the button](http://x).')).toBe('Found it.');
expect(firstSentence('No full stop here')).toBe('No full stop here');
});
it('formats durations for both captions', () => {
expect(roughDuration(432_000)).toBe('7 minutes');
expect(exactDuration(432_000)).toBe('7 min 12 s');
expect(roughDuration(4_000)).toBe('4 seconds');
expect(roughDuration(3_900_000)).toBe('1 h 5 min');
});
});
describe('createStoryReducer', () => {
it('never keeps file contents, originals or images', () => {
const entries = entriesOf([prompt('go'), ...read('r1', '/w/a.ts'), ...edit('e1', '/w/a.ts', ['+x'])]);
const text = JSON.stringify(entries);
expect(text).not.toContain('FILE CONTENT');
expect(text).not.toContain('WHOLE ORIGINAL FILE');
});
it('keeps an edit as structuredPatch hunks with exact counts', () => {
const entries = entriesOf([prompt('go'), ...edit('e1', '/w/a.ts', [' a', '-b', '+c', '+d'], 7)]);
const r = entries.find((e) => e.k === 'result');
expect(r).toMatchObject({
k: 'result',
edit: {
file: '/w/a.ts',
added: 2,
removed: 1,
created: false,
hunks: [{ oldStart: 7, lines: [' a', '-b', '+c', '+d'] }],
},
});
});
it('turns a new file written with Write into added lines', () => {
const entries = entriesOf([
prompt('go'),
tool('w1', 'Write', { file_path: '/w/new.ts', content: 'a\nb\n' }),
result('w1', 'File created', {
toolUseResult: { type: 'create', filePath: '/w/new.ts', content: 'a\nb\n', structuredPatch: [] },
}),
]);
expect(entries.find((e) => e.k === 'result')).toMatchObject({
edit: { created: true, added: 2, removed: 0, hunks: [{ lines: ['+a', '+b'] }] },
});
});
it('keeps only the tail of a long output', () => {
const long = Array.from({ length: 500 }, (_, i) => `line ${i}`).join('\n');
const entries = entriesOf([prompt('go'), ...bash('b1', 'node x.js', 'Run it', long)]);
const r = entries.find((e) => e.k === 'result') as Extract<StoryEntry, { k: 'result' }>;
expect(r.outputTruncated).toBe(true);
expect(r.output?.split('\n')).toHaveLength(STORY_LIMITS.outputLines);
expect(r.output?.endsWith('line 499')).toBe(true);
});
it('skips meta, compact summaries, companions, sidechains and local command wrappers', () => {
const entries = entriesOf([
prompt('meta', { isMeta: true }),
prompt('summary', { isCompactSummary: true }),
prompt('companion', { turnCompanion: true }),
prompt('side', { isSidechain: true }),
prompt('<local-command-stdout>ok</local-command-stdout>'),
prompt('<bash-input>ls</bash-input>'),
]);
expect(entries).toEqual([]);
});
it('reads slash commands, task notifications, interrupts and strips system reminders', () => {
const entries = entriesOf([
prompt('<command-name>/review</command-name><command-args>42</command-args>'),
prompt('<task-notification><summary>Build finished</summary></task-notification>', {
turnOrigin: 'task_notification',
}),
prompt('[Request interrupted by user]'),
prompt('Fix it<system-reminder>secret plumbing</system-reminder>'),
]);
expect(entries.map((e) => (e.k === 'prompt' ? [e.k, e.text, e.origin] : [e.k]))).toEqual([
['prompt', '/review 42', 'human'],
['prompt', 'Build finished', 'task'],
['interrupt'],
['prompt', 'Fix it', 'human'],
]);
});
it('ignores lines that are not transcript objects', () => {
const reducer = createStoryReducer();
expect(reducer.reduce(null)).toEqual([]);
expect(reducer.reduce('text')).toEqual([]);
expect(reducer.reduce({ type: 'ai-title', title: 'x' })).toEqual([]);
});
});
describe('buildStoryTurns', () => {
it('splits turns at prompts and ends them at turn_duration', () => {
const turns = buildStoryTurns(
entriesOf([prompt('one'), said('ok'), end(1000), prompt('two'), ...bash('b', 'ls', 'List', 'a')])
);
expect(turns.map((t) => [t.prompt, t.ended])).toEqual([
['one', true],
['two', false],
]);
expect(turns[0].durationMs).toBe(1000);
});
it('a prompt queued while Claude works joins the running turn', () => {
const turns = buildStoryTurns(
entriesOf([prompt('one'), ...bash('b', 'ls', 'List', 'a'), prompt('also this', { promptSource: 'queued' })])
);
expect(turns).toHaveLength(1);
expect(turns[0].events.filter((e) => e.intent === 'prompt')).toHaveLength(2);
});
it('marks a turn whose prompt is outside the window as partial', () => {
const turns = buildStoryTurns(entriesOf([...bash('b', 'ls', 'List', 'a'), end(5000)]));
expect(turns[0]).toMatchObject({ partial: true, prompt: '', ended: true });
});
it('flags failed calls and pending calls', () => {
const turns = buildStoryTurns(
entriesOf([
prompt('go'),
...bash('b1', 'npm test', 'Test', 'Exit code 1', true),
tool('b2', 'Bash', { command: 'sleep 9' }),
])
);
const [failed, running] = turns[0].events.filter((e) => e.tool === 'Bash');
expect(failed).toMatchObject({ ok: false, intent: 'test' });
expect(running).toMatchObject({ pending: true });
expect(running.ok).toBeUndefined();
});
it('an interrupt ends the turn', () => {
const turns = buildStoryTurns(
entriesOf([prompt('go'), ...bash('b', 'ls', 'List', 'a'), prompt('[Request interrupted by user]')])
);
expect(turns[0]).toMatchObject({ ended: true, interrupted: true });
expect(summarizeTurns(turns)[0]).toMatchObject({ complete: false, interrupted: true });
});
});
describe('buildStoryPage', () => {
it('draws the demo turn as mission, investigate, plan, backup, loop, save, complete', () => {
const page = pageOf(demoTurn());
expect(page.panels.map((p) => p.kind)).toEqual([
'mission',
'investigate',
'plan',
'backup',
'loop',
'save',
'complete',
]);
expect(page).toMatchObject({ complete: true, toolCalls: 10, filesChanged: 1, added: 2, removed: 1, helpers: 1 });
});
it('writes plain story captions and tool-level director captions', () => {
const page = pageOf(demoTurn());
const byKind = Object.fromEntries(page.panels.map((p) => [p.kind, p]));
expect(byKind.mission.caption.story).toBe('You give Claude a job.');
expect(byKind.investigate.caption.story).toBe('Claude reads 2 files to find its way around.');
expect(byKind.investigate.caption.director).toBe('Read ×2 · Bash');
expect(byKind.plan.caption.story).toBe('Claude writes a plan: 2 steps.');
expect(byKind.backup.caption.story).toBe('Claude calls in 1 helper.');
expect(byKind.backup.caption.director).toContain('3 calls inside');
expect(byKind.loop).toMatchObject({ rounds: 2, ok: true, added: 2, removed: 1 });
expect(byKind.loop.caption.story).toBe('Fix and test, 2 rounds. It passes in the end.');
expect(byKind.save.caption.story).toBe('Claude saves the work.');
expect(byKind.complete.caption.story).toBe('Done in 7 minutes.');
expect(byKind.complete.caption.director).toContain('7 min 12 s');
});
it('gives balloons to the prompt and to what Claude says before a panel and at the end', () => {
const page = pageOf(demoTurn());
expect(page.panels[0].balloon).toEqual({
who: 'you',
text: 'The checkout button does nothing on phones. Can you fix it?',
});
expect(page.panels[1].balloon).toEqual({ who: 'claude', text: 'Let me see how checkout works.' });
expect(page.panels[page.panels.length - 1].balloon).toEqual({ who: 'claude', text: 'Fixed.' });
});
it('lists files and keeps edits reachable from the panel', () => {
const page = pageOf(demoTurn());
const investigate = page.panels.find((p) => p.kind === 'investigate');
expect(investigate?.files).toEqual([
'/work/demo-shop/src/checkout/CheckoutButton.tsx',
'/work/demo-shop/src/styles/banner.css',
]);
const loop = page.panels.find((p) => p.kind === 'loop');
const editEvents = loop?.events.map((i) => page.events[i]).filter((e) => e.edit);
expect(editEvents?.map((e) => e.edit?.hunks[0].lines)).toEqual([
[' z-index: 50;', '+ pointer-events: none;', ' }'],
['-.promo-banner__inner { pointer-events: auto; }', '+.promo-banner__cta { pointer-events: auto; }'],
]);
});
it('a failing check says so and reads the test counts', () => {
const page = pageOf([
prompt('go'),
...bash('b1', 'npm test', 'Run tests', ' Tests 3 failed | 39 passed (42)', true),
]);
const check = page.panels.find((p) => p.kind === 'check');
expect(check).toMatchObject({ ok: false, tests: { passed: 39, failed: 3 }, live: true });
expect(check?.caption.story).toBe('3 tests fail.');
});
it('an unfinished turn has no complete panel and marks its newest panel live', () => {
const page = pageOf([prompt('go'), ...read('r1', '/w/a.ts'), said('Now the fix.')]);
expect(page.panels.map((p) => p.kind)).toEqual(['mission', 'investigate']);
expect(page.panels[1]).toMatchObject({ live: true });
expect(page.complete).toBe(false);
});
it('folds a long turn into montage panels so the page fits', () => {
const lines: unknown[] = [prompt('big job')];
for (let i = 0; i < 30; i++) {
lines.push(...read(`r${i}`, `/w/f${i}.ts`));
lines.push(...bash(`b${i}`, `node step${i}.js`, `Step ${i}`, 'ok'));
}
lines.push(end(60_000));
const page = pageOf(lines);
expect(page.panels.length).toBeLessThanOrEqual(STORY_LIMITS.panelsPerPage);
expect(page.panels[0].kind).toBe('mission');
expect(page.panels[page.panels.length - 1].kind).toBe('complete');
expect(page.panels.some((p) => p.kind === 'montage')).toBe(true);
// Every tool call is still on the page, in some panel.
expect(new Set(page.panels.flatMap((p) => p.events)).size).toBe(61);
});
it('drops the oldest outputs first when a page is over its text budget', () => {
const big = 'x'.repeat(7000);
const lines: unknown[] = [prompt('go')];
for (let i = 0; i < 80; i++) lines.push(...bash(`b${i}`, `node s${i}.js`, `Step ${i}`, big));
const page = pageOf(lines);
expect(page.truncated).toBe(true);
const bashEvents = page.events.filter((e) => e.tool === 'Bash');
expect(bashEvents[0].output).toBeUndefined();
expect(bashEvents[0].outputTruncated).toBe(true);
expect(bashEvents[bashEvents.length - 1].output).toBe(big);
});
it('an origin other than you changes the mission caption', () => {
const page = pageOf([
prompt('<task-notification><summary>Build done</summary></task-notification>', {
turnOrigin: 'task_notification',
}),
said('Nice.'),
end(1000),
]);
expect(page.panels[0].caption.story).toBe('A background job reports back.');
expect(page.origin).toBe('task');
});
});