From 873b899c52ce7c248a18a3525d9b3d186d5c661c Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 11:38:52 +0200 Subject: [PATCH] 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) --- src/story-mode.ts | 56 ++++++++++---- src/web/public/story-mode.css | 17 +++++ src/web/public/story-mode.js | 124 +++++++++++++++++++++++++------ src/web/routes/story-routes.ts | 2 +- test/routes/story-routes.test.ts | 2 +- test/story-mode.test.ts | 35 ++++++++- 6 files changed, 198 insertions(+), 38 deletions(-) diff --git a/src/story-mode.ts b/src/story-mode.ts index 019328d8..6922d4df 100644 --- a/src/story-mode.ts +++ b/src/story-mode.ts @@ -210,13 +210,20 @@ const unique = (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(/]*)>([\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 (`<noreply@anthropic.com>`); 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 (/^/.test(text)) { - const tag = (name: string) => new RegExp(`<${name}>([\\s\\S]*?)`).exec(text)?.[1]?.trim(); + const tag = (name: string) => { + const value = new RegExp(`<${name}>([\\s\\S]*?)`).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, 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', diff --git a/src/web/public/story-mode.css b/src/web/public/story-mode.css index 045dbb5c..6333a370 100644 --- a/src/web/public/story-mode.css +++ b/src/web/public/story-mode.css @@ -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; diff --git a/src/web/public/story-mode.js b/src/web/public/story-mode.js index 1ab1ffbc..a4a11b43 100644 --- a/src/web/public/story-mode.js +++ b/src/web/public/story-mode.js @@ -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'}`, diff --git a/src/web/routes/story-routes.ts b/src/web/routes/story-routes.ts index 09bfed4f..038decf8 100644 --- a/src/web/routes/story-routes.ts +++ b/src/web/routes/story-routes.ts @@ -56,7 +56,7 @@ type Source = { unsupported: string } | { path: string | null }; async function storySource(ctx: SessionPort & ConfigPort, id: string, req: FastifyRequest): Promise { 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 { diff --git a/test/routes/story-routes.test.ts b/test/routes/story-routes.test.ts index 38b75ab7..b6f95916 100644 --- a/test/routes/story-routes.test.ts +++ b/test/routes/story-routes.test.ts @@ -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 () => { diff --git a/test/story-mode.test.ts b/test/story-mode.test.ts index 66a98485..6d611caa 100644 --- a/test/story-mode.test.ts +++ b/test/story-mode.test.ts @@ -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( + '\nAgent "Docs" finished\nCo-Authored-By: A <a@b.c> & more\n', + { turnOrigin: 'task_notification' } + ), + ]) + ); + expect(turn.prompt).toBe('Agent "Docs" finished'); + expect(turn.events[0].output).toBe('Co-Authored-By: A & 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([