bus, cli, webui: every message to the human, read state, Messages page (#1588)

Row 94, round 2. Rocko built it, Dewey approved it (comment 28104) on
manifest 53b01ea9 (16 paths, base e918a915). The bus keeps a messages
view with read state, where delivered but not read stays read:false. The
CLI gains messages and follow, and inbox leaves out messages already
read while inbox --json stays byte-compatible. The Console gets a
Messages page and an unread count in the nav. Mos gave the go under
decision 123 (public/ files). Sage gate on 645489e4 plus the patch,
12:52:14Z to 12:55:44Z: bus 115, cli 129, webui 104, all fail 0. Every
scripts/test-*.sh exited 0 (auth 15, conductor 17, config 24, discord
66, extension-package 18, foundation 44, queue 27, release 14, task 98).
Manifest 16/16 before and after.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-13 07:58:36 -05:00
co-authored by Claude Opus 5.5
parent 645489e4cc
commit 2b2a9e14ee
16 changed files with 441 additions and 42 deletions
+3
View File
@@ -39,3 +39,6 @@ table.s1-table tbody th{font-weight:600;text-transform:none;letter-spacing:0;col
@media(max-width:699px){.s1-trow{grid-template-columns:3.6em minmax(0,1fr)}.s1-ttext{grid-column:1/-1}}
@media(max-width:959px){.s1-sections{display:flex;flex-wrap:wrap;flex-basis:100%;gap:6px}.s1-section{border-color:var(--border);border-radius:99px;padding:5px 12px}}
@media (forced-colors:active){.s1-section[aria-current=page]{outline:2px solid Highlight}.s1-trow::before{background:CanvasText}}
/* Human message bodies keep their line breaks and wrap long literal text. */
#message-list .s1-message { white-space: pre-wrap; overflow-wrap: anywhere; }
+29 -9
View File
@@ -34,12 +34,12 @@
async function fetchRead(verb, subject) {
if (out()) throw signedOut();
let res, body;
try { res = await fetch(`/api/bus/${verb}${subject === undefined ? '' : `?${verb === 'replies' ? 'id' : 'subject'}=${enc(subject)}`}`, { cache: 'no-store', redirect: 'manual' }); }
try { res = await fetch(`/api/bus/${verb}${subject === undefined ? '' : `?${verb === 'replies' ? 'id' : verb === 'messages' ? 'after' : 'subject'}=${enc(subject)}`}`, { cache: 'no-store', redirect: 'manual' }); }
catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
try { body = await res.json(); } catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
if (!res.ok || !body || !Array.isArray(body.rows)) throw new ReadError(typeof body?.error === 'string' ? body.error : 'invalid-response', typeof body?.message === 'string' ? body.message : `HTTP ${res.status}`, res.status);
return { rows: body.rows, at: body.at };
return { ...body, rows: body.rows, at: body.at };
}
// Row 54 (#1543): the queue, business and settings reads (src/reads.mjs), GET only. Their
// error carries the module's own message, which the server has already redacted.
@@ -62,6 +62,7 @@
if (live) { r = await fetchRead(verb, subject); last.set(k, r); }
else if (!(r = last.get(k))) throw new ReadError('not-read', 'not read before');
used.push(r.at);
if (verb === 'messages') { messageCount = r.unread; return r; }
return r.rows;
};
get.api = async path => {
@@ -235,9 +236,9 @@
const user = u => `user ${esc(u)}`;
// ---------------------------------------------------------------- views
let inboxCount = null;
let inboxCount = null, messageCount = null;
function sections(route) {
const items = [['/', 'Board', '', 'board'], ['/inbox', 'Inbox', inboxCount ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents'], ['/queue', 'Queue', '', 'queue'], ['/business', 'Business', '', 'business'], ['/settings', 'Settings', '', 'settings']];
const items = [['/', 'Board', '', 'board'], ['/inbox', 'Inbox', inboxCount ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/messages', 'Messages', messageCount === null ? '' : `<span class="count" aria-label="${messageCount} unread messages">${messageCount}</span>`, 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents'], ['/queue', 'Queue', '', 'queue'], ['/business', 'Business', '', 'business'], ['/settings', 'Settings', '', 'settings']];
$('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') || route.startsWith('/trail/message')));
return `<li><a href="#${h}" class="s1-section"${cur ? ' aria-current="page"' : ''}><svg class="i" aria-hidden="true"><use href="/icons.svg#i-${icon}"/></svg>${l}${extra ? ` ${extra}` : ''}</a></li>`;
@@ -312,6 +313,17 @@
<h2 id="b-for-you">For you <span class="count">${rows.length}</span></h2>${body}
<p class="small muted">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.</p>`;
}
async function messagesView(get, q) {
const after = q.get('after');
if (after !== null && !SAFE.test(after)) return notFound('The message cursor is not a message id.');
const page = await get('messages', after === null ? undefined : after);
const body = page.rows.length ? `<ul id="message-list" class="waiting s1-replies">${page.rows.map(m =>
`<li class="wait-item"><p class="small muted">${txt(m.from_role)} · ${txt(m.class)} · ${when(m.at)} · ${m.read ? 'Read' : 'Unread'} · <a href="#/trail/message/${enc(m.id)}">trail</a></p><p class="s1-message">${txt(m.body)}</p></li>`).join('')}</ul>`
: empty('No messages to you.', 'Messages appear here as the bus is read.');
return `${head('Messages', `${readNote()} Every message to you, newest first. Reading here marks nothing read.`)}
<p class="small muted">${page.total} messages · ${page.unread} unread</p>${body}
<nav aria-label="Message pages">${after ? '<a href="#/messages">Newest messages</a> · ' : ''}${page.next ? `<a data-older href="#/messages?after=${enc(page.next)}">Older messages</a>` : ''}</nav>`;
}
async function decisionView(get, id) {
const open = (await get('inbox')).find(r => r.id === id) || null, d = await decision(get, id, open);
if (!d) return notFound(`No decision with id ${txt(id)}.`);
@@ -621,7 +633,7 @@
// 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', 'queue-refused'].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; }
function forget() { last.clear(); inboxCount = null; messageCount = 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.' : err.module ? 'Read again.' : '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.
@@ -642,6 +654,7 @@
const [a, b, c] = parts, n = parts.length;
if (a === 'inbox' && n === 1) return inboxView(get);
if (a === 'inbox' && n === 2) return SAFE.test(b) ? decisionView(get, b) : notFound(`${txt(b)} is not a decision id.`);
if (a === 'messages' && n === 1) return messagesView(get, q);
if (a === 'tasks' && n === 1) return tasksView(get);
if (a === 'tasks' && n === 2) return TASK.test(b) ? taskView(get, b) : notFound(`${txt(b)} is not a task reference.`);
if (a === 'agents' && n === 1) return agentsView(get);
@@ -690,15 +703,17 @@
// 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(); }
// The held roles (row 95) are read with it. A refusal of either forgets both, as on the bus pages.
const [inbox, agents] = await Promise.allSettled([fetchRead('inbox'), fetchRead('agents')]);
const failed = [inbox, agents].filter(s => s.status === 'rejected').map(s => s.reason);
const [inbox, agents, messages] = await Promise.allSettled([fetchRead('inbox'), fetchRead('agents'), fetchRead('messages')]);
const failed = [inbox, agents, messages].filter(s => s.status === 'rejected').map(s => s.reason);
if (failed.some(err => err.code === 'signed-out')) return;
if (inbox.status === 'fulfilled') last.set('inbox', inbox.value);
if (agents.status === 'fulfilled') last.set('agents', agents.value);
if (messages.status === 'fulfilled') last.set('messages', messages.value);
const refused = failed.find(refusal);
if (refused) forget();
if (g !== gen) return;
if (inbox.status === 'fulfilled' && !refused) countInbox(inbox.value.rows);
if (messages.status === 'fulfilled' && !refused) messageCount = messages.value.unread;
paintHeld(refused || (agents.status === 'rejected' ? agents.reason : null));
sections(path); schedule();
return;
@@ -706,7 +721,12 @@
if (navigated || shown !== route) { $('bus-view').innerHTML = loadingView(SOURCES[path.split('/')[1]]); }
let html, state = 'fresh';
const live = reader(true);
try { html = await view(live, path, new URLSearchParams(query)); readAt = live.oldest(); }
try {
if (path !== '/messages') {
try { await live('messages'); }
catch (err) { messageCount = null; if (err.status === 403 || err.code === 'signed-out') throw err; }
}
html = await view(live, path, new URLSearchParams(query)); readAt = live.oldest(); }
catch (err) {
if (err.code === 'signed-out') return;
const kept = reader(false);
@@ -771,7 +791,7 @@
// 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'], ['#/queue', 'Queue'], ['#/business', 'Business'], ['#/settings', 'Settings']];
const VIEWS = [['#/', 'Board'], ['#/inbox', 'Inbox'], ['#/messages', 'Messages'], ['#/tasks', 'Tasks'], ['#/agents', 'Agents'], ['#/queue', 'Queue'], ['#/business', 'Business'], ['#/settings', 'Settings']];
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)}` }));
+20 -1
View File
@@ -49,7 +49,7 @@ async function body(req, max = 4096) {
// Row 57 (#1546) adds `decisions`, also a read, for the Inbox tabs. Row 60
// (#1549) adds `credentials`: what the bus host loaded, metadata only. Row 81
// (#1575) adds `replies`: the messages answering one message, for the send receipt.
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies'];
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies', 'messages'];
const DECISION_LISTS = ['roles', 'recent'];
async function busRead(res, bus, verb, search) {
let arg;
@@ -65,6 +65,25 @@ async function busRead(res, bus, verb, search) {
arg = new URLSearchParams(search).get('id');
if (!subjectOk(arg)) return json(res, 400, { error: 'invalid-request', message: 'id must be a message id' });
}
if (verb === 'messages') {
const query = new URLSearchParams(search), args = {};
const invalid = [...query.keys()].some(k => !['limit', 'after'].includes(k) || query.getAll(k).length !== 1);
if (invalid || (query.has('limit') && (!/^[1-9]\d*$/.test(query.get('limit')) || Number(query.get('limit')) > 200)) ||
(query.has('after') && !subjectOk(query.get('after'))))
return json(res, 400, { error: 'invalid-request', message: 'messages takes limit (1–200) and an after message id' });
if (query.has('limit')) args.limit = Number(query.get('limit'));
if (query.has('after')) args.after = query.get('after');
if (!bus) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
try {
const page = await bus.messages(args);
if (!page || !Array.isArray(page.messages)) throw new BusReadError('invalid-response');
const { messages: rows, ...metadata } = page;
return json(res, 200, { rows, ...metadata, at: new Date().toISOString() });
} catch (err) {
const code = err instanceof BusReadError ? err.code : 'invalid-response';
return json(res, busStatus(code), { error: code, message: err instanceof BusReadError ? err.message : code });
}
}
if (!bus) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
try {
const rows = await bus[verb](arg);