--- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -91,7 +91,9 @@ open sections and the copy status survive a refresh. - A failed read keeps the last good answer for that read and says it is old, with the time it was read. A refusal (403, `not-configured` or `no-bus-host`) - never shows kept rows (row 53). A page never read, or refused, shows the failure instead, + never shows kept rows (row 53), and it drops every kept bus read: the Inbox + count and the palette's decisions and tasks go too, until a read succeeds. + A page never read, or refused, shows the failure instead, titled "Bus refused the read" (403: the bus refused the Console's read, for example `human-required` from a Console started inside an agent run), "No bus to read" (no system config, or no bus host and no `--business`) or "The read failed". @@ -120,12 +122,16 @@ and tasks; arrows move, Enter opens, Esc closes and returns focus. Arrow keys also move between task links in the bus tables. - Copy reports in a toast. If the clipboard is unavailable, the toast stays - until dismissed and the command is selected for Ctrl+C. -- The command bar shows when the board was scanned and the bus read, and - says when the last attempt failed. A board refusal hides the board rather - than showing the last scan. + until dismissed and the command is selected for Ctrl+C. When no toast shows, + the toast is an empty live region, visually hidden but still in the + accessibility tree, so the first copy is announced. +- The command bar shows, in UTC, when the board was scanned and the bus read, + and says when the last attempt failed. A board refusal drops the last scan + and everything drawn from it: the table, the inspector, the project tree, + the counts, the footer and the scan line all say the board was not read. - From 760px the footer is a one-line status bar and the side column fits - between it and the command bar. Below 760px the section list wraps. + between it and the command bar. Below 760px the section list is one strip + that scrolls inside itself. ## Data and boundaries @@ -204,11 +210,15 @@ Shell tests (`shell.test.mjs`) check the copies, the font hashes and the kept strings, and in the browser at 400px: System mode under an emulated dark and -light OS, the empty, stale and refused states of the board and the bus views, -not found, the palette, the toast, keyboard and visible focus, no sideways +light OS, the empty, stale and refused states of the board and the bus views +(a board refusal with the inspector open), not found, the palette, the toast, +keyboard and visible focus, the section strip at 360 and 400px, no sideways scroll, no script error and no request other than GET. A second test walks the Board, Inbox, Tasks, Agents and a decision trail through loading (a held -read), normal, a failed read over kept rows, and a refusal. +read), normal, a failed read over kept rows, and a refusal by the bus +(`human-required`) and for want of one (`no-bus-host`). It then checks that a +refusal empties the palette and the Inbox count, from a view, from the +palette's own reads and from the board page, and that palette labels are text. No root CI workflow is configured for this package. These local tests are not a claim of CI, deployment, live-seat delivery or user acceptance. --- a/packages/webui/src/public/app.js +++ b/packages/webui/src/public/app.js @@ -282,8 +282,8 @@ // Freshness line in the command bar: when the board last scanned, and warning words when the last refresh failed. function fresh() { const at = Date.parse(data?.generatedAt), ago = Number.isFinite(at) ? age(Math.max(0, Math.round((Date.now() - at) / 1000))) : null; - if (!data) { $('fresh-board').innerHTML = failed ? 'Board not read: the read failed' : 'Board not read yet'; return; } - const scan = `Board scanned ${esc(Number.isFinite(at) ? new Date(at).toLocaleTimeString([], { hour12: false }) : 'unknown')}${ago ? ` (${ago} ago)` : ''}`; + if (!data) { $('fresh-board').innerHTML = failed === 'refused' ? 'Board not read: refused' : failed ? 'Board not read: the read failed' : 'Board not read yet'; return; } + const scan = `Board scanned ${esc(Number.isFinite(at) ? `${new Date(at).toISOString().slice(11, 19)} UTC` : 'unknown')}${ago ? ` (${ago} ago)` : ''}`; $('fresh-board').innerHTML = failed ? `${scan} · stale: the last refresh failed` : scan; } setInterval(() => { if (data) fresh(); }, 5000); @@ -300,10 +300,20 @@ for (const id of awaiting.keys()) { const r = row(id); if (r && !pending(r)) awaiting.delete(id); } render(); } - // A refusal (403) fails closed: the last data is hidden, not shown as current. Any other failure keeps the last data with its scan time. + // A refusal (403) fails closed: the last data is dropped, not kept or shown as current, and so is + // everything drawn from it: the inspector, the project tree, the counts and the footer. Any other + // failure keeps the last data with its scan time. function error(err) { - failed = true; $('error').hidden = false; const refused = err.status === 403; + failed = refused ? 'refused' : true; $('error').hidden = false; + if (refused) { + data = null; selected = null; project = null; + $('projects').innerHTML = '

Not read: the board refused the read.

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