diff --git a/packages/webui/README.md b/packages/webui/README.md index be26d0502..cf91674c9 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 9b50235c5..3587fd718 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 ? `${esc(labels[value] || value)}` : ''; // 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 not read: refused' : failed ? 'Board not read: the read failed' : 'Board not read yet'; return; } const scan = `Board scanned ${esc(Number.isFinite(at) ? `${new Date(at).toISOString().slice(11, 19)} UTC` : 'unknown')}${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 = '

Machine sessions hidden.

'; + 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 = '

Signed out. Reload the page to sign in.

'; 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 8b4bcf430..e5a8e1796 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 => ``).join('') : ''; const r = receipt; const said = !r ? '' : r.kind === 'sent' - ? `

Sent to ${txt(r.to)} ${when(r.at)}, message ${txt(short(r.id))}. Its trail.

${repliesHtml()}` + ? `

Sent to ${txt(r.to)} ${when(r.at)}, message ${txt(short(r.id))}. Its trail.

${out() ? '' : repliesHtml()}` : r.kind === 'refused' ? `

Not sent to ${txt(r.to)}: ${txt(r.message)}${r.message.includes(`(${r.code})`) ? '' : ` (${txt(r.code)})`}. The draft is kept.

` : `

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.

`; @@ -407,6 +415,38 @@ const d = asks[0]; return `Input needed: ${txt(first(d.question))} ${txt(short(d.id))}${d.blocking ? ` ${blockChip('blocking')}` : ''}${asks.length > 1 ? ` +${esc(asks.length - 1)} more` : ''}`; } + // 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 => `${txt(c.role)}${stateMark(c.state)}${c.working ? `${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}` : ''}${attention(c)}${c.address ? txt(c.address) : 'no address'}${txt(c.holder_run)}${c.harness ? `${txt(c.harness)}` : ''}${when(c.at)}${canSend ? `${typeof c.role === 'string' && SAFE.test(c.role) ? `` : ''}` : ''}`).join(''); + return `
${cols.map(h => ``).join('')}${canSend ? '' : ''}${rows}
${h}Message
`; + } + function paintHeld(err) { + heldErr = err; + const box = $('held'), r = last.get('agents'); + let html; + if (err && (refusal(err) || !r)) html = ``; + else if (!r) return; // not read yet: the skeleton stays + else { + const claims = r.rows; + html = `${err ? `` : ''} + ${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.')} +

Read from the bus ${when(r.at)}.${canSend ? '' : ' Messaging a role needs a Console started with --send behind the sign-in proxy; this one only reads.'}

`; + } + $('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 => `${txt(c.role)}${stateMark(c.state)}${c.working ? `${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}` : ''}${attention(c)}${txt(c.holder_run)}${txt(c.harness)}${c.address ? `${txt(c.address)}` : ''}${when(c.at)}`).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
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(); }); - // 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(() => {}); + // 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; 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 7faa18833..adbcce42f 100644 --- a/packages/webui/src/public/index.html +++ b/packages/webui/src/public/index.html @@ -11,7 +11,8 @@
-

Control board

Loading board…

+

Control board

Loading board…

+

Held roles –

The stack's seats: each role a run holds on the bus, from the bus agents read. Machine sessions are under Show machine sessions.

Reading the held roles.