webui: the Console's Board shows held roles, machine sessions behind a toggle, a signed-out line (#1589)
Row 95, round 1. The Board's first section lists each role a run holds
on the bus (from /api/bus/agents), with a Message button on a Console
started with --send. Machine sessions sit behind a "Show machine
sessions" checkbox, off by default, kept in localStorage; while off the
page never reads /api/board. Every Console fetch passes
redirect: 'manual'; the first opaqueredirect signs the page out: timers
stop, reads are dropped, one line says to reload, and the send form
keeps its draft.
Candidate manifest 246c0557 (agents/filbert/work/queue-95/), base
a3f6e9e6, no drift on the manifest paths. Builder Filbert. Approved by
Darkwing (comment 27953). Mutants 30/35 killed, 5 redundant guards.
Sage's gate 2026-10-12T21:52:13Z-21:54:35Z: webui 86/0, control-board
124/0, every scripts/test-*.sh green.
Live on landing: serve.mjs reads src/public on every request with
no-store (decision 123). Mos gave the go (sage-0115).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -186,7 +186,7 @@ On the page:
|
|||||||
- Not sent shows the broker's code and keeps the draft. A redirect counts
|
- 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
|
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
|
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
|
- 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
|
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
|
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
|
the server passes the rows through unchanged. How the bus derives the state
|
||||||
and its limits: `packages/bus/README.md`, "Agent 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)
|
## Shell (row 53, #1542)
|
||||||
|
|
||||||
The Console is restyled to `docs/design` (`IMPLEMENTING.md`). It is a restyle
|
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
|
draft is typed, a peer message, a 4.5-million-character answer split into
|
||||||
continuation parts, and a relaunch mid-turn.
|
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
|
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
|
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.
|
row has the broker's own shape. `humanCall` runs against a stub CLI script.
|
||||||
|
|||||||
@@ -8,6 +8,15 @@
|
|||||||
const awaiting = new Map();
|
const awaiting = new Map();
|
||||||
let data = null, project = null, selected = null, returnFocus = null, paused = false, busy = false, timer;
|
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;
|
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 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>` : '';
|
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.
|
// 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 FIELDS = { text: 'text', thinking: 'text', 'tool-call': 'argumentsText', 'tool-result': 'text', compaction: 'summary' };
|
||||||
const convURL = params => '/api/conversation?' + new URLSearchParams(params);
|
const convURL = params => '/api/conversation?' + new URLSearchParams(params);
|
||||||
async function getJSON(path) {
|
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}` }; }
|
let body; try { body = await res.json(); } catch { body = { error: `HTTP ${res.status}` }; }
|
||||||
return { ok: res.ok, body };
|
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.
|
// Freshness line in the command bar: when the board last scanned, and warning words when the last refresh failed.
|
||||||
function fresh() {
|
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;
|
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; }
|
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)` : ''}`;
|
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);
|
setInterval(() => { if (data) fresh(); }, 5000);
|
||||||
async function api(path, value) {
|
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();
|
const result = await res.json();
|
||||||
if (!res.ok) throw Object.assign(new Error(result.error || `HTTP ${res.status}`), { status: res.status });
|
if (!res.ok) throw Object.assign(new Error(result.error || `HTTP ${res.status}`), { status: res.status });
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
function accept(value) {
|
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');
|
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';
|
data = value; failed = false; $('error').hidden = true; $('error').className = 'banner err';
|
||||||
$('board-refused').hidden = true; document.body.classList.remove('board-refused');
|
$('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
|
// 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.
|
// and says why, so a later failure never brings back old rows.
|
||||||
function error(err) {
|
function error(err) {
|
||||||
|
if (err.signedOut || out()) return;
|
||||||
const refused = err.status === 403, focused = document.activeElement !== document.body;
|
const refused = err.status === 403, focused = document.activeElement !== document.body;
|
||||||
failed = refused ? 'refused' : true; $('error').hidden = false;
|
failed = refused ? 'refused' : true; $('error').hidden = false;
|
||||||
if (refused) { data = null; selected = null; project = null; if (conv) { $('conv-log').replaceChildren(); closeConversation(); } }
|
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.
|
// 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();
|
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() {
|
async function refresh() {
|
||||||
if (busy) return;
|
if (busy || !machine || out()) return;
|
||||||
busy = true; $('refresh').disabled = true;
|
busy = true; $('refresh').disabled = true;
|
||||||
try { try { accept(await api('/api/board')); } catch (err) { error(err); } await pollConversation(); }
|
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 = false; schedule(); }
|
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;
|
$('refresh').onclick = refresh;
|
||||||
$('pause').onclick = () => { paused = !paused; $('pause').textContent = paused ? 'Resume' : 'Pause'; $('pause').setAttribute('aria-pressed', String(paused)); schedule(); if (data) render(); };
|
$('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;
|
$('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 && drafts.get(id) === text) { drafts.delete(id); if (inView && box.value === text) box.value = ''; }
|
||||||
if (result.delivered && !paused) await refresh();
|
if (result.delivered && !paused) await refresh();
|
||||||
} catch (err) {
|
} 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
|
// 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
|
// nothing was sent. No answer, an unreadable one, a 5xx or a 408 (a proxy can answer it
|
||||||
// after forwarding) leaves delivery unknown.
|
// 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];
|
const next = { ArrowDown: i + 1, ArrowUp: i - 1, Home: 0, End: buttons.length - 1 }[e.key];
|
||||||
if (next !== undefined) { e.preventDefault(); buttons[next]?.focus(); }
|
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);
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -24,24 +24,32 @@
|
|||||||
// can show what was read before, labelled as such.
|
// can show what was read before, labelled as such.
|
||||||
const last = new Map();
|
const last = new Map();
|
||||||
class ReadError extends Error { constructor(code, message, status) { super(message); this.code = code; this.status = status; } }
|
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`.
|
// `replies` takes a message id as `id`; trail takes `subject`.
|
||||||
async function fetchRead(verb, subject) {
|
async function fetchRead(verb, subject) {
|
||||||
|
if (out()) throw signedOut();
|
||||||
let res, body;
|
let res, body;
|
||||||
try {
|
try { res = await fetch(`/api/bus/${verb}${subject === undefined ? '' : `?${verb === 'replies' ? 'id' : 'subject'}=${enc(subject)}`}`, { cache: 'no-store', redirect: 'manual' }); }
|
||||||
res = await fetch(`/api/bus/${verb}${subject === undefined ? '' : `?${verb === 'replies' ? 'id' : 'subject'}=${enc(subject)}`}`, { cache: 'no-store' });
|
catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
|
||||||
body = await res.json();
|
if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
|
||||||
} catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
|
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);
|
if (!res.ok || !body || !Array.isArray(body.rows)) throw new ReadError(typeof body?.error === 'string' ? body.error : 'invalid-response', typeof body?.message === 'string' ? body.message : `HTTP ${res.status}`, res.status);
|
||||||
return { rows: body.rows, at: body.at };
|
return { rows: body.rows, at: body.at };
|
||||||
}
|
}
|
||||||
// Row 54 (#1543): the queue, business and settings reads (src/reads.mjs), GET only. Their
|
// 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.
|
// error carries the module's own message, which the server has already redacted.
|
||||||
async function fetchApi(path) {
|
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;
|
let res, body;
|
||||||
try {
|
try { res = await fetch(`/api/${path}`, { cache: 'no-store', redirect: 'manual' }); } catch { throw unreachable(); }
|
||||||
res = await fetch(`/api/${path}`, { cache: 'no-store' });
|
if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
|
||||||
body = await res.json();
|
try { body = await res.json(); } catch { throw unreachable(); }
|
||||||
} catch { throw Object.assign(new ReadError('unreachable', 'The Console server did not answer.', 0), { module: true }); }
|
|
||||||
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 });
|
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;
|
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 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 r = receipt;
|
||||||
const said = !r ? '' : r.kind === 'sent'
|
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'
|
: 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">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>`;
|
: `<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];
|
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>` : ''}`;
|
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) {
|
async function agentsView(get) {
|
||||||
const claims = await get('agents');
|
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('');
|
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;
|
let gen = 0, shown = null, timer;
|
||||||
const paused = () => $('pause')?.getAttribute('aria-pressed') === 'true';
|
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.
|
// 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`).
|
// In the send form, the focused field and its caret too (its values come from `draft`).
|
||||||
function keep() {
|
function keep() {
|
||||||
@@ -640,6 +680,7 @@
|
|||||||
async function render(navigated) {
|
async function render(navigated) {
|
||||||
// A refresh due while this render reads would replace it: a navigation would lose its heading focus.
|
// A refresh due while this render reads would replace it: a navigation would lose its heading focus.
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
|
if (out()) return;
|
||||||
const route = routeOf(), [path, query = ''] = route.split('?'), board = path === '/' || path === '';
|
const route = routeOf(), [path, query = ''] = route.split('?'), board = path === '/' || path === '';
|
||||||
const g = ++gen;
|
const g = ++gen;
|
||||||
document.body.classList.toggle('on-bus', !board);
|
document.body.classList.toggle('on-bus', !board);
|
||||||
@@ -648,9 +689,18 @@
|
|||||||
if (board) {
|
if (board) {
|
||||||
// The board page keeps its own title; the Inbox count still follows the bus.
|
// 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(); }
|
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); } }
|
// The held roles (row 95) are read with it. A refusal of either forgets both, as on the bus pages.
|
||||||
catch (err) { if (refusal(err)) { forget(); if (g === gen) sections(path); } }
|
const [inbox, agents] = await Promise.allSettled([fetchRead('inbox'), fetchRead('agents')]);
|
||||||
if (g === gen) schedule();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (navigated || shown !== route) { $('bus-view').innerHTML = loadingView(SOURCES[path.split('/')[1]]); }
|
if (navigated || shown !== route) { $('bus-view').innerHTML = loadingView(SOURCES[path.split('/')[1]]); }
|
||||||
@@ -658,6 +708,7 @@
|
|||||||
const live = reader(true);
|
const live = reader(true);
|
||||||
try { html = await view(live, path, new URLSearchParams(query)); readAt = live.oldest(); }
|
try { html = await view(live, path, new URLSearchParams(query)); readAt = live.oldest(); }
|
||||||
catch (err) {
|
catch (err) {
|
||||||
|
if (err.code === 'signed-out') return;
|
||||||
const kept = reader(false);
|
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'; }
|
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'; }
|
catch { html = failedView(err) + (path === '/settings' ? appearance() : ''); state = refusal(err) ? 'refused' : 'failed'; }
|
||||||
@@ -671,7 +722,10 @@
|
|||||||
const h1 = $('bus-view').querySelector('h1');
|
const h1 = $('bus-view').querySelector('h1');
|
||||||
document.title = `${h1 ? h1.textContent : 'Console'} · Mosaic Console`;
|
document.title = `${h1 ? h1.textContent : 'Console'} · Mosaic Console`;
|
||||||
if (k) restore(k);
|
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); }
|
else if (navigated && h1) { h1.focus(); announce(h1.textContent); }
|
||||||
|
focusSend = false;
|
||||||
schedule();
|
schedule();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -697,7 +751,9 @@
|
|||||||
const item = e.target.closest('#cmdk-list [data-href]');
|
const item = e.target.closest('#cmdk-list [data-href]');
|
||||||
if (item) { pkGo(item.dataset.href); return; }
|
if (item) { pkGo(item.dataset.href); return; }
|
||||||
if (e.target.closest('#cmdk')) { pkOpen(); 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
|
// ---------------------------------------------------------------- toast
|
||||||
@@ -774,6 +830,7 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (sending) return;
|
if (sending) return;
|
||||||
const to = draft.to, text = draft.body;
|
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; }
|
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();
|
sending = true; receipt = null; paintSend();
|
||||||
let res = null, body = null;
|
let res = null, body = null;
|
||||||
@@ -795,9 +852,24 @@
|
|||||||
} else receipt = { kind: 'unknown', to };
|
} else receipt = { kind: 'unknown', to };
|
||||||
sending = false; paintSend();
|
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');
|
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();
|
||||||
});
|
});
|
||||||
// Whether this server takes a send here. The form is drawn on the next Inbox render.
|
// Signed out: drop every bus read, kept ones included, and the page drawn from them. The send
|
||||||
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(() => {});
|
// 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; 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));
|
window.addEventListener('hashchange', () => render(true));
|
||||||
$('refresh').addEventListener('click', () => render(false));
|
$('refresh').addEventListener('click', () => render(false));
|
||||||
$('pause').addEventListener('click', () => setTimeout(schedule)); // after app.js flips aria-pressed
|
$('pause').addEventListener('click', () => setTimeout(schedule)); // after app.js flips aria-pressed
|
||||||
|
|||||||
@@ -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="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">
|
<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>
|
<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="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>
|
<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>
|
<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>
|
||||||
|
|||||||
@@ -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 (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 (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}}
|
@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}
|
||||||
|
|||||||
@@ -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(); }
|
||||||
|
});
|
||||||
@@ -22,6 +22,9 @@ test('browser edge states: loading, empty, malformed, stale, hostile/long values
|
|||||||
try {
|
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.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}`);
|
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/);
|
assert.match(await b.evaluate('document.querySelector("#status").textContent'), /Loading/);
|
||||||
await boardRequested;
|
await boardRequested;
|
||||||
holdBoard = false; boardResponse.end(JSON.stringify(index));
|
holdBoard = false; boardResponse.end(JSON.stringify(index));
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
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 profile = await mkdtemp(join(tmpdir(), 'dewey-brand-browser-'));
|
||||||
const child = spawn(process.env.CHROMIUM || '/usr/bin/chromium', [
|
const child = spawn(process.env.CHROMIUM || '/usr/bin/chromium', [
|
||||||
'--headless', '--no-first-run', '--no-default-browser-check',
|
'--headless', '--no-first-run', '--no-default-browser-check',
|
||||||
@@ -39,6 +42,7 @@ export async function browser() {
|
|||||||
const call = (method, params) => send(method, params, sessionId);
|
const call = (method, params) => send(method, params, sessionId);
|
||||||
await call('Page.enable');
|
await call('Page.enable');
|
||||||
await call('Runtime.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 evaluate = async expression => {
|
||||||
const r = await call('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
|
const r = await call('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
|
||||||
if (r.exceptionDetails) throw new Error(r.exceptionDetails.text + ': ' + r.result.description);
|
if (r.exceptionDetails) throw new Error(r.exceptionDetails.text + ': ' + r.result.description);
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ const STUB = `(() => {
|
|||||||
const c = await (await real(url, init)).json();
|
const c = await (await real(url, init)).json();
|
||||||
return new Response(JSON.stringify({ ...c, send: true }), { headers: { 'content-type': 'application/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 });
|
window.__sends.push({ method: init.method, headers: init.headers, body: init.body, redirect: init.redirect });
|
||||||
const a = window.__answers.shift();
|
const a = window.__answers.shift();
|
||||||
if (!a) throw new TypeError('no answer queued');
|
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();
|
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 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);
|
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.
|
// 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' } }]) {
|
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);
|
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.
|
// Each Send was one POST; none was repeated after the unknown ones.
|
||||||
await new Promise(r => setTimeout(r, 500));
|
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
|
// While a send is pending, Send is disabled and a second press sends nothing; text typed
|
||||||
// meanwhile is kept when the first one succeeds.
|
// 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…');
|
assert.equal(await text('#send-form [data-send]'), 'Sending…');
|
||||||
await b.evaluate('document.querySelector("#send-form [data-send]").click(), true');
|
await b.evaluate('document.querySelector("#send-form [data-send]").click(), true');
|
||||||
await set('#send-body', 'A second thought');
|
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 b.evaluate('window.__release(), true');
|
||||||
await wait('document.querySelector("#send-section .receipt") && document.querySelector("#send-form [data-send]").getAttribute("aria-disabled") === "false"');
|
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.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 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.
|
// A plain success clears the text and keeps the role.
|
||||||
await answer({ status: 200, body: { id: seeded.id, at: new Date().toISOString() } });
|
await answer({ status: 200, body: { id: seeded.id, at: new Date().toISOString() } });
|
||||||
await send();
|
await send();
|
||||||
assert.deepEqual([await value('#send-to'), await value('#send-body')], ['coder', '']);
|
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.
|
// 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}`);
|
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 b.evaluate(`location.hash = '#/trail/message/no-such-message', true`);
|
||||||
await wait('document.querySelector("#bus-view h1")?.textContent === "Not found"');
|
await wait('document.querySelector("#bus-view h1")?.textContent === "Not found"');
|
||||||
assert.match(await text('#bus-view'), /No message with id no-such-message\./);
|
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 {
|
} finally {
|
||||||
await b.close(); await close(web); await f.close(); store.close();
|
await b.close(); await close(web); await f.close(); store.close();
|
||||||
rmSync(root, { recursive: true, force: true });
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -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.errors'), []);
|
||||||
assert.deepEqual(await b.evaluate('window.requests.filter(([m])=>m!=="GET")'), []);
|
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');
|
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,
|
// 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.
|
// and scrolled to the end of a long one the column is still below the bar.
|
||||||
|
|||||||
Reference in New Issue
Block a user