--- 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))}
${refusal(err) ? `refused: ${txt(err.code)}. ` : ''}${why(err)} Console shows nothing rather than a guess. Nothing was written.
${retry}
${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 => `
The last read failed. ${why(err)} This is what was read before, ${when(readAt)}. Nothing was changed.
${retry}
`;
// 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, /