From d92e927184846d26c7cbbca414dffaa5169301d1 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 12:57:58 +0200 Subject: [PATCH] fix(story-mode): focus returns to the chip that opened the drawer; document this round's fields - The final panel's chips were rebuilt on every redraw, so the chip that opened a drawer was gone by the time it closed and focus fell to the page body. Unchanged chips are now kept, and a trigger replaced anyway hands focus to the control that opens the same thing. - API reference: `older`, background Bash calls (`running`), `subject`, append hunks (`atEnd`), `files` resolved against the command's folder, test counts read before the output is cut, `roundsOk`, the `note` speaker and `noneWorked`. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/api-reference.md | 10 ++++++---- src/web/public/story-mode.js | 36 ++++++++++++++++++++++-------------- test/story-mode-ui.test.ts | 20 ++++++++++++++++++++ 3 files changed, 48 insertions(+), 18 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index e3ea0d37..1f0d2655 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1056,7 +1056,7 @@ What Story Mode reads (the opt-in header button, App Settings → Header buttons ### Routes -- `GET /api/sessions/:id/story[?turn=]`: one turn as a page. `data` is `{ supported: true, turns, page }`: `turns` lists up to 60 turns, oldest first (`{ id, at, prompt, origin, complete, interrupted, superseded?, toolCalls }`), and `page` is the newest turn, or the one `turn=` names, or `null` when there is none (an unknown id included). +- `GET /api/sessions/:id/story[?turn=]`: one turn as a page. `data` is `{ supported: true, turns, page }`: `turns` lists up to 60 turns, oldest first (`{ id, at, prompt, origin, complete, interrupted, superseded?, toolCalls }`), and `page` is the newest turn, or the one `turn=` names, or `null` when there is none (an unknown id included). `older: true` means the transcript is longer than the part that was read, so older turns are not listed. - `GET /api/sessions/:id/story/helpers/:agentId`: one helper (an Agent call, `agentId` from its event's `helper.agentId`) drawn from its own transcript, `/subagents/agent-.jsonl` beside the session's. `data` is `{ supported: true, helper: { agentId, type?, description?, finished? }, page }`, the same page shape, or `page: null` while the helper has written nothing yet. `agentId` must match `[A-Za-z0-9_-]{1,64}` (`400` otherwise). Both answer `{ supported: false, reason }` for a session they cannot draw: its CLI writes no Claude Code transcript (registry `capabilities.transcript` is not `claude-jsonl`), or it is remote or Docker, whose transcript is not on this host. Both are read-only. The session resolves 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, else `.jsonl` under the Claude projects directory. Only its tail is read (8 MB, widened in steps to 64 MB while that tail holds no prompt to start the turn); file contents, original files and images never leave the server. A transcript that cannot be read is a `500` with `OPERATION_FAILED`. @@ -1076,13 +1076,15 @@ A turn is one prompt and everything Claude did for it. `origin` says who sent th A helper started in the background (`run_in_background`, or as a teammate: `status: teammate_spawned`) has `helper.background` and `helper.running: true` until it reports back. A `` (matched by tool call) or a `` (matched by name) joins the answer, tool count and duration back onto the helper in the turn that started it. A report that arrives after that turn ended starts a turn of its own (origin `task`) whose prompt event carries the report as `output` and the helper as `helper`; one that lands mid-turn is an event with intent `notice` instead of a new mission. `page.helpersRunning` counts the helpers still out. +A Bash call sent to the background (its result is only `Command running in background with ID: …`) has `running: true` and no `ok`: it started, nothing more is known. Its `` settles `ok` from the exit code in the summary, and reading the job's output file later is a test run when that output reports test counts. + ### A page A `page` carries the turn's summary fields plus: -- `events`: one per prompt, sentence Claude wrote and tool call. Each has `intent`, `tool`, `label` and, when the call returned, `ok` (`false` when the result was flagged as an error, or reported failed tests or lint problems a pipe kept from the exit code). Depending on the call: `edit` (an Edit/Write, hunks in Claude Code's own `structuredPatch` shape), `edits` (files a Bash call wrote through here-documents or `printf`/`echo` redirects, or that Claude Code says it changed), the tail of `output`, `tests` (`{ passed, failed, errors? }`), `helper`, `files` (files a Bash call printed with `cat`/`head`/`tail`), `headline` (the part of a one-liner that decided its intent), `commits`, `ships` (`push` / `pr`), `question` and, for prompts, `origin`. The advisor (a server tool) is an event with tool `Advisor` and intent `helper`; its answer is encrypted in the transcript. In plan mode the plan file under `~/.claude/plans/` is planning (intent `plan`, its numbered steps as `todos`), and `ExitPlanMode`'s result carries the plan (`output`) and your answer (`Approved` / `Sent back`). -- `panels`: at most 12, each `{ kind, events, caption: { story, director }, balloon?, files?, notes?, helpers?, todos?, tests?, ok?, rounds?, question?, live? }`, with `events` as indices into `events`. Kinds: `mission`, `investigate`, `research`, `plan`, `backup`, `fix`, `check`, `loop` (fix and check rounds folded), `run`, `save`, `ask`, `compact`, `montage` (quieter panels folded to fit) and `complete`. -- `filesChanged`, `added`, `removed` (summed per edit), `helpers`, `helpersRunning`, `durationMs`, `partial` (the turn began before the part that was read) and `truncated` (older outputs were dropped to keep the page small). +- `events`: one per prompt, sentence Claude wrote and tool call. Each has `intent`, `tool`, `label` and, when the call returned, `ok` (`false` when the result was flagged as an error, or reported failed tests or lint problems a pipe kept from the exit code). Depending on the call: `edit` (an Edit/Write, hunks in Claude Code's own `structuredPatch` shape), `edits` (files a Bash call wrote through here-documents or `printf`/`echo` redirects, or that Claude Code says it changed; an append's hunk has `atEnd: true` and no line numbers, since where it lands is not in the command), the tail of `output`, `tests` (`{ passed, failed, errors? }`, counted on the whole output before it is cut), `helper`, `files` (files a Bash call printed with `cat`/`head`/`tail`, resolved against the command's folder), `headline` (the part of a one-liner that decided its intent), `commits`, `subject` (the first commit's message, when the command spells it out with `-m` or a `git commit -F -` here-document), `ships` (`push` / `pr`), `running` (see Background work), `question` and, for prompts, `origin`. The advisor (a server tool) is an event with tool `Advisor` and intent `helper`; its answer is encrypted in the transcript. In plan mode the plan file under `~/.claude/plans/` is planning (intent `plan`, its numbered steps as `todos`), and `ExitPlanMode`'s result carries the plan (`output`) and your answer (`Approved` / `Sent back`). +- `panels`: at most 12, each `{ kind, events, caption: { story, director }, balloon?, files?, notes?, helpers?, todos?, tests?, ok?, rounds?, roundsOk?, question?, live? }`, with `events` as indices into `events`. `balloon.who` is `you`, `claude` or `note` (words that start a turn but are not yours: a background job's report, another session's message). A loop's `roundsOk` lists every test run in order, `true` where it passed, so a failure inside a round that passed in the end still shows. Kinds: `mission`, `investigate`, `research`, `plan`, `backup`, `fix`, `check`, `loop` (fix and check rounds folded), `run`, `save`, `ask`, `compact`, `montage` (quieter panels folded to fit) and `complete`. +- `filesChanged`, `added`, `removed` (summed per edit), `helpers`, `helpersRunning`, `noneWorked` (every tool call of the turn failed, so the last panel is no celebration), `durationMs`, `partial` (the turn began before the part that was read) and `truncated` (older outputs were dropped to keep the page small). ## Git worktrees of a case diff --git a/src/web/public/story-mode.js b/src/web/public/story-mode.js index 077ba7da..58870aba 100644 --- a/src/web/public/story-mode.js +++ b/src/web/public/story-mode.js @@ -2358,19 +2358,24 @@ Object.assign(CodemanApp.prototype, { page.helpersRunning ? `, ${page.helpersRunning} still working` : '' }`, }); - chips.replaceChildren( - ...items.map((item) => { - const li = document.createElement('li'); - const inner = document.createElement(item.open ? 'button' : 'span'); - if (item.open) { - inner.type = 'button'; - inner.dataset.storyOpen = item.open; - } - inner.textContent = item.text; - li.append(inner); - return li; - }) - ); + // Rebuilt only when they change: a chip that opened the drawer must still be there to take focus back. + const chipsKey = JSON.stringify(items); + if (chips.dataset.key !== chipsKey) { + chips.dataset.key = chipsKey; + chips.replaceChildren( + ...items.map((item) => { + const li = document.createElement('li'); + const inner = document.createElement(item.open ? 'button' : 'span'); + if (item.open) { + inner.type = 'button'; + inner.dataset.storyOpen = item.open; + } + inner.textContent = item.text; + li.append(inner); + return li; + }) + ); + } } }, @@ -2938,8 +2943,11 @@ Object.assign(CodemanApp.prototype, { st.drawerEl.hidden = true; st.root.classList.remove('has-drawer'); st.drawerBody.replaceChildren(); - const back = st.drawerReturn; + let back = st.drawerReturn; st.drawerReturn = null; + // A control rebuilt while the drawer was open: its replacement opens the same thing. + const key = back && !document.contains(back) ? back.dataset?.storyOpen : undefined; + if (key) back = [...st.root.querySelectorAll('[data-story-open]')].find((n) => n.dataset.storyOpen === key); if (refocus && back && document.contains(back)) back.focus({ preventScroll: true }); }, diff --git a/test/story-mode-ui.test.ts b/test/story-mode-ui.test.ts index 71a5933e..3a6841bf 100644 --- a/test/story-mode-ui.test.ts +++ b/test/story-mode-ui.test.ts @@ -563,6 +563,26 @@ describe('story-mode.js overlay', () => { expect($$('.story-ln--add').map((l) => l.textContent)).toEqual(['+more']); }); + it('a chip that opened the drawer takes focus back, even when the page was redrawn meanwhile', async () => { + boot(storyOf(demoTurn())); + app.openStoryMode(SID); + await settle(); + sizeAndDraw(); + const chip = $$('.story-chips button').find((b) => b.dataset.storyOpen === 'diff:page')!; + chip.focus(); + chip.click(); + expect($('.story-drawer-kind')!.textContent).toBe('Diff'); + // A redraw with the same chips keeps the same element. + app._storyRender(); + expect(win.document.contains(chip)).toBe(true); + // One that did replace it: focus goes to the chip that opens the same thing. + const twin = chip.cloneNode(true) as HTMLElement; + chip.replaceWith(twin); + press('Escape'); + expect($('.story-drawer')!.hidden).toBe(true); + expect(win.document.activeElement).toBe(twin); + }); + it('while a question waits, the pencils say so; the chosen answer is marked, not struck', async () => { const ask = (answered: boolean) => storyOf([