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, basee918a915). 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 on645489e4plus 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:
@@ -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; }
|
||||
|
||||
@@ -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)}` }));
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user