diff --git a/src/story-mode.ts b/src/story-mode.ts index ed47aab3..db95cf5e 100644 --- a/src/story-mode.ts +++ b/src/story-mode.ts @@ -80,6 +80,8 @@ interface ReducedInput { headline?: string; /** How many commits a save makes, when more than one. */ commits?: number; + /** The first commit's message, when the command spells it out. */ + subject?: string; /** Files a Bash call writes through here-documents. */ writes?: StoryEdit[]; description?: string; @@ -458,6 +460,21 @@ function echoWrites(command: string, cwd?: string): StoryEdit[] { return out; } +/** + * The first commit's message in a command: `-m "..."` / `-m '...'`, or the first line of the + * here-document a `git commit -F -` reads. + */ +function commitSubject(command: string): string | undefined { + // `-m`, a combined `-am` / `-qm`, or `--message`; a trailer (`Co-Authored-By:`) is never the subject. + const m = /\bgit\b[^\n;&|]*\bcommit\b[^\n;&|]*?\s-(?:[a-zA-Z]*m|-message=?)\s*(["'])((?:(?!\1)[\s\S])*)\1/.exec( + command + ); + const first = m?.[2].split('\n')[0].trim(); + if (first && !/^[\w-]+-By:/i.test(first)) return clip(first, 100); + const f = /\bgit\b[^\n;&|]*\bcommit\b[^\n]*\s-F\s*-[^\n]*<<-?\s*(['"]?)(\w+)\1[^\n]*\n([^\n]*)/.exec(command); + return f && f[3].trim() && f[3].trim() !== f[2] ? clip(f[3].trim(), 100) : undefined; +} + /** A here-document's body is data, not commands: `cat > a.py <<'EOF' ... EOF` keeps only its first line. */ function withoutHeredocBodies(command: string): string { const out: string[] = []; @@ -632,7 +649,8 @@ export function reportsProblems(output: string): boolean { * Scans the last lines only; returns null when no summary is recognisable. */ export function parseTestCounts(output: string): StoryTests | null { - const lines = output.split('\n').slice(-40).reverse(); + // The newest summary wins; a long output is searched from its end (the last 2,000 lines). + const lines = output.split('\n').slice(-2000).reverse(); // node:test (TAP `# pass 7` / spec reporter `ℹ pass 7`), with its own fail line. const nodePass = lines.map((l) => /^\s*[#ℹ]\s*pass\s+(\d+)\s*$/.exec(l)).find(Boolean); if (nodePass) { @@ -807,6 +825,10 @@ function reduceInput(name: string, input: unknown, cwd?: string): ReducedInput { else if (heads.some((h) => /^git push\b/.test(h))) input.ships = 'push'; const commits = heads.filter((h) => /^git commit\b/.test(h)).length; if (commits > 1 || (commits && intent !== 'save')) input.commits = commits; + if (commits) { + const subject = commitSubject(full); + if (subject) input.subject = subject; + } } return input; } @@ -1004,7 +1026,8 @@ export function createStoryReducer({ sidechain = false }: { sidechain?: boolean if (name === 'Bash' && /^\s*Command running in background with ID:/.test(text)) { result.background = true; } else if (name === 'Bash') { - const tests = parseTestCounts(tail.text); + // Counted on the whole output: a later command's lines can push the summary out of the tail. + const tests = parseTestCounts(text); if (tests) result.tests = tests; const intent = call?.input.intent; if ((intent === 'build' || intent === 'test') && reportsProblems(tail.text)) result.problems = true; @@ -1299,6 +1322,7 @@ function toolEvent(e: Extract, actor: StoryActor): St if (i.ships) ev.ships = i.ships; if (i.headline) ev.headline = i.headline; if (i.commits) ev.commits = i.commits; + if (i.subject) ev.subject = i.subject; if (i.writes) ev.edits = i.writes; if (i.offset) ev.line = i.offset; if (e.name === 'Bash' && i.description) ev.description = i.description; @@ -2159,6 +2183,9 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde if (savedAnyway) panel.ok = true; const commits = evs.reduce((n, e) => n + (e.commits ?? (e.intent === 'save' ? 1 : 0)), 0); const inCommits = commits > 1 ? ` in ${commits} commits` : ''; + // One commit: its message says what was saved. + const subject = commits === 1 ? evs.find((e) => e.subject)?.subject : undefined; + const said = subject ? `: "${clip(subject, 60).replace(/[.]$/, '')}"` : ''; story = savedAnyway ? ships === 'push' ? `Claude saves the work${inCommits} and uploads it, but a step after that fails.` @@ -2172,8 +2199,8 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde : ships === 'pr' ? 'Claude opens a pull request.' : ships === 'push' - ? `Claude saves the work${inCommits} and uploads it.` - : `Claude saves the work${inCommits}.`; + ? `Claude saves the work${inCommits} and uploads it${said}.` + : `Claude saves the work${inCommits}${said}.`; const wrote = unique(edits.map((d) => d.file)).length; if (wrote && story.startsWith('Claude ')) story = `Claude writes ${plural(wrote, 'file')} and ${story.slice(7)}`; director = `${evs.map(shortCommand).slice(0, 2).join(' · ')}${errors ? ` · ${plural(errors, 'error')}` : ''}${ diff --git a/src/types/story.ts b/src/types/story.ts index fdd76514..6f0093b1 100644 --- a/src/types/story.ts +++ b/src/types/story.ts @@ -130,6 +130,8 @@ export interface StoryEvent { ships?: 'pr' | 'push'; /** How many commits a Bash call makes (`git commit` segments). */ commits?: number; + /** The first commit's message, when the command spells it out (`-m "..."` or a here-document). */ + subject?: string; /** Who a prompt came from (a prompt added mid-turn can come from someone other than the turn's starter). */ origin?: StoryTurnOrigin; output?: string; diff --git a/src/web/public/story-mode.css b/src/web/public/story-mode.css index 6333a370..be815cef 100644 --- a/src/web/public/story-mode.css +++ b/src/web/public/story-mode.css @@ -731,10 +731,10 @@ body.story-open .toast-container { padding-right: max(16px, env(safe-area-inset-right, 0px)); } .story-drawer { - top: auto; + top: calc(var(--story-bar-h, 18vh) + 6px); left: 0; width: auto; - height: 82vh; + height: auto; border-left: 0; border-top: 3px solid var(--story-ink); padding-left: env(safe-area-inset-left, 0px); @@ -933,6 +933,42 @@ body.story-open .toast-container { content: '✓ '; } +/* Touch screens: every control a finger can hit (44 px), and the small type a little larger. */ +@media (pointer: coarse) { + .story-btn, + .story-drawer-close { + min-width: 44px; + min-height: 44px; + } + .story-seg-btn, + .story-save, + .story-btn-s, + .story-chips li > * { + min-height: 44px; + display: inline-flex; + align-items: center; + } + .story-link { + padding-block: 6px; + } + .story-cap-dir, + .story-ev, + .story-code, + .story-pre { + font-size: 13px; + } +} +/* A phone on its side: the sheet's head takes less of the little height there is. */ +@media (max-height: 480px) { + .story-drawer-head { + gap: 2px; + padding-block: 6px; + } + .story-drawer-sub { + display: none; + } +} + @media (prefers-reduced-motion: reduce) { .story-fresh *, .story-arrive, diff --git a/src/web/public/story-mode.js b/src/web/public/story-mode.js index 93610c6e..9d6a3c86 100644 --- a/src/web/public/story-mode.js +++ b/src/web/public/story-mode.js @@ -1702,6 +1702,18 @@ Object.assign(CodemanApp.prototype, { drawer.hidden = true; drawer.setAttribute('role', 'dialog'); const drawerHead = el('header', 'story-drawer-head'); + // A sheet comes down the way it went up: drag its head down to close it. + let dragFrom = null; + drawerHead.addEventListener('touchstart', (e) => (dragFrom = e.touches[0]?.clientY ?? null), { passive: true }); + drawerHead.addEventListener( + 'touchend', + (e) => { + const y = e.changedTouches[0]?.clientY; + if (dragFrom !== null && y !== undefined && y - dragFrom > 70) this._storyCloseDrawer(); + dragFrom = null; + }, + { passive: true } + ); const drawerKind = el('span', 'story-drawer-kind'); const drawerLive = el('span', 'story-live story-drawer-live'); drawerLive.append(el('i'), el('span', null, 'LIVE')); @@ -1808,6 +1820,7 @@ Object.assign(CodemanApp.prototype, { _storyPickTurn(id) { const st = this._story; + st.picked = true; // a turn you chose is not news to announce const turns = st.data?.turns || []; st.turnId = id && id !== turns[turns.length - 1]?.id ? id : null; st.first = true; @@ -1947,7 +1960,8 @@ Object.assign(CodemanApp.prototype, { } // A finished newest page: look again now and then (a local command like /compact raises no // hook). An unchanged page is a 304. - if (!st.turnId && Date.now() - (st.lastIdlePoll || 0) > STORY_IDLE_POLL_MS) { + // Parked on an older turn: the turn list still learns about new turns now and then. + if (Date.now() - (st.lastIdlePoll || 0) > STORY_IDLE_POLL_MS) { st.lastIdlePoll = Date.now(); this._storyRefresh(); } @@ -2066,6 +2080,7 @@ Object.assign(CodemanApp.prototype, { if (page) sel.value = page.id; sel.disabled = turns.length < 2; const idx = page ? turns.findIndex((t) => t.id === page.id) : -1; + sel.setAttribute('aria-label', idx >= 0 ? `Turn ${idx + 1} of ${turns.length}` : 'Turn'); st.prev.disabled = idx <= 0; st.next.disabled = idx < 0 || idx >= turns.length - 1; st.live.hidden = !(page && !page.complete && !page.interrupted && !st.turnId); @@ -2102,7 +2117,9 @@ Object.assign(CodemanApp.prototype, { const first = st.announced === undefined; const newTurn = !first && !st.announced.startsWith(`${page.id}|`); st.announced = key; - if (first) return; + const picked = st.picked; + st.picked = false; + if (first || (picked && newTurn)) return; const last = page.panels[page.panels.length - 1]; const finished = `${done ? `Turn finished. ${last?.caption.story || ''}` : ''}`.trim(); box.textContent = newTurn ? `New turn: ${page.prompt.slice(0, 140)}${finished ? `. ${finished}` : ''}` : finished; @@ -2893,6 +2910,9 @@ Object.assign(CodemanApp.prototype, { st.drawerSeen = new Set(); st.drawerEl.hidden = false; st.root.classList.add('has-drawer'); + // On a phone the drawer is a sheet from the bottom: it starts below the bar, whatever its height. + const bar = st.root.querySelector('.story-bar'); + if (bar) st.root.style.setProperty('--story-bar-h', `${bar.offsetHeight}px`); this._storyRenderDrawer(true); st.drawerEl.querySelector('.story-drawer-close')?.focus({ preventScroll: true }); }, @@ -3251,6 +3271,17 @@ Object.assign(CodemanApp.prototype, { if (ev.output !== undefined) { section.append(this._storyLinkifiedPre(ev.output)); if (ev.outputTruncated) section.append(el('p', 'story-empty', 'Only the end of this output is kept.')); + // A command that also wrote files (a here-document) offers the change, not only what it printed. + if (ev.edits?.length) { + const b = el( + 'button', + 'story-btn-s', + `See the ${ev.edits.length === 1 ? 'file' : `${ev.edits.length} files`} it wrote` + ); + b.type = 'button'; + b.dataset.storyOpen = `diff:event:${index}`; + section.append(b); + } } else if (ev.outputTruncated) { section.append(el('p', 'story-empty', 'This output was left out to keep a long turn fast.')); } else if (target && !target.startsWith('output:')) { diff --git a/test/fixtures/story-corpus/compact-then-plan-mode.expected.json b/test/fixtures/story-corpus/compact-then-plan-mode.expected.json index b08f4c05..2f2b4995 100644 --- a/test/fixtures/story-corpus/compact-then-plan-mode.expected.json +++ b/test/fixtures/story-corpus/compact-then-plan-mode.expected.json @@ -63,7 +63,7 @@ { "kind": "save", "ok": true, - "story": "Claude saves the work.", + "story": "Claude saves the work: \"feat: add clamp() with pytest coverage\".", "director": "git commit -q -F -", "balloon": null, "tests": null, @@ -213,7 +213,7 @@ { "kind": "save", "ok": true, - "story": "Claude saves the work.", + "story": "Claude saves the work: \"feat: add wrap() for modular wrapping into a range\".", "director": "git commit -q -F -", "balloon": null, "tests": null, diff --git a/test/fixtures/story-corpus/failing-test-on-purpose.expected.json b/test/fixtures/story-corpus/failing-test-on-purpose.expected.json index e90e8237..928414ac 100644 --- a/test/fixtures/story-corpus/failing-test-on-purpose.expected.json +++ b/test/fixtures/story-corpus/failing-test-on-purpose.expected.json @@ -63,7 +63,7 @@ { "kind": "save", "ok": true, - "story": "Claude saves the work.", + "story": "Claude saves the work: \"feat: add slugify module with node:test suite\".", "director": "git commit -q -F -", "balloon": null, "tests": null, @@ -117,7 +117,7 @@ { "kind": "save", "ok": true, - "story": "Claude saves the work.", + "story": "Claude saves the work: \"test: cover empty-string input for slugify\".", "director": "git commit -q -F -", "balloon": null, "tests": null, diff --git a/test/fixtures/story-corpus/long-turn-prompt-added.expected.json b/test/fixtures/story-corpus/long-turn-prompt-added.expected.json index 5cd0fb97..62c4e4da 100644 --- a/test/fixtures/story-corpus/long-turn-prompt-added.expected.json +++ b/test/fixtures/story-corpus/long-turn-prompt-added.expected.json @@ -180,7 +180,7 @@ { "kind": "save", "ok": true, - "story": "Claude saves the work.", + "story": "Claude saves the work: \"docs: changelog\".", "director": "git commit -m \"docs: changelog\" -m \"Co-Authored-By: Claude…", "balloon": "Step 10:", "tests": null, @@ -228,7 +228,7 @@ { "kind": "save", "ok": true, - "story": "Claude saves the work.", + "story": "Claude saves the work: \"docs: changelog entry\".", "director": "git commit -am \"docs: changelog entry\" -m \"Co-Authored-By:…", "balloon": "Step 15:", "tests": null, diff --git a/test/fixtures/story-corpus/python-cli-fix-loop.expected.json b/test/fixtures/story-corpus/python-cli-fix-loop.expected.json index c96c7a4d..0e1d6be8 100644 --- a/test/fixtures/story-corpus/python-cli-fix-loop.expected.json +++ b/test/fixtures/story-corpus/python-cli-fix-loop.expected.json @@ -54,7 +54,7 @@ { "kind": "save", "ok": true, - "story": "Claude writes 1 file and saves the work.", + "story": "Claude writes 1 file and saves the work: \"chore: ignore Python and pytest caches\".", "director": "git commit -q -m \"chore: ignore Python and pytest caches · writes 1 file", "balloon": null, "tests": null, diff --git a/test/fixtures/story-corpus/teammates-then-interrupt.expected.json b/test/fixtures/story-corpus/teammates-then-interrupt.expected.json index f1abdad9..ea174129 100644 --- a/test/fixtures/story-corpus/teammates-then-interrupt.expected.json +++ b/test/fixtures/story-corpus/teammates-then-interrupt.expected.json @@ -42,7 +42,7 @@ { "kind": "save", "ok": true, - "story": "Claude writes 2 files and saves the work.", + "story": "Claude writes 2 files and saves the work: \"feat: add package.json and index.js with add and slugify\".", "director": "git commit -q -m \"feat: add package.json and index.js with… · writes 2 files", "balloon": null, "tests": null, @@ -72,7 +72,7 @@ { "kind": "save", "ok": true, - "story": "Claude saves the work.", + "story": "Claude saves the work: \"test: add node:test suite for add and slugify\".", "director": "git commit -q -m \"test: add node:test suite for add and slu…", "balloon": null, "tests": null, @@ -123,7 +123,7 @@ { "kind": "save", "ok": true, - "story": "Claude saves the work.", + "story": "Claude saves the work: \"docs: add CONTRIBUTING.md\".", "director": "git commit -q -m \"docs: add CONTRIBUTING.md", "balloon": null, "tests": null, @@ -240,7 +240,7 @@ { "kind": "save", "ok": true, - "story": "Claude saves the work.", + "story": "Claude saves the work: \"refactor: split index.js into lib/ modules behind an index.…\".", "director": "git commit -q -m \"refactor: split index.js into lib/ module…", "balloon": null, "tests": null, @@ -261,7 +261,7 @@ { "kind": "save", "ok": true, - "story": "Claude saves the work.", + "story": "Claude saves the work: \"docs: add full JSDoc to every function and module\".", "director": "git commit -q -m \"docs: add full JSDoc to every function an…", "balloon": null, "tests": null, diff --git a/test/fixtures/story-corpus/web-page-node-test.expected.json b/test/fixtures/story-corpus/web-page-node-test.expected.json index 25b07845..7735f903 100644 --- a/test/fixtures/story-corpus/web-page-node-test.expected.json +++ b/test/fixtures/story-corpus/web-page-node-test.expected.json @@ -72,7 +72,7 @@ { "kind": "save", "ok": true, - "story": "Claude saves the work.", + "story": "Claude saves the work: \"feat: add pomodoro timer with tests\".", "director": "git commit -q -m \"feat: add pomodoro timer with tests\" -m \"…", "balloon": null, "tests": null, diff --git a/test/story-mode.test.ts b/test/story-mode.test.ts index 00735647..6d83ac2e 100644 --- a/test/story-mode.test.ts +++ b/test/story-mode.test.ts @@ -794,7 +794,7 @@ describe('buildStoryPage', () => { expect(byKind.backup.caption.director).toContain('3 calls inside'); expect(byKind.loop).toMatchObject({ rounds: 2, ok: true, added: 2, removed: 1 }); expect(byKind.loop.caption.story).toBe('Fix and test: the first run fails, the second passes.'); - expect(byKind.save.caption.story).toBe('Claude saves the work.'); + expect(byKind.save.caption.story).toBe('Claude saves the work: "fix: banner".'); expect(byKind.complete.caption.story).toBe('Done in 7 minutes.'); expect(byKind.complete.caption.director).toContain('7 min 12 s'); });