From abf71275ab0614d958850e81edb35a60149f40de Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 10 Oct 2026 14:49:52 -0500 Subject: [PATCH] review(53): Dewey rounds 1 to 3 build packet (candidate 2562c05d) Co-Authored-By: Claude Opus 5.5 --- .../dewey/work/queue-53/candidate-files.txt | 16 + agents/dewey/work/queue-53/evidence.md | 348 ++++++++++++++++ agents/dewey/work/queue-53/round2.patch | 386 ++++++++++++++++++ agents/dewey/work/queue-53/round3.patch | 160 ++++++++ 4 files changed, 910 insertions(+) create mode 100644 agents/dewey/work/queue-53/candidate-files.txt create mode 100644 agents/dewey/work/queue-53/evidence.md create mode 100644 agents/dewey/work/queue-53/round2.patch create mode 100644 agents/dewey/work/queue-53/round3.patch diff --git a/agents/dewey/work/queue-53/candidate-files.txt b/agents/dewey/work/queue-53/candidate-files.txt new file mode 100644 index 00000000..52001987 --- /dev/null +++ b/agents/dewey/work/queue-53/candidate-files.txt @@ -0,0 +1,16 @@ +packages/webui/README.md +packages/webui/src/public/app.js +packages/webui/src/public/assets/fonts/jetbrains-mono-400.woff2 +packages/webui/src/public/assets/fonts/jetbrains-mono-500.woff2 +packages/webui/src/public/assets/fonts/jetbrains-mono-700.woff2 +packages/webui/src/public/assets/fonts/jetbrains-mono-OFL.txt +packages/webui/src/public/assets/fonts/jetbrains-mono-sources.txt +packages/webui/src/public/bus.js +packages/webui/src/public/icons.svg +packages/webui/src/public/index.html +packages/webui/src/public/shell.css +packages/webui/src/public/tokens.css +packages/webui/src/serve.mjs +packages/webui/tests/browser.mjs +packages/webui/tests/serve.test.mjs +packages/webui/tests/shell.test.mjs diff --git a/agents/dewey/work/queue-53/evidence.md b/agents/dewey/work/queue-53/evidence.md new file mode 100644 index 00000000..a044134d --- /dev/null +++ b/agents/dewey/work/queue-53/evidence.md @@ -0,0 +1,348 @@ +# Row 53 (#1542): Console tokens and shell restyle + +Dewey, 2026-10-10. Brief: `docs/plans/2026-10-10_design-implementation.md`, +section "Console tokens and shell restyle" (origin/refactor a81915e8), with +`docs/design/IMPLEMENTING.md` sections "Tokens", "Icons and the mark", +"Components", "Strings", "Boundaries" and "Acceptance". Lead decision 81 +(Harbor following the system setting by default, the Relay placeholder mark +stays, no Decision Seen from the web). + +Built on 923957e2; no file under `packages/webui`, `docs/design` or the +test scripts changed between it and the gate base 8a7871ff (only +`scripts/mosaic-task.mjs`, row 56). Candidate: 16 files, uncommitted in the +canonical checkout, listed in `candidate-manifest.sha256` beside this file +(round 1 manifest sha256 `75569953c1a2cd7b7266208d6e6607290335f1bfe0cf94d96ed224e480a1dda8`; +rounds 2 and 3 below; round 3's is current). + +## Round 3 (answers #1542 comments 27163 and 27165) + +Scope from Sage: two fixes, each with a test. 1: a board 403 followed by a +503 or 500 must not bring back the refused scan's rows or waiting card +(Filbert C4, Darkwing P9). 2: a board 403 must clear an open conversation's +history (Darkwing P10). + +Round 3 candidate: the same 16 files, manifest sha256 +`2562c05d4a92f90096c0fe68ef8c2adfa7244fa547f3390155f5bee5cce64cba` +(`candidate-manifest.sha256` beside this file, replacing round 2's +`78c3aef9`). Three files changed from round 2: `README.md`, +`src/public/app.js`, `tests/shell.test.mjs`. The round 2 to round 3 delta is +`round3.patch` beside this file. Applied to the round 2 files, it reproduces +the manifest (16 OK, checked). Base for the gate: 03969219. + +### Fixes + +| Item | Change | Test | +|---|---|---| +| Filbert C4, Darkwing P9: a 403 then a 500 or 503 showed the refused scan's table and waiting card again | `error()` now draws the empty board whenever it holds no data, not only on a refusal. It clears `#waiting`, `#seen` and `#sessions`, sets the three counts to `–`, closes the inspector, and says why in the tree, the footer and `#status`: "the board refused the read" or "the read failed". | Shell browser test: after the refusal, `brd.unavailable()` (503) and a refresh. The banner is `banner err` with "No board data loaded."; `#board-view` shows again; no session row, open or history button, waiting card or seen card; the three counts are `–`; the footer and `#fresh-board` read "Board not read: the read failed", `#status` "Not read: the read failed", the tree "Not read: the read failed.". Screenshot `board-refused-then-failed-400`. | +| Darkwing P10: a 403 with History open left `#conversation` and the old `#conv-log` messages | On a refusal with a conversation open, `error()` empties `#conv-log` and calls `closeConversation()`, which hides the panel and its reply form. | A session with `registered` set; History open; the fixture message "Fixture history line" in `#conv-log` and the reply form shown. After a 403: `#conversation` hidden, no `has-conversation` on ``, `#conv-log` has no child, `#conv-form` not visible, the board hidden and the refusal state shown. Then recovery. | + +Test fixtures in `shell.test.mjs`'s stub board: `one()` adds a third +session that is seen (so the seen card is tested), `registered()` answers +one live session with a conversation, `unavailable()` answers 503, and with +status 200 the stub answers `/api/conversations` and `/api/conversation` +with one fixture page. The new assertions sit in the "shell in a browser" +test, where the board refusal state already lives. + +### Also answered + +| Item | Done | +|---|---| +| Filbert T7: the counts after a refusal | Asserted: `#waiting-count`, `#seen-count` and `#session-count` are `–` after the 403 and after the 503 that follows it. | +| Filbert N7 (my call): the project filter on a refusal | Tested, not changed: a project is picked before the refusal (`aria-pressed` true), and after recovery the "all" filter is the pressed one. | +| README | The refusal line in the command bar section now names the cards, the project filter, the empty board on a later failure and the closed conversation. The shell tests line names the two new cases. | + +### Changes in behaviour beyond the two fixes + +- A failed read before the first scan (no data yet, not a refusal) now draws + the empty board with "Not read: the read failed" in the tree, footer and + `#status`, where round 2 left the skeleton rows, "Loading board…" and + "Loading projects…" under the error banner. This is + the same code path as the C4 fix; the banner was already shown in both. +- `error()` no longer calls `convForm()`. With P10, a refusal closes the + conversation and `convForm()` does nothing without one; a non-refusal + failure with no data never had an open conversation. The `error-noconvform` + mutant survived for that reason and is dropped. + +### Round 3 mutants + +Each applied to a scratch copy of the round 3 tree, then `shell.test.mjs` +run (`~/dewey-scratch/r53b/r3/mutants.py`, outputs in `r3/out/`). 13 of 13 +killed, every one by the "shell in a browser" test. + +| Mutant | Change | Killed at | +|---|---|---| +| c4-no-clear | no-data branch leaves the cards and the table | no rows or cards after the 503 (11, expected 0) | +| c4-keep-sessions | the table kept | same (7) | +| c4-keep-waiting | the waiting cards kept | same (2) | +| c4-keep-seen | the seen cards kept | same (2) | +| c4-refused-only | the empty board only on a refusal (round 2) | footer "refused" after the 503 | +| c4-footer-refused | footer always says "refused" | footer after the 503 | +| c4-status-refused | `#status` always says "refused" | `#status` after the 503 | +| c4-tree-refused | the tree always says "refused" | tree after the 503 | +| error-keeps-counts | the counts left as they were | `#waiting-count` after the 403 | +| error-noinspect | the inspector left open | `#inspector` hidden after the 403 | +| error-keeps-project | a 403 keeps the project filter | "all" filter pressed after recovery | +| p10-no-close | the conversation left open | `#conversation` hidden after the 403 | +| p10-no-clear | the panel closed but the history kept | `#conv-log` empty after the 403 | + +### Round 3 gate + +Worktree at 03969219 plus the 16 round 3 files (`sha256sum -c` of the +manifest: 16 OK), node_modules linked from the checkout, TMPDIR in +scratch, `DOCKER_HOST` pointed at a missing socket, suites sequential, +2026-10-10T19:13:43Z to 19:18:19Z. `core.hooksPath` unset. Script +`~/dewey-scratch/r53b/r3/gate.sh`, outputs `~/dewey-scratch/r53b/r3/gate/out/`. + +| Suite | Result | +|---|---| +| `node --test 'packages/webui/tests/*.test.mjs'` | 27 pass, 0 fail | +| node suites of business, bus, cli, control-board, conversation, discord, ledger, mosaic, queue, runs, seat, tasks | 60, 67, 66, 124, 182, 178, 78, 69, 148, 41, 19, 51 pass; 0 fail in each | +| `node docs/design/tools/build-tokens.mjs --check` | rc 0, "tokens.css is current" | +| test-auth | 15 passed, 0 failed | +| test-conductor | 17 passed, 0 failed | +| test-config | 24 passed, 0 failed | +| test-discord | 66 passed, 0 failed | +| test-extension-package | 18 passed, 0 failed | +| test-foundation | 44 passed, 0 failed | +| test-queue | 27 passed, 0 failed | +| test-release, no Docker | 4 passed, 0 failed (state-machine cases skipped: daemon unavailable) | +| test-task, no Docker | 26 passed, 0 failed (adapter seam, workspace and live cases skipped) | + +Round 2's gate had Docker for test-release and test-task (14 and 98). +Round 3 changes nothing those Docker cases run; Sage's gate rerun covers them. + +### Not in this round + +Darkwing note 3, the refresh-timer focus race in `bus.js` `render()`: it is +in HEAD, so per Sage it goes to row 54 with its own test, named there as a +fix to HEAD behaviour beside the H1 `keep()`/`restore()` fix. + +## Round 2 (answers #1542 comments 27143 and 27148) + +Round 2 candidate: the same 16 files, manifest sha256 +`78c3aef963b7e35500c9f8afa6341057f1a6372ced70915d175e507b5fac8406` +(`candidate-manifest.sha256` beside this file, replacing round 1's +`75569953`). Five files changed from round 1: `README.md`, +`src/public/app.js`, `src/public/bus.js`, `src/public/shell.css`, +`tests/shell.test.mjs`. The round 1 to round 2 delta is +`round2.patch` beside this file. Base for the gate: 2d4d4e7d (nothing under +`packages/webui`, `docs/design` or `scripts` changed since 8a7871ff). + +### Blocking items + +| Item | Change | Test | +|---|---|---| +| Filbert C1, Darkwing 1: a board 403 left the inspector, project tree, counts, footer and `#status` "Scanned" line | `error()` on a 403 drops `data`, `selected` and `project`, closes the inspector, and sets the tree to "Not read: the board refused the read.", the three counts to `–`, the footer to "Board not read: refused" and `#status` to "Not read: the board refused the read". The next good read restores them through `accept()` and `render()`. | Shell browser test opens the inspector and checks the footer "waiting 1 · working 1" and two tree counts before the refusal; after it, the inspector is hidden, `` has no `has-inspector`, the tree has no `.count` or `[data-project]`, the footer and `#fresh-board` read "Board not read: refused", `#status` has no "Scanned". After recovery the footer is back. | +| Filbert C3: the freshness line called a refusal "stale" | `fresh()` with no data: "Board not read: refused", "Board not read: the read failed" or "Board not read yet". | Same test: `#fresh-board` equals "Board not read: refused". | +| Filbert C2, Darkwing 2 and note 6: a bus refusal left the Inbox count, BLOCKING chip and palette rows | `forget()` clears `last` and the Inbox count on any refusal: in the view's read, in the board route's inbox read (was `catch {}`), and in `pkOpen`'s refetch, which then redraws the section list. A refused read anywhere drops every kept bus read, so the pre-refusal rows can't come back as stale on a later failure either (Darkwing's P7). The bus freshness line reads "Bus not read: refused". | Five-states test: `#sections` first reads "Inbox 1 1 blocking"; after the refusal it has no `.count` or `.tag-block`, and Ctrl+K lists only the four views. A second step refuses only `pkOpen`'s own refetch and checks the kept tasks and the count are gone. On the board page a `no-bus-host` refusal of the board route's inbox read drops the count and the palette shows views only. | +| Filbert T1, Darkwing 4 (M01): only 403 was tested as a refusal | none needed | The five-states refusal step runs for `human-required` (403) and `no-bus-host` over kept rows: banner code, 0 rows, "Nothing to show.", "Bus not read: refused", no section counts, then recovery. | +| Darkwing 3: tests with the inspector open | as C1 | as C1 | +| Darkwing 5 (M08): palette labels with markup | none needed | The inbox question is `Push?` and a task title `Bold task`. The palette lists views plus three items, no `img` or `b` in the list, the labels equal the literal text, and `window.errors` stays empty. | +| Sage: the section list below 760px | The deviation is withdrawn. Below 760px `.s1-sections` is one strip, `flex-wrap:nowrap; overflow-x:auto`, items `flex:none`; it scrolls inside itself, so row 54's three sections add no rows. The command bar keeps its four controls on one row. | At 360px and 400px: every section link has one `offsetTop`, the list's computed `overflow-x` is `auto`, no label wraps, and the page has no sideways scroll. | + +### Non-blocking items + +| Item | Done | +|---|---| +| Filbert T2 (`accept()` keeps `failed`) | Test: a failed refresh, then a good one; `#fresh-board` no longer says "stale". | +| Filbert T3 (clipboard-denied toast stays) | Test: after 3 s the error toast's class is still `toast on err`. | +| Filbert T4 (empty states' second line) | Each bus empty state and the trail's is matched whole, both lines, anchored. The board's was already. | +| Filbert T5 (nav wrap untested) | Covered by Sage's strip test. | +| Filbert T6 (Ctrl+Alt+K) | Pinned: Ctrl+Alt+K opens nothing. Only Ctrl+K and Cmd+K open the palette. | +| Filbert N1 (local board time, UTC bus time) | Both are UTC now: "Board scanned 18:01:02 UTC (4s ago)". Test matches `^Board scanned \d\d:\d\d:\d\d UTC`. | +| Filbert N2 (strings counted across files) | The strings test counts each string per file, with the expected count (`No task ${txt(ref)} in this business.` twice in bus.js; "Nothing is waiting on you." once in app.js and once in bus.js). | +| Filbert N3, round 1 pkback | Unchanged: equivalent in Chromium; the listener stays for other browsers. | +| Darkwing note 3, M09 (focus offset) | Test: `#cmdk`'s `outline-offset` is 2px. Table rows keep their inset ring (`-2px`), unchanged and untested. | +| Darkwing note 4 (toast live region `display:none` before the first copy) | The toast is always rendered; while off it is empty and visually hidden (`clip-path: inset(50%)`, 1px box), so it stays in the accessibility tree. Test: before the first copy it is not `display:none`, is visible, is empty and is at most 1px. | +| Darkwing note 7, M16 (tabular numbers) | Test: `font-variant-numeric` is `tabular-nums` on `#fresh` and `#session-count`. | +| Darkwing M07 (toast text escaping) | Not added. The toast text is `Copied: `, the commands come from validated refs, and `toast()` passes the text through `esc()`. | +| Darkwing note 2 (freshness hidden below 760px) | Unchanged. The command bar has no room at 360px, and the stale and refusal banners still state it in the view. Left for row 54 or the design session. | +| Darkwing note 5 (Seen 403 wording, prose in ``) | Unchanged. A Seen 403 now also drops the board data, the same as a read 403; since only control-board's Host/Origin check gives it, the read would be refused too. | + +### Round 2 mutants + +Each applied to a scratch copy of the round 2 webui, then `shell.test.mjs` +run (`mut/tools/mutants.py`, `run.sh`). 20 of 20 killed. + +| Mutant | Change | Killed by | +|---|---|---| +| C1-keep-data | a board 403 keeps `data` | shell browser test | +| C1-keep-inspector | a board 403 keeps `selected` and the inspector | shell browser test | +| C1-keep-footer | a board 403 leaves the footer | shell browser test | +| C3-fresh-wording | "stale" wording on a refusal | shell browser test | +| C2-render-forget | the view's refused read doesn't `forget()` | five-states test | +| C2-pk-forget | `pkOpen`'s refused refetch doesn't `forget()` | five-states test | +| C2-board-forget | the board route's refused inbox read doesn't `forget()` | five-states test | +| C2-forget-keeps-count | `forget()` clears `last` but not the count | five-states test | +| M01-refusal-403-only | `refusal()` only for 403 | five-states test | +| M08-pk-label-raw | palette label set as HTML | five-states test | +| nav-wraps | the round 1 wrapping section list | shell browser test (strip) | +| toast-display-none | the round 1 `display:none` toast | shell browser test | +| M09-offset-0 | focus `outline-offset: 0` | shell browser test | +| M16-no-tabular | no `tabular-nums` | shell browser test | +| N1-local-time | board time in local time | shell browser test | +| N2-one-copy | one copy of the duplicated task string changed | strings test | +| T6-ctrl-alt-k | Ctrl+Alt+K opens the palette | shell browser test | +| T3-err-toast-hides | the error toast closes after 2.6 s | shell browser test | +| T2-stale-sticks | `accept()` keeps `failed` | shell browser test | +| bus-fresh-refused | bus refusal shows the round 1 freshness wording | shell browser test | + +### Round 2 gate + +Worktree at 2d4d4e7d plus the 16 round 2 files (`sha256sum -c` of the +manifest: all OK), node_modules linked from the checkout, TMPDIR in scratch, +suites sequential, 2026-10-10T18:15:17Z to 18:19:16Z. Outputs: +`~/dewey-scratch/r53b/gate/out/`. + +| Suite | Result | +|---|---| +| `node --test 'packages/webui/tests/*.test.mjs'` | 27 pass, 0 fail | +| `node docs/design/tools/build-tokens.mjs --check` | rc 0, "tokens.css is current" | +| test-auth | 15 passed, 0 failed | +| test-conductor | 17 passed, 0 failed | +| test-config | 24 passed, 0 failed | +| test-discord | 66 passed, 0 failed | +| test-extension-package | 18 passed, 0 failed | +| test-foundation | 44 passed, 0 failed | +| test-queue | 27 passed, 0 failed | +| test-release | 14 passed, 0 failed | +| test-task, Docker present | 98 passed, 0 failed | + +The round 1 packet follows unchanged, except where rounds 2 and 3 supersede it +(the nav deviation is withdrawn; the manifest above replaces `75569953`). + +# Round 1 packet + +## What changed + +All in `packages/webui`. No new endpoint, read, write or dependency. + +| File | Change | +|---|---| +| `src/public/tokens.css` | New. Byte copy of `docs/design/tokens.css`. | +| `src/public/icons.svg` | New. Byte copy of `docs/design/icons.svg`: 17 icons and the `mark` symbol. | +| `src/public/shell.css` | New. The components on top of the tokens: command bar, freshness line, section list with icons, dense table with tabular numbers, inspector, status mark (glyph plus word), class chip and BLOCKING chip, copy command, toast, Ctrl+K palette, empty, not found, banners (`err`, `ref`), skeleton rows, `@font-face` for JetBrains Mono, `--r` 6px and `--r-lg` 10px use, focus rings, the 400px layout. Loaded last. | +| `src/public/assets/fonts/jetbrains-mono-{400,500,700}.woff2`, `jetbrains-mono-OFL.txt`, `jetbrains-mono-sources.txt` | JetBrains Mono 2.304 from the official release archive (URL and archive sha256 in the sources file), each file's sha256, and the OFL text from the archive. | +| `src/public/index.html` | Loads `tokens.css` after `shared/app.css` and `shell.css` last; no `data-mode` on ``; the mark from the sprite in the wordmark; the Ctrl+K button, freshness line, System option, banner error element, board refusal empty state, board skeleton, toast and palette ``. | +| `src/public/app.js` | `theme()` sets no property inline: it sets `data-palette`, and `data-mode` unless the mode is System, which removes it. Default mode `system`. Same key `mosaic-console-appearance`. Status mark classes on the state badge. Empty states with "what would appear and where it comes from". Board freshness line. A 403 from the board fails closed (kept rows hidden, `banner ref`); any other failure keeps the rows with their scan time. | +| `src/public/bus.js` | Class and BLOCKING chips, section icons, empty states, the skeleton loading view, not found that names the address and points to the Board and Ctrl K, freshness for bus pages, a refusal (403, `not-configured`, `no-bus-host`) never showing kept rows, the toast on copy, the Ctrl+K palette, ↑/↓ between task links. | +| `src/serve.mjs` | The files map only: `tokens.css`, `shell.css`, `icons.svg`, the three mono fonts. | +| `tests/shell.test.mjs` | New: copies, font digests, strings and the write boundary; two browser tests (below). | +| `tests/serve.test.mjs` | The new static files are served with their types. | +| `tests/browser.mjs` | `ArrowUp` and `k` key codes; no text insert when a modifier is held (so Ctrl+K doesn't type `k`). | +| `README.md` | "Shell (row 53, #1542)" section; the stale/refusal rule; the verify lines. | + +## Acceptance against the brief + +| Brief line | Where it is checked | +|---|---| +| `tokens.css` served, `theme()` no inline properties | serve.test (type); shell browser test: `` has no `style` attribute; inline mutant fails | +| `data-palette` and `data-mode` stay; System removes `data-mode`; key kept; Harbor + System default | shell browser test: fresh load has no `data-mode`, palette `harbor`, select `system`; dark and light OS give dark and light pages; picking dark sets it and stores it under `mosaic-console-appearance`; System removes it again. Mode mutant fails. | +| `--r` 6px, `--r-lg` 10px, type scale, tabular numbers | computed `--r` and `--r-lg` asserted; type scale and `font-variant-numeric: tabular-nums` in shell.css | +| JetBrains Mono 400/500/700 with sha256 and OFL | static test: shipped files equal the listed set, each digest matches, OFL heading present, `@font-face` for each weight; `document.fonts.check` in the browser | +| Icon sprite; mark in one component; Relay placeholder | `icons.svg` copy test; the mark is drawn once, in the wordmark, from `#mark` | +| Components | each rendered in the browser test and its screenshots: command bar, section list (4 icons asserted), dense table, inspector (opened by keyboard), status mark (`◐` glyph asserted), class chip (`code` action, uppercase BLOCKING asserted), copy command and toast (success and clipboard-denied), palette, not found | +| Five states on Board, Inbox, Tasks, Agents, Trail | second browser test walks each view through loading (a held read shows the skeleton), normal, error over kept rows (same row count under `banner err s1-stale`), refusal (`banner ref`, code in mono, no rows, "Nothing to show.") and back. Empty is seeded per view in the first test. | +| Browser: no script errors, no sideways scroll at 400px, keyboard ↑/↓, Enter, Esc returning focus, visible focus | `window.errors` empty at the end of both tests; `scrollWidth <= clientWidth` after every state; board ↑/↓/Enter/Esc, task-link ↑/↓, palette ↑/↓/Enter/Esc with focus back on the opener; outline ≥2px on `#cmdk`, a section link and Refresh | +| String grep test | static test: the kept strings from bus.js, app.js and index.html, no Resolve/Decide/Approve button, no non-GET method in bus.js, no script in the sprite | +| No writes | both browser tests: every request the page made is a GET | + +## Tests + +Outputs in `~/dewey-scratch/r53/out/`. + +| Run | Result | +|---|---| +| `node --test 'tests/*.test.mjs'` in `packages/webui`, with `WEBUI_EVIDENCE` (`full-4.txt`) | 27 pass, 0 fail | +| `shell.test.mjs` alone, three repeats after the last test change (`shell-rep-{1,2,3}.txt`) | 5 pass each | +| Five-states test alone (`states-1.txt`) | pass | + +Screenshots (`out/evidence-4/`, 400px unless named): `shell-board-empty`, +`-board-stale`, `-board-refused`, `-bus-empty-{inbox,tasks,agents,trail}`, +`-bus-stale`, `-bus-refused`, `-bus-inbox`, `-palette`, `-toast`, +`-bus-tasks-1440`, `-board-1440`, and `states-{board,inbox,tasks,agents,trail}-{loading,normal,stale,refused}` +(board: loading and normal). I looked at each. + +## Mutations + +Each applied to a scratch copy of the candidate, then `shell.test.mjs` run +(`mutbin/run.sh`, outputs `out/mut-*.txt`). + +| Mutant | Result | +|---|---| +| tokens: a comment appended to the `src/public/tokens.css` copy | fails: "tokens.css differs from docs/design/tokens.css" | +| inline: `theme()` sets one property inline again | fails: `` style is `--canvas: #fff;` | +| mode: System sets `data-mode="light"` instead of removing it | fails: `true !== false` | +| refusal: drop `if (refusal(err)) throw err` (refusal shows kept rows) | fails: no `.banner.ref` | +| focus: `:focus-visible{outline:none}` appended to shell.css | fails: `#cmdk focus ring` | +| foot: the side column's top and height rule removed (760px and up) | fails: column bottom 930 against 900 | +| post: `method: 'POST'` in `fetchRead` | fails: static check and the GET-only browser check | +| loading: no loading view on navigation | fails: no `#bus-view .skel-rows[aria-busy=true]` (five-states test) | +| skel: no board skeleton | fails: no `#sessions .skel-rows[aria-busy=true]` (five-states test) | +| pkback: drop the `b.focus()` in the palette's close listener | survives: Chromium's native `` close puts focus back on the opener, so the listener is redundant there and the mutant is equivalent in this browser. Kept for browsers that don't. | + +## Gate + +Worktree at 8a7871ff plus the 16 candidate files (`sha256sum -c` of the +manifest: all OK), node_modules linked from the checkout, TMPDIR in scratch, +suites sequential, 2026-10-10T17:24:05Z to 17:28:05Z. Outputs: +`~/dewey-scratch/r53/gate/out/`. + +| Suite | Result | +|---|---| +| `node --test 'packages/webui/tests/*.test.mjs'` | 27 pass, 0 fail | +| `node docs/design/tools/build-tokens.mjs --check` | rc 0, "tokens.css is current" | +| test-auth | 15 passed, 0 failed | +| test-conductor | 17 passed, 0 failed | +| test-config | 24 passed, 0 failed | +| test-discord | 66 passed, 0 failed | +| test-extension-package | 18 passed, 0 failed | +| test-foundation | 44 passed, 0 failed | +| test-queue | 27 passed, 0 failed (verify and render --check skip outside the canonical root) | +| test-release | 14 passed, 0 failed | +| test-task, Docker present | 98 passed, 0 failed | + +An earlier gate run on 3265383f with the manifest before the five-states +test was also green (26 webui tests); it is superseded by this one. + +## Decisions and deviations from the design + +- **Bus refusal fails closed.** Before this row a 403 from the bus still + showed kept rows under a stale banner. Now a refusal (403, + `not-configured`, `no-bus-host`) shows only the failure: "Console shows + nothing rather than a guess". Other failures keep the rows, labelled with + their read time. The board does the same for its 403. +- **Footer is a sticky status bar from 760px.** With the side column + sticky under the command bar, a page taller than the viewport slid the + column under the bar at 1440×900. The footer now sits at the bottom, + the column fills the space between, and the browser test checks the + edges on a short page and on a long one scrolled to the end. +- **Nav wraps below 760px** (withdrawn in round 2: the section list is + one strip that scrolls inside itself, per IMPLEMENTING.md) instead of + scrolling sideways, and the command bar keeps Palette, Appearance, Refresh and Pause on one row down + to 360px. +- **Manrope 800 renders as 700**: only 400 to 700 are shipped (row 53 + adds no Manrope weight). Visible on the large headings only. +- **No business pill** in the command bar: there is one business and no + business read yet; the Business view is row 54. +- **Not found keeps the existing string** "No page at this address." and + adds the address, a Board link and Ctrl K. +- **`#copy-status` lost `role="status"`.** The toast is the live region + now; both announcing the same line would read it twice. The status line + still shows the text. +- **Class chip colours** use the design's four classes; an unknown class + gets the plain chip. + +## Not done here (follow-ups) + +- Row 54 (#1543) adds Queue, Business and Settings and will reuse these + components; both rows change `app.js`, so 54 starts after 53 lands. +- The pkback mutant above: a test in a browser without native dialog + focus restore would need another engine; none is in the repo. + +## Boundaries kept + +Commits: none (Sage commits). No change outside `packages/webui` and this +directory. `docs/design/` read only. The Q14 frozen paths untouched. No +request to the tracker from node. No new dependency. Scratch worktrees +removed after use; `core.hooksPath` unset. diff --git a/agents/dewey/work/queue-53/round2.patch b/agents/dewey/work/queue-53/round2.patch new file mode 100644 index 00000000..911bb5e3 --- /dev/null +++ b/agents/dewey/work/queue-53/round2.patch @@ -0,0 +1,386 @@ +--- a/packages/webui/README.md ++++ b/packages/webui/README.md +@@ -91,7 +91,9 @@ + open sections and the copy status survive a refresh. + - A failed read keeps the last good answer for that read and says it is old, + with the time it was read. A refusal (403, `not-configured` or `no-bus-host`) +- never shows kept rows (row 53). A page never read, or refused, shows the failure instead, ++ never shows kept rows (row 53), and it drops every kept bus read: the Inbox ++ count and the palette's decisions and tasks go too, until a read succeeds. ++ A page never read, or refused, shows the failure instead, + titled "Bus refused the read" (403: the bus refused the Console's read, for + example `human-required` from a Console started inside an agent run), "No bus to read" (no system + config, or no bus host and no `--business`) or "The read failed". +@@ -120,12 +122,16 @@ + and tasks; arrows move, Enter opens, Esc closes and returns focus. Arrow + keys also move between task links in the bus tables. + - Copy reports in a toast. If the clipboard is unavailable, the toast stays +- until dismissed and the command is selected for Ctrl+C. +-- The command bar shows when the board was scanned and the bus read, and +- says when the last attempt failed. A board refusal hides the board rather +- than showing the last scan. ++ until dismissed and the command is selected for Ctrl+C. When no toast shows, ++ the toast is an empty live region, visually hidden but still in the ++ accessibility tree, so the first copy is announced. ++- The command bar shows, in UTC, when the board was scanned and the bus read, ++ and says when the last attempt failed. A board refusal drops the last scan ++ and everything drawn from it: the table, the inspector, the project tree, ++ the counts, the footer and the scan line all say the board was not read. + - From 760px the footer is a one-line status bar and the side column fits +- between it and the command bar. Below 760px the section list wraps. ++ between it and the command bar. Below 760px the section list is one strip ++ that scrolls inside itself. + + ## Data and boundaries + +@@ -204,11 +210,15 @@ + + Shell tests (`shell.test.mjs`) check the copies, the font hashes and the kept + strings, and in the browser at 400px: System mode under an emulated dark and +-light OS, the empty, stale and refused states of the board and the bus views, +-not found, the palette, the toast, keyboard and visible focus, no sideways ++light OS, the empty, stale and refused states of the board and the bus views ++(a board refusal with the inspector open), not found, the palette, the toast, ++keyboard and visible focus, the section strip at 360 and 400px, no sideways + scroll, no script error and no request other than GET. A second test walks + the Board, Inbox, Tasks, Agents and a decision trail through loading (a held +-read), normal, a failed read over kept rows, and a refusal. ++read), normal, a failed read over kept rows, and a refusal by the bus ++(`human-required`) and for want of one (`no-bus-host`). It then checks that a ++refusal empties the palette and the Inbox count, from a view, from the ++palette's own reads and from the board page, and that palette labels are text. + + No root CI workflow is configured for this package. These local tests are not a + claim of CI, deployment, live-seat delivery or user acceptance. +--- a/packages/webui/src/public/app.js ++++ b/packages/webui/src/public/app.js +@@ -282,8 +282,8 @@ + // Freshness line in the command bar: when the board last scanned, and warning words when the last refresh failed. + function fresh() { + const at = Date.parse(data?.generatedAt), ago = Number.isFinite(at) ? age(Math.max(0, Math.round((Date.now() - at) / 1000))) : null; +- if (!data) { $('fresh-board').innerHTML = failed ? 'Board not read: the read failed' : 'Board not read yet'; return; } +- const scan = `Board scanned ${esc(Number.isFinite(at) ? new Date(at).toLocaleTimeString([], { hour12: false }) : 'unknown')}${ago ? ` (${ago} ago)` : ''}`; ++ if (!data) { $('fresh-board').innerHTML = failed === 'refused' ? 'Board not read: refused' : failed ? 'Board not read: the read failed' : 'Board not read yet'; return; } ++ const scan = `Board scanned ${esc(Number.isFinite(at) ? `${new Date(at).toISOString().slice(11, 19)} UTC` : 'unknown')}${ago ? ` (${ago} ago)` : ''}`; + $('fresh-board').innerHTML = failed ? `${scan} · stale: the last refresh failed` : scan; + } + setInterval(() => { if (data) fresh(); }, 5000); +@@ -300,10 +300,20 @@ + for (const id of awaiting.keys()) { const r = row(id); if (r && !pending(r)) awaiting.delete(id); } + render(); + } +- // A refusal (403) fails closed: the last data is hidden, not shown as current. Any other failure keeps the last data with its scan time. ++ // A refusal (403) fails closed: the last data is dropped, not kept or shown as current, and so is ++ // everything drawn from it: the inspector, the project tree, the counts and the footer. Any other ++ // failure keeps the last data with its scan time. + function error(err) { +- failed = true; $('error').hidden = false; + const refused = err.status === 403; ++ failed = refused ? 'refused' : true; $('error').hidden = false; ++ if (refused) { ++ data = null; selected = null; project = null; ++ $('projects').innerHTML = '

