diff --git a/agents/dewey/work/queue-53/candidate-manifest.sha256 b/agents/dewey/work/queue-53/candidate-manifest.sha256
new file mode 100644
index 00000000..7910a15e
--- /dev/null
+++ b/agents/dewey/work/queue-53/candidate-manifest.sha256
@@ -0,0 +1,16 @@
+eb355c2d22fbfcc14e973a1f793891b0caab04a50cf6fd83c64b9de73a84bfac packages/webui/README.md
+6154b49628a613059bb35c26b577817e8ed5fd084c9dfb50aef11431578f14e7 packages/webui/src/public/app.js
+a9cb1cd82332b23a47e3a1239d25d13c86d16c4220695e34b243effa999f45f2 packages/webui/src/public/assets/fonts/jetbrains-mono-400.woff2
+086c48dfbea9ddaff1320f7e09399b8e2924e88ce67453721255db3bdbb5a353 packages/webui/src/public/assets/fonts/jetbrains-mono-500.woff2
+c503cc5ec5f8b2c7666b7ecda1adf44bd45f2e6579b2eba0fc292150416588a2 packages/webui/src/public/assets/fonts/jetbrains-mono-700.woff2
+30f0c136e3c88e422d0791acd97238870f9054a9729bc34cf2ff0d4ed8cac4ad packages/webui/src/public/assets/fonts/jetbrains-mono-OFL.txt
+f6f89aaaa6a02e9a16020de26fb6a664f1da152e0b0413248da2d9f6b68395cc packages/webui/src/public/assets/fonts/jetbrains-mono-sources.txt
+d8bc0fc7ad10fa188a6f68b925449476af4ca56ba343b24b089fb48cca296295 packages/webui/src/public/bus.js
+01b456c1ca685e69e2974d66a615b7b8736abbd51aef7a3f014bca3783710430 packages/webui/src/public/icons.svg
+fbbb51e5669a1f559015e0fdebf6f450b63383070472d2461f0bce9b9723d890 packages/webui/src/public/index.html
+d7b4d0ca32f07a63265d89131847cbb72c7e532101e693b31b0f38839b22f7e6 packages/webui/src/public/shell.css
+4fdb72ec24c7245584c89280415e10113dba1c4cb03d98f5570ac745ba2e95cb packages/webui/src/public/tokens.css
+5a65fea95e6225f812c196b0199f3f6562a60e523da685b255f749f63b480c64 packages/webui/src/serve.mjs
+6f68f67e99d5e832c0ba219911201b8adf08505e234647f2d9eac7292d55f50f packages/webui/tests/browser.mjs
+713c86bd6b1d1b27949bbae071f1bdf1f7d744f84b723ad5a1bdbd1e8adc5fb6 packages/webui/tests/serve.test.mjs
+da37a24308e51875502b81a5694d4e9c69dae24d36ee272a0b99b1ace7c4d317 packages/webui/tests/shell.test.mjs
diff --git a/packages/webui/README.md b/packages/webui/README.md
index 90f905ed..edb3118b 100644
--- a/packages/webui/README.md
+++ b/packages/webui/README.md
@@ -38,8 +38,9 @@ bytes. Do not expose either unauthenticated server through a public proxy.
- Refresh runs the existing board scan. Automatic refresh uses the board page's
ten-second interval; Pause stops it. Failed refreshes keep the last snapshot
with a warning. There are no automatic action retries.
-- Palette and appearance use the existing brand tokens and persist in this
- browser when local storage is available. No settings screen is added.
+- Palette and appearance come from `tokens.css` (see "Shell" below) and persist
+ in this browser when local storage is available. Appearance defaults to
+ System. No settings screen is added.
- History opens a read-only conversation view for a seat, from its Waiting
card, its table row or its inspector (#1507, CHAT-02). It shows the whole
branch with nothing clipped. Tool calls, tool results and thinking start
@@ -89,11 +90,53 @@ note is `agents/dewey/work/wui/SLICE1-VIEWS.md`.
- Views refresh on the board's ten-second interval and stop on Pause. Focus,
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 page never read shows the failure instead,
+ with the time it was read. A refusal (403, `not-configured` or `no-bus-host`)
+ 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".
+## Shell (row 53, #1542)
+
+The Console is restyled to `docs/design` (`IMPLEMENTING.md`). It is a restyle
+only: no new data, route or write.
+
+- `src/public/tokens.css` and `src/public/icons.svg` are byte copies of
+ `docs/design/tokens.css` and `docs/design/icons.svg`; `tests/shell.test.mjs`
+ fails if either differs. Edit `docs/design`, rebuild there, then copy.
+- Colours come only from `tokens.css`, selected by `data-palette` and
+ `data-mode` on ``. The page sets no colour inline. Appearance System
+ removes `data-mode`, so the OS light or dark setting applies; it is the
+ default, with Harbor. The choice is kept under `mosaic-console-appearance`.
+- `shell.css` loads last and holds the restyle: radii `--r` 6px and `--r-lg`
+ 10px, the type scale, tabular numbers, the command bar, section list, dense
+ table, inspector, status marks, class chips, toast, command palette and
+ not-found page. The earlier CSS files are unchanged.
+- JetBrains Mono 400, 500 and 700 come from the official JetBrains release
+ (v2.304); `assets/fonts/jetbrains-mono-sources.txt` has the URL and sha256
+ of the archive and each file, and `jetbrains-mono-OFL.txt` the licence.
+ Manrope has no 800 file here, so 800 headings render at 700.
+- Ctrl+K (or the search field) opens the palette over views, inbox decisions
+ 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. 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 cards, the table, the inspector, the
+ project filter and tree, the counts, the footer and the scan line all say
+ the board was not read. A failed read with no scan held, after a refusal or
+ before the first scan, shows the board empty and says the read failed; it
+ never brings back rows from a refused scan. A refusal also closes an open
+ conversation and clears its history.
+- 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 is one strip
+ that scrolls inside itself.
+
## Data and boundaries
GET `/api/board`, `/api/conversations` and `/api/conversation`, and POST
@@ -138,6 +181,7 @@ node --test packages/control-board/tests/ packages/seat/tests/ packages/ledger/t
WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/browser.test.mjs
WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/conversation.test.mjs
WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/bus-browser.test.mjs
+WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/shell.test.mjs
```
Node's test runner and installed `/usr/bin/chromium` are required. Set `CHROMIUM`
@@ -168,6 +212,19 @@ They cover each view, the gap labels, inert hostile text, Copy, the stale
fallback and each failure title, and that no route writes. They never start
the human CLI against a live bus.
+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
+(a board refusal with the inspector open and a project picked, then a failed
+read, and a refusal with a conversation 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 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.
diff --git a/packages/webui/src/public/app.js b/packages/webui/src/public/app.js
index b862a985..efe7ba9e 100644
--- a/packages/webui/src/public/app.js
+++ b/packages/webui/src/public/app.js
@@ -7,12 +7,13 @@
// Delivered replies with no newer answer yet, by row: the board's sentAt. Page memory only.
const awaiting = new Map();
let data = null, project = null, selected = null, returnFocus = null, paused = false, busy = false, timer;
- let board = 'the configured board', palette = 'harbor', mode = 'light';
+ let board = 'the configured board', palette = 'harbor', mode = 'system', failed = false;
const labels = { registration: 'registered', 'first-user-message': 'first message', 'tmux-pane': 'tmux pane', 'session-cwd': 'session cwd', 'workspace-git-root': 'git root' };
const source = value => value ? `${esc(labels[value] || value)}` : '';
// Who set a registered task (#1511): null from the scan unless the task shown is the registered one. A caller's claim, not verified; it grants nothing.
const setBy = r => r.taskSetBy ? `set by ${esc(r.taskSetBy)}` : '';
- const badge = state => `${esc(state || 'unknown')}`;
+ // Status mark: the class adds the glyph (shell.css); the word is the text, so colour is never the only signal.
+ const badge = state => `${esc(state || 'unknown')}`;
const connectorStatus = r => r.connector ? `${r.connector.braked === true ? 'braked (STOP)' : r.connector.braked === false ? 'not braked' : 'brake unknown'} · owner ${esc(r.connector.ownerState)}` : '';
const relaunchNotice = r => r.relaunchedAt ? `relaunched at ${r.relaunchedAt}, no messages since` : '';
// Relative age is the board's ageSeconds: time since last activity as of the scan, not session lifetime (#1507).
@@ -34,12 +35,13 @@
const canReply = r => !r.connector && r.registered && r.registered.alive !== false && r.registered.tmux?.session;
const row = id => data?.sessions.find(r => key(r) === id);
const announce = text => { $('announce').textContent = text; };
+ // Colours come from tokens.css by data-palette and data-mode. System removes data-mode, so the OS setting picks light or dark.
function theme() {
const p = Brand.palettes.find(p => p.id === palette) || Brand.palettes[0];
palette = p.id;
- if (!['light', 'dim', 'dark'].includes(mode)) mode = 'light';
- for (const [name, value] of Object.entries(Brand.tokens(p, mode))) document.documentElement.style.setProperty('--' + name, value);
- document.documentElement.dataset.palette = palette; document.documentElement.dataset.mode = mode;
+ if (!['system', 'light', 'dim', 'dark'].includes(mode)) mode = 'system';
+ document.documentElement.dataset.palette = palette;
+ if (mode === 'system') delete document.documentElement.dataset.mode; else document.documentElement.dataset.mode = mode;
$('palette').value = palette; $('mode').value = mode;
try { localStorage.setItem('mosaic-console-appearance', JSON.stringify({ palette, mode })); } catch {}
}
@@ -59,8 +61,8 @@
return `
${data.sessions.length ? 'Clear Hide offline or Hide seen, or pick All projects, to see the rest.' : 'Sessions appear here when the board scan finds an agent session on this machine.'}
A session shows here when its agent stops on a question or an error and you have not marked it seen.
';
$('seen-count').textContent = seen.length; $('seen').innerHTML = cards(seen);
const group = data.sessions.filter(r => !project || r.project === project);
const visible = group.filter(r => !($('hide-offline').checked && r.state === 'offline') && !($('hide-seen').checked && r.seen));
@@ -275,21 +277,54 @@
$('sessions').innerHTML = table(visible);
$('footer').textContent = Object.entries(data.counts || {}).map(([s, n]) => `${s} ${n}`).join(' · ') || 'No sessions';
$('status').textContent = `Scanned ${data.generatedAt || 'unknown'} · ${paused ? 'auto-refresh paused' : 'refresh every 10s'}`;
- inspect(); convForm(); if (conv) convStatus(); restore(focus);
+ fresh(); inspect(); convForm(); if (conv) convStatus(); restore(focus);
}
+ // 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 === '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);
async function api(path, value) {
const res = await fetch(path, value === undefined ? { cache: 'no-store' } : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value) });
const result = await res.json();
- if (!res.ok) throw new Error(result.error || `HTTP ${res.status}`);
+ if (!res.ok) throw Object.assign(new Error(result.error || `HTTP ${res.status}`), { status: res.status });
return result;
}
function accept(value) {
if (!value || !Array.isArray(value.sessions) || value.sessions.some(r => !r || typeof r.project !== 'string' || typeof r.agent !== 'string')) throw new Error('Invalid board session data');
- data = value; $('error').hidden = true;
+ data = value; failed = false; $('error').hidden = true; $('error').className = 'banner err';
+ $('board-refused').hidden = true; document.body.classList.remove('board-refused');
for (const id of awaiting.keys()) { const r = row(id); if (r && !pending(r)) awaiting.delete(id); }
render();
}
- function error(err) { $('error').hidden = false; $('error').textContent = `${err.message} Board: ${board}. ${data ? 'Showing last known data.' : 'No board data loaded.'} Use Refresh to try again.`; }
+ // A refusal (403) fails closed: the last data is dropped, not kept or shown as current, and so is
+ // everything drawn from it: the cards, the table, the inspector, an open conversation and its
+ // history, the project tree, the counts and the footer. Any other failure keeps the last data with
+ // its scan time. With no data, from a refusal or before the first scan, the board is drawn empty
+ // and says why, so a later failure never brings back old rows.
+ function error(err) {
+ const refused = err.status === 403;
+ failed = refused ? 'refused' : true; $('error').hidden = false;
+ if (refused) { data = null; selected = null; project = null; if (conv) { $('conv-log').replaceChildren(); closeConversation(); } }
+ if (!data) {
+ const why = refused ? 'the board refused the read' : 'the read failed';
+ $('projects').innerHTML = `
Not read: ${why}.
`;
+ for (const id of ['waiting', 'seen', 'sessions']) $(id).replaceChildren();
+ for (const id of ['waiting-count', 'seen-count', 'session-count']) $(id).textContent = '–';
+ $('footer').textContent = `Board not read: ${refused ? 'refused' : 'the read failed'}`;
+ $('status').textContent = `Not read: ${why}`;
+ inspect();
+ }
+ $('error').className = refused ? 'banner ref' : 'banner err';
+ $('board-refused').hidden = !refused; document.body.classList.toggle('board-refused', refused);
+ $('error').innerHTML = refused
+ ? `
The board refused the read. refused: ${esc(err.message)} Board: ${esc(board)}. Console shows nothing rather than a guess. Use Refresh to try again.
`
+ : `
${esc(err.message)} Board: ${esc(board)}. ${data ? `Showing last known data.${data.generatedAt ? ` Scanned ${esc(data.generatedAt)}.` : ''}` : 'No board data loaded.'} Use Refresh to try again.
`;
+ fresh();
+ }
function schedule() { clearTimeout(timer); if (!paused) timer = setTimeout(refresh, 10000); }
async function refresh() {
if (busy) return;
diff --git a/packages/webui/src/public/assets/fonts/jetbrains-mono-400.woff2 b/packages/webui/src/public/assets/fonts/jetbrains-mono-400.woff2
new file mode 100644
index 00000000..40da4276
Binary files /dev/null and b/packages/webui/src/public/assets/fonts/jetbrains-mono-400.woff2 differ
diff --git a/packages/webui/src/public/assets/fonts/jetbrains-mono-500.woff2 b/packages/webui/src/public/assets/fonts/jetbrains-mono-500.woff2
new file mode 100644
index 00000000..669d04cd
Binary files /dev/null and b/packages/webui/src/public/assets/fonts/jetbrains-mono-500.woff2 differ
diff --git a/packages/webui/src/public/assets/fonts/jetbrains-mono-700.woff2 b/packages/webui/src/public/assets/fonts/jetbrains-mono-700.woff2
new file mode 100644
index 00000000..4917f434
Binary files /dev/null and b/packages/webui/src/public/assets/fonts/jetbrains-mono-700.woff2 differ
diff --git a/packages/webui/src/public/assets/fonts/jetbrains-mono-OFL.txt b/packages/webui/src/public/assets/fonts/jetbrains-mono-OFL.txt
new file mode 100644
index 00000000..8bee4148
--- /dev/null
+++ b/packages/webui/src/public/assets/fonts/jetbrains-mono-OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/packages/webui/src/public/assets/fonts/jetbrains-mono-sources.txt b/packages/webui/src/public/assets/fonts/jetbrains-mono-sources.txt
new file mode 100644
index 00000000..5d8ac649
--- /dev/null
+++ b/packages/webui/src/public/assets/fonts/jetbrains-mono-sources.txt
@@ -0,0 +1,12 @@
+JetBrains Mono 2.304
+Release: https://github.com/JetBrains/JetBrainsMono/releases/tag/v2.304
+Archive: https://github.com/JetBrains/JetBrainsMono/releases/download/v2.304/JetBrainsMono-2.304.zip
+Archive sha256: 6f6376c6ed2960ea8a963cd7387ec9d76e3f629125bc33d1fdcd7eb7012f7bbf
+License: OFL.txt in the archive, copied as jetbrains-mono-OFL.txt
+400: fonts/webfonts/JetBrainsMono-Regular.woff2 -> jetbrains-mono-400.woff2
+500: fonts/webfonts/JetBrainsMono-Medium.woff2 -> jetbrains-mono-500.woff2
+700: fonts/webfonts/JetBrainsMono-Bold.woff2 -> jetbrains-mono-700.woff2
+a9cb1cd82332b23a47e3a1239d25d13c86d16c4220695e34b243effa999f45f2 jetbrains-mono-400.woff2
+086c48dfbea9ddaff1320f7e09399b8e2924e88ce67453721255db3bdbb5a353 jetbrains-mono-500.woff2
+c503cc5ec5f8b2c7666b7ecda1adf44bd45f2e6579b2eba0fc292150416588a2 jetbrains-mono-700.woff2
+30f0c136e3c88e422d0791acd97238870f9054a9729bc34cf2ff0d4ed8cac4ad jetbrains-mono-OFL.txt
diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js
index 4a759708..93df4371 100644
--- a/packages/webui/src/public/bus.js
+++ b/packages/webui/src/public/bus.js
@@ -181,6 +181,12 @@
const when = iso => iso ? `` : 'never';
const clock = iso => iso ? `` : '';
const badge = (text, kind = 'muted') => `${txt(text)}`;
+ // Class chip: the decision class, the action in mono. BLOCKING is its own chip (CSS capitalises it; the text stays lowercase).
+ const CLASSES = ['routine', 'within-role', 'cross-role', 'gated'];
+ const chip = (cls, action) => `${txt(cls)} · ${txt(action)}`;
+ const blockChip = text => `${txt(text)}`;
+ // An empty state says what would appear and where it comes from.
+ const empty = (what, from) => `
${what}${from ? `
${from}
` : ''}
`;
const taskLink = ref => ref ? `#${esc(String(ref).split('/')[1])}` : '';
// Rows without an actor role come from the human (CLI, outside any run) or from the broker itself.
const actor = (a, by) => `${a ? `${txt(a.role)} · ${txt(a.run)}` : by === 'human' ? 'human, CLI outside any run' : 'broker'}`;
@@ -200,10 +206,10 @@
// ---------------------------------------------------------------- views
let inboxCount = null;
function sections(route) {
- const items = [['/', 'Control board', ''], ['/inbox', 'Inbox', inboxCount ? `${inboxCount.open}${inboxCount.blocking ? ` ${badge(`${inboxCount.blocking} blocking`, 'attn')}` : ''}` : ''], ['/tasks', 'Tasks', ''], ['/agents', 'Agents', '']];
- $('sections').innerHTML = items.map(([h, l, extra]) => {
+ const items = [['/', 'Board', '', 'board'], ['/inbox', 'Inbox', inboxCount ? `${inboxCount.open}${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents']];
+ $('sections').innerHTML = items.map(([h, l, extra, icon]) => {
const cur = h === '/' ? route === '/' : route.startsWith(h) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && route.startsWith('/trail/decision'));
- return `
`;
}
@@ -219,7 +225,7 @@
// The broker orders gated first, then oldest; lift blocking ones to the top.
const rows = (await get('inbox')).slice().sort((a, b) => (b.blocking - a.blocking) || String(a.at).localeCompare(String(b.at)));
countInbox(rows);
- const body = rows.length ? `
${rows.map(decisionCard).join('')}
` : '
Nothing is waiting on you.
';
+ const body = rows.length ? `
${rows.map(decisionCard).join('')}
` : empty('Nothing is waiting on you.', 'A decision shows here when an agent raises one routed to you through the bus.');
return `${head('Inbox', `${readNote()} Decisions are answered in a terminal with mosaic decide; Console only shows them.`)}
For you ${rows.length}
${body}
Not shown here yet: ${gap('Decisions routed to roles')} ${gap('Closed decisions')} ${gap('Seen and DM state per row')} Open a decision for its seen, DM and closing history.
`;
@@ -245,8 +251,8 @@
: !c ? `
This decision is routed to ${txt(d.routeTo)}, not to you, so Console offers no command for it.
No tasks in this business yet. Tasks appear after the PM creates them or the poller reads them from Vikunja.
`;
+ if (!ts.length) return `${head('Tasks', freshness(rows))}${empty('No tasks in this business yet.', 'Tasks appear after the PM creates them or the poller reads them from Vikunja.')}`;
const open = ts.filter(t => !t.missing && !t.done), done = ts.filter(t => !t.missing && t.done).sort(byChanged), missing = ts.filter(t => t.missing);
const buckets = [...new Set(open.map(t => t.bucket))].sort((a, b) => a - b);
const groups = buckets.map(b => {
@@ -282,7 +288,7 @@
}).join('');
const doneHtml = done.length ? `
` : empty('No role is held right now.', 'A role shows here while a run holds its claim on the bus.')}
Not shown here yet: ${gap('Role instances, launch limits and model families')} ${gap('Launch state')} ${gap('Live and ended sessions')} ${gap('Credential notices')}
Board seats stay on the control board. This view is about roles.
';
+ if (!shown.length) return empty('Nothing of this kind in the trail.', 'Every step the stack takes on this subject writes a trail row.');
return `${shown.map(r => `
`).join('')}`;
}
async function trailView(get, kind, ref, q) {
@@ -341,8 +347,8 @@
}
// Not found and states
- function notFound(why) { return `${head('Not found')}
`;
const retry = '';
// Why a read failed, in words, with the bus code as given. Never a path or a guess.
function why(err) {
@@ -361,8 +367,18 @@
return `${say} (${code})`;
}
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';
- const failedView = err => `${head(failTitle(err))}
${why(err)} Console shows nothing rather than a guess. Nothing was written.
${retry}
`;
- const staleBanner = err => `
The last read failed. ${why(err)} This is what was read before, ${when(readAt)}. Nothing was changed.
${retry}
`;
+ // 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 === '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' : ''}`;
+ }
// ---------------------------------------------------------------- router
// #/ and anything that is not a #/ route is the control board; the rest is this file's.
@@ -406,20 +422,23 @@
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;
}
if (navigated || shown !== route) { $('bus-view').innerHTML = loadingView(); }
- let html;
+ let html, state = 'fresh';
const live = reader(true);
try { html = await view(live, path, new URLSearchParams(query)); readAt = live.oldest(); }
catch (err) {
const kept = reader(false);
- try { html = await view(kept, path, new URLSearchParams(query)); readAt = kept.oldest(); html = staleBanner(err) + html; }
- catch { html = failedView(err); }
+ 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 = refusal(err) ? 'refused' : 'failed'; }
+ if (refusal(err)) forget();
}
if (g !== gen) return;
+ freshBus(state);
const k = !navigated && shown === route ? keep() : null;
$('bus-view').innerHTML = html; shown = route;
sections(path);
@@ -439,16 +458,82 @@
const b = e.target.closest('#bus-view [data-copy]');
if (b) {
const text = b.dataset.copy, out = $('copy-status');
- try { await navigator.clipboard.writeText(text); out.textContent = `Copied: ${text}`; }
+ try { await navigator.clipboard.writeText(text); out.textContent = `Copied: ${text}`; toast(out.textContent); }
catch {
const r = document.createRange(); r.selectNodeContents(b.previousElementSibling);
const s = getSelection(); s.removeAllRanges(); s.addRange(r);
out.textContent = 'The clipboard is unavailable. The command is selected; press Ctrl+C to copy it.';
+ toast(out.textContent, true);
}
return;
}
+ if (e.target.closest('#toast [data-dismiss]')) { toast(null); return; }
+ const item = e.target.closest('#cmdk-list [data-href]');
+ if (item) { pkGo(item.dataset.href); return; }
+ if (e.target.closest('#cmdk')) { pkOpen(); return; }
if (e.target.closest('#bus-view [data-retry]')) render(false);
});
+
+ // ---------------------------------------------------------------- toast
+ // One line in a polite live region. Success goes after 2.6 s; an error stays until dismissed.
+ let toastTimer;
+ function toast(text, error = false) {
+ const t = $('toast'); clearTimeout(toastTimer);
+ if (text === null) { t.className = 'toast'; t.textContent = ''; return; }
+ t.className = `toast on${error ? ' err' : ''}`;
+ t.innerHTML = `${esc(text)}${error ? '' : ''}`;
+ if (!error) toastTimer = setTimeout(() => toast(null), 2600);
+ }
+
+ // ---------------------------------------------------------------- command palette
+ // Ctrl+K opens it. It lists the views, and the decisions and tasks from the last inbox and
+ // tasks reads (opening it reads both again, GET only). Enter goes; Esc closes and puts focus
+ // back where it was. It only navigates.
+ const VIEWS = [['#/', 'Board'], ['#/inbox', 'Inbox'], ['#/tasks', 'Tasks'], ['#/agents', 'Agents']];
+ let pkAll = [], pkShown = [], pkAt = 0, pkBack = null;
+ function pkItems() {
+ const decisions = (last.get('inbox')?.rows || []).map(d => ({ href: `#/inbox/${enc(d.id)}`, label: first(d.question), group: `decision ${short(d.id)}` }));
+ const tasks = (last.get('tasks')?.rows || []).filter(r => TASK.test(String(r.task_ref))).map(r => { const t = task(r); return { href: `#/tasks/${enc(t.ref)}`, label: `#${t.id} ${t.title ?? '(no title in the current read)'}`, group: 'task' }; });
+ pkAll = [...VIEWS.map(([href, label]) => ({ href, label, group: 'view' })), ...decisions, ...tasks];
+ }
+ function pkFill() {
+ const q = $('cmdk-input').value.trim().toLowerCase();
+ pkShown = pkAll.filter(i => !q || `${i.label} ${i.group}`.toLowerCase().includes(q)).slice(0, 50);
+ pkAt = Math.min(pkAt, Math.max(0, pkShown.length - 1));
+ $('cmdk-list').innerHTML = pkShown.length ? pkShown.map((i, n) => `
${txt(i.label)}${txt(i.group)}
`).join('')
+ : '
No view, decision or task matches.
';
+ if (pkShown.length) { $('cmdk-input').setAttribute('aria-activedescendant', `cmdk-opt-${pkAt}`); $(`cmdk-opt-${pkAt}`)?.scrollIntoView({ block: 'nearest' }); }
+ else $('cmdk-input').removeAttribute('aria-activedescendant');
+ }
+ function pkOpen() {
+ const d = $('cmdk-dialog');
+ 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(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();
+ if (location.hash === href || (href === '#/' && !location.hash.startsWith('#/'))) { render(true); return; }
+ location.hash = href;
+ }
+ $('cmdk-dialog').addEventListener('close', () => { const b = pkBack; pkBack = null; if (b && document.contains(b)) b.focus(); });
+ $('cmdk-input').addEventListener('input', () => { pkAt = 0; pkFill(); });
+ $('cmdk-input').addEventListener('keydown', e => {
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); if (pkShown.length) { pkAt = (pkAt + (e.key === 'ArrowDown' ? 1 : -1) + pkShown.length) % pkShown.length; pkFill(); } }
+ else if (e.key === 'Enter') { e.preventDefault(); if (pkShown[pkAt]) pkGo(pkShown[pkAt].href); }
+ });
+ document.addEventListener('keydown', e => {
+ if ((e.ctrlKey || e.metaKey) && !e.altKey && e.key.toLowerCase() === 'k') { e.preventDefault(); pkOpen(); return; }
+ // Up and down move between the task links of a table, as on the board's session table.
+ const a = e.target.closest?.('#bus-view table.s1-table td.s1-title a');
+ if (!a || (e.key !== 'ArrowDown' && e.key !== 'ArrowUp')) return;
+ const links = [...a.closest('table').querySelectorAll('td.s1-title a')], i = links.indexOf(a);
+ e.preventDefault(); links[i + (e.key === 'ArrowDown' ? 1 : -1)]?.focus();
+ });
window.addEventListener('hashchange', () => render(true));
$('refresh').addEventListener('click', () => render(false));
$('pause').addEventListener('click', () => setTimeout(schedule)); // after app.js flips aria-pressed
diff --git a/packages/webui/src/public/icons.svg b/packages/webui/src/public/icons.svg
new file mode 100644
index 00000000..bda6fd69
--- /dev/null
+++ b/packages/webui/src/public/icons.svg
@@ -0,0 +1,23 @@
+
diff --git a/packages/webui/src/public/index.html b/packages/webui/src/public/index.html
index e87a0824..7faa1883 100644
--- a/packages/webui/src/public/index.html
+++ b/packages/webui/src/public/index.html
@@ -1,24 +1,29 @@
-
+
Mosaic Console
-
+
Skip to content
-MMosaicConsole
-
+MosaicConsole
+
+
+
Control board
Loading board…
-
+
+
Nothing to show.
The board refused the read, so Console shows nothing rather than a guess. The reason is above.
Conversation
Waiting on you 0
Loading sessions…
Seen 0
-
All sessions 0
Select an agent to inspect. Arrow keys move between agents; Enter opens the inspector. Scroll the table for all columns.
+
All sessions 0
Select an agent to inspect. Arrow keys move between agents; Enter opens the inspector. Scroll the table for all columns.