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
+106
-2
@@ -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 () => {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in new issue
Block a user