Files
stack/agents/filbert/work/queue-95/build.patch
T
jason.woltjeandClaude Opus 5.5 27eda72309 filbert: row 95 build packet (#1589), manifest 246c0557
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]>
2026-10-12 16:31:43 -05:00

791 lines
56 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(); }
+});