Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25ff7cd3f4 |
@@ -0,0 +1,63 @@
|
||||
# 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
|
||||
`<agent> already seen` or `<agent> 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.
|
||||
@@ -66,7 +66,7 @@
|
||||
$('mode').onchange = e => { mode = e.target.value; theme(); };
|
||||
function seenButton(r) {
|
||||
if (!['waiting', 'error'].includes(r.state) || !r.lastActivity) return '';
|
||||
return `<button type="button" class="btn" data-seen="${esc(key(r))}" aria-label="${r.seen ? 'Unsee' : 'Mark seen'} ${esc(r.agent)}">${r.seen ? 'Unsee' : 'Seen'}</button>`;
|
||||
return `<button type="button" class="btn" data-seen="${esc(key(r))}" data-intent="${r.seen ? 'unsee' : 'seen'}" aria-label="${r.seen ? 'Unsee' : 'Mark seen'} ${esc(r.agent)}">${r.seen ? 'Unsee' : 'Seen'}</button>`;
|
||||
}
|
||||
const historyButton = r => `<button type="button" class="btn conv-open" data-history="${esc(key(r))}" aria-label="History for ${esc(r.agent)}">History</button>`;
|
||||
const openButton = r => `<button type="button" class="session-open" data-open="${esc(key(r))}" aria-controls="inspector" aria-expanded="${selected === key(r)}">${esc(r.agent)}</button>`;
|
||||
@@ -415,12 +415,16 @@
|
||||
if (!seen || writing) return;
|
||||
// A Board read in flight (reading) holds busy. The click waits for that read and posts on the row it
|
||||
// drew; dropping it left the click doing nothing (row 172). A second click while one waits or posts is dropped.
|
||||
// The click means what the button said when clicked (Seen marks, Unsee returns the row to waiting), read before
|
||||
// the wait. If the fresh row already holds that, another viewer got there first: nothing is posted (row 173).
|
||||
const mark = seen.dataset.intent === 'seen';
|
||||
writing = true; seen.disabled = true;
|
||||
try {
|
||||
while (reading) await reading;
|
||||
const r = row(seen.dataset.seen); if (!r) { seen.disabled = false; return; }
|
||||
if (r.seen === mark) { seen.disabled = false; announce(`${r.agent} already ${mark ? 'seen' : 'waiting'}`); return; }
|
||||
busy = true;
|
||||
try { accept(await api('/api/seen', { project: r.project, agent: r.agent, lastActivity: r.lastActivity, seen: !r.seen })); announce(`${r.agent} ${r.seen ? 'returned to waiting' : 'marked seen'}`); }
|
||||
try { accept(await api('/api/seen', { project: r.project, agent: r.agent, lastActivity: r.lastActivity, seen: mark })); announce(`${r.agent} ${mark ? 'marked seen' : 'returned to waiting'}`); }
|
||||
catch (err) { error(err); seen.disabled = false; }
|
||||
finally { busy = false; schedule(); }
|
||||
} finally { writing = false; }
|
||||
|
||||
@@ -82,7 +82,7 @@ async function setup(machine, fast = false) {
|
||||
const polls = () => b.evaluate('window.__polls');
|
||||
const settle = () => new Promise(r => setTimeout(r, 600));
|
||||
const done = async () => { await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); };
|
||||
return { b, base, wait, text, shown, reqs, polls, settle, done };
|
||||
return { b, base, boardURL: f.boardURL, wait, text, shown, reqs, polls, settle, done };
|
||||
}
|
||||
|
||||
const HELD = 'document.querySelectorAll("#held table tbody tr").length === 2';
|
||||
@@ -191,6 +191,37 @@ test('a Seen click while a Board read is in flight waits for that read, then pos
|
||||
} finally { await done(); }
|
||||
});
|
||||
|
||||
// Row 173 (#1671): the click took its meaning from the fresh row, so another viewer's mark landing in the read
|
||||
// it waited for turned "Seen" into an unsee. The click means what its button said; a fresh row that already
|
||||
// holds that posts nothing and says so. The other viewer posts straight to the board while the read is held.
|
||||
test('a Seen or Unsee click that waited out a Board read keeps its meaning: another viewer\'s mark in that read posts nothing', { timeout: 120000 }, async () => {
|
||||
const { b, base, boardURL, wait, text, reqs, done } = await setup(false);
|
||||
const posts = async () => (await reqs()).filter(r => r.path === '/api/seen').length;
|
||||
const other = async seen => {
|
||||
const r = (await (await fetch(`${boardURL}/api/board`)).json()).sessions.find(s => s.project === 'proj' && s.agent === 'agent1');
|
||||
const res = await fetch(`${boardURL}/api/seen`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ project: 'proj', agent: 'agent1', lastActivity: r.lastActivity, seen }) });
|
||||
assert.equal(res.status, 200);
|
||||
};
|
||||
try {
|
||||
await b.navigate(base);
|
||||
await wait(`${SESSIONS} && ${HELD}`);
|
||||
await b.evaluate(`document.querySelector('table.sessions [data-open="proj/agent1"]').click(), true`);
|
||||
await wait('document.querySelector("#inspection [data-seen]")');
|
||||
for (const [label, seen, count, said] of [['Seen', true, '1', 'agent1 already seen'], ['Unsee', false, '0', 'agent1 already waiting']]) {
|
||||
assert.equal(await text('#inspection [data-seen]'), label);
|
||||
await b.evaluate(`window.__hold = '/api/board', document.querySelector('#refresh').click(), true`);
|
||||
await wait('window.__held.length === 1');
|
||||
// The held fetch has not reached the board yet, so its answer carries the other viewer's mark.
|
||||
await other(seen);
|
||||
await b.evaluate(`document.querySelector('#inspection [data-seen]').click(), true`);
|
||||
await b.evaluate('window.__held.splice(0).forEach(r => r()), true');
|
||||
await wait(`document.querySelector("#announce").textContent === ${JSON.stringify(said)}`);
|
||||
assert.equal(await text('#seen-count'), count);
|
||||
assert.equal(await posts(), 0);
|
||||
}
|
||||
} finally { await done(); }
|
||||
});
|
||||
|
||||
test('a redirect answer on a Board read, a bus read, a conversation read, a board POST, a module read and a send signs the page out: one line, nothing stale, no further reads', { timeout: 120000 }, async () => {
|
||||
// The poll timers fire after 150 ms here, so the 600 ms settle below spans several ticks.
|
||||
const { b, base, wait, text, shown, reqs, polls, settle, done } = await setup(true, true);
|
||||
|
||||
Reference in New Issue
Block a user