Files
stack/agents/dewey/work/queue-53/round2.patch
T

387 lines
30 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
--- 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); }