diff --git a/src/story-mode.ts b/src/story-mode.ts index 5799f97a..72f18d84 100644 --- a/src/story-mode.ts +++ b/src/story-mode.ts @@ -1801,7 +1801,7 @@ function applyTextBudget(events: StoryEvent[]): boolean { } /** Claude reporting how a check went ("All 7 tests pass"), as opposed to announcing its next step. */ -const TALKS_RESULTS = /\b(pass(es|ed|ing)?|fail(s|ed|ing|ures?)?|green|clean)\b/i; +const TALKS_RESULTS = /\b(pass(es|ed|ing)?|fail(s|ed|ing|ures?)?|green|red|clean|errors?)\b/i; /** Claude reporting a save ("Commits are in.", "Pushed to origin."). */ const TALKS_SAVES = /\b(commit(s|ted)?|pushed|merged|tagged)\b/i; /** The remark that reports on a panel of this kind, if any. */ @@ -2132,7 +2132,8 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde // Every test run's verdict, so a run that failed (often the one you asked for) is not lost, // even inside a round that passed in the end. const runs = checks.map(passed); - if (runs.every((r) => r !== undefined)) panel.roundsOk = runs as boolean[]; + // A run still going is null, so a live loop already shows the runs that finished. + if (runs.some((r) => r !== undefined)) panel.roundsOk = runs.map((r) => r ?? null); const failed = checks.slice(0, -1).filter((e) => passed(e) === false); const fails = failed.length; // A failure where the tests passed and only a linter or formatter complained. diff --git a/src/types/story.ts b/src/types/story.ts index c4bb5edc..8f51866c 100644 --- a/src/types/story.ts +++ b/src/types/story.ts @@ -163,8 +163,8 @@ export interface StoryPanel { removed?: number; ok?: boolean; rounds?: number; - /** A loop's test runs in order: whether each passed (absent while one is still running). */ - roundsOk?: boolean[]; + /** A loop's test runs in order: whether each passed, `null` for one still running. */ + roundsOk?: (boolean | null)[]; tests?: StoryTests; todos?: StoryTodo[]; question?: { text: string; answer?: string }; diff --git a/src/web/public/story-mode.css b/src/web/public/story-mode.css index 6b927b7f..22ca88fb 100644 --- a/src/web/public/story-mode.css +++ b/src/web/public/story-mode.css @@ -740,7 +740,8 @@ body.story-open .toast-container { .story-drawer { position: absolute; z-index: 5; - top: 0; + /* Below the bar, which stays usable while the drawer is open (story-mode.js measures it). */ + top: var(--story-bar-h, 0px); right: 0; bottom: 0; width: var(--story-drawer-w); @@ -987,6 +988,9 @@ body.story-open .toast-container { .story-link { padding-block: 6px; } + .story-turns { + min-height: 44px; + } .story-cap-dir, .story-ev, .story-code, diff --git a/src/web/public/story-mode.js b/src/web/public/story-mode.js index 1042e1a5..5118d57d 100644 --- a/src/web/public/story-mode.js +++ b/src/web/public/story-mode.js @@ -1184,6 +1184,15 @@ const StoryArt = (() => { ctx.scale(pr, pr); ctx.beginPath(); ctx.arc(0, 0, 9, 0, TAU); + if (ok === null) { + // Still running: an open circle, no verdict yet. + ctx.setLineDash([3, 3]); + ctx.lineWidth = 2.5; + ctx.strokeStyle = C.balloon; + ctx.stroke(); + ctx.restore(); + return; + } inked(ctx, ok ? C.go : C.magenta, 2.5); ctx.strokeStyle = C.balloon; ctx.lineWidth = 2.5; @@ -1766,6 +1775,7 @@ Object.assign(CodemanApp.prototype, { saveBtn, note, stage, + mainEl: main, pageEl, wire, wireCount, @@ -1821,6 +1831,7 @@ Object.assign(CodemanApp.prototype, { _storyPickTurn(id) { const st = this._story; st.picked = true; // a turn you chose is not news to announce + st.drawerHolds = false; // your choice wins over a page held for a drawer const turns = st.data?.turns || []; st.turnId = id && id !== turns[turns.length - 1]?.id ? id : null; st.first = true; @@ -1877,8 +1888,23 @@ Object.assign(CodemanApp.prototype, { body?.error || (res?.status === 404 ? 'This session is gone.' : 'Story Mode could not read this session.'); } else { st.error = null; - st.data = body.data; - st.etag = etag; + const held = st.drawer?.pageId; + if (held && !st.turnId && body.data.page && body.data.page.id !== held && st.data?.page?.id === held) { + // The newest turn moved on while a drawer reads this one: hold this turn (the picker + // already lists the new one) until the drawer closes, and fetch this turn's last state. + st.turnId = held; + st.drawerHolds = true; + st.data = { ...body.data, page: st.data.page }; + st.etag = null; + st.again = true; + const box = st.root.querySelector('.story-sr'); + if (box) + box.textContent = + 'A new turn started. This page stays on the turn you are reading until you close the panel.'; + } else { + st.data = body.data; + st.etag = etag; + } } this._storyRender(); } finally { @@ -1898,7 +1924,10 @@ Object.assign(CodemanApp.prototype, { st.ro = new ResizeObserver((entries) => { for (const entry of entries) { // The bar's height places the phone drawer below it, and changes when the phone turns. - if (entry.target === bar) st.root.style.setProperty('--story-bar-h', `${bar.offsetHeight}px`); + if (entry.target === bar) { + st.root.style.setProperty('--story-bar-h', `${bar.offsetHeight}px`); + this._storyDrawerCover(); + } const rt = st.panels.find((p) => p.el === entry.target); if (rt) this._storySizePanel(rt); } @@ -2031,7 +2060,7 @@ Object.assign(CodemanApp.prototype, { const scope = preview?.classList.contains('visible') ? preview : st.root; const items = [ ...scope.querySelectorAll('button, select, textarea, input, a[href], [tabindex]:not([tabindex="-1"])'), - ].filter((n) => !n.disabled && n.offsetParent !== null); + ].filter((n) => !n.disabled && n.offsetParent !== null && !n.closest('[inert]')); if (!items.length) return; const first = items[0]; const last = items[items.length - 1]; @@ -2046,6 +2075,18 @@ Object.assign(CodemanApp.prototype, { } }, + /** + * A drawer that covers the whole page (a narrow window, a phone) makes the page behind it inert: + * Tab would otherwise walk the panels it hides. Beside the page (a wide window) it does not. + */ + _storyDrawerCover() { + const st = this._story; + if (!st?.mainEl) return; + const d = st.drawer ? st.drawerEl.getBoundingClientRect() : null; + const m = st.mainEl.getBoundingClientRect(); + st.mainEl.inert = !!d && d.width > 0 && m.width > 0 && d.left <= m.left + 8; + }, + _storyStopRuntime() { const st = this._story; cancelAnimationFrame(st.raf); @@ -2095,8 +2136,23 @@ Object.assign(CodemanApp.prototype, { 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; + const noPrev = idx <= 0; + const noNext = idx < 0 || idx >= turns.length - 1; + // A button that is about to be disabled under focus hands it to the turn picker first. + const a = document.activeElement; + if ((a === st.prev && noPrev) || (a === st.next && noNext)) + (sel.disabled ? st.closeBtn : sel).focus({ preventScroll: true }); + st.prev.disabled = noPrev; + st.next.disabled = noNext; + // Parked on an older turn, a new one is news. + if (st.turnId && st.knownTurns !== undefined && turns.length > st.knownTurns && !st.drawerHolds) { + const box = st.root.querySelector('.story-sr'); + if (box) + box.textContent = `A new turn started: ${(turns[turns.length - 1].prompt || '').slice(0, 100)}. You are on turn ${ + idx + 1 + } of ${turns.length}.`; + } + st.knownTurns = turns.length; st.live.hidden = !(page && !page.complete && !page.interrupted && !st.turnId); // Note line: errors, unsupported sessions, empty sessions, cut pages. @@ -2170,6 +2226,7 @@ Object.assign(CodemanApp.prototype, { if (rt) old.delete(id); else { rt = this._storyCreatePanel(panel.kind, fresh); + rt.isNew = true; st.ro?.observe(rt.el); st.io?.observe(rt.el); } @@ -2189,6 +2246,11 @@ Object.assign(CodemanApp.prototype, { want.forEach((panel, i) => { const rt = st.panels[i]; rt.panel = panel; + if (rt.isNew) { + // Sized now, not when the resize observer reports: a new panel never shows an empty frame. + rt.isNew = false; + this._storySizePanel(rt); + } rt.el.style.setProperty('--story-span6', String(spans6[i])); rt.el.style.setProperty('--story-span2', String(spans2[i])); this._storyFillPanelText(rt, page, i); @@ -2937,11 +2999,13 @@ Object.assign(CodemanApp.prototype, { _storyOpenDrawer(source, trigger) { const st = this._story; if (!st.drawer) st.drawerReturn = trigger || null; - st.drawer = source; + // Bound to the turn it was opened on: if the newest turn moves on, the page holds this one. + st.drawer = { ...source, pageId: st.data?.page?.id }; st.drawerSeen = new Set(); st.drawerSig = null; st.drawerEl.hidden = false; st.root.classList.add('has-drawer'); + this._storyDrawerCover(); // 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`); @@ -2955,6 +3019,14 @@ Object.assign(CodemanApp.prototype, { st.drawer = null; st.drawerEl.hidden = true; st.root.classList.remove('has-drawer'); + this._storyDrawerCover(); + // A page held for this drawer follows the newest turn again. + if (st.drawerHolds) { + st.drawerHolds = false; + st.turnId = null; + st.etag = null; + if (st.open) this._storyRefresh(); + } st.drawerBody.replaceChildren(); let back = st.drawerReturn; st.drawerReturn = null; @@ -3052,7 +3124,7 @@ Object.assign(CodemanApp.prototype, { const edits = items.flatMap((x) => StoryArt.editsOf(x.ev).map((d, k) => ({ ev: x.ev, edit: d, key: `${x.ev.id}#${k}` })) ); - kind = live ? 'Live diff' : 'Diff'; + kind = 'Diff'; const added = edits.reduce((n, x) => n + x.edit.added, 0); const removed = edits.reduce((n, x) => n + x.edit.removed, 0); const files = new Set(edits.map((x) => x.edit.file)).size; @@ -3068,6 +3140,10 @@ Object.assign(CodemanApp.prototype, { } out.push(section); } + // A command that changed files and also printed something (`cat a.py`): what it printed too. + for (const { ev, i } of items) + if (ev.tool === 'Bash' && StoryArt.editsOf(ev).length && (ev.files?.length || (ev.output || '').trim())) + out.push(this._storyOutputSection(ev, i, el, { inDiff: true })); } else if (view === 'output' || view === 'list') { const withOutput = items.filter((x) => x.ev.tool !== 'Text' && x.ev.intent !== 'prompt'); kind = view === 'list' ? 'Steps' : 'Output'; @@ -3337,7 +3413,7 @@ Object.assign(CodemanApp.prototype, { return section; }, - _storyOutputSection(ev, index, el) { + _storyOutputSection(ev, index, el, { inDiff = false } = {}) { const section = el('section', 'story-out'); const head = el('div', 'story-out-head'); const shown = this._storyShortPath(ev.label, { dot: ev.tool === 'Bash' }); @@ -3357,7 +3433,7 @@ Object.assign(CodemanApp.prototype, { 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) { + if (ev.edits?.length && !inDiff) { const b = el( 'button', 'story-btn-s', diff --git a/test/story-mode-ui.test.ts b/test/story-mode-ui.test.ts index 16b257bc..ce4b3f47 100644 --- a/test/story-mode-ui.test.ts +++ b/test/story-mode-ui.test.ts @@ -337,6 +337,108 @@ describe('story-mode.js overlay', () => { expect((win.document.activeElement as HTMLElement).dataset.storyOpen).toMatch(/^diff@loop:/); }); + it('a drawer holds its turn when the newest turn moves on, and lets go when it closes', async () => { + const one = [prompt('first job'), ...read('r1', '/work/demo-shop/a.ts'), said('Done.'), end(2000)]; + const two = [...one, prompt('second job'), said('On it.')]; + boot(storyOf(one)); + app.openStoryMode(SID); + await settle(); + sizeAndDraw(); + const first = app._story.data.page.id; + $('[data-story-open="list:page"]')!.click(); + expect($('.story-drawer')!.hidden).toBe(false); + const title = $('.story-drawer-title')!.textContent; + responses = [ + { status: 200, body: { success: true, data: storyOf(two) } }, + { status: 200, body: { success: true, data: storyAt(two, 0) } }, + ]; + calls.length = 0; + await app._storyRefresh(); + await settle(12); + expect(app._story.data.page.id).toBe(first); + expect(app._story.turnId).toBe(first); + expect($('.story-drawer')!.hidden).toBe(false); + expect($('.story-drawer-title')!.textContent).toBe(title); + expect($$('.story-turns option')).toHaveLength(2); + // The held turn's last state is fetched a moment later (the refresh's own retry, 250 ms). + await new Promise((r) => setTimeout(r, 320)); + await settle(); + expect(calls.some((c) => c.includes(`turn=${encodeURIComponent(first)}`))).toBe(true); + expect(app._story.data.page.id).toBe(first); + expect($('.story-sr')!.textContent).toMatch(/stays on the turn you are reading/); + // Closing the drawer follows the newest turn again. + responses = [{ status: 200, body: { success: true, data: storyOf(two) } }]; + press('Escape'); + await settle(12); + expect(app._story.turnId).toBeNull(); + expect(app._story.data.page.id).not.toBe(first); + }); + + it('Previous on the second turn lands on the first with focus on the picker, not the page', async () => { + const two = [prompt('first job'), said('Done.'), end(2000), prompt('second job'), said('Done too.'), end(2000)]; + boot(storyOf(two)); + app.openStoryMode(SID); + await settle(); + responses = [{ status: 200, body: { success: true, data: storyAt(two, 0) } }]; + const prev = $('.story-prev') as HTMLButtonElement; + prev.focus(); + prev.click(); + await settle(12); + expect(prev.disabled).toBe(true); + expect(win.document.activeElement).toBe($('.story-turns')); + }); + + it('the diff of a command that also printed a file shows what it printed, with the file to open', async () => { + const lines = [ + prompt('note it'), + ...bash( + 'b1', + "cat >> NOTES.md <<'EOF'\nmore\nEOF\ncat stats.py", + 'Append a note and show the module', + 'def f(): pass' + ), + end(1000), + ]; + (lines[1] as { cwd?: string }).cwd = '/work/demo-shop'; + boot(storyOf(lines)); + app.openStoryMode(SID); + await settle(); + sizeAndDraw(); + $$('.story-panel .story-hit') + .find((h) => h.dataset.storyOpen?.startsWith('diff@'))! + .click(); + expect($('.story-drawer-kind')!.textContent).toBe('Diff'); + expect($('.story-drawer-body')!.textContent).toContain('def f(): pass'); + expect($$('.story-drawer-body .story-out-files button').map((b) => b.textContent)).toEqual(['Open stats.py']); + expect($$('.story-drawer-body button').some((b) => /it wrote/.test(b.textContent || ''))).toBe(false); + }); + + it('a drawer covering the whole page makes the page behind it inert; one beside it does not', async () => { + boot(storyOf(demoTurn())); + app.openStoryMode(SID); + await settle(); + sizeAndDraw(); + const st = app._story; + const box = (left: number, width: number) => () => ({ + left, + width, + top: 0, + height: 500, + right: left + width, + bottom: 500, + }); + st.mainEl.getBoundingClientRect = box(0, 400); + st.drawerEl.getBoundingClientRect = box(0, 400); + $('.story-panel[data-kind="loop"] .story-hit')!.click(); + expect(st.mainEl.inert).toBe(true); + press('Escape'); + expect(st.mainEl.inert).toBe(false); + st.mainEl.getBoundingClientRect = box(0, 1400); + st.drawerEl.getBoundingClientRect = box(900, 500); + $('.story-panel[data-kind="loop"] .story-hit')!.click(); + expect(st.mainEl.inert).toBe(false); + }); + it('activity for this session refreshes, activity for another does not', async () => { vi.useFakeTimers(); try { @@ -695,7 +797,7 @@ describe('story-mode.js overlay', () => { expect(body).not.toMatch(/No output|nothing to show/i); }); - it('parked on an older turn, the poll learns about new turns without moving the reader', async () => { + it('parked on an older turn, the poll learns about new turns and says so, without moving the reader', async () => { const two = [prompt('first job'), said('Done.'), end(2000), prompt('second job'), said('Done too.'), end(2000)]; const three = [...two, prompt('third job'), said('All three.'), end(2000)]; let poll: (() => void) | undefined; @@ -724,7 +826,9 @@ describe('story-mode.js overlay', () => { expect($$('.story-turns option')).toHaveLength(3); expect(app._story.data.page.id).toBe(first); expect($('.story-turns')!.getAttribute('aria-label')).toBe('Turn 1 of 3'); - expect($('.story-sr')!.textContent).toBe(announced); + // The new turn is news, said once, without moving the reader. + expect($('.story-sr')!.textContent).not.toBe(announced); + expect($('.story-sr')!.textContent).toBe('A new turn started: third job. You are on turn 1 of 3.'); }); it('while a question waits, the pencils say so; the chosen answer is marked, not struck', async () => { diff --git a/test/story-mode.test.ts b/test/story-mode.test.ts index 353cdbaa..64ef6bab 100644 --- a/test/story-mode.test.ts +++ b/test/story-mode.test.ts @@ -1083,6 +1083,31 @@ describe('buildStoryPage', () => { expect(loop.caption.story).toBe('Build and test, 2 rounds. A check fails once, then everything passes.'); }); + it('a live loop marks the runs that finished, and the one still going as null', () => { + const page = pageOf([ + prompt('fix'), + ...edit('e1', '/w/a.py', ['+a = 1']), + ...bash('b1', 'pytest -q', 'Run tests', '1 failed, 2 passed in 0.1s'), + ...edit('e2', '/w/a.py', ['-a = 1', '+a = 2']), + tool('b2', 'Bash', { command: 'pytest -q', description: 'Run tests again' }), + ]); + expect(page.panels.find((p) => p.kind === 'loop')?.roundsOk).toEqual([false, null]); + }); + + it("Claude's word about a red run stays on that run's check", () => { + const page = pageOf([ + prompt('test first'), + ...edit('e1', '/w/test_a.py', ['+import a']), + ...bash('b1', 'pytest -q', 'Run tests', 'ERROR test_a.py\n1 error in 0.1s'), + said('Red as expected: collection error, exit code 2.'), + ...bash('b2', 'git commit -qm "test: first"', 'Commit', ''), + end(1000), + ]); + const check = page.panels.find((p) => p.kind === 'check' || p.kind === 'loop')!; + expect(check.balloon?.text).toBe('Red as expected: collection error, exit code 2.'); + expect(page.panels.find((p) => p.kind === 'save')!.balloon).toBeUndefined(); + }); + it('checks that all pass from the start read as building, not fixing', () => { const page = pageOf([ prompt('build it'),