Not read: the board refused the read.

'; ++ for (const id of ['waiting-count', 'seen-count', 'session-count']) $(id).textContent = '–'; ++ $('footer').textContent = 'Board not read: refused'; ++ $('status').textContent = 'Not read: the board refused the read'; ++ inspect(); convForm(); ++ } + $('error').className = refused ? 'banner ref' : 'banner err'; + $('board-refused').hidden = !refused; document.body.classList.toggle('board-refused', refused); + $('error').innerHTML = refused +--- a/packages/webui/src/public/bus.js ++++ b/packages/webui/src/public/bus.js +@@ -369,12 +369,14 @@ + const failTitle = err => err.status === 403 ? 'Bus refused the read' : ['not-configured', 'no-bus-host'].includes(err.code) ? 'No bus to read' : 'The read failed'; + // A refusal fails closed: no kept data, the code in mono. Any other failure keeps the last read, labelled with its time. + const refusal = err => err.status === 403 || ['not-configured', 'no-bus-host'].includes(err.code); ++ // After a refusal nothing read before is shown anywhere: not in a view, the palette or the Inbox count. ++ function forget() { last.clear(); inboxCount = null; } + const failedView = err => `${head(failTitle(err))}${empty('Nothing to show.', refusal(err) ? 'The view stays empty until the problem above is fixed.' : 'Read again, or come back when the bus answers.')}`; + const staleBanner = err => ``; + // Freshness line in the command bar for bus pages. + const stamp = iso => { const t = Date.parse(iso); return Number.isFinite(t) ? `${new Date(t).toISOString().slice(11, 19)} UTC` : 'unknown'; }; + function freshBus(state) { +- $('fresh-bus').innerHTML = state === 'failed' ? 'Bus not read: the read failed' ++ $('fresh-bus').innerHTML = state === 'refused' ? 'Bus not read: refused' : state === 'failed' ? 'Bus not read: the read failed' + : `Bus read ${esc(stamp(readAt))}${readAt ? ` (${esc(ago(readAt))})` : ''}${state === 'stale' ? ' · stale: the last read failed' : ''}`; + } + +@@ -420,7 +422,8 @@ + if (board) { + // The board page keeps its own title; the Inbox count still follows the bus. + if (shown !== null) { shown = null; $('bus-view').innerHTML = ''; document.title = 'Mosaic Console'; if (navigated) $('main').focus(); } +- try { const r = await fetchRead('inbox'); last.set('inbox', r); if (g === gen) { countInbox(r.rows); sections(path); } } catch {} ++ try { const r = await fetchRead('inbox'); last.set('inbox', r); if (g === gen) { countInbox(r.rows); sections(path); } } ++ catch (err) { if (refusal(err)) { forget(); if (g === gen) sections(path); } } + if (g === gen) schedule(); + return; + } +@@ -431,7 +434,8 @@ + catch (err) { + const kept = reader(false); + try { if (refusal(err)) throw err; html = await view(kept, path, new URLSearchParams(query)); readAt = kept.oldest(); html = staleBanner(err) + html; state = 'stale'; } +- catch { html = failedView(err); state = 'failed'; } ++ catch { html = failedView(err); state = refusal(err) ? 'refused' : 'failed'; } ++ if (refusal(err)) forget(); + } + if (g !== gen) return; + freshBus(state); +@@ -506,7 +510,10 @@ + if (d.open) return; + pkBack = document.activeElement; pkAt = 0; $('cmdk-input').value = ''; + pkItems(); pkFill(); d.showModal(); $('cmdk-input').focus(); +- Promise.allSettled(['inbox', 'tasks'].map(async v => last.set(v, await fetchRead(v)))).then(() => { if (d.open) { pkItems(); pkFill(); } }); ++ Promise.allSettled(['inbox', 'tasks'].map(async v => last.set(v, await fetchRead(v)))).then(rs => { ++ if (rs.some(r => r.status === 'rejected' && refusal(r.reason))) { forget(); sections(routeOf().split('?')[0]); } ++ if (d.open) { pkItems(); pkFill(); } ++ }); + } + function pkGo(href) { + pkBack = null; $('cmdk-dialog').close(); +--- a/packages/webui/src/public/shell.css ++++ b/packages/webui/src/public/shell.css +@@ -101,8 +101,9 @@ + body.on-bus #board-refused{display:none} + + /* Toast: one line, polite, bottom centre. */ +-.toast{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);display:none;align-items:center;gap:10px;background:var(--strong);color:var(--surface);padding:6px 8px 6px 14px;border-radius:var(--r);font-size:13px;z-index:60;max-width:calc(100% - 24px);box-shadow:var(--shadow)} +-.toast.on{display:flex} ++.toast{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);display:flex;align-items:center;gap:10px;background:var(--strong);color:var(--surface);padding:6px 8px 6px 14px;border-radius:var(--r);font-size:13px;z-index:60;max-width:calc(100% - 24px);box-shadow:var(--shadow)} ++/* Off, the toast stays in the accessibility tree, empty and visually hidden, so the first copy is announced. */ ++.toast:not(.on){width:1px;height:1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap;box-shadow:none} + .toast span{min-width:0;overflow-wrap:anywhere} + .toast.err{background:var(--danger)} + .toast button{font:inherit;font-weight:600;border:1px solid currentColor;background:none;color:inherit;border-radius:4px;padding:1px 8px;cursor:pointer} +@@ -164,8 +165,9 @@ + .cmd-right{margin-left:0;flex:1 1 100%;flex-wrap:nowrap;min-width:0} + .cmd-right label{flex:1 1 0;min-width:0} + .cmd-right .btn{flex:none} +- /* The section list wraps rather than scrolling, so a blocking count never hides a section. */ +- .s1-sections{display:flex;flex-wrap:wrap;overflow-x:visible;flex-basis:100%;gap:2px;padding-bottom:2px} ++ /* The section list is one strip that scrolls inside itself (IMPLEMENTING.md), so more sections never add rows. */ ++ .s1-sections{display:flex;flex-wrap:nowrap;overflow-x:auto;flex-basis:100%;min-width:0;max-width:100%;gap:2px;padding-bottom:2px;scrollbar-width:thin} ++ .s1-sections>li{flex:none} + .s1-section{border-radius:var(--r);padding:5px 8px;white-space:nowrap;flex:none} + .inspector{inset:0;top:0;width:100%;z-index:40;box-shadow:none} + } +--- a/packages/webui/tests/shell.test.mjs ++++ b/packages/webui/tests/shell.test.mjs +@@ -35,17 +35,21 @@ + }); + + test('shipped strings are unchanged, and the web keeps its write boundary', () => { +- // Markup in the source (, ) is not part of the string. ++ // Markup in the source (, ) is not part of the string. Each string is counted in the ++ // file that ships it, so dropping one of two copies fails too. + const src = ['app.js', 'bus.js', 'index.html'].map(f => read(f).toString()).join('\n'); +- const plain = src.replace(/<\/?(code|b)>/g, ''); +- for (const s of [ +- 'Copied: ${text}', +- 'The clipboard is unavailable. The command is selected; press Ctrl+C to copy it.', +- 'Decisions are answered in a terminal with mosaic decide; Console only shows them.', +- 'No task ${txt(ref)} in this business.', 'No decision with id ${txt(id)}.', '${txt(b)} is not a decision id.', '${txt(b)} is not a task reference.', +- 'No page at this address.', 'Nothing is waiting on you.', 'No tasks in this business yet.', 'Tasks appear after the PM creates them or the poller reads them from Vikunja.', +- 'No open tasks.', 'No role is held right now.', 'Nothing of this kind in the trail.', 'Console shows nothing rather than a guess. Nothing was written.', +- ]) assert.ok(plain.includes(s), s); ++ const plain = Object.fromEntries(['app.js', 'bus.js'].map(f => [f, read(f).toString().replace(/<\/?(code|b)>/g, '')])); ++ for (const [file, s, n = 1] of [ ++ ['bus.js', 'Copied: ${text}'], ++ ['bus.js', 'The clipboard is unavailable. The command is selected; press Ctrl+C to copy it.'], ++ ['bus.js', 'Decisions are answered in a terminal with mosaic decide; Console only shows them.'], ++ ['bus.js', 'No task ${txt(ref)} in this business.', 2], ['bus.js', 'No decision with id ${txt(id)}.'], ++ ['bus.js', '${txt(b)} is not a decision id.'], ['bus.js', '${txt(b)} is not a task reference.'], ++ ['bus.js', 'No page at this address.'], ['app.js', 'Nothing is waiting on you.'], ['bus.js', 'Nothing is waiting on you.'], ++ ['bus.js', 'No tasks in this business yet.'], ['bus.js', 'Tasks appear after the PM creates them or the poller reads them from Vikunja.'], ++ ['bus.js', 'No open tasks.'], ['bus.js', 'No role is held right now.'], ['bus.js', 'Nothing of this kind in the trail.'], ++ ['bus.js', 'Console shows nothing rather than a guess. Nothing was written.'], ++ ]) assert.equal(plain[file].split(s).length - 1, n, `${file}: ${s}`); + // No Resolve control and no bus write: the only POSTs are the board's Seen and reply. + assert.doesNotMatch(src, /]*>[^<]*(Resolve|Decide|Approve)/i); + assert.doesNotMatch(read('bus.js').toString(), /method:\s*['"](POST|PUT|PATCH|DELETE)/); +@@ -121,7 +125,7 @@ + // Board, empty: what would appear and where it comes from. + assert.match(await text('#sessions .empty'), /No sessions match these filters\.Sessions appear here when the board scan finds/); + assert.match(await text('#waiting .empty'), /Nothing is waiting on you\./); +- assert.match(await text('#fresh-board'), /^Board scanned \d\d:\d\d:\d\d/); ++ assert.match(await text('#fresh-board'), /^Board scanned \d\d:\d\d:\d\d UTC/); + await flat(); await shot('board-empty-400'); + + // Board, a failed read over kept data: the data stays, labelled with its scan time, and the bar says stale. +@@ -135,16 +139,30 @@ + assert.match(await text('#fresh-board'), /stale: the last refresh failed/); + await flat(); await shot('board-stale-400'); + +- // Board, a refusal: fails closed, the kept rows are hidden, not shown as current. ++ // Board, a refusal: fails closed. The kept rows are hidden, and so is everything drawn from ++ // them: the open inspector, the project tree's counts and the footer. ++ await b.evaluate('document.querySelector("table.sessions [data-open]").click()'); await wait('!document.querySelector("#inspector").hidden'); ++ assert.equal(await text('#footer'), 'waiting 1 · working 1'); ++ assert.equal(await b.evaluate('document.querySelectorAll("#projects .count").length'), 2); + brd.refuse(); await refresh(); + assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner ref'); + assert.match(await text('#error'), /The board refused the read\. refused: board-refused/); + assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none'); + assert.equal(await b.evaluate('document.querySelector("#board-refused").hidden'), false); ++ assert.equal(await b.evaluate('document.querySelector("#inspector").hidden'), true); ++ assert.equal(await b.evaluate('document.body.classList.contains("has-inspector")'), false); ++ assert.equal(await b.evaluate('document.querySelectorAll("#projects .count, #projects [data-project]").length'), 0); ++ assert.equal(await text('#footer'), 'Board not read: refused'); ++ assert.equal(await text('#fresh-board'), 'Board not read: refused'); ++ assert.doesNotMatch(await text('#status'), /Scanned/); + await flat(); await shot('board-refused-400'); + brd.one(); await refresh(); + assert.equal(await b.evaluate('document.querySelector("#error").hidden'), true); + assert.notEqual(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none'); ++ assert.equal(await text('#footer'), 'waiting 1 · working 1'); ++ // A good read after a failed one drops the stale words. ++ brd.fail(); await refresh(); assert.match(await text('#fresh-board'), /stale/); ++ brd.one(); await refresh(); assert.match(await text('#fresh-board'), /^Board scanned \d\d:\d\d:\d\d UTC/); assert.doesNotMatch(await text('#fresh-board'), /stale/); + + // Keyboard on the board: down moves between rows, Enter opens, Esc closes and returns focus. + await b.evaluate('document.querySelector("table.sessions [data-open]").focus()'); +@@ -159,12 +177,16 @@ + await b.evaluate('document.querySelector(".skip").focus();document.querySelector(".skip").blur()'); + for (const sel of ['#cmdk', '#sections a', '#refresh']) { + await b.evaluate(`document.querySelector(${JSON.stringify(sel)}).focus()`); +- const ring = await b.evaluate(`(()=>{const c=getComputedStyle(document.activeElement);return [c.outlineStyle, parseFloat(c.outlineWidth)]})()`); +- assert.notEqual(ring[0], 'none', `${sel} focus ring`); assert.ok(ring[1] >= 2, `${sel} outline ${ring[1]}px`); ++ const ring = await b.evaluate(`(()=>{const c=getComputedStyle(document.activeElement);return [c.outlineStyle, parseFloat(c.outlineWidth), parseFloat(c.outlineOffset)]})()`); ++ assert.notEqual(ring[0], 'none', `${sel} focus ring`); assert.ok(ring[1] >= 2, `${sel} outline ${ring[1]}px`); assert.equal(ring[2], 2, `${sel} outline offset`); + } + + // Bus pages, empty: each says what would appear and where it comes from. +- for (const [hash, h1, words] of [['#/inbox', 'Inbox', /Nothing is waiting on you\./], ['#/tasks', 'Tasks', /No tasks in this business yet\./], ['#/agents', 'Agents', /No role is held right now\./]]) { ++ for (const [hash, h1, words] of [ ++ ['#/inbox', 'Inbox', /^Nothing is waiting on you\.A decision shows here when an agent raises one routed to you through the bus\.$/], ++ ['#/tasks', 'Tasks', /^No tasks in this business yet\.Tasks appear after the PM creates them or the poller reads them from Vikunja\.$/], ++ ['#/agents', 'Agents', /^No role is held right now\.A role shows here while a run holds its claim on the bus\.$/], ++ ]) { + await go(hash, h1); + assert.match(await text('#bus-view .empty'), words, hash); + assert.match(await text('#fresh-bus'), /^Bus read \d\d:\d\d:\d\d UTC/, hash); +@@ -172,11 +194,23 @@ + } + assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#fresh-board")).display'), 'none'); + assert.equal(await b.evaluate('document.querySelectorAll("#sections svg use").length'), 4); ++ for (const sel of ['#fresh', '#session-count']) assert.equal(await b.evaluate(`getComputedStyle(document.querySelector(${JSON.stringify(sel)})).fontVariantNumeric`), 'tabular-nums', sel); ++ // Before any copy the toast is an empty live region still in the accessibility tree, out of sight. ++ assert.deepEqual(await b.evaluate('(()=>{const t=document.querySelector("#toast"),c=getComputedStyle(t),r=t.getBoundingClientRect();return [c.display!=="none",c.visibility,t.textContent,r.width<=1&&r.height<=1]})()'), [true, 'visible', '', true]); ++ // Below 760px the section list is one strip that scrolls inside itself: every link on one ++ // line, no label wrapped, and still no sideways scroll on the page. 360px, then back to 400px. ++ for (const w of [360, 400]) { ++ await b.viewport(w, 900); ++ assert.equal(await b.evaluate('new Set([...document.querySelectorAll("#sections a")].map(a=>a.offsetTop)).size'), 1, `one row at ${w}px`); ++ assert.match(await b.evaluate('getComputedStyle(document.querySelector("#sections")).overflowX'), /^(auto|scroll)$/, `strip scrolls at ${w}px`); ++ assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#sections a")].filter(a=>a.getClientRects().length>1||a.scrollWidth>a.clientWidth+1).map(a=>a.textContent)'), [], `no wrapped label at ${w}px`); ++ await flat(); ++ } + + // Bus, a failed read over kept data: the kept rows stay under a stale banner; the bar says stale. + s.rows.tasks = [{ task_ref: 'vikunja:32/7', fields: { title: 'Kept task', bucket: 2, done: false, assignees: [] }, source: 'self' }, { task_ref: 'vikunja:32/8', fields: { title: 'Second task', bucket: 2, done: false, assignees: [] }, source: 'self' }]; + await go('#/trail/task/vikunja%3A32%2F7', 'Trail for task #7'); +- assert.match(await text('#bus-view .empty'), /Nothing of this kind in the trail\./); ++ assert.match(await text('#bus-view .empty'), /^Nothing of this kind in the trail\.Every step the stack takes on this subject writes a trail row\.$/); + await flat(); await shot('bus-empty-trail-400'); + await go('#/tasks', 'Tasks'); + assert.equal(await b.evaluate('document.querySelectorAll("#bus-view td.s1-title a").length'), 2); +@@ -197,7 +231,7 @@ + assert.equal(await b.evaluate('document.querySelector("#bus-view .banner.ref code").textContent'), 'human-required'); + assert.equal(await b.evaluate('document.querySelectorAll("#bus-view td.s1-title a").length'), 0); + assert.match(await text('#bus-view .empty'), /Nothing to show\./); +- assert.match(await text('#fresh-bus'), /not read: the read failed/); ++ assert.equal(await text('#fresh-bus'), 'Bus not read: refused'); + await flat(); await shot('bus-refused-400'); + s.fail(null); + +@@ -209,6 +243,8 @@ + // Ctrl+K: opens on the views and the kept tasks, filters, Enter goes, Esc restores focus. + await go('#/inbox', 'Inbox'); + await b.evaluate('document.querySelector("#refresh").focus()'); ++ await b.key('k', 'KeyK', 3); await new Promise(r => setTimeout(r, 200)); ++ assert.equal(await b.evaluate('document.querySelector("#cmdk-dialog").open'), false, 'Ctrl+Alt+K opens nothing'); + await b.key('k', 'KeyK', 2); await wait('document.querySelector("#cmdk-dialog").open'); + assert.equal(await b.evaluate('document.activeElement.id'), 'cmdk-input'); + await wait('[...document.querySelectorAll("#cmdk-list [data-href]")].some(e=>e.dataset.href==="#/tasks/vikunja%3A32%2F7")'); +@@ -251,6 +287,8 @@ + await wait('document.querySelector("#toast").classList.contains("err")'); + assert.equal(await text('#toast span'), 'The clipboard is unavailable. The command is selected; press Ctrl+C to copy it.'); + assert.equal(await b.evaluate('getSelection().toString()'), `mosaic decide ${'d'.repeat(32)} yes`); ++ await new Promise(r => setTimeout(r, 3000)); ++ assert.equal(await b.evaluate('document.querySelector("#toast").className'), 'toast on err', 'the error toast stays until dismissed'); + await b.evaluate('document.querySelector("#toast [data-dismiss]").click()'); + assert.equal(await b.evaluate('document.querySelector("#toast").classList.contains("on")'), false); + +@@ -293,6 +331,7 @@ + const count = sel => b.evaluate(`document.querySelectorAll(${JSON.stringify(sel)}).length`); + const flat = async () => assert.equal(await b.evaluate('document.documentElement.scrollWidth<=document.documentElement.clientWidth'), true, 'sideways scroll at 400px'); + const refresh = async () => { await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled'); }; ++ const go = async (hash, h1) => { await b.evaluate(`location.hash=${JSON.stringify(hash)}`); await wait(`document.querySelector("#bus-view h1")?.textContent.startsWith(${JSON.stringify(h1)}) && !document.querySelector("#bus-view [aria-busy]")`); }; + try { + await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'window.errors=[];addEventListener("error",e=>errors.push(e.message));addEventListener("unhandledrejection",e=>errors.push(String(e.reason)));window.requests=[];const f=window.fetch;window.fetch=(u,o={})=>{requests.push([(o.method||"GET").toUpperCase(),String(u)]);return f(u,o)};' }); + await b.viewport(400, 900); +@@ -332,17 +371,55 @@ + assert.equal(await count(rows), n, `${hash} keeps its rows`); + assert.match(await text('#fresh-bus'), /stale: the last read failed/, hash); + await flat(); await shot(`${name}-stale-400`); +- // A refusal: no kept rows, the code shown, nothing current. +- s.fail('human-required'); await refresh(); +- await wait('!!document.querySelector("#bus-view .banner.ref")'); +- assert.equal(await text('#bus-view .banner.ref code'), 'human-required', hash); +- assert.equal(await count(rows), 0, `${hash} hides kept rows on a refusal`); +- assert.match(await text('#bus-view .empty'), /Nothing to show\./, hash); +- await flat(); await shot(`${name}-refused-400`); +- s.fail(null); await refresh(); +- await wait(`!document.querySelector("#bus-view .banner") && document.querySelectorAll(${JSON.stringify(rows)}).length===${n}`); ++ // A refusal, by the bus (403) or for want of one (no-bus-host): no kept rows, the code ++ // shown, nothing current, and no count in the section list. ++ for (const code of ['human-required', 'no-bus-host']) { ++ s.fail(code); await refresh(); ++ await wait('!!document.querySelector("#bus-view .banner.ref")'); ++ assert.equal(await text('#bus-view .banner.ref code'), code, hash); ++ assert.equal(await count(rows), 0, `${hash} hides kept rows on a ${code} refusal`); ++ assert.match(await text('#bus-view .empty'), /Nothing to show\./, hash); ++ assert.equal(await text('#fresh-bus'), 'Bus not read: refused', hash); ++ assert.equal(await count('#sections .count, #sections .tag-block'), 0, `${hash} drops the Inbox count on a ${code} refusal`); ++ if (code === 'human-required') { await flat(); await shot(`${name}-refused-400`); } ++ s.fail(null); await refresh(); ++ await wait(`!document.querySelector("#bus-view .banner") && document.querySelectorAll(${JSON.stringify(rows)}).length===${n}`); ++ } + } + ++ // After a refusal the palette lists only the views: no kept decision or task. ++ const views = ['#/', '#/inbox', '#/tasks', '#/agents']; ++ const palette = '[...document.querySelectorAll("#cmdk-list [data-href]")].map(e=>e.dataset.href)'; ++ await b.evaluate('location.hash="#/inbox"'); await wait('document.querySelectorAll("#sections .count").length===1'); ++ assert.match(await text('#sections'), /Inbox 1 1 blocking/); ++ s.fail('human-required'); await refresh(); await wait('!!document.querySelector("#bus-view .banner.ref")'); ++ await b.key('k', 'KeyK', 2); await wait('document.querySelector("#cmdk-dialog").open'); ++ assert.deepEqual(await b.evaluate(palette), views); ++ await b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open'); ++ s.fail(null); ++ // The palette's own reads: kept rows show until a refused refetch, then go, with the count. ++ await go('#/tasks', 'Tasks'); await b.evaluate('location.hash="#/inbox"'); await wait('document.querySelectorAll("#sections .count").length===1'); ++ await b.key('k', 'KeyK', 2); await wait(`${palette}.some(h=>h.startsWith("#/tasks/"))`); ++ await b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open'); ++ s.fail('human-required'); ++ await b.key('k', 'KeyK', 2); await wait(`document.querySelector("#cmdk-dialog").open && ${palette}.length===${views.length} && !document.querySelector("#sections .count")`); ++ assert.deepEqual(await b.evaluate(palette), views); ++ await b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open'); ++ // Palette labels are bus text: markup in a question or a task title shows as text. ++ s.fail(null); ++ s.rows.inbox = [{ ...s.rows.inbox[0], question: 'Push?' }]; ++ s.rows.tasks = [{ ...s.rows.tasks[0], fields: { ...s.rows.tasks[0].fields, title: 'Bold task' } }, s.rows.tasks[1]]; ++ await b.key('k', 'KeyK', 2); await wait(`${palette}.length===${views.length + 3}`); ++ assert.equal(await count('#cmdk-list #pk-q, #cmdk-list #pk-t, #cmdk-list img, #cmdk-list b'), 0); ++ assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#cmdk-list [data-href] span:first-child")].slice(4).map(e=>e.textContent)'), ['Push?', '#7 Bold task', '#8 Second task']); ++ await b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open'); ++ // The board page's Inbox read: a refusal there drops the count too. ++ s.fail(null); await refresh(); await wait('document.querySelectorAll("#sections .count").length===1'); ++ s.fail('no-bus-host'); await b.evaluate('location.hash="#/"'); await wait('!document.querySelector("#sections .count")'); ++ await b.key('k', 'KeyK', 2); await wait('document.querySelector("#cmdk-dialog").open'); ++ assert.deepEqual(await b.evaluate(palette), views); ++ await b.key('Escape'); s.fail(null); ++ + assert.deepEqual(await b.evaluate('window.errors'), []); + assert.deepEqual(await b.evaluate('window.requests.filter(([m])=>m!=="GET")'), []); + } finally { await b.close(); await close(web); await close(brd.server); } diff --git a/agents/dewey/work/queue-53/round3.patch b/agents/dewey/work/queue-53/round3.patch new file mode 100644 index 00000000..ca35d96f --- /dev/null +++ b/agents/dewey/work/queue-53/round3.patch @@ -0,0 +1,160 @@ +--- a/packages/webui/README.md ++++ b/packages/webui/README.md +@@ -127,8 +127,12 @@ + accessibility tree, so the first copy is announced. + - The command bar shows, in UTC, when the board was scanned and the bus read, + and says when the last attempt failed. A board refusal drops the last scan +- and everything drawn from it: the table, the inspector, the project tree, +- the counts, the footer and the scan line all say the board was not read. ++ and everything drawn from it: the cards, the table, the inspector, the ++ project filter and tree, the counts, the footer and the scan line all say ++ the board was not read. A failed read with no scan held, after a refusal or ++ before the first scan, shows the board empty and says the read failed; it ++ never brings back rows from a refused scan. A refusal also closes an open ++ conversation and clears its history. + - From 760px the footer is a one-line status bar and the side column fits + between it and the command bar. Below 760px the section list is one strip + that scrolls inside itself. +@@ -211,7 +215,8 @@ + Shell tests (`shell.test.mjs`) check the copies, the font hashes and the kept + strings, and in the browser at 400px: System mode under an emulated dark and + light OS, the empty, stale and refused states of the board and the bus views +-(a board refusal with the inspector open), not found, the palette, the toast, ++(a board refusal with the inspector open and a project picked, then a failed ++read, and a refusal with a conversation open), not found, the palette, the toast, + keyboard and visible focus, the section strip at 360 and 400px, no sideways + scroll, no script error and no request other than GET. A second test walks + the Board, Inbox, Tasks, Agents and a decision trail through loading (a held +--- a/packages/webui/src/public/app.js ++++ b/packages/webui/src/public/app.js +@@ -301,18 +301,22 @@ + render(); + } + // A refusal (403) fails closed: the last data is dropped, not kept or shown as current, and so is +- // everything drawn from it: the inspector, the project tree, the counts and the footer. Any other +- // failure keeps the last data with its scan time. ++ // everything drawn from it: the cards, the table, the inspector, an open conversation and its ++ // history, the project tree, the counts and the footer. Any other failure keeps the last data with ++ // its scan time. With no data, from a refusal or before the first scan, the board is drawn empty ++ // and says why, so a later failure never brings back old rows. + function error(err) { + const refused = err.status === 403; + failed = refused ? 'refused' : true; $('error').hidden = false; +- if (refused) { +- data = null; selected = null; project = null; +- $('projects').innerHTML = '

Not read: the board refused the read.

'; ++ if (refused) { data = null; selected = null; project = null; if (conv) { $('conv-log').replaceChildren(); closeConversation(); } } ++ if (!data) { ++ const why = refused ? 'the board refused the read' : 'the read failed'; ++ $('projects').innerHTML = `

Not read: ${why}.

`; ++ for (const id of ['waiting', 'seen', 'sessions']) $(id).replaceChildren(); + for (const id of ['waiting-count', 'seen-count', 'session-count']) $(id).textContent = '–'; +- $('footer').textContent = 'Board not read: refused'; +- $('status').textContent = 'Not read: the board refused the read'; +- inspect(); convForm(); ++ $('footer').textContent = `Board not read: ${refused ? 'refused' : 'the read failed'}`; ++ $('status').textContent = `Not read: ${why}`; ++ inspect(); + } + $('error').className = refused ? 'banner ref' : 'banner err'; + $('board-refused').hidden = !refused; document.body.classList.toggle('board-refused', refused); +--- a/packages/webui/tests/shell.test.mjs ++++ b/packages/webui/tests/shell.test.mjs +@@ -59,20 +59,28 @@ + function board() { + let reply = { status: 200, body: { sessions: [], counts: {}, generatedAt: new Date().toISOString() } }; + const one = { agent: 'agent1', project: 'proj', state: 'waiting', waitingOnYou: true, task: 'Fixture task', lastActivity: '2026-10-10T00:00:00Z', lastAssistantText: 'Input needed: fixture', registered: null }; ++ // While the board answers, agent1 has one conversation with one message. A failure or refusal answers every path. ++ const catalogue = { conversations: [{ project: 'proj', seat: 'agent1', conversation: 'c1', availability: 'available', title: 'Fixture', conversationCreatedAt: '2026-10-10T00:00:00Z', lastActivityAt: '2026-10-10T00:00:00Z' }] }; ++ const page = { page: { branch: 'b1', entries: [{ message: 'm1', role: 'assistant', createdAt: '2026-10-10T00:00:00Z', content: [{ block: 0, type: 'text', text: 'Fixture history line' }] }] }, view: { incomplete: false, defaultBranch: 'b1', branch: 'b1' }, cursor: null, follow: null }; + let gate = null; + const server = createServer(async (req, res) => { + for await (const chunk of req) { void chunk; } + await gate; +- res.writeHead(reply.status, { 'content-type': 'application/json' }); res.end(JSON.stringify(reply.body)); ++ const path = new URL(req.url, 'http://localhost').pathname; ++ const body = reply.status !== 200 ? reply.body : path === '/api/conversations' ? catalogue : path === '/api/conversation' ? page : reply.body; ++ res.writeHead(reply.status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); + }); + return { + server, + // Holds every reply until the returned function is called. + hold: () => { let go; gate = new Promise(r => { go = r; }); return () => { gate = null; go(); }; }, + empty: () => { reply = { status: 200, body: { sessions: [], counts: {}, generatedAt: new Date().toISOString() } }; }, +- one: () => { reply = { status: 200, body: { sessions: [one, { ...one, agent: 'agent2', state: 'working', waitingOnYou: false }], counts: { waiting: 1, working: 1 }, generatedAt: new Date().toISOString() } }; }, ++ one: () => { reply = { status: 200, body: { sessions: [one, { ...one, agent: 'agent2', state: 'working', waitingOnYou: false }, { ...one, agent: 'agent3', state: 'idle', waitingOnYou: false, seen: true }], counts: { waiting: 1, working: 1 }, generatedAt: new Date().toISOString() } }; }, + fail: () => { reply = { status: 500, body: { error: 'board scan failed' } }; }, + refuse: () => { reply = { status: 403, body: { error: 'board-refused' } }; }, ++ // One seat a reply can reach: registered, alive, with a tmux session. ++ registered: () => { reply = { status: 200, body: { sessions: [{ ...one, registered: { alive: true, tmux: { session: 'fixture' } } }], counts: { waiting: 1 }, generatedAt: new Date().toISOString() } }; }, ++ unavailable: () => { reply = { status: 503, body: { error: 'board unavailable' } }; }, + }; + } + +@@ -95,6 +103,7 @@ + const shot = async name => { if (out) await b.screenshot(join(out, `shell-${name}.png`)); }; + const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`); + const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`); ++ const count = sel => b.evaluate(`document.querySelectorAll(${JSON.stringify(sel)}).length`); + const flat = async () => assert.equal(await b.evaluate('document.documentElement.scrollWidth<=document.documentElement.clientWidth'), true, 'sideways scroll at 400px'); + const refresh = async () => { await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled'); }; + const go = async (hash, h1) => { await b.evaluate(`location.hash=${JSON.stringify(hash)}`); await wait(`document.querySelector("#bus-view h1")?.textContent.startsWith(${JSON.stringify(h1)}) && !document.querySelector("#bus-view [aria-busy]")`); }; +@@ -140,10 +149,13 @@ + await flat(); await shot('board-stale-400'); + + // Board, a refusal: fails closed. The kept rows are hidden, and so is everything drawn from +- // them: the open inspector, the project tree's counts and the footer. ++ // them: the open inspector, the project filter, the project tree's counts and the footer. ++ await b.evaluate('document.querySelector("#projects [data-project=proj]").click()'); ++ assert.equal(await b.evaluate('document.querySelector("#projects [data-project=proj]").getAttribute("aria-pressed")'), 'true'); + await b.evaluate('document.querySelector("table.sessions [data-open]").click()'); await wait('!document.querySelector("#inspector").hidden'); + assert.equal(await text('#footer'), 'waiting 1 · working 1'); + assert.equal(await b.evaluate('document.querySelectorAll("#projects .count").length'), 2); ++ assert.equal(await count('#seen .wait-item'), 1); + brd.refuse(); await refresh(); + assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner ref'); + assert.match(await text('#error'), /The board refused the read\. refused: board-refused/); +@@ -155,14 +167,43 @@ + assert.equal(await text('#footer'), 'Board not read: refused'); + assert.equal(await text('#fresh-board'), 'Board not read: refused'); + assert.doesNotMatch(await text('#status'), /Scanned/); ++ for (const id of ['#waiting-count', '#seen-count', '#session-count']) assert.equal(await text(id), '–', id); + await flat(); await shot('board-refused-400'); ++ // A failed read after the refusal shows the board again, empty: no row, card or count from ++ // the refused scan, and the footer says what the banner says. ++ brd.unavailable(); await refresh(); ++ assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner err'); ++ assert.match(await text('#error'), /board unavailable.*No board data loaded\./); ++ assert.notEqual(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none'); ++ assert.equal(await count('#sessions tr, #sessions [data-open], #waiting .wait-item, #seen .wait-item, #board-view [data-history]'), 0); ++ for (const id of ['#waiting-count', '#seen-count', '#session-count']) assert.equal(await text(id), '–', id); ++ assert.equal(await text('#footer'), 'Board not read: the read failed'); ++ assert.equal(await text('#fresh-board'), 'Board not read: the read failed'); ++ assert.equal(await text('#status'), 'Not read: the read failed'); ++ assert.equal(await text('#projects'), 'Not read: the read failed.'); ++ await flat(); await shot('board-refused-then-failed-400'); + brd.one(); await refresh(); + assert.equal(await b.evaluate('document.querySelector("#error").hidden'), true); ++ assert.equal(await b.evaluate('document.querySelector("#projects [data-project=\'\']").getAttribute("aria-pressed")'), 'true'); + assert.notEqual(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none'); + assert.equal(await text('#footer'), 'waiting 1 · working 1'); + // A good read after a failed one drops the stale words. + brd.fail(); await refresh(); assert.match(await text('#fresh-board'), /stale/); + brd.one(); await refresh(); assert.match(await text('#fresh-board'), /^Board scanned \d\d:\d\d:\d\d UTC/); assert.doesNotMatch(await text('#fresh-board'), /stale/); ++ // A refusal with a conversation open closes it and clears its history and reply form: the ++ // session is in no scan the page holds. ++ brd.registered(); await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===1'); ++ await b.evaluate('document.querySelector("table.sessions [data-history]").click()'); ++ await wait('document.querySelector("#conv-log").textContent.includes("Fixture history line")'); ++ assert.equal(await b.evaluate('document.querySelector("#conv-form").hidden'), false); ++ brd.refuse(); await refresh(); ++ assert.equal(await b.evaluate('document.querySelector("#conversation").hidden'), true); ++ assert.equal(await b.evaluate('document.body.classList.contains("has-conversation")'), false); ++ assert.equal(await count('#conv-log > *'), 0); ++ assert.equal(await b.evaluate('document.querySelector("#conv-form").checkVisibility()'), false); ++ assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none'); ++ assert.equal(await b.evaluate('document.querySelector("#board-refused").hidden'), false); ++ brd.one(); await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===2'); + + // Keyboard on the board: down moves between rows, Enter opens, Esc closes and returns focus. + await b.evaluate('document.querySelector("table.sessions [data-open]").focus()');