Candidate on base a3f6e9e6, not committed here. Gate green 21:27:35Z-21:30:19Z;
mutants 30/35 killed, 5 equivalent guards explained in build.md.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
791 lines
56 KiB
Diff
791 lines
56 KiB
Diff
diff --git a/packages/webui/README.md b/packages/webui/README.md
|
||
index be26d050..cf91674c 100644
|
||
--- a/packages/webui/README.md
|
||
+++ b/packages/webui/README.md
|
||
@@ -186,7 +186,7 @@ On the page:
|
||
- Not sent shows the broker's code and keeps the draft. A redirect counts
|
||
as not sent: the Console never redirects this route, so only the sign-in
|
||
proxy does, when its session has expired. The page asks for a reload to
|
||
- sign in again.
|
||
+ sign in again, and signs out (row 95, below).
|
||
- Outcome unknown says the message may have been sent, keeps the draft, and
|
||
has no trail to link, since no id came back. Check the role's side, or
|
||
the bus trail from a terminal, before sending again. A 408 counts as
|
||
@@ -312,6 +312,47 @@ there are others. The page says what each state means. It writes nothing;
|
||
the server passes the rows through unchanged. How the bus derives the state
|
||
and its limits: `packages/bus/README.md`, "Agent state".
|
||
|
||
+## Board: held roles and signing out (row 95, #1589)
|
||
+
|
||
+The Board (`#/`) opens with **Held roles**: each role a run holds on the
|
||
+bus, from the same bus `agents` read as `#/agents`, including a claim with
|
||
+no address ("no address"). Each row shows the role, its state and attention
|
||
+(as in "Agent state"), the address, the holding run with its harness, and
|
||
+since when. With send on (`--send`, "Message a role"), each row has a
|
||
+Message action: it opens `#/inbox` with the send form addressed to that role
|
||
+and the caret in the message. Without send there is no action, and the
|
||
+section says so. A refused or failed first read says why and shows no roles
|
||
+rather than a guess; a failed later read keeps the last roles under a stale
|
||
+warning.
|
||
+
|
||
+The machine scan's sessions (Waiting on you, Seen, All sessions and the
|
||
+project list) sit behind **Show machine sessions**, off by default. While
|
||
+it is off the page makes no `/api/board` request and polls nothing for it.
|
||
+The choice is kept in this browser's local storage under
|
||
+`mosaic-console-board` (`{"machine":true}`), like the appearance; where
|
||
+storage is refused it starts off on each load. Turning it off drops the
|
||
+board data and closes an open conversation.
|
||
+
|
||
+Every Console fetch the page makes uses `redirect: 'manual'`. The Console
|
||
+never redirects, so a redirect answer (`opaqueredirect`) is the sign-in
|
||
+proxy's: its session has ended. The first one, on a board read, a bus read,
|
||
+a page read or a send, signs the page out:
|
||
+
|
||
+- One line says "Signed out. Reload the page to sign in." No NetworkError,
|
||
+ failure banner or stale warning is shown for it.
|
||
+- Everything drawn from a Console read is removed (the board, the held
|
||
+ roles, the bus views, an open conversation and the inspector), because
|
||
+ none of it can be refreshed. A read that was in flight is dropped when it
|
||
+ lands.
|
||
+- Refresh and Pause are disabled; nothing polls and no route change reads.
|
||
+- The send form, if open, keeps its draft and receipt so the text isn't
|
||
+ lost; a further Send is refused in the page ("signed out"). A board
|
||
+ Reply that meets the redirect is not sent, and its inspector closes with
|
||
+ the rest of the board, so its draft isn't shown again.
|
||
+- Reloading the page goes through the sign-in proxy again.
|
||
+
|
||
+How long a sign-in lasts is the edge's setting (ops-01), not the Console's.
|
||
+
|
||
## Shell (row 53, #1542)
|
||
|
||
The Console is restyled to `docs/design` (`IMPLEMENTING.md`). It is a restyle
|
||
@@ -501,6 +542,18 @@ the return flow: a send from the view, a tool call and a delayed result while a
|
||
draft is typed, a peer message, a 4.5-million-character answer split into
|
||
continuation parts, and a relaunch mid-turn.
|
||
|
||
+Row 95's tests (`board-held.test.mjs`) seed an in-process broker with one
|
||
+claim that has an address and one that has none. They check the held roles
|
||
+with the machine sessions off by default and no `/api/board` request, the
|
||
+toggle on and off and its persistence across reloads, Message addressing
|
||
+the send form to each role, no action without send, and that every fetch
|
||
+asked for `redirect: 'manual'`. Then a redirect answer on a board read, an
|
||
+`agents` read and a send: one line, no NetworkError, nothing stale left, no
|
||
+further request on a route change, Refresh or the toggle, and the draft
|
||
+kept. `send-browser.test.mjs` also checks the sign-out after its redirect
|
||
+receipt and that a reload works again. Since the held roles lead the Board,
|
||
+the shell test's no-scroll check at 1440px uses a 1000px-tall window.
|
||
+
|
||
Bus tests (`bus.test.mjs`, `bus-browser.test.mjs`) run the routes and the
|
||
views against an in-process broker read through its reader session, so every
|
||
row has the broker's own shape. `humanCall` runs against a stub CLI script.
|
||
diff --git a/packages/webui/src/public/app.js b/packages/webui/src/public/app.js
|
||
index 9b50235c..3587fd71 100644
|
||
--- a/packages/webui/src/public/app.js
|
||
+++ b/packages/webui/src/public/app.js
|
||
@@ -8,6 +8,15 @@
|
||
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 = 'system', failed = false;
|
||
+ // Show machine sessions (#1589): off by default, kept with the page's settings. While it is off the
|
||
+ // page doesn't read /api/board at all; the held roles above come from the bus (bus.js).
|
||
+ let machine = false;
|
||
+ try { machine = JSON.parse(localStorage.getItem('mosaic-console-board'))?.machine === true; } catch {}
|
||
+ // Signed out (#1589): a Console fetch answered with a redirect, so the sign-in proxy turned it away.
|
||
+ // The first one sets body.signed-out (the listener below); after that no Console fetch is made.
|
||
+ const out = () => document.body.classList.contains('signed-out');
|
||
+ const signOut = () => { if (!out()) document.dispatchEvent(new Event('mosaic-signed-out')); };
|
||
+ const signedOut = () => Object.assign(new Error('the sign-in proxy redirected it, so it never reached the Console'), { signedOut: true });
|
||
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 ? `<span class="source">${esc(labels[value] || value)}</span>` : '';
|
||
// 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.
|
||
@@ -97,7 +106,9 @@
|
||
const FIELDS = { text: 'text', thinking: 'text', 'tool-call': 'argumentsText', 'tool-result': 'text', compaction: 'summary' };
|
||
const convURL = params => '/api/conversation?' + new URLSearchParams(params);
|
||
async function getJSON(path) {
|
||
- const res = await fetch(path, { cache: 'no-store' });
|
||
+ if (out()) throw signedOut();
|
||
+ const res = await fetch(path, { cache: 'no-store', redirect: 'manual' });
|
||
+ if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
|
||
let body; try { body = await res.json(); } catch { body = { error: `HTTP ${res.status}` }; }
|
||
return { ok: res.ok, body };
|
||
}
|
||
@@ -281,6 +292,7 @@
|
||
}
|
||
// Freshness line in the command bar: when the board last scanned, and warning words when the last refresh failed.
|
||
function fresh() {
|
||
+ if (!machine) { $('fresh-board').textContent = 'Machine sessions hidden'; return; }
|
||
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 <span class="warn">not read: refused</span>' : failed ? 'Board <span class="warn">not read: the read failed</span>' : 'Board not read yet'; return; }
|
||
const scan = `Board scanned <b>${esc(Number.isFinite(at) ? `${new Date(at).toISOString().slice(11, 19)} UTC` : 'unknown')}</b>${ago ? ` (${ago} ago)` : ''}`;
|
||
@@ -288,12 +300,15 @@
|
||
}
|
||
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) });
|
||
+ if (out()) throw signedOut();
|
||
+ const res = await fetch(path, value === undefined ? { cache: 'no-store', redirect: 'manual' } : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value), redirect: 'manual' });
|
||
+ if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
|
||
const result = await res.json();
|
||
if (!res.ok) throw Object.assign(new Error(result.error || `HTTP ${res.status}`), { status: res.status });
|
||
return result;
|
||
}
|
||
function accept(value) {
|
||
+ if (out()) return; // a read that was in flight when the page signed out
|
||
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; failed = false; $('error').hidden = true; $('error').className = 'banner err';
|
||
$('board-refused').hidden = true; document.body.classList.remove('board-refused');
|
||
@@ -306,6 +321,7 @@
|
||
// 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) {
|
||
+ if (err.signedOut || out()) return;
|
||
const refused = err.status === 403, focused = document.activeElement !== document.body;
|
||
failed = refused ? 'refused' : true; $('error').hidden = false;
|
||
if (refused) { data = null; selected = null; project = null; if (conv) { $('conv-log').replaceChildren(); closeConversation(); } }
|
||
@@ -327,13 +343,36 @@
|
||
// Clearing the board or closing a conversation can remove what had focus: give it to the page.
|
||
if (focused && document.activeElement === document.body) $('main').focus();
|
||
}
|
||
- function schedule() { clearTimeout(timer); if (!paused) timer = setTimeout(refresh, 10000); }
|
||
+ function schedule() { clearTimeout(timer); if (!paused && machine && !out()) timer = setTimeout(refresh, 10000); }
|
||
+ // A board answer that arrives after the toggle went off is dropped, not drawn.
|
||
async function refresh() {
|
||
- if (busy) return;
|
||
+ if (busy || !machine || out()) return;
|
||
busy = true; $('refresh').disabled = true;
|
||
- try { try { accept(await api('/api/board')); } catch (err) { error(err); } await pollConversation(); }
|
||
- finally { busy = false; $('refresh').disabled = false; schedule(); }
|
||
+ try { try { const value = await api('/api/board'); if (machine) accept(value); } catch (err) { if (machine) error(err); } await pollConversation(); }
|
||
+ finally { busy = false; $('refresh').disabled = out(); schedule(); }
|
||
}
|
||
+ // With the toggle off, nothing from the scan stays on the page: the next time it goes on, the board
|
||
+ // starts from a fresh read rather than showing an old scan as current.
|
||
+ function hideMachine() {
|
||
+ clearTimeout(timer);
|
||
+ if (conv) closeConversation();
|
||
+ data = null; selected = null; project = null; failed = false; inspect();
|
||
+ $('error').hidden = true; $('board-refused').hidden = true; document.body.classList.remove('board-refused');
|
||
+ $('projects').innerHTML = '<p class="muted">Machine sessions hidden.</p>';
|
||
+ for (const id of ['waiting', 'seen', 'sessions']) $(id).replaceChildren();
|
||
+ for (const id of ['waiting-count', 'seen-count', 'session-count']) $(id).textContent = '–';
|
||
+ $('footer').textContent = 'Mosaic Stack';
|
||
+ $('status').textContent = 'Machine sessions hidden. Turn on Show machine sessions to read this machine\'s board scan.';
|
||
+ fresh();
|
||
+ }
|
||
+ function showMachine() { $('show-machine').checked = machine; document.body.classList.toggle('machine-off', !machine); }
|
||
+ $('show-machine').onchange = e => {
|
||
+ machine = e.target.checked; showMachine();
|
||
+ try { localStorage.setItem('mosaic-console-board', JSON.stringify({ machine })); } catch {}
|
||
+ if (machine) { $('status').textContent = 'Loading board…'; fresh(); refresh(); } else hideMachine();
|
||
+ };
|
||
+ showMachine();
|
||
+ if (!machine) hideMachine();
|
||
$('refresh').onclick = refresh;
|
||
$('pause').onclick = () => { paused = !paused; $('pause').textContent = paused ? 'Resume' : 'Pause'; $('pause').setAttribute('aria-pressed', String(paused)); schedule(); if (data) render(); };
|
||
$('hide-offline').onchange = $('hide-seen').onchange = render;
|
||
@@ -373,6 +412,7 @@
|
||
if (result.delivered && drafts.get(id) === text) { drafts.delete(id); if (inView && box.value === text) box.value = ''; }
|
||
if (result.delivered && !paused) await refresh();
|
||
} catch (err) {
|
||
+ if (err.signedOut) { receipts.set(id, { error: `Signed out: ${err.message}. Not sent.` }); return; }
|
||
// A 4xx is a refusal before anything ran (the Console's, or the board's: see replyToRow), so
|
||
// nothing was sent. No answer, an unreadable one, a 5xx or a 408 (a proxy can answer it
|
||
// after forwarding) leaves delivery unknown.
|
||
@@ -388,5 +428,20 @@
|
||
const next = { ArrowDown: i + 1, ArrowUp: i - 1, Home: 0, End: buttons.length - 1 }[e.key];
|
||
if (next !== undefined) { e.preventDefault(); buttons[next]?.focus(); }
|
||
});
|
||
- api('/api/config').then(config => { board = config.board; $('board-url').textContent = `Board: ${board}`; }).catch(error).finally(refresh);
|
||
+ // Signed out: everything drawn from a Console read goes, because none of it can be refreshed. The
|
||
+ // line says so once; the send form (bus.js) keeps its draft and receipt. Reload is the way back.
|
||
+ document.addEventListener('mosaic-signed-out', () => {
|
||
+ if (out()) return;
|
||
+ document.body.classList.add('signed-out');
|
||
+ clearTimeout(timer); convGen++;
|
||
+ if (conv) { $('conv-log').replaceChildren(); conv = null; showConversation(false); }
|
||
+ data = null; selected = null; project = null; inspect();
|
||
+ for (const id of ['projects', 'waiting', 'seen', 'sessions']) $(id).replaceChildren();
|
||
+ $('refresh').disabled = $('pause').disabled = true;
|
||
+ const line = $('signed-out');
|
||
+ line.innerHTML = '<p><b>Signed out.</b> Reload the page to sign in.</p>'; line.hidden = false;
|
||
+ // Focus moves to the line only if what had it is gone or disabled, never out of a form being typed in.
|
||
+ setTimeout(() => { const a = document.activeElement; if (!a || a === document.body || a.disabled || !a.getClientRects().length) line.focus(); });
|
||
+ });
|
||
+ api('/api/config').then(config => { board = config.board; $('board-url').textContent = `Board: ${board}`; }).catch(err => { if (machine) error(err); }).finally(refresh);
|
||
})();
|
||
diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js
|
||
index 8b4bcf43..e5a8e179 100644
|
||
--- a/packages/webui/src/public/bus.js
|
||
+++ b/packages/webui/src/public/bus.js
|
||
@@ -24,24 +24,32 @@
|
||
// can show what was read before, labelled as such.
|
||
const last = new Map();
|
||
class ReadError extends Error { constructor(code, message, status) { super(message); this.code = code; this.status = status; } }
|
||
+ // Row 95 (#1589): every Console fetch takes `redirect: 'manual'`. A redirect comes only from the
|
||
+ // sign-in proxy, so the session ended: app.js's listener sets body.signed-out and draws the line,
|
||
+ // and from then on no read is made. A signed-out read throws code `signed-out`, which no view shows.
|
||
+ const out = () => document.body.classList.contains('signed-out');
|
||
+ const signOut = () => { if (!out()) document.dispatchEvent(new Event('mosaic-signed-out')); };
|
||
+ const signedOut = () => new ReadError('signed-out', 'The sign-in proxy redirected the read.', 0);
|
||
// `replies` takes a message id as `id`; trail takes `subject`.
|
||
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' });
|
||
- body = await res.json();
|
||
- } catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
|
||
+ try { res = await fetch(`/api/bus/${verb}${subject === undefined ? '' : `?${verb === 'replies' ? 'id' : '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 };
|
||
}
|
||
// 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.
|
||
async function fetchApi(path) {
|
||
+ if (out()) throw signedOut();
|
||
+ const unreachable = () => Object.assign(new ReadError('unreachable', 'The Console server did not answer.', 0), { module: true });
|
||
let res, body;
|
||
- try {
|
||
- res = await fetch(`/api/${path}`, { cache: 'no-store' });
|
||
- body = await res.json();
|
||
- } catch { throw Object.assign(new ReadError('unreachable', 'The Console server did not answer.', 0), { module: true }); }
|
||
+ try { res = await fetch(`/api/${path}`, { cache: 'no-store', redirect: 'manual' }); } catch { throw unreachable(); }
|
||
+ if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
|
||
+ try { body = await res.json(); } catch { throw unreachable(); }
|
||
if (!res.ok || !body || typeof body !== 'object' || Array.isArray(body)) throw Object.assign(new ReadError(typeof body?.error === 'string' ? body.error : 'invalid-response', typeof body?.message === 'string' ? body.message : `HTTP ${res.status}`, res.status), { module: true });
|
||
return body;
|
||
}
|
||
@@ -263,7 +271,7 @@
|
||
const options = roles ? roles.map(r => `<option value="${esc(r)}"${r === draft.to ? ' selected' : ''}>${txt(r)}${sendRoles.includes(r) ? '' : ' (not held now)'}</option>`).join('') : '';
|
||
const r = receipt;
|
||
const said = !r ? '' : r.kind === 'sent'
|
||
- ? `<p class="receipt">Sent to ${txt(r.to)} ${when(r.at)}, message <code>${txt(short(r.id))}</code>. <a href="#/trail/message/${enc(r.id)}">Its trail</a>.</p>${repliesHtml()}`
|
||
+ ? `<p class="receipt">Sent to ${txt(r.to)} ${when(r.at)}, message <code>${txt(short(r.id))}</code>. <a href="#/trail/message/${enc(r.id)}">Its trail</a>.</p>${out() ? '' : repliesHtml()}`
|
||
: r.kind === 'refused'
|
||
? `<p class="receipt failed">Not sent to ${txt(r.to)}: ${txt(r.message)}${r.message.includes(`(${r.code})`) ? '' : ` (<code>${txt(r.code)}</code>)`}. The draft is kept.</p>`
|
||
: `<p class="receipt failed">Outcome unknown: the message to ${txt(r.to)} may have been sent. Nothing was sent again, and the draft is kept. The bus gave no message id, so there is no trail to link: before sending again, look for it on the role's side or in the bus trail from a terminal.</p>`;
|
||
@@ -407,6 +415,38 @@
|
||
const d = asks[0];
|
||
return `Input needed: <a href="#/inbox/${enc(d.id)}">${txt(first(d.question))}</a> <span class="source">${txt(short(d.id))}</span>${d.blocking ? ` ${blockChip('blocking')}` : ''}${asks.length > 1 ? ` <a href="#/inbox">+${esc(asks.length - 1)} more</a>` : ''}`;
|
||
}
|
||
+ // Row 95 (#1589): the Board's first section, the stack's seats: each held role from the bus
|
||
+ // `agents` read, a claim with no address included. Message opens the send form addressed to the
|
||
+ // role, so it shows only where a send can pass (`send` in /api/config). A refusal keeps nothing;
|
||
+ // any other failure keeps the last read, labelled with its time, as the bus pages do.
|
||
+ let heldErr = null, heldHtml = null, focusSend = false;
|
||
+ function heldTable(claims) {
|
||
+ const cols = ['Role', 'State', 'Attention', 'Address', 'Holder', 'Held since'];
|
||
+ const rows = claims.map(c => `<tr><th scope="row">${txt(c.role)}</th><td>${stateMark(c.state)}${c.working ? `<span class="source">${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}</span>` : ''}</td><td>${attention(c)}</td><td>${c.address ? txt(c.address) : '<span class="muted">no address</span>'}</td><td>${txt(c.holder_run)}${c.harness ? `<span class="source">${txt(c.harness)}</span>` : ''}</td><td>${when(c.at)}</td>${canSend ? `<td>${typeof c.role === 'string' && SAFE.test(c.role) ? `<button type="button" class="btn" data-message-role="${esc(c.role)}" aria-label="Message ${esc(c.role)}">Message</button>` : ''}</td>` : ''}</tr>`).join('');
|
||
+ return `<div class="table-wrap" tabindex="0" role="region" aria-label="Held roles on the board, scroll for all columns"><table class="s1-table s1-agents s1-held"><thead><tr>${cols.map(h => `<th scope="col">${h}</th>`).join('')}${canSend ? '<th scope="col"><span class="sr-only">Message</span></th>' : ''}</tr></thead><tbody>${rows}</tbody></table></div>`;
|
||
+ }
|
||
+ function paintHeld(err) {
|
||
+ heldErr = err;
|
||
+ const box = $('held'), r = last.get('agents');
|
||
+ let html;
|
||
+ if (err && (refusal(err) || !r)) html = `<div class="banner ${refusal(err) ? 'ref' : 'err'}" role="alert"><p>${refusal(err) ? `<b>refused: <code>${txt(err.code)}</code>.</b> ` : ''}${why(err)} Console shows no held roles rather than a guess.</p>${retry}</div>`;
|
||
+ else if (!r) return; // not read yet: the skeleton stays
|
||
+ else {
|
||
+ const claims = r.rows;
|
||
+ html = `${err ? `<div class="banner err s1-stale" role="alert"><p><b>The last read failed.</b> ${why(err)} These are the roles read before, ${when(r.at)}. Nothing was changed.</p>${retry}</div>` : ''}
|
||
+ ${claims.length ? heldTable(claims) : empty('No role is held on the bus right now.', 'A role shows here while a run holds its claim on the bus. Machine sessions are under Show machine sessions.')}
|
||
+ <p class="small muted">Read from the bus ${when(r.at)}.${canSend ? '' : ' Messaging a role needs a Console started with <code>--send</code> behind the sign-in proxy; this one only reads.'}</p>`;
|
||
+ }
|
||
+ $('held-count').textContent = err && (refusal(err) || !r) ? '–' : String(r.rows.length);
|
||
+ if (html === heldHtml) return; // unchanged: no repeated alert, focus stays put
|
||
+ // Keep focus on the same control across the 10 s repaint.
|
||
+ const a = document.activeElement, k = box.contains(a) ? { role: a.dataset?.messageRole, retry: a.hasAttribute?.('data-retry'), wrap: a.classList?.contains('table-wrap') } : null;
|
||
+ box.innerHTML = html; heldHtml = html;
|
||
+ if (k) {
|
||
+ const el = k.role ? [...box.querySelectorAll('[data-message-role]')].find(b => b.dataset.messageRole === k.role) : k.retry ? box.querySelector('[data-retry]') : k.wrap ? box.querySelector('.table-wrap') : null;
|
||
+ (el || $('main')).focus({ preventScroll: true });
|
||
+ }
|
||
+ }
|
||
async function agentsView(get) {
|
||
const claims = await get('agents');
|
||
const rows = claims.map(c => `<tr><th scope="row">${txt(c.role)}</th><td>${stateMark(c.state)}${c.working ? `<span class="source">${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}</span>` : ''}</td><td>${attention(c)}</td><td>${txt(c.holder_run)}</td><td>${txt(c.harness)}${c.address ? `<span class="source">${txt(c.address)}</span>` : ''}</td><td>${when(c.at)}</td></tr>`).join('');
|
||
@@ -616,7 +656,7 @@
|
||
}
|
||
let gen = 0, shown = null, timer;
|
||
const paused = () => $('pause')?.getAttribute('aria-pressed') === 'true';
|
||
- function schedule() { clearTimeout(timer); if (!paused()) timer = setTimeout(() => render(false), 10000); }
|
||
+ function schedule() { clearTimeout(timer); if (!paused() && !out()) timer = setTimeout(() => render(false), 10000); }
|
||
// Keep focus, open <details> and the copy status across a refresh of the same page.
|
||
// In the send form, the focused field and its caret too (its values come from `draft`).
|
||
function keep() {
|
||
@@ -640,6 +680,7 @@
|
||
async function render(navigated) {
|
||
// A refresh due while this render reads would replace it: a navigation would lose its heading focus.
|
||
clearTimeout(timer);
|
||
+ if (out()) return;
|
||
const route = routeOf(), [path, query = ''] = route.split('?'), board = path === '/' || path === '';
|
||
const g = ++gen;
|
||
document.body.classList.toggle('on-bus', !board);
|
||
@@ -648,9 +689,18 @@
|
||
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 (err) { if (refusal(err)) { forget(); if (g === gen) sections(path); } }
|
||
- if (g === gen) schedule();
|
||
+ // 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);
|
||
+ 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);
|
||
+ const refused = failed.find(refusal);
|
||
+ if (refused) forget();
|
||
+ if (g !== gen) return;
|
||
+ if (inbox.status === 'fulfilled' && !refused) countInbox(inbox.value.rows);
|
||
+ paintHeld(refused || (agents.status === 'rejected' ? agents.reason : null));
|
||
+ sections(path); schedule();
|
||
return;
|
||
}
|
||
if (navigated || shown !== route) { $('bus-view').innerHTML = loadingView(SOURCES[path.split('/')[1]]); }
|
||
@@ -658,6 +708,7 @@
|
||
const live = reader(true);
|
||
try { html = await view(live, path, new URLSearchParams(query)); readAt = live.oldest(); }
|
||
catch (err) {
|
||
+ if (err.code === 'signed-out') return;
|
||
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) + (path === '/settings' ? appearance() : ''); state = refusal(err) ? 'refused' : 'failed'; }
|
||
@@ -671,7 +722,10 @@
|
||
const h1 = $('bus-view').querySelector('h1');
|
||
document.title = `${h1 ? h1.textContent : 'Console'} · Mosaic Console`;
|
||
if (k) restore(k);
|
||
+ // Message on the Board (row 95) lands in the form it opened, addressed to the role.
|
||
+ else if (focusSend && $('send-body')) { $('send-body').focus(); announce(`Message to ${draft.to}`); }
|
||
else if (navigated && h1) { h1.focus(); announce(h1.textContent); }
|
||
+ focusSend = false;
|
||
schedule();
|
||
}
|
||
|
||
@@ -697,7 +751,9 @@
|
||
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);
|
||
+ const message = e.target.closest('#held [data-message-role]');
|
||
+ if (message) { draft.to = message.dataset.messageRole; focusSend = true; location.hash = '#/inbox'; return; }
|
||
+ if (e.target.closest('#bus-view [data-retry], #held [data-retry]')) render(false);
|
||
});
|
||
|
||
// ---------------------------------------------------------------- toast
|
||
@@ -774,6 +830,7 @@
|
||
e.preventDefault();
|
||
if (sending) return;
|
||
const to = draft.to, text = draft.body;
|
||
+ if (out()) { receipt = { kind: 'refused', to: to || 'no role', code: 'sign-in', message: 'signed out; reload the page to sign in again' }; paintSend(); announce('Not sent: ' + receipt.message); return; }
|
||
if (!to || !text.trim()) { receipt = { kind: 'refused', to: to || 'no role', code: 'invalid-request', message: to ? 'the message is empty' : 'choose a role first' }; paintSend(); announce('Not sent: ' + receipt.message); return; }
|
||
sending = true; receipt = null; paintSend();
|
||
let res = null, body = null;
|
||
@@ -795,9 +852,24 @@
|
||
} else receipt = { kind: 'unknown', to };
|
||
sending = false; paintSend();
|
||
announce(receipt.kind === 'sent' ? `Sent to ${to}` : receipt.kind === 'refused' ? 'Not sent; draft kept' : 'Outcome unknown; it may have been sent; draft kept');
|
||
+ if (res?.type === 'opaqueredirect') signOut();
|
||
+ });
|
||
+ // Signed out: drop every bus read, kept ones included, and the page drawn from them. The send
|
||
+ // form stays, with its draft and receipt, so nothing typed is lost; it sends nothing more.
|
||
+ document.addEventListener('mosaic-signed-out', () => {
|
||
+ clearTimeout(timer); gen++; forget();
|
||
+ for (const el of [...$('bus-view').children]) if (el.id !== 'send-section') el.remove();
|
||
+ $('held').replaceChildren(); $('held-count').textContent = '–'; heldHtml = null;
|
||
+ sections(routeOf().split('?')[0]); paintSend();
|
||
});
|
||
- // Whether this server takes a send here. The form is drawn on the next Inbox render.
|
||
- fetch('/api/config', { cache: 'no-store' }).then(r => r.json()).then(c => { if (c?.send === true) { canSend = true; if (routeOf().startsWith('/inbox')) render(false); } }).catch(() => {});
|
||
+ // Whether this server takes a send here. The form is drawn on the next Inbox render; the Board's
|
||
+ // Message buttons are drawn from the roles already read.
|
||
+ fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(c => {
|
||
+ if (c?.send !== true) return;
|
||
+ canSend = true;
|
||
+ const path = routeOf().split('?')[0];
|
||
+ if (path.startsWith('/inbox')) render(false); else if (path === '/' || path === '') paintHeld(heldErr);
|
||
+ }).catch(() => {});
|
||
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/index.html b/packages/webui/src/public/index.html
|
||
index 7faa1883..adbcce42 100644
|
||
--- a/packages/webui/src/public/index.html
|
||
+++ b/packages/webui/src/public/index.html
|
||
@@ -11,7 +11,8 @@
|
||
<div class="cmd-right"><label>Palette<select id="palette"></select></label><label>Appearance<select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></label><button class="btn" id="refresh" type="button">Refresh</button><button class="btn" id="pause" type="button" aria-pressed="false">Pause</button></div></header>
|
||
<div class="frame">
|
||
<nav class="tree" aria-label="Console"><h2 class="tree-title">Console</h2><ul class="s1-sections" id="sections"></ul><div id="board-tree"><h2 class="tree-title">Projects</h2><div id="projects"><p class="muted">Loading projects…</p></div><p class="small muted">Select a project to filter sessions. Waiting on you always shows all projects.</p></div></nav>
|
||
-<main id="main" class="content" tabindex="-1"><div class="page-head" id="board-head"><div><h1>Control board</h1><p id="status" class="small muted" role="status">Loading board…</p></div></div>
|
||
+<main id="main" class="content" tabindex="-1"><div id="signed-out" class="banner ref" role="alert" tabindex="-1" hidden></div><div class="page-head" id="board-head"><div><h1>Control board</h1><p id="status" class="small muted" role="status">Loading board…</p></div><div class="filters"><label><input id="show-machine" type="checkbox">Show machine sessions</label></div></div>
|
||
+<section id="held-section" aria-labelledby="held-title"><h2 id="held-title">Held roles <span id="held-count" class="count">–</span></h2><p class="small muted">The stack's seats: each role a run holds on the bus, from the bus <code>agents</code> read. Machine sessions are under Show machine sessions.</p><div id="held"><div class="skel-rows" aria-busy="true"><span class="sr-only">Reading the held roles.</span><span class="skel"></span><span class="skel"></span></div></div></section>
|
||
<div id="error" class="banner err" role="alert" hidden></div>
|
||
<div id="board-refused" class="empty" hidden><b>Nothing to show.</b><p>The board refused the read, so Console shows nothing rather than a guess. The reason is above.</p></div>
|
||
<section id="conversation" class="conversation" aria-labelledby="conv-title" hidden><div class="page-head"><div><h2 id="conv-title" tabindex="-1">Conversation</h2><p id="conv-meta" class="small muted"></p></div><div class="conv-actions"><label>Session<select id="conv-pick"></select></label><button class="btn" id="conv-back" type="button">Back to board</button></div></div>
|
||
diff --git a/packages/webui/src/public/shared/app.css b/packages/webui/src/public/shared/app.css
|
||
index de94a98a..6a1a2f2a 100644
|
||
--- a/packages/webui/src/public/shared/app.css
|
||
+++ b/packages/webui/src/public/shared/app.css
|
||
@@ -214,3 +214,10 @@ footer.foot{font-size:.8rem;color:var(--muted);padding:16px var(--pad);display:f
|
||
@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}
|
||
@media (forced-colors:active){.badge,.btn,.chip,.panel,.wait-item,.task-card,.session-card{border:1px solid CanvasText}.badge::before{background:CanvasText}.nav-link[aria-current=page]{outline:2px solid Highlight}}
|
||
@media print{#toasts,.skip,nav{display:none}.panel{box-shadow:none}}
|
||
+
|
||
+/* Console Board (row 95, #1589): the held roles lead the Board and leave with it; machine sessions
|
||
+ show only under Show machine sessions; signed out, only the line and the send form stay. */
|
||
+body.on-bus #held-section,body.has-conversation #held-section{display:none}
|
||
+#held-section{margin-bottom:20px}
|
||
+body.machine-off #board-view,body.machine-off #board-tree,body.machine-off #error,body.machine-off #board-refused{display:none}
|
||
+body.signed-out #board-head,body.signed-out #held-section,body.signed-out #error,body.signed-out #board-refused,body.signed-out #conversation,body.signed-out #board-view,body.signed-out #inspector,body.signed-out #board-tree,body.signed-out #fresh,body.signed-out #bus-view>:not(#send-section){display:none}
|
||
diff --git a/packages/webui/tests/browser-edge.test.mjs b/packages/webui/tests/browser-edge.test.mjs
|
||
index 27813054..446c9cc2 100644
|
||
--- a/packages/webui/tests/browser-edge.test.mjs
|
||
+++ b/packages/webui/tests/browser-edge.test.mjs
|
||
@@ -22,6 +22,9 @@ test('browser edge states: loading, empty, malformed, stale, hostile/long values
|
||
try {
|
||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'Object.defineProperty(window,"localStorage",{get(){throw new Error("storage denied")}});window.errors=[];addEventListener("error",e=>errors.push(e.message));' });
|
||
await b.viewport(320); await b.navigate(`http://127.0.0.1:${web.address().port}`);
|
||
+ // Row 95: with storage denied the toggle starts off and still turns on; the page throws nothing.
|
||
+ assert.match(await b.evaluate('document.querySelector("#status").textContent'), /Machine sessions hidden/);
|
||
+ await b.evaluate('document.querySelector("#show-machine").click()');
|
||
assert.match(await b.evaluate('document.querySelector("#status").textContent'), /Loading/);
|
||
await boardRequested;
|
||
holdBoard = false; boardResponse.end(JSON.stringify(index));
|
||
diff --git a/packages/webui/tests/browser.mjs b/packages/webui/tests/browser.mjs
|
||
index 6aa7ad77..b6a19826 100644
|
||
--- a/packages/webui/tests/browser.mjs
|
||
+++ b/packages/webui/tests/browser.mjs
|
||
@@ -4,7 +4,10 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
|
||
-export async function browser() {
|
||
+// Row 95 (#1589): the Board's machine sessions are off by default. Most tests read the board scan,
|
||
+// so by default every page this browser opens starts with Show machine sessions on, as if the
|
||
+// person had turned it on before. Pass { machine: false } to see the page as a first visit does.
|
||
+export async function browser({ machine = true } = {}) {
|
||
const profile = await mkdtemp(join(tmpdir(), 'dewey-brand-browser-'));
|
||
const child = spawn(process.env.CHROMIUM || '/usr/bin/chromium', [
|
||
'--headless', '--no-first-run', '--no-default-browser-check',
|
||
@@ -39,6 +42,7 @@ export async function browser() {
|
||
const call = (method, params) => send(method, params, sessionId);
|
||
await call('Page.enable');
|
||
await call('Runtime.enable');
|
||
+ if (machine) await call('Page.addScriptToEvaluateOnNewDocument', { source: `try { localStorage.setItem('mosaic-console-board', '{"machine":true}'); } catch {}` });
|
||
const evaluate = async expression => {
|
||
const r = await call('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
|
||
if (r.exceptionDetails) throw new Error(r.exceptionDetails.text + ': ' + r.result.description);
|
||
diff --git a/packages/webui/tests/send-browser.test.mjs b/packages/webui/tests/send-browser.test.mjs
|
||
index 675c813e..6007e0fc 100644
|
||
--- a/packages/webui/tests/send-browser.test.mjs
|
||
+++ b/packages/webui/tests/send-browser.test.mjs
|
||
@@ -44,7 +44,7 @@ const STUB = `(() => {
|
||
const c = await (await real(url, init)).json();
|
||
return new Response(JSON.stringify({ ...c, send: true }), { headers: { 'content-type': 'application/json' } });
|
||
}
|
||
- if (u !== '/api/bus/message') return real(url, init);
|
||
+ if (u !== '/api/bus/message') { window.__reads = (window.__reads || 0) + 1; return real(url, init); }
|
||
window.__sends.push({ method: init.method, headers: init.headers, body: init.body, redirect: init.redirect });
|
||
const a = window.__answers.shift();
|
||
if (!a) throw new TypeError('no answer queued');
|
||
@@ -145,6 +145,28 @@ test('the send box: held roles, the draft and caret across a refresh, each recei
|
||
await send();
|
||
assert.equal(await receipt(), 'Not sent to coder: the sign-in proxy redirected it, so it never reached the Console; reload the page to sign in again (sign-in). The draft is kept.');
|
||
assert.equal(await value('#send-body'), msg);
|
||
+ // Row 95 (#1589): the redirect also signs the page out. One line says so; the form keeps its
|
||
+ // draft and receipt; nothing read before stays; no further Console fetch is made, and a Send
|
||
+ // is refused in the page.
|
||
+ await wait('!document.querySelector("#signed-out").hidden');
|
||
+ assert.equal(await text('#signed-out'), 'Signed out. Reload the page to sign in.');
|
||
+ assert.equal(await b.evaluate('document.body.classList.contains("signed-out")'), true);
|
||
+ assert.deepEqual(await b.evaluate('[...document.querySelector("#bus-view").children].map(e => e.id)'), ['send-section']);
|
||
+ assert.doesNotMatch(await b.evaluate('document.body.innerText'), /NetworkError|Failed to fetch|did not answer/);
|
||
+ assert.equal(await b.evaluate('document.querySelector("#refresh").disabled && document.querySelector("#pause").disabled'), true);
|
||
+ const reads = await b.evaluate('window.__reads');
|
||
+ await b.evaluate(`location.hash = '#/tasks', true`);
|
||
+ await send();
|
||
+ assert.equal(await receipt(), 'Not sent to coder: signed out; reload the page to sign in again (sign-in). The draft is kept.');
|
||
+ await new Promise(r => setTimeout(r, 500));
|
||
+ assert.equal(await b.evaluate('window.__reads'), reads);
|
||
+ assert.equal((await sends()).length, 3);
|
||
+ assert.equal(await b.evaluate('!!document.querySelector("#bus-view h1")'), false);
|
||
+ // Reloading signs in again (here, nothing redirects) and the page works as before.
|
||
+ await b.navigate(`${base}?again#/inbox`);
|
||
+ await wait('document.querySelector("#send-to option[value=coder]") && document.querySelector("#send-form [data-send]")');
|
||
+ assert.equal(await b.evaluate('document.querySelector("#signed-out").hidden'), true);
|
||
+ await set('#send-to', 'coder'); await set('#send-body', msg);
|
||
// A 408 too: a proxy can answer it after forwarding.
|
||
for (const a of [{ status: 503, body: { error: 'outcome-unknown', message: 'the message may have been sent', sent: 'unknown' } }, { reject: true }, { status: 200, body: { at: 'x' } }, { status: 408, body: { error: 'request timeout' } }]) {
|
||
await answer(a);
|
||
@@ -155,7 +177,7 @@ test('the send box: held roles, the draft and caret across a refresh, each recei
|
||
}
|
||
// Each Send was one POST; none was repeated after the unknown ones.
|
||
await new Promise(r => setTimeout(r, 500));
|
||
- assert.equal((await sends()).length, 7);
|
||
+ assert.equal((await sends()).length, 4);
|
||
|
||
// While a send is pending, Send is disabled and a second press sends nothing; text typed
|
||
// meanwhile is kept when the first one succeeds.
|
||
@@ -165,18 +187,18 @@ test('the send box: held roles, the draft and caret across a refresh, each recei
|
||
assert.equal(await text('#send-form [data-send]'), 'Sending…');
|
||
await b.evaluate('document.querySelector("#send-form [data-send]").click(), true');
|
||
await set('#send-body', 'A second thought');
|
||
- assert.equal((await sends()).length, 8);
|
||
+ assert.equal((await sends()).length, 5);
|
||
await b.evaluate('window.__release(), true');
|
||
await wait('document.querySelector("#send-section .receipt") && document.querySelector("#send-form [data-send]").getAttribute("aria-disabled") === "false"');
|
||
assert.match(await receipt(), new RegExp(`^Sent to coder .*, message ${seeded.id.slice(0, 8)}\\. Its trail\\.$`));
|
||
assert.equal(await value('#send-body'), 'A second thought');
|
||
- assert.equal((await sends()).length, 8);
|
||
+ assert.equal((await sends()).length, 5);
|
||
|
||
// A plain success clears the text and keeps the role.
|
||
await answer({ status: 200, body: { id: seeded.id, at: new Date().toISOString() } });
|
||
await send();
|
||
assert.deepEqual([await value('#send-to'), await value('#send-body')], ['coder', '']);
|
||
- assert.equal(JSON.parse((await sends())[8].body).body, 'A second thought');
|
||
+ assert.equal(JSON.parse((await sends())[5].body).body, 'A second thought');
|
||
|
||
// The receipt links the message's trail, which the bus reads.
|
||
assert.equal(await b.evaluate('document.querySelector("#send-section .receipt a").getAttribute("href")'), `#/trail/message/${seeded.id}`);
|
||
@@ -189,7 +211,7 @@ test('the send box: held roles, the draft and caret across a refresh, each recei
|
||
await b.evaluate(`location.hash = '#/trail/message/no-such-message', true`);
|
||
await wait('document.querySelector("#bus-view h1")?.textContent === "Not found"');
|
||
assert.match(await text('#bus-view'), /No message with id no-such-message\./);
|
||
- assert.equal((await sends()).length, 9);
|
||
+ assert.equal((await sends()).length, 6);
|
||
} finally {
|
||
await b.close(); await close(web); await f.close(); store.close();
|
||
rmSync(root, { recursive: true, force: true });
|
||
diff --git a/packages/webui/tests/shell.test.mjs b/packages/webui/tests/shell.test.mjs
|
||
index fbea206c..2e372903 100644
|
||
--- a/packages/webui/tests/shell.test.mjs
|
||
+++ b/packages/webui/tests/shell.test.mjs
|
||
@@ -364,7 +364,8 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
|
||
|
||
assert.deepEqual(await b.evaluate('window.errors'), []);
|
||
assert.deepEqual(await b.evaluate('window.requests.filter(([m])=>m!=="GET")'), []);
|
||
- await b.viewport(1440, 900); await go('#/tasks', 'Tasks'); await shot('bus-tasks-1440');
|
||
+ // 1000px tall: since row 95 the Board leads with the held roles, so the short page is taller.
|
||
+ await b.viewport(1440, 1000); await go('#/tasks', 'Tasks'); await shot('bus-tasks-1440');
|
||
await b.evaluate('location.hash="#/"'); await wait('document.querySelectorAll("table.sessions tbody tr").length===2'); await shot('board-1440');
|
||
// The side column sits between the command bar and the status footer: a short page has no scroll,
|
||
// and scrolled to the end of a long one the column is still below the bar.
|
||
diff --git a/packages/webui/tests/board-held.test.mjs b/packages/webui/tests/board-held.test.mjs
|
||
new file mode 100644
|
||
index 00000000..19b875a8
|
||
--- /dev/null
|
||
+++ b/packages/webui/tests/board-held.test.mjs
|
||
@@ -0,0 +1,209 @@
|
||
+// Row 95 (#1589): the Board leads with the stack's held roles, from the bus `agents` read, and
|
||
+// keeps the machine scan's sessions behind "Show machine sessions", off by default and kept in
|
||
+// the page's settings. A redirect answer on any Console fetch is the sign-in proxy's: the page
|
||
+// stops and says, in one line, to reload and sign in. The roles come from an in-process broker
|
||
+// through its reader session. A script added before the page loads records every fetch the page
|
||
+// makes, answers /api/config with send on (unless the URL has ?nosend), answers POST
|
||
+// /api/bus/message from a queue, and answers any path in window.__redirect the way fetch does
|
||
+// under redirect: 'manual' (type opaqueredirect, status 0).
|
||
+import { test } from 'node:test';
|
||
+import assert from 'node:assert/strict';
|
||
+import { mkdtempSync, rmSync } from 'node:fs';
|
||
+import { tmpdir } from 'node:os';
|
||
+import { join } from 'node:path';
|
||
+import { Store } from '../../bus/src/store.mjs';
|
||
+import { Broker } from '../../bus/src/broker.mjs';
|
||
+import { views } from '../../bus/src/views.mjs';
|
||
+import { startServer } from '../src/serve.mjs';
|
||
+import { browser } from './browser.mjs';
|
||
+import { fixture, close } from './fixture.mjs';
|
||
+
|
||
+const businesses = {
|
||
+ demo: {
|
||
+ id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' },
|
||
+ roles: {
|
||
+ pm: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||
+ cto: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||
+ coder: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||
+ },
|
||
+ },
|
||
+};
|
||
+
|
||
+const STUB = `(() => {
|
||
+ const real = window.fetch.bind(window);
|
||
+ window.__reqs = []; window.__answers = []; window.__redirect = [];
|
||
+ const redirect = () => ({ type: 'opaqueredirect', ok: false, status: 0, json: async () => { throw new SyntaxError('no body'); } });
|
||
+ window.fetch = async (url, init = {}) => {
|
||
+ const u = String(url), path = u.split('?')[0];
|
||
+ window.__reqs.push({ path, redirect: init.redirect ?? null });
|
||
+ if (window.__redirect.includes(path)) return redirect();
|
||
+ if (path === '/api/config' && !location.search.includes('nosend')) {
|
||
+ const c = await (await real(url, init)).json();
|
||
+ return new Response(JSON.stringify({ ...c, send: true }), { headers: { 'content-type': 'application/json' } });
|
||
+ }
|
||
+ if (path !== '/api/bus/message') return real(url, init);
|
||
+ const a = window.__answers.shift();
|
||
+ if (!a) throw new TypeError('no answer queued');
|
||
+ if (a.redirect) return redirect();
|
||
+ return new Response(JSON.stringify(a.body), { status: a.status, headers: { 'content-type': 'application/json' } });
|
||
+ };
|
||
+})();`;
|
||
+
|
||
+async function setup(machine) {
|
||
+ const root = mkdtempSync(join(tmpdir(), 'webui-board-held-'));
|
||
+ const store = new Store(root), b0 = new Broker({ store, businesses });
|
||
+ // pm's run has an address; coder's claim has none, which the Board still lists.
|
||
+ b0.request(b0.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-run', harness: 'pi', address: 'pm-run' }), { verb: 'role.claim' });
|
||
+ b0.request(b0.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'claude-code' }), { verb: 'role.claim' });
|
||
+ const reader = b0.bindReader({ business: 'demo' });
|
||
+ const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) });
|
||
+ const f = await fixture(), b = await browser({ machine });
|
||
+ const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`;
|
||
+ await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB });
|
||
+ await b.viewport(1280);
|
||
+ const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
|
||
+ const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
|
||
+ const shown = sel => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(sel)});return !!e && !e.hidden && getComputedStyle(e).display !== 'none' && !!e.getClientRects().length})()`);
|
||
+ const reqs = () => b.evaluate('window.__reqs');
|
||
+ const settle = () => new Promise(r => setTimeout(r, 600));
|
||
+ const done = async () => { await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); };
|
||
+ return { b, base, wait, text, shown, reqs, settle, done };
|
||
+}
|
||
+
|
||
+const HELD = 'document.querySelectorAll("#held table tbody tr").length === 2';
|
||
+const SESSIONS = 'document.querySelector("table.sessions [data-open=\'proj/agent1\']")';
|
||
+
|
||
+test('the Board: held roles with no machine sessions, a claim with no address, the toggle kept across a reload, and Message addressed to the role', { timeout: 120000 }, async () => {
|
||
+ const { b, base, wait, text, shown, reqs, settle, done } = await setup(false);
|
||
+ try {
|
||
+ // Off by default: the held roles show, the machine scan is never read.
|
||
+ await b.navigate(base);
|
||
+ await wait(HELD);
|
||
+ const rows = await b.evaluate('[...document.querySelectorAll("#held table tbody tr")].map(r => [...r.cells].map(c => c.textContent))');
|
||
+ const byRole = Object.fromEntries(rows.map(r => [r[0], r]));
|
||
+ assert.deepEqual(Object.keys(byRole).sort(), ['coder', 'pm']);
|
||
+ assert.equal(byRole.coder[3], 'no address');
|
||
+ assert.equal(byRole.pm[3], 'pm-run');
|
||
+ assert.match(byRole.coder[4], /^coder-run/);
|
||
+ assert.match(byRole.coder[1], /idle|waiting|working|unknown/);
|
||
+ assert.equal(await text('#held-count'), '2');
|
||
+ assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#held [data-message-role]")].map(e => e.getAttribute("aria-label")).sort()'), ['Message coder', 'Message pm']);
|
||
+ assert.equal(await b.evaluate('document.querySelector("#show-machine").checked'), false);
|
||
+ assert.match(await text('#status'), /^Machine sessions hidden\./);
|
||
+ assert.equal(await shown('#board-view'), false);
|
||
+ assert.equal(await shown('#board-tree'), false);
|
||
+ await settle();
|
||
+ assert.equal((await reqs()).some(r => r.path === '/api/board'), false);
|
||
+ assert.equal(await b.evaluate(`!!${SESSIONS}`), false);
|
||
+
|
||
+ // On: the machine sessions are read and shown, and the choice is kept for the next load.
|
||
+ await b.evaluate('document.querySelector("#show-machine").click(), true');
|
||
+ await wait(SESSIONS);
|
||
+ assert.equal(await shown('#board-view'), true);
|
||
+ assert.equal(await b.evaluate('localStorage.getItem("mosaic-console-board")'), '{"machine":true}');
|
||
+ await b.navigate(`${base}?on`);
|
||
+ await wait(SESSIONS);
|
||
+ assert.equal(await b.evaluate('document.querySelector("#show-machine").checked'), true);
|
||
+ await wait(HELD);
|
||
+
|
||
+ // Off again: the sessions go, the held roles stay, and the next load reads no board.
|
||
+ await b.evaluate('document.querySelector("#show-machine").click(), true');
|
||
+ await wait(`!${SESSIONS}`);
|
||
+ assert.equal(await shown('#board-view'), false);
|
||
+ assert.equal(await shown('#held-section'), true);
|
||
+ assert.equal(await b.evaluate('localStorage.getItem("mosaic-console-board")'), '{"machine":false}');
|
||
+ await b.navigate(`${base}?off`);
|
||
+ await wait(HELD);
|
||
+ assert.equal(await b.evaluate('document.querySelector("#show-machine").checked'), false);
|
||
+ await settle();
|
||
+ assert.equal((await reqs()).some(r => r.path === '/api/board'), false);
|
||
+
|
||
+ // Message opens the send form addressed to that role, with the caret in the message.
|
||
+ for (const role of ['coder', 'pm']) {
|
||
+ await b.evaluate(`location.hash = '#/', true`);
|
||
+ await wait(HELD);
|
||
+ await b.evaluate(`document.querySelector('#held [data-message-role="${role}"]').click(), true`);
|
||
+ await wait(`location.hash === '#/inbox' && document.querySelector('#send-to')?.value === '${role}' && document.activeElement === document.querySelector('#send-body')`);
|
||
+ assert.equal(await shown('#held-section'), false);
|
||
+ }
|
||
+
|
||
+ // Without send there is no Message action; the roles still show.
|
||
+ await b.navigate(`${base}?nosend`);
|
||
+ await wait(HELD);
|
||
+ assert.equal(await b.evaluate('document.querySelectorAll("#held [data-message-role]").length'), 0);
|
||
+ assert.match(await text('#held'), /--send/);
|
||
+ // Every Console fetch the page made asked for redirect: 'manual'.
|
||
+ assert.deepEqual((await reqs()).filter(r => r.redirect !== 'manual'), []);
|
||
+ } finally { await done(); }
|
||
+});
|
||
+
|
||
+test('a redirect answer on a Board read, a bus read, a conversation read, a board POST, a module read and a send signs the page out: one line, nothing stale, no further reads', { timeout: 120000 }, async () => {
|
||
+ const { b, base, wait, text, shown, reqs, settle, done } = await setup(true);
|
||
+ const LINE = 'Signed out. Reload the page to sign in.';
|
||
+ const out = async () => {
|
||
+ await wait('!document.querySelector("#signed-out").hidden');
|
||
+ assert.equal(await text('#signed-out'), LINE);
|
||
+ assert.equal(await b.evaluate('document.activeElement === document.querySelector("#signed-out") || document.activeElement?.closest?.("#send-section") !== null'), true);
|
||
+ const page = await b.evaluate('document.body.innerText');
|
||
+ // No failure text from the redirected read either: the footer stays visible when signed out.
|
||
+ assert.doesNotMatch(page, /NetworkError|Failed to fetch|did not answer|Board unreachable|[Nn]ot read|last known data/);
|
||
+ assert.equal(page.split(LINE).length, 2);
|
||
+ // Nothing read before stays on the page as if current.
|
||
+ assert.equal(await b.evaluate(`!!${SESSIONS}`), false);
|
||
+ assert.equal(await b.evaluate('document.querySelector("#held").children.length'), 0);
|
||
+ for (const sel of ['#held-section', '#board-view', '#board-head', '#error']) assert.equal(await shown(sel), false, sel);
|
||
+ assert.equal(await b.evaluate('document.querySelector("#refresh").disabled'), true);
|
||
+ // No further Console fetch: not on a route change, a Refresh, the toggle or the poll.
|
||
+ const n = (await reqs()).length;
|
||
+ await b.evaluate(`location.hash = location.hash === '#/agents' ? '#/inbox' : '#/agents', true`);
|
||
+ await b.evaluate('document.querySelector("#refresh").click(), document.querySelector("#show-machine").click(), document.querySelector("#show-machine").click(), true');
|
||
+ await settle();
|
||
+ assert.equal((await reqs()).length, n);
|
||
+ assert.equal(await b.evaluate('!!document.querySelector("#bus-view h1")'), false);
|
||
+ assert.deepEqual((await reqs()).filter(r => r.redirect !== 'manual'), []);
|
||
+ };
|
||
+ try {
|
||
+ // A Board read: the machine scan's /api/board, after a good first read.
|
||
+ await b.navigate(base);
|
||
+ await wait(`${SESSIONS} && ${HELD}`);
|
||
+ await b.evaluate(`window.__redirect.push('/api/board'), document.querySelector('#refresh').click(), true`);
|
||
+ await out();
|
||
+
|
||
+ // A bus read: the held roles' agents read, on a later Refresh.
|
||
+ await b.navigate(`${base}?bus`);
|
||
+ await wait(`${SESSIONS} && ${HELD}`);
|
||
+ assert.equal(await b.evaluate('document.querySelector("#signed-out").hidden'), true);
|
||
+ await b.evaluate(`window.__redirect.push('/api/bus/agents'), document.querySelector('#refresh').click(), true`);
|
||
+ await out();
|
||
+
|
||
+ // A conversation read: History on a session asks for the catalogue.
|
||
+ await b.navigate(`${base}?conv`);
|
||
+ await wait(`${SESSIONS} && ${HELD}`);
|
||
+ await b.evaluate(`window.__redirect.push('/api/conversations'), document.querySelector('table.sessions [data-history="proj/agent1"]').click(), true`);
|
||
+ await out();
|
||
+
|
||
+ // A board POST: Seen in the inspector posts /api/seen.
|
||
+ await b.navigate(`${base}?seen`);
|
||
+ await wait(`${SESSIONS} && ${HELD}`);
|
||
+ await b.evaluate(`document.querySelector('table.sessions [data-open="proj/agent1"]').click(), true`);
|
||
+ await wait('document.querySelector("#inspection [data-seen]")');
|
||
+ await b.evaluate(`window.__redirect.push('/api/seen'), document.querySelector('#inspection [data-seen]').click(), true`);
|
||
+ await out();
|
||
+
|
||
+ // A module read: the Queue page's /api/queue.
|
||
+ await b.navigate(`${base}?queue`);
|
||
+ await wait(`${SESSIONS} && ${HELD}`);
|
||
+ await b.evaluate(`window.__redirect.push('/api/queue'), location.hash = '#/queue', true`);
|
||
+ await out();
|
||
+
|
||
+ // A send: the receipt says it was not sent and keeps the draft; the form stays for the draft.
|
||
+ await b.navigate(`${base}?send#/inbox`);
|
||
+ await wait('document.querySelector("#send-to option[value=coder]") && document.querySelector("#send-form [data-send]")');
|
||
+ await b.evaluate(`(()=>{const t=document.querySelector('#send-to'),m=document.querySelector('#send-body');t.value='coder';t.dispatchEvent(new Event('input',{bubbles:true}));m.value='Kept draft';m.dispatchEvent(new Event('input',{bubbles:true}));window.__answers.push({redirect:true});document.querySelector('#send-form [data-send]').click();return true})()`);
|
||
+ await wait('document.querySelector("#send-section .receipt")');
|
||
+ assert.match(await text('#send-section .receipt'), /^Not sent to coder: the sign-in proxy redirected it, so it never reached the Console; reload the page to sign in again \(sign-in\)\. The draft is kept\./);
|
||
+ assert.equal(await b.evaluate('document.querySelector("#send-body").value'), 'Kept draft');
|
||
+ assert.equal(await shown('#send-section'), true);
|
||
+ await out();
|
||
+ } finally { await done(); }
|
||
+});
|