# Row 173 (#1671): a Seen click that waited out a Board read keeps the meaning it was clicked with Brief: docs/plans/2026-10-19_seen-click-intent.md (blob 225ac823b). Branch dewey/row173 off aca16404c. Queue reviewer: Darkwing. Gate (queue row): Darkwing approves on #1671 naming the candidate manifest; webui and every scripts/test-*.sh green on Sage's gate rerun. ## Design - Intent. `seenButton` draws `data-intent="seen"` on a Seen button and `data-intent="unsee"` on an Unsee button, from the same `r.seen` that picks the label. The click handler reads it into `mark` before `writing = true` and before the wait, so the fresh row can't change it. I chose a `data-` value over reading the label text so a later relabel can't change what a click posts. - The fresh row already holds the intent (`r.seen === mark`). This happens when another viewer marked or unmarked the row while the read was in flight. Nothing is posted, the button is re-enabled, and the page announces ` already seen` or ` already waiting`. - Why post nothing. The route takes a repeat without harm: `markSeen` with `seen:true` rewrites the same mark, and `seen:false` deletes a key that is already gone (control-board/src/scan.mjs:255-256). So posting the same value would also be correct. It would cost a write and a rescan, though, and the read that just finished has already drawn the row. The brief allows either. - An unseen row can still hold an old mark for an earlier lastActivity. Posting nothing on Unsee leaves that mark, which is harmless because `scanAgent` only counts a mark equal to the row's lastActivity (scan.mjs:340). - Otherwise the click posts `seen: mark` and announces from `mark`, not from the fresh row. - Unchanged: the row 172 wait, the single-click guard (`writing`), the no-row branch, and the `/api/seen` route (out of scope). - `seen.disabled = false` in the new branch matches the no-row branch. After the read, `accept` redraws the inspector and the cards, so `seen` is usually a detached button and the line has no visible effect. No test can tell it apart; see M5. ## Test packages/webui/tests/board-held.test.mjs gains one test: "a Seen or Unsee click that waited out a Board read keeps its meaning: another viewer's mark in that read posts nothing". `setup` returns `boardURL: f.boardURL` so the test can post to the board as the other viewer. - Steps, for each of Seen and Unsee: - it holds `/api/board` with `window.__hold` and clicks Refresh; - while the read is held, it posts the other viewer's mark straight to the board; - it clicks the inspector's button and releases the read. - Checks: the announcement (`agent1 already seen`, then `agent1 already waiting`), `#seen-count` (1, then 0), and that the page sent no `/api/seen` at all. - The held fetch hasn't reached the board when the other viewer posts (the stub awaits the hold before calling the real fetch), so the released answer carries the other viewer's mark. Hash pins and readers checked (git grep, with a positive control for 64-hex): nothing pins app.js. shell.test counts existing app.js strings only. browser.test, sso-browser.test and the other board-held tests click `[data-seen]` buttons the page draws, so they get `data-intent` with them. ## Mutants (run on CLEAR only; each one edit to app.js, then board-held.test.mjs whole) | id | edit | expected kill | |----|------|---------------| | M1 | post `seen: !r.seen` and drop the `r.seen === mark` branch (the old toggle) | new test (a post, and "returned to waiting") | | M2 | drop the `r.seen === mark` branch only (post the repeat) | new test (a post, and "marked seen") | | M3 | `seenButton` swaps the intent (`r.seen ? 'seen' : 'unsee'`) | row 172 test (`#seen-count` never 1) and the new test | | M4 | the new branch returns without `announce` | new test (announcement wait times out) | | M5 | drop `seen.disabled = false` in the new branch | equivalent: the button is redrawn by `accept`; named, not run | Each run: `timeout -k 10 180 node --test --test-force-exit packages/webui/tests/board-held.test.mjs`; a kill counts only as an assertion or a named wait failing in the named test. ## Runs None yet. Gander holds the gate slot (row 253 run 5) from 16:32Z. The claim, the push and every run wait for Sage's CLEAR. ## Round 2: rebase onto 78b69e1e6 (sage-4361) - Branch dewey/row173-r2 off refactor 78b69e1e6. The round 1 commit 25ff7cd3f (on aca16404c, 555 commits behind) was cherry-picked as a new commit; dewey/row173 is untouched. No force-push. - The pick merged on its own. `git range-diff aca16404c..25ff7cd3f 78b69e1e6..` reports the commit equal. - Overlap with refactor: one app.js line, the `/api/config` handler at the end of the file (federation render). It is far from both row 173 hunks and changes no assertion or reviewed line. board-held.test.mjs and the brief (blob 225ac823b) are unchanged on refactor. - No test pins app.js by hash (grepped tests for app.js plus 64-hex). ### Run list app.js is a page script, loaded by index.html, not imported by any module. Its test readers: - by name: board-held, browser, granted-user, serve, shell, sso-browser, user-option-swap; - by page load: every webui test that opens the Console through tests/browser.mjs (31 test files). No test outside packages/webui reads or serves app.js (`git grep` over packages/*/tests and scripts: none). Order, each run with `TMPDIR` from `mktemp -d /mnt/storage/scratch/tmp/d173XXXXXX` and absolute paths: 1. M1-M4 below, each with board-held.test.mjs whole. 2. board-held.test.mjs whole on the restored tree. 3. packages/webui whole (all 70 test files), which covers both reader groups. case1-accept.test.mjs is not in the list (mos-1612). Runs need Sage's CLEAR. ### Mutant procedure The M1-M5 table above stands. M5 stays equivalent: the new branch is reached only after the waited-out Board read's `accept` (refresh, app.js:372) has redrawn the row, so `seen` is the detached old button. Each mutant is one edit to app.js. Before the next mutant, the restored app.js must match the candidate's sha256. Each run is in its own process group so a timeout kill takes its browser with it. Afterwards I sweep for orphaned node/chromium processes in my run directory.