fix(story-mode): the rest of review round 3

- A report's turn links to the helper it answers: its drawer shows what the
  helper did. Reports arrive HTML-escaped (`<a@b.c>`) and now read as
  text.
- Screen readers: a polite status region says when a new turn starts and
  when one finishes; the note is a status, the drawer a dialog named by its
  title; "Open file" buttons name their file.
- Save image draws the balloons' tails (from their CSS triangles) and is
  disabled with nothing to save.
- App toasts move to the bottom while Story Mode is open: at the top they
  covered its bar and turn picker on phones.
- The bar names the session as its tab does; a click on the mission balloon
  opens the prompt.
- A heading ends at its line (a plan's "# Plan: add wrap" no longer runs
  into its next paragraph) and a short one ("## Summary") is dropped; the
  plan you approved stamps "You approved it".
- "Claude's part is done in 3 minutes. A helper is still working."; the
  helper steps say "still working" while the helper runs; the advisor's
  labels call it a second opinion; a check run twice the same way reads once
  with a count; the session folder reads "." in commands only, never in
  prose; the loop's POW keeps off its board on a narrow panel; a shell
  session's note says it runs a shell or another CLI.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 11:38:52 +02:00
1 parent 757e14e991
commit 873b899c52
6 files changed
+198 -38

No files matched your search

