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) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 12:57:58 +02:00
1 parent 6a1bac6ebe
commit d92e927184
3 files changed
+48 -18

No files matched your search

+6 -4
View File
@@ -1056,7 +1056,7 @@ What Story Mode reads (the opt-in header button, App Settings → Header buttons
### Routes
- `GET /api/sessions/:id/story[?turn=<id>]`: 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=<id>` names, or `null` when there is none (an unknown id included).
- `GET /api/sessions/:id/story[?turn=<id>]`: 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=<id>` 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, `<conversation>/subagents/agent-<agentId>.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 `<conversation id>.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 `<task-notification>` (matched by tool call) or a `<teammate-message>` (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 `<task-notification>` 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
+22 -14
View File
@@ -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 });
},
+20
View File
@@ -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([