mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
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:
1 parent
7f2304725b
commit
d4df9b18b2
6 files changed
+227
-17
No files matched your search
+3
-2
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user