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

+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,