review(53): Dewey rounds 1 to 3 build packet (candidate 2562c05d)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-10 14:49:52 -05:00
co-authored by Claude Opus 5.5
parent 1bdb6f9b13
commit abf71275ab
4 changed files with 910 additions and 0 deletions
@@ -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
+348
View File
@@ -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 `<body>`, `#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, `<body>` 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 `<img src=x onerror="errors.push(1)" id="pk-q">Push?` and a task title `<b id="pk-t">Bold</b> 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: <command>`, 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 `<code>`) | 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 `<html>`; 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 `<dialog>`. |
| `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: `<html>` 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: `<html>` 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 `<dialog>` 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.
+386
View File
@@ -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 <span class="warn">not read: the read failed</span>' : 'Board not read yet'; return; }
- const scan = `Board scanned <b>${esc(Number.isFinite(at) ? new Date(at).toLocaleTimeString([], { hour12: false }) : 'unknown')}</b>${ago ? ` (${ago} ago)` : ''}`;
+ if (!data) { $('fresh-board').innerHTML = failed === 'refused' ? 'Board <span class="warn">not read: refused</span>' : failed ? 'Board <span class="warn">not read: the read failed</span>' : 'Board not read yet'; return; }
+ const scan = `Board scanned <b>${esc(Number.isFinite(at) ? `${new Date(at).toISOString().slice(11, 19)} UTC` : 'unknown')}</b>${ago ? ` (${ago} ago)` : ''}`;
$('fresh-board').innerHTML = failed ? `${scan} · <span class="warn">stale: the last refresh failed</span>` : 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 = '<p class="muted">Not read: the board refused the read.</p>';
+ 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))}<div class="banner ${refusal(err) ? 'ref' : 'err'}" role="alert"><p>${refusal(err) ? `<b>refused: <code>${txt(err.code)}</code>.</b> ` : ''}${why(err)} Console shows nothing rather than a guess. Nothing was written.</p>${retry}</div>${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 => `<div class="banner err s1-stale" role="alert"><p><b>The last read failed.</b> ${why(err)} This is what was read before, ${when(readAt)}. Nothing was changed.</p>${retry}</div>`;
// 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 <span class="warn">not read: the read failed</span>'
+ $('fresh-bus').innerHTML = state === 'refused' ? 'Bus <span class="warn">not read: refused</span>' : state === 'failed' ? 'Bus <span class="warn">not read: the read failed</span>'
: `Bus read <b>${esc(stamp(readAt))}</b>${readAt ? ` (${esc(ago(readAt))})` : ''}${state === 'stale' ? ' · <span class="warn">stale: the last read failed</span>' : ''}`;
}
@@ -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 (<code>, <b>) is not part of the string.
+ // Markup in the source (<code>, <b>) 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, /<button[^>]*>[^<]*(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: '<img src=x onerror="errors.push(1)" id="pk-q">Push?' }];
+ s.rows.tasks = [{ ...s.rows.tasks[0], fields: { ...s.rows.tasks[0].fields, title: '<b id="pk-t">Bold</b> 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)'), ['<img src=x onerror="errors.push(1)" id="pk-q">Push?', '#7 <b id="pk-t">Bold</b> 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); }
+160
View File
@@ -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 = '<p class="muted">Not read: the board refused the read.</p>';
+ 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 = `<p class="muted">Not read: ${why}.</p>`;
+ 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()');