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

+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',