+43 -13
View File
@@ -210,13 +210,20 @@ const unique = <T>(items: T[]): T[] => [...new Set(items)];
* markdown or as the end of a sentence.
*/
function plainMarkdown(text: string, spans: string[]): string {
return text
.replace(/```[\s\S]*?```/g, ' ')
.replace(/`([^`\n]*)`/g, (_, code: string) => `\u0000${spans.push(code) - 1}\u0000`)
.replace(/!?\[([^\]\n]*)\]\([^)\n]*\)/g, '$1')
.replace(/(^|[\s(])(\*\*|__)(?=\S)([^\n]*?\S)\2(?=[\s),!?:;]|\.(?:\s|$)|$)/g, '$1$3')
.replace(/(^|[\s(])([*_])(?=\S)([^*_\n]*?\S)\2(?=[\s),!?:;]|\.(?:\s|$)|$)/g, '$1$3')
.replace(/^[ \t]{0,3}(#{1,6}|>|[-*+]|\d+[.)])[ \t]+/gm, '');
return (
text
.replace(/```[\s\S]*?```/g, ' ')
.replace(/`([^`\n]*)`/g, (_, code: string) => `\u0000${spans.push(code) - 1}\u0000`)
.replace(/!?\[([^\]\n]*)\]\([^)\n]*\)/g, '$1')
.replace(/(^|[\s(])(\*\*|__)(?=\S)([^\n]*?\S)\2(?=[\s),!?:;]|\.(?:\s|$)|$)/g, '$1$3')
.replace(/(^|[\s(])([*_])(?=\S)([^*_\n]*?\S)\2(?=[\s),!?:;]|\.(?:\s|$)|$)/g, '$1$3')
// A heading ends at its line. A short one (`## Summary`) is a label and goes; a longer one
// (`# Plan: add wrap to mathx.py`) is a sentence of its own.
.replace(/^[ \t]{0,3}#{1,6}[ \t]+(.*?)[ \t]*$/gm, (_, h: string) =>
h.split(/\s+/).length <= 3 ? '' : /[.!?:]$/.test(h) ? h : `${h}.`
)
.replace(/^[ \t]{0,3}(>|[-*+]|\d+[.)])[ \t]+/gm, '')
);
}
const restoreSpans = (text: string, spans: string[]): string =>
@@ -629,8 +636,9 @@ function readTeammateMessages(text: string): StoryTeammateReport[] {
for (const m of text.matchAll(/<teammate-message\b([^>]*)>([\s\S]*?)<\/teammate-message>/g)) {
const from = /teammate_id="([^"]*)"/.exec(m[1])?.[1]?.split('@')[0];
if (!from) continue;
const summary = /summary="([^"]*)"/.exec(m[1])?.[1];
let body: string | undefined = m[2].trim();
const rawSummary = /summary="([^"]*)"/.exec(m[1])?.[1];
const summary = rawSummary === undefined ? undefined : unescapeXml(rawSummary);
let body: string | undefined = unescapeXml(m[2].trim());
if (body.startsWith('{')) {
try {
const note: unknown = JSON.parse(body);
@@ -648,6 +656,13 @@ function readTeammateMessages(text: string): StoryTeammateReport[] {
return reports;
}
/** Claude Code escapes a notification's text (`&lt;noreply@anthropic.com&gt;`); undo the five it uses. */
const unescapeXml = (s: string): string =>
s.replace(
/&(lt|gt|quot|apos|amp);/g,
(_, e: string) => ({ lt: '<', gt: '>', quot: '"', apos: "'", amp: '&' })[e] ?? _
);
function readPrompt(raw: string): {
text: string;
kind: 'prompt' | 'task' | 'peer' | 'interrupt';
@@ -685,7 +700,10 @@ function readPrompt(raw: string): {
return { text: `${command[1].trim()} ${args.trim()}`.trim(), kind: 'prompt', slash: true };
}
if (/^<task-notification>/.test(text)) {
const tag = (name: string) => new RegExp(`<${name}>([\\s\\S]*?)</${name}>`).exec(text)?.[1]?.trim();
const tag = (name: string) => {
const value = new RegExp(`<${name}>([\\s\\S]*?)</${name}>`).exec(text)?.[1]?.trim();
return value === undefined ? undefined : unescapeXml(value);
};
const count = (name: string) => {
const n = Number(tag(name));
return tag(name) !== undefined && Number.isFinite(n) ? n : undefined;
@@ -1348,6 +1366,8 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] {
if (report.toolUses !== undefined) helper.toolUses = report.toolUses;
if (report.durationMs !== undefined) helper.durationMs = report.durationMs;
ev.helper = helper;
// The report's own turn links back to the helper: its drawer shows what the helper did.
prompt.helper = { ...helper };
if (report.status && !/^(completed|done|finished|success)/i.test(report.status)) ev.ok = false;
};
const ensure = (at: string): StoryTurn => {
@@ -1950,9 +1970,17 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
? `${tests.failed} tests fail.`
: 'A test fails.'
: 'A check fails.';
director = checks
// The same command with the same outcome twice in a row reads once, with a count.
const runs: Array<{ text: string; n: number }> = [];
for (const e of checks) {
const text = `${shortCommand(e)} → ${outcome(e)}`;
const prev = runs[runs.length - 1];
if (prev?.text === text) prev.n++;
else runs.push({ text, n: 1 });
}
director = runs
.slice(-3)
.map((e) => `${shortCommand(e)} → ${outcome(e)}`)
.map((r) => (r.n > 1 ? `${r.text} ×${r.n}` : r.text))
.join(' · ');
if (tests)
director += ` · ${tests.passed} passed${tests.failed ? `, ${tests.failed} failed` : ''}${
@@ -2035,7 +2063,9 @@ function completePanel(turn: StoryTurn, page: Omit<StoryPage, 'panels'>, trailin
const story = turn.interrupted
? 'You stop Claude here.'
: running
? `${done.slice(0, -1)} for now. ${running === 1 ? 'A helper is' : `${running} helpers are`} still working.`
? `Claude's part is done${turn.durationMs !== undefined ? ` in ${roughDuration(turn.durationMs)}` : ''}. ${
running === 1 ? 'A helper is' : `${running} helpers are`
} still working.`
: done;
const director = [
turn.interrupted ? 'interrupted' : 'turn_duration',
+17
View File
@@ -342,6 +342,23 @@ body.story-open {
.story-balloon[hidden] {
display: none;
}
.story-panel[data-kind='mission'] .story-balloon {
cursor: pointer;
}
/* Spoken, not shown: the status region a screen reader announces from. */
.story-sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* App toasts move to the bottom while Story Mode is open: at the top they cover its bar. */
body.story-open .toast-container {
top: auto;
bottom: calc(env(safe-area-inset-bottom, 0px) + 0.75rem);
}
/* story-mode.js sets --story-lines when a balloon would leave its panel or cover Claude's head. */
.story-balloon-text {
display: -webkit-box;
+103 -21
View File
@@ -989,7 +989,14 @@ const StoryArt = (() => {
p.director,
(pr - 0.85) / 0.15
);
hit(`helper:${h.event}`, x - 30, y - 112, 60, 142, `See what the helper did: ${h.task || h.name}`);
hit(
`helper:${h.event}`,
x - 30,
y - 112,
60,
142,
h.type === 'advisor' ? 'See the second opinion' : `See what the helper did: ${h.task || h.name}`
);
});
},
},
@@ -1153,7 +1160,8 @@ const StoryArt = (() => {
-0.1,
spring(f - 20, { damping: 9, stiffness: 160 })
);
if (p.panel.ok !== undefined) {
// The verdict stamp only where it clears the board (a narrow panel shows the board alone).
if (p.panel.ok !== undefined && W - 108 > bx + 131) {
const word = p.panel.ok ? 'POW!' : 'KRAK!';
sfx(
ctx,
@@ -1509,7 +1517,9 @@ Object.assign(CodemanApp.prototype, {
document.body.classList.add('story-open');
this._storyApplyTheme();
const session = this.sessions?.get(sessionId);
st.sessionLabel.textContent = session?.name || sessionId.slice(0, 8);
st.sessionLabel.textContent = session
? this.getSessionName?.(session) || session.name || sessionId.slice(0, 8)
: sessionId.slice(0, 8);
this._storyRender();
this._storyStartRuntime();
this._storyRefresh();
@@ -1605,9 +1615,14 @@ Object.assign(CodemanApp.prototype, {
saveBtn.title = 'Save this page as a PNG to share';
const closeBtn = btn('story-btn story-close', '×', 'Close Story Mode');
bar.append(brand, nav, seg, live, saveBtn, closeBtn);
// What a screen reader hears as the page changes: a new turn, a finished one.
const announcer = el('p', 'story-sr');
announcer.setAttribute('role', 'status');
announcer.setAttribute('aria-live', 'polite');
const main = el('div', 'story-main');
const note = el('p', 'story-note');
note.setAttribute('role', 'status');
note.hidden = true;
note.dataset.i18nSkip = '';
const stage = el('div', 'story-stage');
@@ -1628,7 +1643,7 @@ Object.assign(CodemanApp.prototype, {
const drawer = el('aside', 'story-drawer');
drawer.hidden = true;
drawer.setAttribute('aria-label', 'Details');
drawer.setAttribute('role', 'dialog');
const drawerHead = el('header', 'story-drawer-head');
const drawerKind = el('span', 'story-drawer-kind');
const drawerLive = el('span', 'story-live story-drawer-live');
@@ -1637,6 +1652,8 @@ Object.assign(CodemanApp.prototype, {
const drawerMeta = el('div', 'story-drawer-meta');
drawerMeta.append(drawerKind, drawerLive);
const drawerTitle = el('p', 'story-drawer-title');
drawerTitle.id = 'storyDrawerTitle';
drawer.setAttribute('aria-labelledby', 'storyDrawerTitle');
const drawerSub = el('p', 'story-drawer-sub');
drawerTitle.dataset.i18nSkip = '';
drawerSub.dataset.i18nSkip = '';
@@ -1646,7 +1663,7 @@ Object.assign(CodemanApp.prototype, {
drawerBody.dataset.i18nSkip = '';
drawer.append(drawerHead, drawerBody);
root.append(bar, main, drawer);
root.append(bar, main, drawer, announcer);
document.body.appendChild(root);
const st = {
@@ -1677,6 +1694,7 @@ Object.assign(CodemanApp.prototype, {
dirBtn,
live,
closeBtn,
saveBtn,
note,
stage,
pageEl,
@@ -1980,13 +1998,32 @@ Object.assign(CodemanApp.prototype, {
st.note.textContent = note;
st.note.hidden = !note;
st.saveBtn.disabled = !page || !page.panels.length;
this._storyRenderPanels(page);
if (st.director) this._storyRenderWire();
this._storyAnnounce(page);
if (st.drawer) this._storyRenderDrawer(false);
// Panels on screen when a page first loads are drawn finished; only later arrivals animate in.
if (data) st.first = false;
},
/** Says a new turn or a finished one out loud (the polite status region), once each. */
_storyAnnounce(page) {
const st = this._story;
const box = st.root.querySelector('.story-sr');
if (!box || !page) return;
const done = page.complete || page.interrupted;
const key = `${page.id}|${done}`;
if (st.announced === key) return;
const first = st.announced === undefined;
st.announced = key;
if (first) return;
const last = page.panels[page.panels.length - 1];
box.textContent = done
? `Turn finished. ${last?.caption.story || ''}`.trim()
: `New turn: ${page.prompt.slice(0, 140)}`;
},
_storyRenderPanels(page) {
const st = this._story;
const want = page ? page.panels : [];
@@ -2153,6 +2190,14 @@ Object.assign(CodemanApp.prototype, {
// A prompt somebody else sent (a background job, another session) is a notice, not your speech.
const origin = page.events[p.events[0]]?.origin ?? (index === 0 ? page.origin : 'human');
const notice = p.kind === 'mission' && origin && origin !== 'human';
rt.balloon.onclick =
p.kind === 'mission'
? () =>
this._storyOpenDrawer(
{ view: 'prompt', panel: index, kind: 'mission', first: page.events[p.events[0]]?.id },
null
)
: null;
rt.balloon.className = `story-balloon ${
notice
? 'story-balloon--note'
@@ -2165,7 +2210,14 @@ Object.assign(CodemanApp.prototype, {
rt.el.setAttribute('aria-label', `Panel ${index + 1}: ${p.caption.story}`);
if (rt.answer) {
rt.answer.hidden = !p.question?.answer;
rt.answer.textContent = p.question?.answer ? `You chose: ${p.question.answer}` : '';
const planned = page.events[p.events[0]]?.tool === 'ExitPlanMode';
rt.answer.textContent = !p.question?.answer
? ''
: planned
? p.question.answer === 'Approved'
? 'You approved it'
: 'You sent it back'
: `You chose: ${p.question.answer}`;
}
if (rt.splash) {
rt.splash.querySelector('.story-splash-title').textContent = page.interrupted ? 'Stopped!' : 'Mission complete!';
@@ -2459,6 +2511,30 @@ Object.assign(CodemanApp.prototype, {
g.setLineDash([]);
}
}
// A balloon's tail is two CSS triangles (::before ink, ::after fill): draw them from their style.
if (el.classList.contains('story-balloon')) {
for (const which of ['::before', '::after']) {
const ps = getComputedStyle(el, which);
const poly = /polygon\(([^)]*)\)/.exec(ps.clipPath || '');
if (ps.position !== 'absolute' || !poly) continue;
const w = parseFloat(ps.width) || 0;
const h = parseFloat(ps.height) || 0;
const left = ps.left !== 'auto' ? parseFloat(ps.left) : r.width - parseFloat(ps.right) - w;
const top = ps.top !== 'auto' ? parseFloat(ps.top) : r.height - parseFloat(ps.bottom) - h;
const at = (v, size) => (v.endsWith('%') ? (parseFloat(v) / 100) * size : parseFloat(v) || 0);
g.beginPath();
poly[1].split(',').forEach((pt, k) => {
const [px, py] = pt.trim().split(/\s+/);
const X = x + left + at(px, w);
const Y = y + top + at(py ?? '0', h);
if (k) g.lineTo(X, Y);
else g.moveTo(X, Y);
});
g.closePath();
g.fillStyle = ps.backgroundColor;
g.fill();
}
}
// The text the reader sees (hidden spans, like the other reading level, are left out).
const text = (el.innerText || el.textContent || '').trim();
if (!text) return;
@@ -2591,14 +2667,14 @@ Object.assign(CodemanApp.prototype, {
* A label as the reader wants it: paths inside the session's folder lose that prefix. Display only;
* clicks keep the full path.
*/
_storyShortPath(text) {
_storyShortPath(text, { dot = false } = {}) {
const dir = this.sessions?.get(this._story?.sessionId)?.workingDir;
if (!dir || dir === '/' || !text) return text;
const base = dir.replace(/\/+$/, '');
return text
.split(`${base}/`)
.join('')
.replace(new RegExp(`${base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?=$|[\\s'"\`:;,)])`, 'g'), '.');
const short = text.split(`${base}/`).join('');
// In a command the folder itself reads as `.` (`ls .`); in prose that would say nothing.
if (!dot) return short;
return short.replace(new RegExp(`${base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?=$|[\\s'"\`:;,)])`, 'g'), '.');
},
/** What a click on an event opens, as a `data-story-open` target, or null. */
@@ -2768,6 +2844,11 @@ Object.assign(CodemanApp.prototype, {
out.push(el('h4', 'story-h', 'What it reported'), this._storyLinkifiedPre(ev.output, { rooted: true }));
if (ev.outputTruncated) out.push(el('p', 'story-empty', 'The report is cut here.'));
}
if (ev.helper?.agentId) {
const box = el('div', 'story-helper-steps');
out.push(el('h4', 'story-h', 'What the helper did'), box);
this._storyFillHelperSteps(box, ev.helper.agentId, false);
}
}
if (prompts.some((x) => x.ev.label.length >= 3999))
out.push(el('p', 'story-empty', 'A long prompt is cut here; the terminal has all of it.'));
@@ -2807,7 +2888,7 @@ Object.assign(CodemanApp.prototype, {
}
} else if (view === 'helper') {
const ev = items[0]?.ev;
kind = 'Helper';
kind = ev?.helper?.type === 'advisor' ? 'Advisor' : 'Helper';
title = ev?.helper ? ev.helper.task || ev.helper.name : 'Helper';
const h = ev?.helper;
const working = !!(ev?.pending || h?.running);
@@ -2844,7 +2925,7 @@ Object.assign(CodemanApp.prototype, {
if (h.agentId) {
const box = el('div', 'story-helper-steps');
out.push(el('h4', 'story-h', 'What the helper did'), box);
this._storyFillHelperSteps(box, h.agentId);
this._storyFillHelperSteps(box, h.agentId, working);
}
}
} else if (view === 'plan') {
@@ -2896,8 +2977,9 @@ Object.assign(CodemanApp.prototype, {
},
/** A helper's own steps (GET …/story/helpers/:agentId), cached per helper and revalidated by ETag. */
async _storyFillHelperSteps(box, agentId) {
async _storyFillHelperSteps(box, agentId, working = false) {
const st = this._story;
box.dataset.working = working ? '1' : '';
const cached = st.helperSteps?.get(agentId);
if (cached) this._storyRenderHelperSteps(box, cached.data);
else {
@@ -2943,7 +3025,9 @@ Object.assign(CodemanApp.prototype, {
const summary = [
`${steps.length} step${steps.length === 1 ? '' : 's'}`,
page.filesChanged ? `${page.filesChanged} file${page.filesChanged === 1 ? '' : 's'} changed` : '',
data.helper?.finished === false ? 'still working' : '',
data.helper?.finished === false || (box.dataset.working === '1' && data.helper?.finished !== true)
? 'still working'
: '',
]
.filter(Boolean)
.join(' · ');
@@ -2986,6 +3070,7 @@ Object.assign(CodemanApp.prototype, {
open.type = 'button';
open.className = 'story-btn-s';
open.textContent = 'Open file';
open.setAttribute('aria-label', `Open ${this._storyShortPath(edit.file)}`);
open.addEventListener('click', (e) => this._storyOpenFile(edit.file, e.currentTarget));
head.append(name, cnt, open);
section.append(head);
@@ -3019,12 +3104,9 @@ Object.assign(CodemanApp.prototype, {
_storyOutputSection(ev, index, el) {
const section = el('section', 'story-out');
const head = el('div', 'story-out-head');
const cmd = el(
'span',
'story-out-cmd',
`${ev.tool === 'Bash' ? '$ ' : `${ev.tool} `}${this._storyShortPath(ev.label)}`
);
if (ev.label !== this._storyShortPath(ev.label)) cmd.title = ev.label;
const shown = this._storyShortPath(ev.label, { dot: ev.tool === 'Bash' });
const cmd = el('span', 'story-out-cmd', `${ev.tool === 'Bash' ? '$ ' : `${ev.tool} `}${shown}`);
if (ev.label !== shown) cmd.title = ev.label;
const status = el(
'span',
`story-tag story-tag--${ev.ok === false ? 'bad' : ev.pending ? 'pending' : 'ok'}`,
+1 -1
View File
@@ -56,7 +56,7 @@ type Source = { unsupported: string } | { path: string | null };
async function storySource(ctx: SessionPort & ConfigPort, id: string, req: FastifyRequest): Promise<Source> {
const session = findSessionOrFail(ctx, id, req);
if (getCli(session.mode)?.capabilities.transcript !== 'claude-jsonl') {
return { unsupported: 'Story Mode draws Claude Code sessions. This session runs a different CLI.' };
return { unsupported: 'Story Mode draws Claude Code sessions, and this one runs a shell or another CLI.' };
}
if (session.remote || session.docker) {
return {
+1 -1
View File
@@ -84,7 +84,7 @@ describe('GET /api/sessions/:id/story', () => {
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/);
expect(data.reason).toMatch(/shell or another CLI/);
});
it('answers unsupported for remote and Docker sessions, whose transcript is not on this host', async () => {
+33 -2
View File
@@ -148,7 +148,13 @@ describe('text helpers', () => {
'Renamed rename_by_date.sh to __init__.py style.'
);
expect(firstSentence('It is *really* done and _very_ fast.')).toBe('It is really done and very fast.');
expect(firstSentence('## Summary\n- Fixed the 2*3 bug.')).toBe('Summary Fixed the 2*3 bug.');
expect(firstSentence('## Summary\n- Fixed the 2*3 bug.')).toBe('Fixed the 2*3 bug.');
});
it('firstSentence ends a heading at its line', () => {
expect(firstSentence('# Plan: add `wrap` to mathx.py\n\n**Context:** it has clamp().')).toBe(
'Plan: add wrap to mathx.py.'
);
});
it('firstSentence treats code spans as words and an ellipsis or e.g. as no end', () => {
@@ -347,7 +353,7 @@ describe('buildStoryTurns', () => {
'Claude sends 1 helper off to work in the background.'
);
expect(page.panels[page.panels.length - 1].caption.story).toBe(
'Done in 1 second for now. A helper is still working.'
"Claude's part is done in 1 second. A helper is still working."
);
const notification = [
@@ -371,6 +377,8 @@ describe('buildStoryTurns', () => {
const second = buildStoryPage(turns[1]);
expect(second.prompt).toBe('Agent "Write README" finished');
expect(second.events[0].output).toBe('I wrote README.md.');
// The report's turn links back to the helper it answers.
expect(second.events[0].helper).toMatchObject({ agentId: 'h9', result: 'I wrote README.md.' });
});
it('a prompt typed while Claude works (a queued_command attachment, no user line) joins the turn', () => {
@@ -489,6 +497,29 @@ describe('buildStoryTurns', () => {
expect(first.helpersRunning).toBe(0);
});
it('a report written HTML-escaped reads as text', () => {
const [turn] = buildStoryTurns(
entriesOf([
prompt(
'<task-notification>\n<summary>Agent &quot;Docs&quot; finished</summary>\n<result>Co-Authored-By: A &lt;a@b.c&gt; &amp; more</result>\n</task-notification>',
{ turnOrigin: 'task_notification' }
),
])
);
expect(turn.prompt).toBe('Agent "Docs" finished');
expect(turn.events[0].output).toBe('Co-Authored-By: A <a@b.c> & more');
});
it('a check run twice the same way reads once, with a count', () => {
const page = pageOf([
prompt('test twice'),
...bash('b1', 'pytest -q', 'Run tests', '3 passed in 0.1s'),
...bash('b2', 'pytest -q', 'Run tests again', '3 passed in 0.1s'),
end(1000),
]);
expect(page.panels.find((p) => p.kind === 'check')!.caption.director).toBe('pytest -q → ok ×2 · 3 passed');
});
it('a message from another session names its sender; pasted text loses its wrapper', () => {
const [peer] = buildStoryTurns(
entriesOf([