fix(story-mode): a drawer stays with its turn, focus never falls to the page, live loop marks

From review round 6:

- An open drawer was not tied to its turn: when the newest turn moved on,
  a helper drawer went blank and a report drawer silently became the next
  turn's prompt. The drawer now remembers its turn; when the newest turn
  moves on, the page holds that turn (the picker lists the new one, and
  the status line says so) until the drawer closes, then follows the
  newest again. Picking a turn yourself wins.
- Previous on the second turn (or Next onto the last) disabled the
  focused button and dropped focus to the page body. Focus moves to the
  turn picker first.
- The drawer covered the right half of the bar at desktop widths; it now
  starts under the bar there too. Where it covers the whole page (a
  narrow window, a phone) the page behind it is inert, so Tab no longer
  walks the panels it hides.
- A live loop shows the runs that finished, and the one still going as an
  open circle (`roundsOk` carries `null` for it).
- The diff of a command that also printed something (`cat >> NOTES.md
  <<EOF ... cat calc_stats.py`) shows what it printed, with "Open
  calc_stats.py".
- "Red as expected: collection error." stays on its check, not the save
  after it. Parked on an older turn, a new turn is announced. A new panel
  is sized as it is inserted (no empty first frame). The turn picker is
  44 px on touch screens, and the diff drawer no longer says "Live diff"
  beside its LIVE tag.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 13:42:13 +02:00
1 parent 7f2304725b
commit d4df9b18b2
6 files changed
+227 -17

No files matched your search

+3 -2
View File
@@ -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.
+2 -2
View File
@@ -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 };
+5 -1
View File
@@ -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,
+86 -10
View File
@@ -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',
+106 -2
View File
@@ -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 () => {
+25
View File
@@ -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'),