From e24e7e93a24728af395fb28cedf85f0c21c94eb5 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 14 Nov 2026 22:35:00 -0600 Subject: [PATCH 1/3] feat(webui): one shell, a system dashboard, organization pages as drill-ins (#1873, lane sp1) #/dashboard is the system landing route: one row per organization with its bus state, agent count (organization files), project count and needs-you count, each only when a read gave it, and active agents. Opening an organization from the sidebar or a dashboard row is a route inside the same page: no reload, a Dashboard / breadcrumb, and later /api reads scoped to it through the existing fetch wrapper, whose organization is now a variable the router sets (mosaic-org-switch event). #/orgs//... stays the address. An organization whose bus is down says so on the dashboard and on its page, with the reason the workspace read gives. --- packages/webui/src/public/app.js | 44 +++-- packages/webui/src/public/brand.js | 16 +- packages/webui/src/public/bus.js | 43 +++- packages/webui/src/public/display.js | 187 ++++++++++++------ packages/webui/src/public/projects.js | 10 +- packages/webui/src/public/requests.js | 11 +- packages/webui/src/public/shell.css | 10 + packages/webui/src/public/workspace.js | 59 ++++-- .../webui/tests/org-archive-browser.test.mjs | 17 +- .../webui/tests/org-delete-browser.test.mjs | 3 +- .../tests/org-list-refresh-browser.test.mjs | 7 +- .../webui/tests/org-switch-browser.test.mjs | 15 +- .../webui/tests/single-pane-browser.test.mjs | 174 ++++++++++++++++ .../tests/unified-workspace-browser.test.mjs | 2 +- .../tests/workspace-pages-browser.test.mjs | 24 +-- 15 files changed, 492 insertions(+), 130 deletions(-) create mode 100644 packages/webui/tests/single-pane-browser.test.mjs diff --git a/packages/webui/src/public/app.js b/packages/webui/src/public/app.js index 16ab4cb64..01a7d01ab 100644 --- a/packages/webui/src/public/app.js +++ b/packages/webui/src/public/app.js @@ -11,8 +11,10 @@ // 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; - const boardKey = window.MosaicOrg ? `mosaic-console-board:${window.MosaicOrg.id}` : 'mosaic-console-board'; - try { machine = JSON.parse(localStorage.getItem(boardKey))?.machine === true; } catch {} + // #1873 SP1: kept per organization, read again when the open organization changes. + const boardKey = () => window.MosaicOrg?.id ? `mosaic-console-board:${window.MosaicOrg.id}` : 'mosaic-console-board'; + const readMachine = () => { try { return JSON.parse(localStorage.getItem(boardKey()))?.machine === true; } catch { return false; } }; + machine = readMachine(); // 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'); @@ -151,6 +153,16 @@ override = readOverride(); paint(); document.dispatchEvent(new CustomEvent('mosaic-theme-change', {detail: {palette: current.theme, mode: current.mode, stored: true, warning: null, picked: current.theme}})); + // The open organization's saved appearance, read again when another organization opens (#1873 SP1). + const readBusiness = async () => { + try { + const { ok, body } = await getJSON('/api/business'); + if (ok && orgId(body?.business?.id)) return window.MosaicAppearance.organization(body.business.id, body.business.appearance, body.business.id); + } catch (err) { if (err?.signedOut) return; } + // No fresh answer: keep the organization this browser last showed for its own override, but not its stale saved value. + if (org && !org.confirmed) { org.saved = null; showAppearance(); } + }; + document.addEventListener('mosaic-org-switch', () => { readBusiness(); }); window.MosaicAppearance.ready = (async () => { await Promise.allSettled([ (async () => { @@ -161,14 +173,7 @@ } catch (err) { if (err?.signedOut) return; themes = []; themesUnavailable = true; } showAppearance(); })(), - (async () => { - try { - const { ok, body } = await getJSON('/api/business'); - if (ok && orgId(body?.business?.id)) return window.MosaicAppearance.organization(body.business.id, body.business.appearance, body.business.id); - } catch (err) { if (err?.signedOut) return; } - // No fresh answer: keep the organization this browser last showed for its own override, but not its stale saved value. - if (org && !org.confirmed) { org.saved = null; showAppearance(); } - })(), + readBusiness(), ]); })(); $('palette').onchange = e => window.MosaicAppearance.set({ theme: e.target.value }); @@ -488,12 +493,18 @@ const box = $('bus-down'); // E2E5 review P2: the organization list is read first and on its own, so a failed bus-status read // (likely when the bus is down) still refreshes the list, its banner and the Settings Start control. + // #1873 SP1: the dashboard lists every organization's bus on its own rows; this banner is an organization page's. + if (window.MosaicOrg && window.MosaicOrg.route().split('?')[0] === '/dashboard') { box.hidden = true; return; } + // An answer about an organization that is no longer open is dropped: the new one's read draws the banner. + const asked = window.MosaicOrg?.id ?? null, stale = () => (window.MosaicOrg?.id ?? null) !== asked; if (window.MosaicOrg) { - const org = (await window.MosaicOrg.readList()).find(row => row.id === window.MosaicOrg.id); + const org = (await window.MosaicOrg.readList()).find(row => row.id === asked); + if (stale()) return; if (org?.busHealthy === false) { box.textContent = 'Bus unavailable'; box.hidden = false; window.MosaicConsole?.attachBus(box, 'Restart the bus'); return; } } try { const s = await api('/api/bus-status'); + if (stale()) return; if (!s.down) { box.hidden = true; return; } const b = s.boot; box.replaceChildren(node('b', '', 'Bus down: organization file invalid. '), node('span', '', `${b.reason} `), node('span', 'small muted', `File ${b.file}. Refused at ${b.at}. Last good load: ${b.lastGood ?? 'none'}. Fix the file, then restart the bus.`)); @@ -528,11 +539,20 @@ 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(boardKey, JSON.stringify({ machine })); } catch {} + try { localStorage.setItem(boardKey(), JSON.stringify({ machine })); } catch {} if (machine) { $('status').textContent = 'Loading board…'; fresh(); refresh(); } else hideMachine(); }; showMachine(); if (!machine) hideMachine(); + // #1873 SP1: another organization opened in place. Its board setting and bus state are its own. + const onOrgSwitch = () => { + machine = readMachine(); showMachine(); + if (machine) { fresh(); refresh(); } else { hideMachine(); busDown(); } + }; + document.addEventListener('mosaic-org-switch', onOrgSwitch); + // An address with no organization picks one after the first list read; its own setting applies then. + window.MosaicOrg?.ready?.then(() => { if (readMachine() !== machine) onOrgSwitch(); }); + window.addEventListener('hashchange', () => { if (window.MosaicOrg) busDown(); }); $('refresh').onclick = refresh; $('mobile-pause').onclick = () => $('pause').click(); $('pause').onclick = () => { paused = !paused; $('pause').querySelector('span').textContent = paused ? 'Resume' : 'Pause'; $('pause').setAttribute('aria-label', paused ? 'Resume' : 'Pause'); $('pause').title = paused ? 'Resume' : 'Pause'; $('mobile-pause').textContent = paused ? 'Resume' : 'Pause'; $('pause').setAttribute('aria-pressed', String(paused)); schedule(); if (data) render(); }; diff --git a/packages/webui/src/public/brand.js b/packages/webui/src/public/brand.js index 86aacd68a..37e51994e 100644 --- a/packages/webui/src/public/brand.js +++ b/packages/webui/src/public/brand.js @@ -181,10 +181,22 @@ menu.addEventListener('click', e => { if (e.target.closest('a')) close(); }); document.addEventListener('click', e => { if (!e.target.closest('.avatar-wrap')) close(); }); function breadcrumbs() { - const path = location.hash.slice(2).split('?')[0], parts = path.split('/').filter(Boolean), box = $('breadcrumbs'); + draw(); + // SP1: every page inside an organization says which one, after the dashboard: Dashboard / Acme / ... + const box = $('breadcrumbs'), org = /^#\/orgs\/[^/?]+/.test(location.hash) ? window.MosaicOrg : null; + if (!box || !org?.id || box.querySelector('[data-crumb-org]') || /^#\/orgs\/[^/?]+\/organization(?:[/?]|$)/.test(location.hash)) return; + box.insertAdjacentHTML('afterbegin', `Dashboard${escape(org.label)}`); + } + window.MosaicBreadcrumbs = breadcrumbs; + function draw() { + // SP1: an organization's pages are routes under #/orgs/; the crumb reads the route inside it. + const path = location.hash.replace(/^#\/orgs\/[^/?]+/, '#').slice(2).split('?')[0], parts = path.split('/').filter(Boolean), box = $('breadcrumbs'); if (!box || !$('bus-view')) return; if (window.MosaicProjects?.breadcrumb?.(box, $('bus-view'))) return; - if (!parts.length || parts[0] === 'board') { box.innerHTML = 'Dashboard'; return; } + // Inside an organization the board is its control board; the system dashboard is the crumb before it. + if (parts[0] === 'board' && /^#\/orgs\//.test(location.hash)) { box.innerHTML = 'Control board'; return; } + if (!parts.length || parts[0] === 'board' || parts[0] === 'dashboard') { box.innerHTML = 'Dashboard'; return; } + if (parts[0] === 'organization') { const h = $('bus-view').querySelector('[data-org-dashboard] h1')?.textContent?.trim(); box.innerHTML = `Dashboard${h ? `${escape(h)}` : ''}`; return; } const names = {projects:'Projects',chat:'Projects',inbox:'Inbox',messages:'Messages',tasks:'Tasks',agents:'Agents',queue:'Queue',business:'Business',docs:'Docs',settings:'Settings'}; if (parts[0] === 'settings' && parts[1] === 'business' && $('bus-view').querySelector('#settings-page-title')?.textContent.trim() === 'Business') { box.innerHTML = 'SettingsBusiness'; return; } const name = names[parts[0]] || 'Dashboard', heading = $('bus-view').querySelector('h1')?.textContent?.trim(); diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index bfd81c424..ccb877ee2 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -275,9 +275,10 @@ const focused = $('sections').contains(active) || foot.contains(active) ? active.getAttribute('href') : null; const icon = id => window.MosaicWorkspace?.icon?.(id) ?? ``; const n = (count, what) => count > 0 ? `${count}` : ''; - const items = [['/', 'Dashboard', '', 'board'], ['/inbox', 'Inbox', n(inboxCount?.open, 'open for you'), 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/projects', 'Projects', '', 'folder'], ['/side-by-side', 'Side by side', '', 'columns'], ['/agents', 'Agents', '', 'agents'], ['/messages', 'Assistant', n(messageCount, 'unread messages'), 'talk']]; + const items = [['/dashboard', 'Dashboard', '', 'board'], ['/inbox', 'Inbox', n(inboxCount?.open, 'open for you'), 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/projects', 'Projects', '', 'folder'], ['/side-by-side', 'Side by side', '', 'columns'], ['/agents', 'Agents', '', 'agents'], ['/messages', 'Assistant', n(messageCount, 'unread messages'), 'talk']]; const link = ([h, l, extra, id]) => { - const cur = h === '/' ? (route === '/' || route === '/board') : route.startsWith(h) || (h === '/projects' && route.startsWith('/chat')) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && (route.startsWith('/trail/decision') || route.startsWith('/trail/message'))); + // #1873 SP1: the Dashboard item is the system dashboard, `#/dashboard`, outside any organization. + const cur = h === '/dashboard' ? (route === '/dashboard' || route === '/' || route === '/board') : route.startsWith(h) || (h === '/projects' && route.startsWith('/chat')) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && (route.startsWith('/trail/decision') || route.startsWith('/trail/message'))); return `${icon(id)}${l}${extra}`; }; // No project tree under Projects here: the sidebar's Projects group lists every organization's @@ -342,6 +343,8 @@ }); let configRead = Promise.resolve(); + // #1873 SP1: the config read an organization switch started, until the next render has waited for it. + let switchRead = null; let canSend = false, canMessage = false, chatSend = false, sendRoles = null, sending = false, receipt = null, replies = []; const REPLIES = 50; // the broker's cap function repliesHtml() { @@ -1546,7 +1549,7 @@ }); // Not found and states - function notFound(why) { return `${head('Not found')}
${why || 'No page at this address.'}

Nothing is at ${txt(location.hash || '#/')}. Go to the ${window.MosaicOrg ? 'Dashboard' : 'Board'} or press Ctrl K to search.

`; } + function notFound(why) { return `${head('Not found')}
${why || 'No page at this address.'}

Nothing is at ${txt(location.hash || '#/')}. Go to the ${window.MosaicOrg ? 'Dashboard' : 'Board'} or press Ctrl K to search.

`; } const loadingView = (source = null) => `${head('Reading…')}
${source ? `Reading the ${esc(source.toLowerCase())}.` : 'Reading from the bus.'}
`; const retry = '
'; // Why a read failed, in words, with the bus code as given. Never a path or a guess. @@ -1601,7 +1604,8 @@ const parts = path.split('/').filter(Boolean).map(p => { try { return decodeURIComponent(p); } catch { return null; } }); if (parts.includes(null)) return notFound(); const [a, b, c] = parts, n = parts.length; - if (n === 0 && window.MosaicOrg) return window.MosaicWorkspace.home(); + // #1873 SP1: `#/dashboard` is the system dashboard, every organization on one page. + if ((n === 0 || (a === 'dashboard' && n === 1)) && window.MosaicOrg) return window.MosaicWorkspace.home(); if (a === 'projects' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.projectsPage(q); if (a === 'organization' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.dashboard(); if (a === 'inbox' && n === 1 && window.MosaicOrg) return configRead.then(() => window.MosaicWorkspace.inboxPage(q, { hire: hireStartHtml })); @@ -1626,7 +1630,7 @@ const paused = () => $('pause')?.getAttribute('aria-pressed') === 'true'; // A file open in the editor is never refreshed from under its draft; Reload is the person's to press. // The cross-organization pages (U3) poll at the workspace read's pollMs. - const wsPage = path => !!window.MosaicOrg && (path === '/' || path === '' || path === '/projects' || path === '/inbox'); + const wsPage = path => !!window.MosaicOrg && (path === '/' || path === '' || path === '/dashboard' || path === '/projects' || path === '/inbox'); function schedule() { clearTimeout(timer); if (!paused() && !out() && !routeOf().startsWith('/docs/')) timer = setTimeout(() => render(false), wsPage(routeOf().split('?')[0]) ? window.MosaicWorkspace?.pollMs || 10000 : 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`). @@ -1676,6 +1680,14 @@ // A refresh due while this render reads would replace it: a navigation would lose its heading focus. clearTimeout(timer); if (out()) return; + // #1873 SP1: an address with no organization waits for the first list read to pick one, and a + // page drawn right after a switch waits for the new organization's config. + if ((window.MosaicOrg && !window.MosaicOrg.id) || switchRead) { + const g0 = ++gen, waiting = switchRead; + await window.MosaicOrg?.ready; await waiting; + if (waiting && switchRead === waiting) switchRead = null; + if (g0 !== gen || out()) return; + } const route = routeOf(), [path, query = ''] = route.split('?'), board = window.MosaicOrg ? path === '/board' : path === '/' || path === '' || path === '/board'; // On the cross-organization pages a filter or selection changes only the query: that is a // repaint of the same page, not a navigation (no loading view, focus and scroll kept). @@ -1958,7 +1970,7 @@ // F3: `settingsSend` lets the owner of an organization Console save Settings on the selected // organization's bus; messaging a role still follows `send` (canMessage) and `chatSend`. // A bus restart reads the config again and sends it here as a mosaic-config event. - const applyConfig = c => { + const applyConfig = (c, opts = {}) => { isTester = c?.tester === true; canEdit = !isTester && c?.edit === true; canSend = !isTester && (c?.send === true || c?.settingsSend === true); @@ -1975,7 +1987,7 @@ banner.setAttribute('role', 'status'); banner.textContent = 'Tester Console. Changes are recorded as tester, never as the human.'; $('main').prepend(banner); } - if (!canSend && !isTester && !ssoOn) return; + if (opts.quiet || (!canSend && !isTester && !ssoOn)) return; const path = routeOf().split('?')[0]; if (path.startsWith('/chat')||path.startsWith('/projects')||path.startsWith('/inbox')||path.startsWith('/settings')||path.startsWith('/docs/')||wsPage(path)) render(false); else if (window.MosaicOrg ? path === '/board' : path === '/' || path === '') paintHeld(heldErr); }; @@ -1985,6 +1997,23 @@ // fresh read of the page; editors in progress are kept by holdLive. // Ruling 2026-11-14: an apply, hire or fire redraws the Agents page in the same response cycle, not at the next poll. document.addEventListener('mosaic-org-changed', e => { if (window.MosaicOrg && e.detail?.org === window.MosaicOrg.id && ['/organization', '/agents'].includes(routeOf().split('?')[0])) render(false); }); + // #1873 SP1: another organization opened in place (display.js, before this file's hashchange + // render). Nothing read, drafted or confirmed for the old one carries over; the new one's config + // is read before the next page draws. A write already sent finishes against the old organization. + document.addEventListener('mosaic-org-switch', () => { + forget(); clearTimeout(timer); + answer = null; heldErr = null; heldHtml = null; readAt = null; shown = null; projectContext = null; + draft.to = ''; draft.body = ''; + hireDraft = { op: 'hire', level: 'org' }; hireTemplates = []; hireStatus = ''; hireCode = ''; + fireAsk = null; fireReceipt = null; + settingsSnapshot = null; settingsInputs = null; settingsReceipt = ''; settingInvalidKey = null; + assistantDrafts = Object.create(null); nameDrafts = Object.create(null); assistantReceipts = Object.create(null); assistantReceiptKinds = Object.create(null); assistantCardReceipts = Object.create(null); + instanceView = null; instanceDrafts = { add: null, change: null }; instanceReceipt = ''; instanceReceiptOp = null; instanceReceipts = Object.create(null); + routesView = null; routesDraft = null; routesReceipt = ''; routesEditVersion++; + harnessSettings = null; harnessDraft = null; harnessReceipt = ''; + docKey = null; pkAll = []; pkShown = []; pkAt = 0; + configRead = switchRead = fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(c => applyConfig(c, { quiet: true })).catch(() => {}); + }); document.addEventListener('mosaic-config', e => { if (e.detail && typeof e.detail === 'object') { applyConfig(e.detail); if (window.MosaicOrg && routeOf().split('?')[0] === '/organization') render(false); } }); // The cross-organization pages ask for a repaint after a filter, sort or selection change, // and report the inbox count across organizations for the sidebar. diff --git a/packages/webui/src/public/display.js b/packages/webui/src/public/display.js index c15347877..880c07d00 100644 --- a/packages/webui/src/public/display.js +++ b/packages/webui/src/public/display.js @@ -1,60 +1,63 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(value).toLocaleString() : 'unknown'; -// An organization switch reloads the page, which drops every per-page cache and -// subscription. Requests already sent keep the organization captured here. +// #1873 SP1: one shell for the whole system. `#/dashboard` is the system landing route and +// `#/orgs//...` is a route inside the same page, not a mode: opening another organization +// changes the scope of later /api reads and fires `mosaic-org-switch`. Nothing reloads. +// Requests already sent keep the organization they were sent with. (() => { const nativeFetch = window.fetch.bind(window); - const match = /^#\/orgs\/([^/]+)(\/.*)?$/.exec(location.hash); - const id = match?.[1] ?? null; - if (!id) { - if (!document.documentElement.hasAttribute('data-organizations')) return; - const readInitialOrganizations = () => nativeFetch('/api/orgs', { cache: 'no-store', redirect: 'manual' }).then(async r => { - if (r.ok) { window.MosaicOrganizationError = null; return r.json(); } - const body = await r.json().catch(() => ({})); - window.MosaicOrganizationError = body.message || 'Organization registry could not be read'; - return null; - }).catch(() => { window.MosaicOrganizationError = 'Organization registry could not be read'; return null; }); - const refreshInitial = () => { - const initialOrganizations = window.MosaicInitialOrganizations = readInitialOrganizations(); - initialOrganizations.then(async rows => { - if (!Array.isArray(rows) || !rows.length || location.hash.startsWith('#/orgs/')) return; - rows = rows.filter(row => !row.invalid); - if (!rows.length) return; - let preferred; - try { preferred = (await (await nativeFetch('/api/config', { cache: 'no-store', redirect: 'manual' })).json()).preselect; } catch { /* first authorized organization */ } - const selected = rows.find(row => row.id === preferred) ?? rows[0]; - const tail = location.hash.startsWith('#/') ? location.hash.slice(1) : '/'; - history.replaceState(null, '', `#/orgs/${encodeURIComponent(selected.id)}${tail}`); - location.reload(); - }).catch(() => {}); - return initialOrganizations; - }; - window.MosaicRefreshOrganizations = refreshInitial; - refreshInitial(); - return; - } - const route = () => location.hash.replace(/^#\/orgs\/[^/]+/, '') || '/'; + const orgOf = hash => /^#\/orgs\/([^/]+)/.exec(hash)?.[1] ?? null; + const atLoad = orgOf(location.hash); + if (!atLoad && !document.documentElement.hasAttribute('data-organizations')) return; + // current: the scoping organization as its hash segment. settled: false only while the first + // list read picks one for an address that names none. With no valid organization at all the + // page stays in its first-run mode (window.MosaicOrg reads undefined), as before. + let current = atLoad, settled = !!atLoad, markReady; + const ready = new Promise(resolve => { markReady = resolve; }); + const settle = seg => { current = seg; settled = true; markReady(seg ? decodeURIComponent(seg) : null); }; + // An address that names its organization is settled at load: anything waiting on ready goes on. + if (atLoad) markReady(decodeURIComponent(atLoad)); + const tail = hash => { const m = /^#\/orgs\/[^/]+(.*)$/.exec(hash); return m ? (m[1] || '/') : hash.startsWith('#/') ? hash.slice(1) : '/'; }; + const route = () => tail(location.hash); + // The bare address and the old per-organization dashboard address are the system dashboard; + // an organization page without the prefix gets the current organization's. + const normalize = (hash, seg = current) => { + const path = tail(hash), bare = path.split('?')[0]; + if (bare === '/' || bare === '/dashboard') return `#/dashboard${path.slice(bare.length)}`; + return orgOf(hash) || !seg ? hash : `#/orgs/${seg}${path}`; + }; // /api/workspace reads and writes across organizations (U1, U4b), so it keeps its own path. + const unscoped = /^\/api\/(?:workspace(?:\/|\?|$)|orgs(?:\/|$)|themes(?:\?|$)|org-template(?:\?|$)|org-presets(?:\?|$)|org-admin(?:\?|$)|org-validate(?:\?|$)|agents\/(?:levels|root)(?:\?|$))/; window.fetch = (input, init) => { - if (typeof input === 'string' && input.startsWith('/api/') && !/^\/api\/(?:workspace(?:\/|\?|$)|orgs(?:\/|$)|themes(?:\?|$)|org-template(?:\?|$)|org-presets(?:\?|$)|org-admin(?:\?|$)|org-validate(?:\?|$)|agents\/(?:levels|root)(?:\?|$))/.test(input)) - input = `/api/orgs/${id}/${input.slice('/api/'.length)}`; - return nativeFetch(input, init); + if (typeof input !== 'string' || !input.startsWith('/api/') || unscoped.test(input)) return nativeFetch(input, init); + const send = () => nativeFetch(current ? `/api/orgs/${current}/${input.slice('/api/'.length)}` : input, init); + return settled ? send() : ready.then(send); }; // A failed read keeps its reason, so the header can say why the list is empty. - let listError = null; - const readList = () => nativeFetch('/api/orgs', { cache: 'no-store', redirect: 'manual' }).then(async r => { + let listError = null, rowsSeen = []; + const readRows = () => nativeFetch('/api/orgs', { cache: 'no-store', redirect: 'manual' }).then(async r => { if (r.ok) { - const body = await r.json(); listError = null; + const body = await r.json(); listError = null; window.MosaicOrganizationError = null; const rows = Array.isArray(body) ? body : []; // E2E5 review P2: an open panel (Settings > Organizations) follows every fresh list read, not only its own. document.dispatchEvent(new CustomEvent('mosaic-org-list', { detail: { rows } })); return rows; } const body = await r.json().catch(() => ({})); - listError = typeof body.message === 'string' && body.message ? body.message : 'Organization registry could not be read'; - return []; - }).catch(() => { listError = 'Organization registry could not be read'; return []; }); - // Idempotent: each read replaces the banner and the wordmark name, and removes them on recovery. + listError = window.MosaicOrganizationError = typeof body.message === 'string' && body.message ? body.message : 'Organization registry could not be read'; + return null; + }).catch(() => { listError = window.MosaicOrganizationError = 'Organization registry could not be read'; return null; }); + const readList = () => readRows().then(rows => rows ?? []); + // The bus banner belongs to an organization page; the dashboard lists every bus on its own rows. + const paintBus = () => { + const banner = document.getElementById('bus-down'); + if (!banner) return; + const org = rowsSeen.find(row => current && row.id === decodeURIComponent(current) && !row.invalid); + if (route().split('?')[0] === '/dashboard' || (banner.dataset.orgBus && org?.busHealthy !== false)) { banner.hidden = true; delete banner.dataset.orgBus; return; } + if (org?.busHealthy === false) { banner.textContent = 'Bus unavailable'; banner.hidden = false; banner.dataset.orgBus = '1'; window.MosaicConsole?.attachBus(banner, 'Restart the bus'); } + }; + // Idempotent: each read replaces the banner, and removes it on recovery. const paintHeader = rows => { + rowsSeen = rows; document.getElementById('org-list-banner')?.remove(); const lines = [...(listError ? [listError] : []), ...rows.filter(row => row.invalid).map(row => `${row.id}: invalid — ${row.reason}`)]; if (lines.length) { @@ -62,58 +65,112 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date( banner.textContent = lines.join('; '); document.body.prepend(banner); } + // The shell no longer names one organization in its wordmark: the breadcrumb does. document.getElementById('current-organization')?.remove(); - const org = rows.find(row => row.id === decodeURIComponent(id) && !row.invalid); - const mark = document.querySelector('.wordmark'); - if (mark && org) { const name = document.createElement('span'); name.id = 'current-organization'; name.textContent = ` · ${org.label || org.id}`; mark.append(name); } - if (org?.busHealthy === false) { const banner = document.getElementById('bus-down'); if (banner) { banner.textContent = 'Bus unavailable'; banner.hidden = false; window.MosaicConsole?.attachBus(banner, 'Restart the bus'); } } + paintBus(); + window.MosaicBreadcrumbs?.(); return rows; }; - let list = readList().then(paintHeader); + let list; const refreshList = () => (list = readList().then(paintHeader)); - window.MosaicRefreshOrganizations = refreshList; - let previous = location.hash; + if (atLoad) { + const first = normalize(location.hash); + if (first !== location.hash) history.replaceState(null, '', first); + refreshList(); + } else { + const initial = window.MosaicInitialOrganizations = readRows(); + list = initial.then(rows => paintHeader(rows ?? [])); + initial.then(async rows => { + const valid = Array.isArray(rows) ? rows.filter(row => !row.invalid) : []; + if (!valid.length) return null; + let preferred; + try { preferred = (await (await nativeFetch('/api/config', { cache: 'no-store', redirect: 'manual' })).json()).preselect; } catch { /* first authorized organization */ } + return encodeURIComponent((valid.find(row => row.id === preferred) ?? valid[0]).id); + }).catch(() => null).then(seg => { + settle(orgOf(location.hash) ?? seg); + if (!current) return; + const next = normalize(location.hash); + if (next !== location.hash) history.replaceState(null, '', next); + previous = location.hash; paintBus(); + }); + } + // From the first-run page (no organization yet) the first one changes the page's mode, which + // still takes one reload. Once an organization is open, a list refresh repaints in place. + window.MosaicRefreshOrganizations = () => { + if (current || !settled) return refreshList(); + const read = window.MosaicInitialOrganizations = readRows(); + list = read.then(rows => paintHeader(rows ?? [])); + read.then(rows => { + const valid = Array.isArray(rows) ? rows.filter(row => !row.invalid) : []; + if (!valid.length || orgOf(location.hash)) return; + history.replaceState(null, '', `#/orgs/${encodeURIComponent(valid[0].id)}${tail(location.hash) === '/' ? '/organization' : tail(location.hash)}`); + location.reload(); + }); + return read; + }; + let previous = location.hash, skipGuard = false; const dirty = () => window.MosaicChat?.hasDraft?.() || window.MosaicOrgCreate?.hasDraft?.() || [...document.querySelectorAll('#reply, #conv-reply, #doc-source')].some(el => !!el.value?.trim()); - function change(next) { - history.replaceState(null, '', next); - location.reload(); + // A switch scopes later reads to the new organization; listeners drop what they held for the old one. + function rescope(seg) { + const from = current; + current = seg; + document.dispatchEvent(new CustomEvent('mosaic-org-switch', { detail: { from: from ? decodeURIComponent(from) : null, to: decodeURIComponent(seg) } })); + } + function discard() { + window.MosaicChat?.forget?.(); + for (const el of document.querySelectorAll('#reply, #conv-reply, #doc-source')) el.value = ''; } function confirmSwitch(next) { const dialog = document.createElement('dialog'); dialog.setAttribute('aria-label', 'Unsaved draft'); dialog.innerHTML = '

You have an unsaved draft. Stay here or discard it and continue.

'; document.body.append(dialog); - const stay = () => { const picker = document.getElementById('org-picker'); if (picker) picker.value = decodeURIComponent(id); dialog.close(); dialog.remove(); }; + const close = () => { dialog.close(); dialog.remove(); }; + const stay = () => { const picker = document.getElementById('org-picker'); if (picker && current) picker.value = decodeURIComponent(current); close(); }; dialog.querySelector('[data-stay]').onclick = stay; - dialog.querySelector('[data-discard]').onclick = () => change(next); + dialog.querySelector('[data-discard]').onclick = () => { close(); discard(); skipGuard = true; location.hash = next; }; dialog.addEventListener('cancel', e => { e.preventDefault(); stay(); }); dialog.showModal(); dialog.querySelector('[data-stay]').focus(); } + // Capture phase: this runs before bus.js renders, so a switch is scoped before the page reads. window.addEventListener('hashchange', event => { const oldHash = event.oldURL ? new URL(event.oldURL).hash : previous; - let next = location.hash; - if (!next.startsWith('#/orgs/')) { next = `#/orgs/${id}${next.startsWith('#/') ? next.slice(1) : '/'}`; history.replaceState(null, '', next); } - const nextId = /^#\/orgs\/([^/]+)/.exec(next)?.[1]; - if ((nextId && nextId !== id) || (next !== oldHash && window.MosaicOrgCreate?.hasDraft?.())) { + const raw = location.hash, seg = orgOf(raw) ?? current, next = normalize(raw, seg); + if (next !== raw) history.replaceState(null, '', next); + if (!settled || !current) { previous = next; return; } + const switching = seg !== current; + if (!skipGuard && next !== oldHash && (switching || window.MosaicOrgCreate?.hasDraft?.()) && dirty()) { event.stopImmediatePropagation(); - if (dirty()) { history.replaceState(null, '', oldHash); confirmSwitch(next); } else change(next); + history.replaceState(null, '', oldHash); confirmSwitch(next); return; } - previous = next; + skipGuard = false; previous = next; + if (switching) rescope(seg); + paintBus(); }, true); - window.MosaicOrg = Object.freeze({ id: decodeURIComponent(id), route, get list() { return list; }, readList, refreshList, switchTo: next => { location.hash = `#/orgs/${encodeURIComponent(next)}${route()}`; } }); + const api = Object.freeze({ + get id() { return current ? decodeURIComponent(current) : null; }, + // The open organization's name as the last list read gave it, for the breadcrumb. + get label() { const id = current ? decodeURIComponent(current) : null, row = id && rowsSeen.find(r => r.id === id); return id ? (typeof row?.label === 'string' && row.label ? row.label : id) : null; }, + ready, route, get list() { return list; }, readList, refreshList, + switchTo: next => { const path = route().split('?')[0]; location.hash = `#/orgs/${encodeURIComponent(next)}${path === '/dashboard' || path === '/' ? '/organization' : route()}`; }, + }); + Object.defineProperty(window, 'MosaicOrg', { configurable: true, enumerable: true, get: () => (current || !settled ? api : undefined) }); })(); if (document.documentElement.hasAttribute('data-settings-proxy-document') && !location.hash) location.hash = '#/settings/model-proxy'; /* Browser-local sidebar density. No authority or project data is stored here. */ (() => { - const key=window.MosaicOrg ? `mosaic-sidebar-display-v1:${window.MosaicOrg.id}` : 'mosaic-sidebar-display-v1'; + // #1873 SP1: the key follows the open organization, read again when it changes. + const key=()=>window.MosaicOrg?.id ? `mosaic-sidebar-display-v1:${window.MosaicOrg.id}` : 'mosaic-sidebar-display-v1'; const defaults=()=>({version:1,density:'comfortable',order:'title-first'}); const valid=x=>x&&x.version===1&&['comfortable','compact'].includes(x.density)&&['title-first','contact-first'].includes(x.order)&&Object.keys(x).sort().join(',')==='density,order,version'; let saved=defaults(); - try {const value=JSON.parse(localStorage.getItem(key));if(valid(value))saved=value;} catch {} + const load=()=>{saved=defaults();try {const value=JSON.parse(localStorage.getItem(key()));if(valid(value))saved=value;} catch {}}; const apply=()=>{document.documentElement.dataset.sidebarDensity=saved.density;document.documentElement.dataset.sidebarOrder=saved.order;document.dispatchEvent(new CustomEvent('mosaic-display-change',{detail:{...saved}}));}; - apply(); - window.MosaicDisplay={get:()=>({...saved}),setDensity(density){if(!['comfortable','compact'].includes(density))return false;saved={...saved,density};apply();try{localStorage.setItem(key,JSON.stringify(saved));return true;}catch{return false;}}}; + load();apply(); + window.MosaicOrg?.ready?.then(()=>{load();apply();}); + document.addEventListener('mosaic-org-switch',()=>{load();apply();}); + window.MosaicDisplay={get:()=>({...saved}),setDensity(density){if(!['comfortable','compact'].includes(density))return false;saved={...saved,density};apply();try{localStorage.setItem(key(),JSON.stringify(saved));return true;}catch{return false;}}}; })(); // P4 page: credentials and OAuth state exist only during the current operation. diff --git a/packages/webui/src/public/projects.js b/packages/webui/src/public/projects.js index 4ee0daf57..e97854072 100644 --- a/packages/webui/src/public/projects.js +++ b/packages/webui/src/public/projects.js @@ -27,10 +27,14 @@ } document.addEventListener('focusin',e=>{const tab=e.target.closest?.('.project-tabs a'); if(tab) revealTab(tab.parentElement,tab);}); const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - const storageKey=window.MosaicOrg ? `mosaic-project-tree:${window.MosaicOrg.id}` : 'mosaic-project-tree'; + // #1873 SP1: the tree and its open/closed state belong to the open organization; a switch drops both. + const storageKey=()=>window.MosaicOrg?.id ? `mosaic-project-tree:${window.MosaicOrg.id}` : 'mosaic-project-tree'; let treeRows=[], treeThreads=[], treeGeneration=0, treeState, treeRead=null, treeReadAt=0; const treeFocus = new WeakMap(); - try { treeState=JSON.parse(localStorage.getItem(storageKey) || '{}'); if (!treeState || Array.isArray(treeState) || typeof treeState !== 'object') treeState={}; } catch { treeState={}; } + const loadTreeState=()=>{ try { treeState=JSON.parse(localStorage.getItem(storageKey()) || '{}'); if (!treeState || Array.isArray(treeState) || typeof treeState !== 'object') treeState={}; } catch { treeState={}; } }; + loadTreeState(); + window.MosaicOrg?.ready?.then(loadTreeState); + document.addEventListener('mosaic-org-switch',()=>{ treeGeneration++; treeRows=[]; treeThreads=[]; treeRead=null; treeReadAt=0; loadTreeState(); }); // A side-by-side pane reads its own organization: its reads name that organization explicitly. const at=(org,url)=>org && url.startsWith('/api/') ? `/api/orgs/${encodeURIComponent(org)}/${url.slice(5)}` : url; async function read(url) { @@ -76,7 +80,7 @@ target?.focus({preventScroll:true}); } treeFocus.delete(container); - container.onclick=e=>{const button=e.target.closest('[data-project-disclosure]');if(!button)return;const id=button.dataset.projectDisclosure;treeState[id]=button.getAttribute('aria-expanded')!=='true';try{localStorage.setItem(storageKey,JSON.stringify(treeState));}catch{}drawTree(container,route);container.querySelector(`[data-project-disclosure="${id}"]`)?.focus();}; + container.onclick=e=>{const button=e.target.closest('[data-project-disclosure]');if(!button)return;const id=button.dataset.projectDisclosure;treeState[id]=button.getAttribute('aria-expanded')!=='true';try{localStorage.setItem(storageKey(),JSON.stringify(treeState));}catch{}drawTree(container,route);container.querySelector(`[data-project-disclosure="${id}"]`)?.focus();}; } catch { if(generation===treeGeneration && container.isConnected && !document.body.classList.contains('signed-out')) { treeRead=null; const active=document.activeElement; diff --git a/packages/webui/src/public/requests.js b/packages/webui/src/public/requests.js index bd9260867..dbad9bfff 100644 --- a/packages/webui/src/public/requests.js +++ b/packages/webui/src/public/requests.js @@ -20,7 +20,8 @@ const txt = value => esc(inert(value)); const enc = encodeURIComponent; const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/; - const STORE = window.MosaicOrg ? `mosaic-request-answer:${window.MosaicOrg.id}:` : 'mosaic-request-answer:'; + // #1873 SP1: read per call, so answers stay with the organization open when they were written. + const storeOf = () => window.MosaicOrg?.id ? `mosaic-request-answer:${window.MosaicOrg.id}:` : 'mosaic-request-answer:'; const QUESTION_PHASES = ['discovery', 'prd-review', 'planning']; const MODE_PHASES = [...QUESTION_PHASES, 'implementation', 'review', 'delivery']; const PICKER_CODES = ['request-existing-project', 'request-attachment-selected']; @@ -55,15 +56,15 @@ // removed once the bus has answered either way, so only a lost response leaves one behind. const load = id => { try { - const v = JSON.parse(sessionStorage.getItem(STORE + id)); + const v = JSON.parse(sessionStorage.getItem(storeOf() + id)); return v && text(v.op) && ID.test(v.op) && text(v.text) && text(v.question) && ID.test(v.question) && positive(v.revision) ? { op: v.op, text: v.text, question: v.question, revision: v.revision } : null; } catch { return null; } }; - const save = (id, record) => { try { sessionStorage.setItem(STORE + id, JSON.stringify(record)); } catch { /* kept in memory only */ } }; - const drop = id => { try { sessionStorage.removeItem(STORE + id); } catch { /* nothing stored */ } }; + const save = (id, record) => { try { sessionStorage.setItem(storeOf() + id, JSON.stringify(record)); } catch { /* kept in memory only */ } }; + const drop = id => { try { sessionStorage.removeItem(storeOf() + id); } catch { /* nothing stored */ } }; const dropAll = () => { - try { for (const key of Object.keys(sessionStorage)) if (key.startsWith(STORE)) sessionStorage.removeItem(key); } catch { /* nothing stored */ } + try { for (const key of Object.keys(sessionStorage)) if (key.startsWith(storeOf())) sessionStorage.removeItem(key); } catch { /* nothing stored */ } }; function forget() { diff --git a/packages/webui/src/public/shell.css b/packages/webui/src/public/shell.css index 0cd28a1fb..3517c602e 100644 --- a/packages/webui/src/public/shell.css +++ b/packages/webui/src/public/shell.css @@ -591,6 +591,16 @@ html[data-organizations] .ws-bar i{display:block;background:var(--org,var(--acti html[data-organizations] .ws-by{display:flex;flex-wrap:wrap;gap:4px 10px;font-size:12px;color:var(--text)} html[data-organizations] .ws-by>span{display:inline-flex;align-items:center;gap:4px;white-space:nowrap;font-variant-numeric:tabular-nums} html[data-organizations] .ws-by .ws-av{width:14px;height:14px;border-radius:3px;font-size:7px} +html[data-organizations] .ws-orgtbl td{vertical-align:top} +html[data-organizations] .ws-orgtbl .who{display:flex;flex-wrap:wrap;align-items:center;gap:4px 10px;min-width:0} +html[data-organizations] .ws-orgtbl .who a{display:inline-flex;align-items:center;gap:8px;min-width:0;color:var(--strong);font-weight:700;text-decoration:none} +html[data-organizations] .ws-orgtbl .who a:hover .nm{text-decoration:underline} +html[data-organizations] .ws-orgtbl .who .ws-dot{font-size:12px;color:var(--muted)} +html[data-organizations] .ws-orgtbl .who .ws-dot.s-working b{color:var(--success)} +html[data-organizations] .ws-orgtbl .who .why{flex:1 0 100%;font-size:12px;color:var(--danger);overflow-wrap:anywhere} +html[data-organizations] .ws-orgtbl tr.off .ws-dot{color:var(--danger)} +html[data-organizations] .ws-dash [data-org-down]{margin:0 0 14px} +html[data-organizations] .ws-dash [data-org-down] .why{display:block;margin-top:4px;color:var(--muted)} html[data-organizations] .ws-orgcards{list-style:none;margin:0;padding:0;display:grid;gap:8px;grid-template-columns:repeat(auto-fill,minmax(220px,1fr))} html[data-organizations] .ws-orgcard a{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:2px 10px;align-items:center;padding:9px 12px;border:1px solid var(--line);border-radius:8px;background:var(--surface);box-shadow:inset 3px 0 0 var(--org);color:var(--text);text-decoration:none;font-size:13px;height:100%} html[data-organizations] .ws-orgcard a:hover{background:var(--raised)} diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index 3e5c69090..b5dc943b8 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -104,7 +104,8 @@ const root = document.getElementById('workspace-nav'); if (!root) return; const key = focusKey(root); if (orgError || !orgs) { root.innerHTML = `

${orgError ? 'Organizations unavailable' : 'Reading organizations…'}

`; return; } - const here = route(), selected = window.MosaicOrg?.id; + // SP1: a system route (the dashboard) marks no organization; an organization route marks its own. + const here = route(), selected = /^#\/orgs\//.test(location.hash) ? window.MosaicOrg?.id : null; const rows = orgs.map(org => { const [cls, glyph, word] = state(org), mine = org.id === selected, current = mine && here === '/organization'; return `
  • ${av(org)}${esc(label(org))}${dot(cls, glyph, word, cls !== 's-working')}
  • `; @@ -227,6 +228,13 @@ return Boolean(a && a.el.isConnected); } + // SP1: the reason an organization's bus can't be read, for its pages. `row` is its GET /api/workspace + // status (state, reason, boot), or null when that read gave none. + const downWhy = row => (row && row.state !== 'ok' && typeof row.reason === 'string' && row.reason ? `${row.reason}${row.boot?.at ? `, last start ${when(row.boot.at)}` : ''}` : ''); + function downBanner(org, row) { + const stopped = org.busStopped === true, why = downWhy(row); + return ``; + } // A side-by-side pane passes its own organization and { pane: true, idPrefix }: the pane has no bus // restart (that stays in the organization's full view) and its ids carry the pane's prefix. async function dashboard(id = window.MosaicOrg.id, opts = {}) { @@ -244,6 +252,8 @@ const threads = rowsOf(value(3)), projectRows = rowsOf(value(4)), queue = rowsOf(value(5)); const agents = rowsOf(value(6)), claims = rowsOf(value(7)); const docRead = business?.business ? await docs(id, business.business.projects).catch(() => ({ rows: [], failed: true })) : null; + // SP1: a bus that is down says so above everything else, with the reason the workspace read gives. + const downRow = org.busHealthy ? null : await data.workspace({ org: id, sections: 'projects' }).then(b => arr(b?.orgs).find(r => r?.org === id) ?? null).catch(() => null); await window.MosaicConsole?.ready; const name = esc(label(org)); const tracker = business?.business?.vars?.['tracker.kind']; @@ -290,8 +300,9 @@ const stoppedPanel = (attr, title) => `

    ${title}

    Not read: the bus is stopped.

    `; return `
    - +
    ${av(org, 'lg')}

    ${name}

    ${bus}${reload}
    ${meta}
    + ${org.busHealthy ? '' : downBanner(org, downRow)} ${docrow}
    @@ -320,6 +331,8 @@ // only). Every status comes from the organization's bus: its instances, and since A3 the root and // project agent copies it booted with. Nothing here invents a status, a model or a memory. const NOT_RUNNING = ['s-idle', '–', 'not running']; + // SP1: why a bus-backed read gave nothing, in the membership list's words. + const busWord = org => (org?.busStopped === true ? 'the bus is stopped' : 'the bus is not answering'); // A copy's status: running or not as the bus reports its runs, or why it can't launch. const CANT = ['s-error', '▲', "can't launch"]; // #1873 CB5: the bus adds `binding` to a copy that needs a credential binding: its status code and @@ -364,7 +377,7 @@ } else if (inst) { rows.push(...Object.entries(inst).map(([name, v]) => ({ name, level: 'org', definition: v?.definition, model: null, scope: { kind: 'org' }, overrides: [], narrowed: [] }))); } - if (!set && inst === null) return '

    Agents unavailable. The bus did not answer the instance read.

    '; + if (!set && inst === null) return `

    ${selOrg?.busHealthy === false ? `Agents not read: ${busWord(selOrg)}.` : 'Agents unavailable. The bus did not answer the instance read.'}

    `; if (!rows.length) return '

    No agents configured

    '; const where = a => (a.level === 'root' ? 'Your root configuration' : a.level === 'org' ? `${label(selOrg)} file` : `${names(a.project)} project`); const sub = a => [a.definition && a.definition !== a.name ? a.definition : '', a.level === 'org' && typeof inst?.[a.name]?.pack?.id === 'string' ? `from pack ${inst[a.name].pack.id}` : '', a.overrides?.length ? `replaces the ${a.overrides.map(o => (o.level === 'org' ? 'organization' : o.level)).join(' and ')} ${a.overrides.length > 1 ? 'definitions' : 'definition'}` : '', a.narrowed?.length ? `narrowed here: ${a.narrowed.join(', ')}` : ''].filter(Boolean).join(' · '); @@ -448,7 +461,7 @@ const on = kept.filter(a => !own(a)), replaced = kept.filter(own); return cell(org, `${on.map(a => copyPill(copies, agents, a.name, null, 'copy')).join('') || 'none'}${replaced.length ? `${esc(replaced.map(a => a.name).join(', '))} replaced by ${esc(label(org))}'s own` : ''}${off.length ? `${esc(off.map(a => a.name).join(', '))} turned off for ${esc(label(org))}` : ''}`, endc(i)); }).join(''); - const orgRow = results.map(({ org, inst, agents }, i) => cell(org, inst === null ? 'Agents unavailable' : Object.keys(inst).length ? Object.entries(inst).map(([k, v]) => pill(org, k, v, agents)).join('') : 'No agents configured', endc(i))).join(''); + const orgRow = results.map(({ org, inst, agents }, i) => cell(org, inst === null ? `${org.busHealthy === false ? `Agents not read: ${busWord(org)}` : 'Agents unavailable'}` : Object.keys(inst).length ? Object.entries(inst).map(([k, v]) => pill(org, k, v, agents)).join('') : 'No agents configured', endc(i))).join(''); // Project: the agents a project defines itself. Narrowing and exclusion show in the table. const projRow = results.map(({ org, lv, rows, copies, agents }, i) => { if (lv?.state !== 'ok') return cell(org, `${lv?.state === 'invalid' ? 'Project agents unavailable: the organization file was refused' : 'Project agents unavailable'}`, ` last${endc(i)}`); @@ -549,7 +562,11 @@ // ---- main dashboard (sample 02) ---- async function home() { let body; - try { [body] = await Promise.all([data.workspace({ sections: ['projects', 'sessions', 'inbox', 'tasks', 'metrics'] }), ensureThemes()]); took(body); } + let levels = null, listed = null; + // SP1: the agent counts come from the organization files (GET /api/agents/levels, owner only), so a + // stopped bus still has one; the bus state words come from the membership list. Either read may + // fail or be absent, and its column then shows no figure. + try { [body, levels, listed] = await Promise.all([data.workspace({ sections: ['projects', 'sessions', 'inbox', 'tasks', 'metrics'] }), data.agentLevels().catch(() => null), data.organizations().catch(() => null), ensureThemes()]); took(body); } catch (err) { return failedPage('ws-home', 'data-ws-home', 'Dashboard', err); } const o = orgsOf(body), m = body.metrics, t = m?.totals, per = arr(m?.orgs); const inbox = arr(body.inbox), sessions = arr(body.sessions), projectRows = arr(body.projects), tasks = arr(body.tasks); @@ -573,11 +590,27 @@
    Model spendNo spend figures yetNo model prices are read yet, so today's use has no cost figure.
    ` : '

    No organization answered, so there are no totals yet.

    '; - const cards = o.rows.map(r => { - const [cls, glyph, word] = orgState(r), id = r.org; - const extra = r.state === 'ok' ? '' : `${esc(r.reason || '')}${r.boot?.at ? `${r.reason ? ' · ' : ''}last start ${esc(when(r.boot.at))}` : ''}`; - return `
  • ${av(o.meta(id), 'md')}${esc(o.name(id))}${dot(cls, glyph, word, true)}read ${esc(hm(r.at))}${extra}
  • `; + // SP1 (sample r2-01): one row per organization. A figure shows only when its read gave it; an + // organization that did not answer shows its state and reason instead of figures. + const listRow = id => (Array.isArray(listed) ? listed.find(x => x?.id === id) : null); + const projectsRead = readers(o, SRC.projects), inboxRead = readers(o, SRC.needs), sessionsRead = readers(o, SRC.sessions); + const none = '–'; + // The bus state as the workspace read found it; "stopped" only when the membership list says the owner stopped it. + const ROW_STATE = { ok: ['s-working', '●', 'bus running'], starting: ['s-waiting', '◐', 'bus starting'], timeout: ['s-error', '▲', 'bus not answering'], invalid: ['s-error', '▲', 'invalid'] }; + const rowState = (r, fromList) => (r.state === 'down' ? ['s-offline', '○', fromList?.busStopped === true ? 'bus stopped' : 'bus down'] : ROW_STATE[r.state] || orgState(r)); + const orgTable = o.rows.map(r => { + const id = r.org, fromList = listRow(id), x = per.find(p => p.org === id); + const [cls, glyph, word] = rowState(r, fromList); + const lv = levels?.orgs?.find(v => v?.id === id); + const count = lv?.state === 'ok' && Array.isArray(lv.base?.agents) ? lv.base.agents.length : null; + const working = x && sessionsRead.has(id) ? num(x.sessions?.working) : null; + const agentsCell = count === null ? none : `${plural(count, 'agent')}${working === null ? '' : ` (${working} working)`}`; + const projectsCell = x && projectsRead.has(id) ? plural(num(x.projects?.active), 'project') : none; + const needsCell = x && inboxRead.has(id) ? (num(x.needsYou) ? `${num(x.needsYou)} needs you` : 'none waiting') : none; + const why = r.state === 'ok' ? '' : `${esc(r.reason || '')}${r.boot?.at ? `${r.reason ? ' · ' : ''}last start ${esc(when(r.boot.at))}` : ''}`; + return `${av(o.meta(id))}${esc(o.name(id))}${dot(cls, glyph, word, true)}${why}${agentsCell}${projectsCell}${needsCell}`; }).join(''); + const orgList = o.rows.length ? `
    ${orgTable}
    OrganizationAgentsProjectsNeeds you
    ` : '

    No organizations yet

    '; const recent = projectRows.filter(p => p.lastActivity).slice(0, 6); const STATE_WORD = { working: 'has a session working', waiting: 'is waiting on you', idle: 'has an idle session', none: 'no live session' }; @@ -610,10 +643,10 @@ return `

    Dashboard

    ${sub}

    ${tiles} - ${secH('Organizations', `read ${esc(hm(body.readAt))}`)} -
      ${cards || '
    • No organizations yet
    • '}
    -
    +
    + ${secH('Organizations', `read ${esc(hm(body.readAt))}`)} + ${orgList} ${secH('Recent project activity', 'latest change per project; there is no activity feed yet', `All projects`)} ${trail}${missLine(o, ['projects'], 'recent')} ${secH('Tasks by state', 'open tasks, every tracker', `Open tasks`)} @@ -622,7 +655,7 @@
    ${secH('Needs you', '', `Inbox`)} ${needsList}${missLine(o, ['inbox'], 'needs')} - ${secH('Sessions now', '', `Agents`)} + ${secH('Active agents', 'sessions now', `Agents`)} ${sessTable}${missLine(o, ['agents', 'claims'], 'sessions')}
    diff --git a/packages/webui/tests/org-archive-browser.test.mjs b/packages/webui/tests/org-archive-browser.test.mjs index a4fbe6576..722202779 100644 --- a/packages/webui/tests/org-archive-browser.test.mjs +++ b/packages/webui/tests/org-archive-browser.test.mjs @@ -81,7 +81,8 @@ test('archive does not announce success for an unexpected response', { timeout: test('archiving the current organization switches to the remaining one', { timeout: 45000 }, async t => { const f = await fixture(t); await f.open('alpha'); await f.b.evaluate(`document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-final').click()`); - await f.wait(`window.MosaicOrg?.id === 'beta' && document.querySelector('header')?.textContent.includes('beta')`); + // #1873 SP1: the breadcrumb, not the header, names the open organization. + await f.wait(`window.MosaicOrg?.id === 'beta' && /beta/i.test(document.querySelector('#breadcrumbs [data-crumb-org]')?.textContent ?? '')`); assert.equal((await registry.listOrgs(f.ctx)).find(e => e.id === 'alpha').state, 'archived'); }); @@ -188,6 +189,8 @@ test('fresh archived organization page offers owner restore while writes stay re const f = await fixture(t); await f.open('beta'); await f.b.evaluate(`document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-final').click()`); await f.wait(`!document.querySelector('#org-archived').hidden`); + // #1873 SP1: another organization's address no longer reloads the page, so a fresh page is loaded on purpose. + await f.b.navigate('about:blank'); await f.b.navigate(`http://127.0.0.1:${f.port}/#/orgs/beta/settings/organizations?org=beta`); await f.wait(`document.querySelector('#org-archived') && !document.querySelector('#org-archived').hidden`); assert.equal(await f.b.evaluate(`document.querySelector('#org-restore').disabled`), false); @@ -200,6 +203,16 @@ test('fresh archived organization page offers owner restore while writes stay re assert.equal((await registry.listOrgs(f.ctx)).find(e => e.id === 'beta').state, 'active'); }); +// #1873 SP1: opening the archived organization's page in place (no reload) shows its restore command. +test('an archived organization opened in place shows its restore command', { timeout: 45000 }, async t => { + const f = await fixture(t); await f.open('beta'); + await f.b.evaluate(`document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-final').click()`); + await f.wait(`!document.querySelector('#org-archived').hidden`); + await f.b.evaluate(`window.__sameDocument = true; location.hash = '#/orgs/beta/settings/organizations?org=beta'`); + await f.wait(`window.MosaicOrg?.id === 'beta' && /mosaic org restore beta --if-revision \\d+/.test(document.querySelector('#org-archived')?.innerText ?? '')`); + assert.equal(await f.b.evaluate(`window.__sameDocument`), true); +}); + test('owner can start a down bus after setup recorded done and sees recovery guidance on refusal', { timeout: 45000 }, async t => { const f = await fixture(t); await f.b.evaluate(`(()=>{const old=window.fetch;window.fetch=async (input,init={})=>{const u=String(input);if(u.endsWith('/setup') && (!init.method || init.method==='GET'))return new Response(JSON.stringify({notifications:{state:'done'},queue:{state:'done'},projects:[],bus:{state:'done',verb:'mosaic bus start alpha'}}),{status:200});if(u.endsWith('/setup/bus') && init.method==='POST')return new Response(JSON.stringify({error:'stale-lock',message:'stale bus host lock'}),{status:409});return old(input,init)}})()`); @@ -233,6 +246,8 @@ for (const capability of [{ user:true, orgSetup:false }, { tester:true, orgSetup await f.b.evaluate(`document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-final').click()`); await f.wait(`!document.querySelector('#org-archived').hidden`); await f.b.call('Page.addScriptToEvaluateOnNewDocument', {source:`{const old=window.fetch;window.fetch=(input,init)=>input instanceof URL && input.pathname==='/api/config'?Promise.resolve(new Response(JSON.stringify(${JSON.stringify(capability)}),{status:200})):old(input,init)}`}); + // #1873 SP1: the stub applies to a new document; another organization's address alone no longer reloads. + await f.b.navigate('about:blank'); await f.b.navigate(`http://127.0.0.1:${f.port}/#/orgs/beta/settings/organizations?org=beta`); await f.wait(`document.querySelector('#org-archived') && !document.querySelector('#org-archived').hidden`); assert.equal(await f.b.evaluate(`document.querySelector('#org-restore').disabled`),true); diff --git a/packages/webui/tests/org-delete-browser.test.mjs b/packages/webui/tests/org-delete-browser.test.mjs index eb4b7e323..b656b9c97 100644 --- a/packages/webui/tests/org-delete-browser.test.mjs +++ b/packages/webui/tests/org-delete-browser.test.mjs @@ -78,8 +78,9 @@ test('deleting the current organization switches the Console to a remaining orga await b.evaluate(`document.querySelector('#org-open-form button').click()`); await wait(`document.querySelector('#org-delete') && !document.querySelector('#org-delete').hidden`); await b.evaluate(`document.querySelector('#org-delete-open').click();document.querySelector('#org-delete-continue').click();const i=document.querySelector('#org-delete-confirm');i.value='alpha';i.dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('#org-delete-final').click()`); + // #1873 SP1: the breadcrumb, not the header, names the open organization. let switched = false; - for (let i = 0; i < 300 && !switched; i++) { try { switched = await b.evaluate(`window.MosaicOrg?.id === 'beta' && document.querySelector('header')?.textContent.includes('beta')`); } catch {} if (!switched) await new Promise(r => setTimeout(r, 30)); } + for (let i = 0; i < 300 && !switched; i++) { try { switched = await b.evaluate(`window.MosaicOrg?.id === 'beta' && (document.querySelector('#breadcrumbs [data-crumb-org]')?.textContent ?? '').includes('beta')`); } catch {} if (!switched) await new Promise(r => setTimeout(r, 30)); } assert.equal(switched, true); assert.deepEqual((await registry.listOrgs(ctx)).map(e => e.id), ['beta']); }); diff --git a/packages/webui/tests/org-list-refresh-browser.test.mjs b/packages/webui/tests/org-list-refresh-browser.test.mjs index 4394cca39..65a9c2bd9 100644 --- a/packages/webui/tests/org-list-refresh-browser.test.mjs +++ b/packages/webui/tests/org-list-refresh-browser.test.mjs @@ -1,5 +1,6 @@ // On a selected org's page, the header shows why the org list failed or which org is broken, // and Retry re-reads the list and clears the banner once it recovers (polish review F3, P19 #868). +// #1873 SP1: the breadcrumb names the open organization; the header no longer does. import { test } from 'node:test'; import assert from 'node:assert/strict'; import * as fs from 'node:fs/promises'; @@ -35,9 +36,9 @@ test('a registry error shows on a selected org page and Retry recovers', async t await wait(f.b, `(${bannerText}).includes('Organization registry could not be read') && document.querySelector('#held [data-retry]')`); const before = f.requests(); await fs.writeFile(file, original); await f.b.evaluate("document.querySelector('#held [data-retry]').click()"); - await wait(f.b, "!!document.querySelector('#current-organization') && !document.getElementById('org-list-banner')"); + await wait(f.b, "!!document.querySelector('#breadcrumbs [data-crumb-org]') && !document.getElementById('org-list-banner')"); assert.ok(f.requests() > before, 'Retry re-read the organization list'); - assert.equal(await f.b.evaluate("document.querySelectorAll('#current-organization').length"), 1); + assert.equal(await f.b.evaluate("document.querySelectorAll('#breadcrumbs [data-crumb-org]').length"), 1); }); test('a broken org banner is replaced, not stacked, and clears after repair', async t => { @@ -52,5 +53,5 @@ test('a broken org banner is replaced, not stacked, and clears after repair', as await fs.writeFile(file, original); await f.b.evaluate("document.querySelector('#held [data-retry]').click()"); await wait(f.b, "!document.getElementById('org-list-banner')"); - assert.equal(await f.b.evaluate("document.querySelectorAll('#current-organization').length"), 1); + assert.equal(await f.b.evaluate("document.querySelectorAll('#breadcrumbs [data-crumb-org]').length"), 1); }); diff --git a/packages/webui/tests/org-switch-browser.test.mjs b/packages/webui/tests/org-switch-browser.test.mjs index addb69335..036e8ce19 100644 --- a/packages/webui/tests/org-switch-browser.test.mjs +++ b/packages/webui/tests/org-switch-browser.test.mjs @@ -92,7 +92,8 @@ test('creating another organization opens its setup, receipt, header and theme', assert.ok(switched, 'new organization setup loaded after the switch'); await b.evaluate('window.MosaicAppearance.ready'); assert.match(await b.evaluate('location.hash'), /^#\/orgs\/beta\/settings\/organizations\?org=beta&step=setup/); - assert.match(await b.evaluate('document.querySelector("header").textContent'), /beta/); + // SP1: the shell no longer names the organization in its header; the breadcrumb does. + assert.match(await b.evaluate('document.querySelector("#breadcrumbs [data-crumb-org]").textContent'), /beta/); assert.equal(await b.evaluate('document.documentElement.dataset.palette'), 'cobalt'); assert.match(await b.evaluate('document.querySelector("#org-receipt").textContent'), /Organization created.*beta/s); }); @@ -142,17 +143,21 @@ test('sidebar lists organizations and navigating changes the route and header', await b.viewport(390, 900); await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/settings/business`); await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelectorAll('[data-workspace-org]').length===2 && document.querySelector('#settings-page-title'))return;await new Promise(r=>setTimeout(r,30))}throw Error('Organizations did not appear')})()`); - assert.match(await b.evaluate(`document.querySelector('header').textContent`), /Alpha/); + await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('#breadcrumbs [data-crumb-org]')?.textContent==='Alpha')return;await new Promise(r=>setTimeout(r,30))}throw Error('organization crumb missing')})()`); + assert.equal(await b.evaluate(`document.querySelector('#current-organization')`), null, 'the header does not name an organization'); assert.equal(await b.evaluate(`document.querySelector('#settings-page-title').textContent`), 'Organization'); + await b.evaluate('window.__sameDocument = true'); assert.equal(await b.evaluate(`!!document.querySelector('#org-picker')`), false); assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-workspace-org]')].map(o=>o.dataset.workspaceOrg)`), ['alpha', 'beta']); await b.evaluate(`document.querySelector('[data-workspace-org=beta] a').click()`); let switched = false; for (let i = 0; i < 150 && !switched; i++) { - try { switched = await b.evaluate(`location.hash.startsWith('#/orgs/beta/') && document.querySelector('header')?.textContent.includes('Beta')`); } catch { /* reload replaced the page */ } + // The sidebar opens the organization's own page: its breadcrumb is Dashboard / Beta. + try { switched = await b.evaluate(`location.hash.startsWith('#/orgs/beta/') && document.querySelector('#breadcrumbs')?.textContent === 'Dashboard/Beta'`); } catch { /* not ready */ } if (!switched) await new Promise(r => setTimeout(r, 30)); } - assert.equal(switched, true, 'switch completed after reload'); + assert.equal(switched, true, 'switch completed in place'); + assert.equal(await b.evaluate('window.__sameDocument'), true, 'the switch did not reload the page'); assert.match(await b.evaluate(`document.querySelector('body').textContent`), /Bus unavailable/); await new Promise(r => setTimeout(r, 400)); assert.equal(await b.evaluate(`document.querySelector('#bus-down').hidden`), false, 'offline banner remains visible after the boot-status read'); @@ -193,7 +198,7 @@ test('a chat draft is kept on Stay and discarded on an organization switch', asy await b.evaluate(`document.querySelector('[data-discard]').click()`).catch(() => {}); let switched = false; for (let i = 0; i < 150 && !switched; i++) { - try { switched = await b.evaluate(`location.hash.startsWith('#/orgs/beta/') && document.querySelector('header')?.textContent.includes('Beta')`); } catch {} + try { switched = await b.evaluate(`location.hash.startsWith('#/orgs/beta/') && document.querySelector('#breadcrumbs [data-crumb-org]')?.textContent === 'Beta'`); } catch {} if (!switched) await new Promise(r => setTimeout(r, 30)); } assert.equal(switched, true); diff --git a/packages/webui/tests/single-pane-browser.test.mjs b/packages/webui/tests/single-pane-browser.test.mjs new file mode 100644 index 000000000..29db7545d --- /dev/null +++ b/packages/webui/tests/single-pane-browser.test.mjs @@ -0,0 +1,174 @@ +// SP1 (#1873, single pane slice 1): #/dashboard is the system landing route, an organization page is a +// drill-in route inside the same shell (no reload, the sidebar and top bar stay), and an organization +// whose bus is down says so with its reason. Every bus is a fake: acme and beta answer, gamma's bus is +// stopped. The real GET /api/orgs, /api/workspace and /api/agents/levels serve the page. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { fileURLToPath } from 'node:url'; +import { startServer } from '../src/serve.mjs'; +import { createDevOwner } from '../src/dev-owner.mjs'; +import { browser } from './browser.mjs'; +import { close } from './fixture.mjs'; + +const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`); +const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString(); + +async function fixture() { + const { checkRootAgents } = await import('../../organization/src/index.mjs'); + const rolesDir = fileURLToPath(new URL('../../../roles', import.meta.url)); + const root = { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: {} }, { rolesDir }) }; + const LIST = [ + { id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true }, + { id: 'beta', label: 'Beta Works', theme: 'grove', busHealthy: true }, + { id: 'gamma', label: 'Gamma Home', theme: 'rosewood', busHealthy: false, busStopped: true }, + ]; + const ROLES = { + acme: { chief: { definition: 'chief-of-staff' }, coder: { definition: 'coder' }, reviewer: { definition: 'reviewer' } }, + beta: { chief: { definition: 'chief-of-staff' } }, + gamma: { chief: { definition: 'chief-of-staff' }, researcher: { definition: 'research' } }, + }; + const DATA = { + acme: { + projects: [{ id: 'p-site', repo: 'site', name: 'Website', archived: false, at: at(30) }, { id: 'p-zeta', repo: 'zeta', name: 'Zeta API', archived: false, at: at(300) }], + agents: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'working', at: at(40), working: { since: at(40) } }], + inbox: [{ id: 'dec-acme-1', question: 'Ship the hero?', class: 'gated', blocking: true, raised_by_role: 'coder', at: at(10), options: [{ key: 'yes', text: 'Ship it' }] }], + }, + beta: { projects: [{ id: 'p-shop', repo: 'shop', name: 'Shop', archived: false, at: at(120) }], agents: [], inbox: [] }, + }; + const value = (id, name) => Promise.resolve(DATA[id]?.[name] ?? (['usage', 'limits'].includes(name) ? {} : [])); + const opts = id => ({ + bus: { inbox: () => value(id, 'inbox'), agents: () => value(id, 'agents'), 'thread.claims': () => value(id, 'claims'), tasks: () => value(id, 'tasks'), usage: () => value(id, 'usage'), limits: () => value(id, 'limits'), decisions: () => value(id, 'decisions') }, + chat: { threads: () => value(id, 'threads'), projects: () => value(id, 'projects') }, + }); + const member = org => ({ + id: org.id, doc: { appearance: { theme: org.theme }, roles: ROLES[org.id], projects: {} }, + status: async () => (org.busHealthy ? { healthy: true, pid: 4242, boot: null } : { healthy: false, boot: { ok: false, at: at(90), lastGood: null, reason: 'stopped by the owner' } }), + options: () => opts(org.id), + }); + const instances = id => Object.fromEntries(Object.entries(ROLES[id]).map(([n, r]) => [n, { definition: r.definition, vars: {}, launched: true, launcher: false, protected: false, kept: false, running: n === 'coder' ? ['run-1'] : [], alive: [] }])); + const organizations = { + list: async () => LIST, + members: async () => LIST.map(member), + resolve: async id => { + const org = LIST.find(o => o.id === id); if (!org) return null; + if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } }; + return { + busHealthy: true, + reads: { business: async () => ({ business: { id, human: 'jason', projects: DATA[id].projects.map(p => p.repo) } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) }, + bus: { messages: async () => ({ messages: [], unread: 0 }), agents: async () => DATA[id].agents, 'thread.claims': async () => [], inbox: async () => DATA[id].inbox.map(d => ({ ...d, business: id, raised_by_run: 'run', routed_to: 'human' })), tasks: async () => [], instances: async () => ({ business: id, revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {}, instances: instances(id) }) }, + chat: { projects: async () => DATA[id].projects, threads: async () => [] }, + }; + }, + }; + return { organizations, agentLevels: { loadRoot: () => root, rolesDir, ctx: { configDir: '/cfg', dataRoot: '/data', instance: 'test' } } }; +} + +async function setup(t, { viewport = [1440, 900], path = '/' } = {}) { + const { organizations, agentLevels } = await fixture(); + const server = await startServer({ port: 0, sso: createDevOwner(), organizations, agentLevels, log: () => {} }); + t.after(() => close(server)); + const b = await browser({ machine: false }); t.after(() => b.close()); + await b.viewport(...viewport); + const base = `http://127.0.0.1:${server.address().port}`; + await b.navigate(`${base}${path}`); + return { b, base, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) }; +} + +const rowText = org => `document.querySelector("[data-ws-orgrow=${org}]")?.innerText.replace(/\\s+/g, " ").trim() ?? ""`; + +test('the bare address lands on #/dashboard without a reload and lists every organization with only the figures read', async t => { + const { b, wait, text } = await setup(t); + await wait('location.hash === "#/dashboard" && document.querySelectorAll("[data-ws-orgrow]").length === 3'); + // One page load: the landing route is set in place, never by location.reload(). + assert.equal(await b.evaluate('performance.getEntriesByType("navigation").length'), 1); + assert.equal(await b.evaluate('performance.getEntriesByType("navigation")[0].type'), 'navigate'); + assert.equal(await text('#bus-view h1'), 'Dashboard'); + assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]")?.getAttribute("href")'), '#/dashboard'); + assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-orgrow]")].map(r => r.dataset.wsOrgrow)'), ['acme', 'beta', 'gamma']); + // Agents from the levels read, projects and needs-you from the workspace read. + await wait(`${rowText('acme')}.includes("3 agents")`); + const acme = await b.evaluate(rowText('acme')); + assert.match(acme, /Acme Studio/); + assert.match(acme, /bus running/); + assert.match(acme, /2 projects/); + assert.match(acme, /1 needs you/); + assert.match(await b.evaluate(rowText('beta')), /1 agent\b/); + // gamma: down with its reason; its agent count comes from its file, the bus figures are not read. + const gamma = await b.evaluate(rowText('gamma')); + assert.match(gamma, /bus stopped/); + assert.match(gamma, /stopped by the owner/); + assert.match(gamma, /2 agents/); + assert.doesNotMatch(gamma, /\d+ projects?|\d+ needs you/); + assert.equal(await b.evaluate('document.querySelector("[data-ws-orgrow=beta] a")?.getAttribute("href")'), '#/orgs/beta/organization'); + // A system route marks no organization in the sidebar and shows no breadcrumb. + assert.equal(await b.evaluate('document.querySelectorAll("#workspace-nav .sel, #workspace-nav [aria-current]").length'), 0); + assert.equal(await b.evaluate('document.querySelector(".ws-crumb")'), null); + // The top bar names no organization. + assert.equal(await b.evaluate('document.getElementById("current-organization")'), null); +}); + +test('an organization opens inside the same shell with a breadcrumb, and its pages keep reading that organization', async t => { + const { b, wait, text } = await setup(t); + await wait('document.querySelectorAll("[data-ws-orgrow]").length === 3'); + await b.evaluate('window.__marker = 1; window.__bar = document.querySelector(".cmdbar"); window.__side = document.getElementById("workspace-nav")'); + await b.evaluate('document.querySelector("[data-ws-orgrow=acme] a").click()'); + await wait('location.hash === "#/orgs/acme/organization" && document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"'); + assert.equal(await b.evaluate('window.__marker'), 1); + assert.equal(await b.evaluate('window.__bar === document.querySelector(".cmdbar") && window.__side === document.getElementById("workspace-nav")'), true); + assert.equal(await b.evaluate('performance.getEntriesByType("navigation").length'), 1); + assert.equal(await b.evaluate('document.querySelector(".ws-crumb")?.innerText.replace(/\\s+/g, " ").trim()'), 'Dashboard › Acme Studio'); + assert.equal(await b.evaluate('document.querySelector(".ws-crumb a")?.getAttribute("href")'), '#/dashboard'); + assert.equal(await b.evaluate('document.getElementById("current-organization")'), null); + // The sidebar marks the open organization. + await wait('document.querySelector("#workspace-nav [aria-current=page]")?.closest("[data-org]")?.dataset.org === "acme" || document.querySelector("#workspace-nav a[aria-current=page]")?.getAttribute("href") === "#/orgs/acme/organization"'); + + // The sidebar opens another organization in place; its page reads that organization's routes. + await b.evaluate('performance.clearResourceTimings(); document.querySelector("#workspace-nav a[href=\'#/orgs/beta/organization\']").click()'); + await wait('location.hash === "#/orgs/beta/organization" && document.querySelector("[data-org-dashboard] h1")?.textContent === "Beta Works"'); + assert.equal(await b.evaluate('window.__marker'), 1); + // A page inside beta scopes its reads to beta. + await b.evaluate('location.hash = "#/agents"'); + await wait('location.hash === "#/orgs/beta/agents" && document.querySelector("[data-org-agents]")'); + assert.equal(await b.evaluate('window.MosaicOrg.id'), 'beta'); + assert.equal(await b.evaluate('window.__marker'), 1); + // The organization's control board is named as such after the organization, not as a second Dashboard. + await b.evaluate('location.hash = "#/orgs/beta/board"'); + await wait('/^Dashboard\\/.+\\/Control board$/.test(document.getElementById("breadcrumbs")?.textContent ?? "")'); + assert.equal(await b.evaluate('document.querySelector("#breadcrumbs [data-crumb-org]")?.getAttribute("href")'), '#/orgs/beta/organization'); + + // The breadcrumb goes back to the system dashboard, still without a reload. + await b.evaluate('location.hash = "#/orgs/beta/organization"'); + await wait('document.querySelector(".ws-crumb a")'); + await b.evaluate('document.querySelector(".ws-crumb a").click()'); + await wait('location.hash === "#/dashboard" && document.querySelectorAll("[data-ws-orgrow]").length === 3'); + assert.equal(await b.evaluate('window.__marker'), 1); + assert.equal(await b.evaluate('performance.getEntriesByType("navigation").length'), 1); + assert.equal(await text('#bus-view h1'), 'Dashboard'); +}); + +test('an old #/orgs//... link still opens that page', async t => { + const { b, wait } = await setup(t, { path: '/#/orgs/beta/agents' }); + await wait('document.querySelector("[data-org-agents]")'); + assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/agents'); + assert.equal(await b.evaluate('window.MosaicOrg.id'), 'beta'); + assert.equal(await b.evaluate('performance.getEntriesByType("navigation").length'), 1); +}); + +test('a stopped organization says so on its page and on its agents, with the reason', async t => { + const { b, wait } = await setup(t, { path: '/#/orgs/gamma/organization' }); + await wait('document.querySelector("[data-org-dashboard] [data-org-down]")'); + const down = await b.evaluate('document.querySelector("[data-org-down]").innerText.replace(/\\s+/g, " ")'); + assert.match(down, /The bus is not running/); + assert.match(down, /stopped by the owner/); + await b.evaluate('location.hash = "#/orgs/gamma/agents"'); + await wait('document.querySelector("[data-org-agents] [data-org=gamma] [data-agents-why]")'); + assert.equal(await b.evaluate('document.querySelector("[data-org=gamma] [data-agents-why]").innerText.trim()'), 'Agents not read: the bus is stopped'); + const agents = await b.evaluate('document.querySelector("[data-org-agents]").innerText.replace(/\\s+/g, " ")'); + assert.doesNotMatch(agents, /Agents unavailable/); +}); + +test('at 390 px the dashboard lists the organizations without a horizontal scroll', async t => { + const { b, wait } = await setup(t, { viewport: [390, 844] }); + await wait('document.querySelectorAll("[data-ws-orgrow]").length === 3'); + assert.equal(await b.evaluate('document.documentElement.scrollWidth <= window.innerWidth'), true); +}); diff --git a/packages/webui/tests/unified-workspace-browser.test.mjs b/packages/webui/tests/unified-workspace-browser.test.mjs index 409a945ab..8c121a7b3 100644 --- a/packages/webui/tests/unified-workspace-browser.test.mjs +++ b/packages/webui/tests/unified-workspace-browser.test.mjs @@ -370,7 +370,7 @@ test('an unavailable selected bus does not hide other organizations on Agents', await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/beta/agents`); await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('[data-org-agents]'))return;await new Promise(r=>setTimeout(r,30))}throw Error('organization agent grid missing')})()`); const text=await b.evaluate('document.querySelector("[data-org-agents]").innerText'); - assert.match(text,/Alpha/); assert.match(text,/chief/); assert.match(text,/Beta/); assert.match(text,/Agents unavailable/); + assert.match(text,/Alpha/); assert.match(text,/chief/); assert.match(text,/Beta/); assert.match(text,/Agents not read: the bus is not answering/); assert.doesNotMatch(text,/Agents unavailable/); }); test('a failed conversation read says unavailable, never that none exists', async t => { diff --git a/packages/webui/tests/workspace-pages-browser.test.mjs b/packages/webui/tests/workspace-pages-browser.test.mjs index eb4d753d7..9f2bce045 100644 --- a/packages/webui/tests/workspace-pages-browser.test.mjs +++ b/packages/webui/tests/workspace-pages-browser.test.mjs @@ -81,10 +81,10 @@ async function setup(t, { viewport = [1440, 900], hash = '/', workspaceTimeoutMs test('Dashboard renders every section across organizations, and a stopped one shows its state while the others render', async t => { const { b, wait, text } = await setup(t); - await wait('document.querySelector("[data-ws-home] [data-ws-tiles]") && document.querySelectorAll("[data-ws-orgcard]").length === 3'); + await wait('document.querySelector("[data-ws-home] [data-ws-tiles]") && document.querySelectorAll("[data-ws-orgrow]").length === 3'); // The Dashboard nav item is this page. assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page] .t")?.textContent'), 'Dashboard'); - assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]").getAttribute("href")'), '#/'); + assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]").getAttribute("href")'), '#/dashboard'); assert.equal(await text('#bus-view h1'), 'Dashboard'); // Tiles: needs you, sessions, open tasks, projects, and an honest line for spend. assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-tile]")].map(e => e.dataset.wsTile)'), ['needs', 'sessions', 'tasks', 'projects', 'spend']); @@ -92,12 +92,12 @@ test('Dashboard renders every section across organizations, and a stopped one sh assert.match(await text('[data-ws-tile=projects]'), /Projects\s*4\s*3 active/i); assert.match(await text('[data-ws-tile=spend]'), /No spend figures yet/); assert.doesNotMatch(await text('[data-ws-tile=spend]'), /\$|\d/); - // Organization cards: two running with their read, the stopped one with its state and the time it was read. - assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-orgcard]")].map(e => [e.dataset.wsOrgcard, e.dataset.state])'), [['acme', 'ok'], ['beta', 'ok'], ['gamma', 'down']]); - assert.match(await text('[data-ws-orgcard=gamma]'), /Gamma Home/); - assert.match(await text('[data-ws-orgcard=gamma]'), /down/); - assert.match(await text('[data-ws-orgcard=gamma]'), /stopped by the owner/); - assert.equal(await b.evaluate('document.querySelector("[data-ws-orgcard=beta] a").getAttribute("href")'), '#/orgs/beta/organization'); + // SP1: one organization row each: two running, the stopped one with its state and reason. + assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-orgrow]")].map(e => [e.dataset.wsOrgrow, e.dataset.state])'), [['acme', 'ok'], ['beta', 'ok'], ['gamma', 'down']]); + assert.match(await text('[data-ws-orgrow=gamma]'), /Gamma Home/); + assert.match(await text('[data-ws-orgrow=gamma]'), /bus down/); + assert.match(await text('[data-ws-orgrow=gamma]'), /stopped by the owner/); + assert.equal(await b.evaluate('document.querySelector("[data-ws-orgrow=beta] a").getAttribute("href")'), '#/orgs/beta/organization'); assert.match(await text('[data-ws-home] .sub'), /2 of 3 organizations answered/); assert.match(await text('[data-ws-home] .sub'), /Gamma Home down, read \d\d:\d\d/); // Recent project activity across both running organizations, newest first, each linking into its org. @@ -243,18 +243,18 @@ test('A poll repaints in place: focus, caret and scroll stay where they were', a assert.deepEqual(await b.evaluate('[document.activeElement?.dataset.focusKey, document.activeElement.selectionStart]'), ['proj-name', 2]); // The Dashboard and the Inbox repaint the same way. await b.evaluate('location.hash = "#/orgs/acme/"'); - await wait('document.querySelectorAll("[data-ws-orgcard]").length === 3'); - await b.evaluate('document.querySelector("[data-ws-orgcard=beta] a").focus()'); + await wait('document.querySelectorAll("[data-ws-orgrow]").length === 3'); + await b.evaluate('document.querySelector("[data-ws-orgrow=beta] a").focus()'); await b.evaluate('window.__marker = document.querySelector("[data-ws-orgs]"); document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))'); await wait('document.querySelector("[data-ws-orgs]") !== window.__marker'); - assert.equal(await b.evaluate('document.activeElement?.dataset.focusKey'), 'orgcard:beta'); + assert.equal(await b.evaluate('document.activeElement?.dataset.focusKey'), 'orgrow:beta'); // No loading view replaced the page during that repaint. assert.equal(await b.evaluate('document.querySelector("#bus-view .loading")'), null); }); test('Phone width: no horizontal scroll on any of the three pages, and the drawer opens and closes', async t => { const { b, wait } = await setup(t, { viewport: [390, 844] }); - for (const [hash, ready] of [['/', '[data-ws-orgcard]'], ['/projects', '[data-ws-project]'], ['/inbox', '[data-ws-item]']]) { + for (const [hash, ready] of [['/', '[data-ws-orgrow]'], ['/projects', '[data-ws-project]'], ['/inbox', '[data-ws-item]']]) { await b.evaluate(`location.hash = "#/orgs/acme${hash}"`); await wait(`document.querySelector(${JSON.stringify(ready)})`); assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, hash); -- 2.54.0 From bb2f79f3d91b46821593fe25453f6833fd16ff8e Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 14 Nov 2026 23:19:05 -0600 Subject: [PATCH 2/3] fix(webui): sp1 review round, a switch carries nothing across and first run keeps its board (#1873, lane sp1) - First run (no organization yet) keeps #/ as its board; the router no longer rewrites it to #/dashboard, which first run cannot draw. - An organization switch stops the agents section's request poll and clears what it held. A route-change restart chain finishes on the organization it started in. - A write in flight at a switch drops its answer: an org epoch in bus.js, checked after each await. The switch clears every busy flag, receipt and reply, and the doc drafts. - The bus banner reads org state again only when the route crosses /dashboard, not on every hashchange inside one organization. - The down banner does not repeat its heading as the reason. - Tests: first run wordmark, poll stop on switch, restart pinned to its organization, settings save in flight at a switch, banner without a repeated reason. The org-switch test comment and wait match the in-place switch. --- packages/webui/src/public/agents.js | 49 +++++-- packages/webui/src/public/app.js | 9 +- packages/webui/src/public/bus.js | 74 ++++++---- packages/webui/src/public/display.js | 7 +- packages/webui/src/public/workspace.js | 12 +- .../webui/tests/org-switch-browser.test.mjs | 10 +- .../webui/tests/single-pane-browser.test.mjs | 129 ++++++++++++++++-- 7 files changed, 226 insertions(+), 64 deletions(-) diff --git a/packages/webui/src/public/agents.js b/packages/webui/src/public/agents.js index 32ebdc554..b7e5fae37 100644 --- a/packages/webui/src/public/agents.js +++ b/packages/webui/src/public/agents.js @@ -76,8 +76,11 @@ if (!usable() || busy || pending || !view) return; const [url, payload] = plan(action, name); busy = true; confirming = null; receipt = ''; redraw(); + const gen = generation; let answer; try { answer = await post(url, payload); } catch { answer = { ok: false, result: { sent: 'unknown' } }; } + // #1873 SP1: an organization switch while this was in flight drops the answer; the switch already cleared busy. + if (gen !== generation) return; busy = false; if (answer === null) return; const { ok, result } = answer; @@ -122,8 +125,10 @@ const instance = create.instance.trim(), definition = create.definition; if (!instance || !definition) { receipt = 'Name the instance and choose a definition.'; redraw(); return; } busy = true; receipt = ''; redraw(); + const gen = generation; let answer; try { answer = await post('/api/instances', { revision: view.revision, instance, op: 'add', definition }); } catch { answer = { ok: false, result: { sent: 'unknown' } }; } + if (gen !== generation) return; busy = false; if (answer === null) return; if (answer.ok && /^[a-f0-9]{64}$/.test(answer.result?.revision ?? '')) { create = { instance: '', definition: '' }; receipt = `Created ${instance}. ${answer.result.restart ? restartText() : NO_RESTART_TEXT}`; reread(); } @@ -154,19 +159,31 @@ document.addEventListener('submit', e => { if (e.target?.id !== 'agent-create-form') return; e.preventDefault(); createAgent(); }); // A sign-out stops any poll; nothing that was asked is retried. document.addEventListener('mosaic-signed-out', () => { generation++; pending = null; confirming = null; }); + // #1873 SP1: opening another organization stops any poll and clears what this section held for the + // old one. A request already sent finishes on the old organization's bus; its page reads it again there. + document.addEventListener('mosaic-org-switch', () => { + generation++; pending = null; receipt = ''; editing = null; confirming = null; busy = false; + create = { instance: '', definition: '' }; + for (const k of Object.keys(reasons)) delete reasons[k]; + }); // F3: restart an agent so a route change reaches it now, offered by the Routing receipt. One // terminate; then reads of the instances until the run has left (up to the 60 s regenerate // allows); then one launch. A thread definition is not launched: its next message starts it. // A refusal, a sign-out or an unknown outcome stops the chain and says so; nothing is retried. - const restarts = Object.create(null); + let restarts = Object.create(null); + // #1873 SP1: the chain's reads and writes go to the organization it started in, which the fetch + // wrapper passes through unchanged. A switch clears what the new page shows; the old chain keeps + // its own entry and stops painting. + const pin = (org, path) => org ? `/api/orgs/${encodeURIComponent(org)}/${path.slice('/api/'.length)}` : path; + document.addEventListener('mosaic-org-switch', () => { restarts = Object.create(null); }); const RESTART_WAIT = 30; function restartMarkup(name, thread = false) { const r = restarts[name]; return ` ${esc(r?.text ?? '')}`; } const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); - const paintRestart = name => document.querySelectorAll('.agent-restart').forEach(el => { + const paintRestart = (name, org = window.MosaicOrg?.id ?? null) => (window.MosaicOrg?.id ?? null) !== org ? undefined : document.querySelectorAll('.agent-restart').forEach(el => { const b = el.querySelector('[data-agent-restart]'); if (b?.dataset.agentRestart === name) el.outerHTML = restartMarkup(name, b.dataset.agentThread === 'yes'); }); @@ -179,45 +196,47 @@ return { ok: res.ok, result }; } // The request's closing read, or a reason the chain stops. - async function closing(request) { + async function closing(request, org) { for (let tries = 0; tries < POLL_LIMIT; tries++) { await new Promise(r => setTimeout(r, POLL_MS)); let res, r = null; - try { res = await fetch(`/api/agent/request?id=${encodeURIComponent(request)}`, { cache: 'no-store', redirect: 'manual' }); } catch { continue; } + try { res = await fetch(pin(org, `/api/agent/request?id=${encodeURIComponent(request)}`), { cache: 'no-store', redirect: 'manual' }); } catch { continue; } if (res.type === 'opaqueredirect') return { stop: 'Sign in again, then read the agents.' }; try { r = await res.json(); } catch { continue; } if (res.ok && r?.request === request && r.state === 'closed') return { done: r }; } return { stop: `Request ${request} is still open. Read the agents again later.` }; } - async function runs(name) { + async function runs(name, org) { try { - const res = await fetch('/api/bus/instances', { cache: 'no-store', redirect: 'manual' }); + const res = await fetch(pin(org, '/api/bus/instances'), { cache: 'no-store', redirect: 'manual' }); if (!res.ok || res.type === 'opaqueredirect') return null; const e = (await res.json())?.rows?.[0]?.instances?.[name]; return e ? (Array.isArray(e.running) ? e.running.length : 0) : 0; } catch { return null; } } async function restartAgent(name, thread) { + const org = window.MosaicOrg?.id ?? null; const r = restarts[name] = { busy: true, text: `Terminating ${name}…` }; - const stop = text => { r.busy = false; r.text = text; paintRestart(name); }; - paintRestart(name); - const asked = await send('/api/agent/terminate', { instance: name, reason: 'route change' }); + const paint = () => paintRestart(name, org); + const stop = text => { r.busy = false; r.text = text; paint(); }; + paint(); + const asked = await send(pin(org, '/api/agent/terminate'), { instance: name, reason: 'route change' }); if (asked.signedOut) return stop('Sign in again, then retry.'); if (asked.unknown || !asked.ok || !REQUEST_ID.test(asked.result?.request ?? '')) return stop(asked.ok || asked.unknown ? UNKNOWN : refused(asked.result)); - const ended = await closing(asked.result.request); + const ended = await closing(asked.result.request, org); if (ended.stop) return stop(ended.stop); if (ended.done.reason) return stop(`${name}: ${ended.done.reason}. It was not restarted.`); - r.text = `Terminated ${name}. Waiting for its session to end…`; paintRestart(name); + r.text = `Terminated ${name}. Waiting for its session to end…`; paint(); let left = false; - for (let i = 0; i < RESTART_WAIT && !left; i++) { await new Promise(res => setTimeout(res, POLL_MS)); left = (await runs(name)) === 0; } + for (let i = 0; i < RESTART_WAIT && !left; i++) { await new Promise(res => setTimeout(res, POLL_MS)); left = (await runs(name, org)) === 0; } if (!left) return stop(`${name} was asked to end but its session is still running. Launch it from Agents once it has ended.`); if (thread) return stop(`Terminated ${name} at ${new Date().toLocaleTimeString()}. Its next message starts it on the new route.`); - r.text = `Launching ${name}…`; paintRestart(name); - const launch = await send('/api/agent/launch', { instance: name }); + r.text = `Launching ${name}…`; paint(); + const launch = await send(pin(org, '/api/agent/launch'), { instance: name }); if (launch.signedOut) return stop('Sign in again. It was terminated and not launched.'); if (launch.unknown || !launch.ok || !REQUEST_ID.test(launch.result?.request ?? '')) return stop(launch.ok || launch.unknown ? `${UNKNOWN} It was terminated.` : `${refused(launch.result)} It was terminated and not launched.`); - const started = await closing(launch.result.request); + const started = await closing(launch.result.request, org); if (started.stop) return stop(started.stop); if (started.done.reason) return stop(`${name}: ${started.done.reason}. It was terminated and not launched.`); return stop(`Restarted ${name} on the new route${started.done.done?.run ? ` (run ${started.done.done.run})` : ''}.`); diff --git a/packages/webui/src/public/app.js b/packages/webui/src/public/app.js index 01a7d01ab..30992510a 100644 --- a/packages/webui/src/public/app.js +++ b/packages/webui/src/public/app.js @@ -552,7 +552,14 @@ document.addEventListener('mosaic-org-switch', onOrgSwitch); // An address with no organization picks one after the first list read; its own setting applies then. window.MosaicOrg?.ready?.then(() => { if (readMachine() !== machine) onOrgSwitch(); }); - window.addEventListener('hashchange', () => { if (window.MosaicOrg) busDown(); }); + // The banner hides on the system dashboard and shows on an organization's pages, so a move across + // that line redraws it. Other moves inside one organization read nothing (the switch path reads its own). + let onDashboard = null; + window.addEventListener('hashchange', () => { + if (!window.MosaicOrg) return; + const now = window.MosaicOrg.route().split('?')[0] === '/dashboard'; + if (now !== onDashboard) { onDashboard = now; busDown(); } + }); $('refresh').onclick = refresh; $('mobile-pause').onclick = () => $('pause').click(); $('pause').onclick = () => { paused = !paused; $('pause').querySelector('span').textContent = paused ? 'Resume' : 'Pause'; $('pause').setAttribute('aria-label', paused ? 'Resume' : 'Pause'); $('pause').title = paused ? 'Resume' : 'Pause'; $('mobile-pause').textContent = paused ? 'Resume' : 'Pause'; $('pause').setAttribute('aria-pressed', String(paused)); schedule(); if (data) render(); }; diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index ccb877ee2..10a1db894 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -345,6 +345,9 @@ let configRead = Promise.resolve(); // #1873 SP1: the config read an organization switch started, until the next render has waited for it. let switchRead = null; + // #1873 SP1: bumped by each in-place organization switch. A write captures it before its await and + // drops its answer when it changed, so nothing the old organization answered lands on the new page. + let orgEpoch = 0; let canSend = false, canMessage = false, chatSend = false, sendRoles = null, sending = false, receipt = null, replies = []; const REPLIES = 50; // the broker's cap function repliesHtml() { @@ -465,7 +468,9 @@ if(!templatesOnly && d.op!=='fire' && !template){hireStatus='Read and choose a role template first.';hireCode='';paintHireStart();return;} const thinking=d.thinking?.trim(),hire={op:d.op,level:d.level,name:d.name,reason:d.reason,...(d.level==='project'?{project:d.project}:{}),...(d.op==='fire'?{}:{role:{template:template?.name,sha256:template?.sha256},route:{harness:d.harness,model:d.model,...(thinking?{thinking}:{})}})}; hireBusy=true;hireStatus='';hireCode='';paintHireStart(); + const epoch=orgEpoch; const {res,json}=await decidePost(`/api/decisions/hire/${templatesOnly?'templates':'start'}`,{business:hireOrg() ?? d.business,...(templatesOnly?{}:{hire})}); + if(epoch!==orgEpoch){if(res?.type==='opaqueredirect')signOut();return;} hireBusy=false; if(res?.type==='opaqueredirect'){signOut();return;} if(res?.ok && templatesOnly && Array.isArray(json?.templates)){hireTemplates=json.templates;hireStatus=hireTemplates.length?'Templates read.':'No templates available.';} @@ -490,7 +495,9 @@ if(fireBusy||!org||out()||!canDecide)return; fireBusy=true; for(const b of document.querySelectorAll('[data-agent-fire-yes],[data-agent-fire-no]'))b.disabled=true; + const epoch=orgEpoch; const {res,json}=await decidePost('/api/decisions/hire/start',{business:org,hire:{op:'fire',level:'org',name,reason:FIRE_REASON}}); + if(epoch!==orgEpoch){if(res?.type==='opaqueredirect')signOut();return;} fireBusy=false;fireAsk=null; if(res?.type==='opaqueredirect'){signOut();return;} if(res?.ok && json?.op==='resolved'){ @@ -1041,9 +1048,9 @@ document.addEventListener('change',e=>{const form=e.target.closest?.('#launch-settings-form');if(form){settingsInputs=settingDraft(form);markSettingsDraft('limits',true);form.querySelector('.settings-actions')?.classList.add('has-draft');const discard=$('launch-settings-reload');if(discard)discard.hidden=false;}}); document.addEventListener('click',e=>{if(e.target.id!=='launch-settings-reload')return;settingsInputs=null;settingsSnapshot=null;settingsReceipt='';last.delete('api:settings');render(true);}); document.addEventListener('submit',async e=>{ - if(e.target.id!=='launch-settings-form')return;e.preventDefault();if(!canSend||out()||settingsSaving)return;settingsInputs=settingDraft(e.target);let payload;try{payload=settingPayload();}catch(err){settingsReceipt=err.message+' Draft kept.';render(false);return;}settingsSaving=true;settingsReceipt='';render(false); - try{const response=await fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){settingsReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();if(response.ok&&/^[a-f0-9]{64}$/.test(result?.revision??'')){settingsInputs=null;settingsSnapshot=result;const prior=last.get('api:settings');if(prior)last.set('api:settings',{...prior,launch:result});settingsReceipt='Launch settings saved.';}else settingsReceipt=result?.sent==='unknown'?'Outcome unknown. Read current settings before trying again. Draft kept.':`${result?.message||'Settings refused.'} Draft kept.`;}} - catch{settingsReceipt='Outcome unknown. Read current settings before trying again. Draft kept.';}finally{settingsSaving=false;if(!out())render(false);} + if(e.target.id!=='launch-settings-form')return;e.preventDefault();if(!canSend||out()||settingsSaving)return;settingsInputs=settingDraft(e.target);let payload;try{payload=settingPayload();}catch(err){settingsReceipt=err.message+' Draft kept.';render(false);return;}settingsSaving=true;settingsReceipt='';render(false);const epoch=orgEpoch; + try{const response=await fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(epoch!==orgEpoch){if(response.type==='opaqueredirect')signOut();return;}if(response.type==='opaqueredirect'){settingsReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();if(epoch!==orgEpoch)return;if(response.ok&&/^[a-f0-9]{64}$/.test(result?.revision??'')){settingsInputs=null;settingsSnapshot=result;const prior=last.get('api:settings');if(prior)last.set('api:settings',{...prior,launch:result});settingsReceipt='Launch settings saved.';}else settingsReceipt=result?.sent==='unknown'?'Outcome unknown. Read current settings before trying again. Draft kept.':`${result?.message||'Settings refused.'} Draft kept.`;}} + catch{if(epoch!==orgEpoch)return;settingsReceipt='Outcome unknown. Read current settings before trying again. Draft kept.';}finally{if(epoch===orgEpoch){settingsSaving=false;if(!out())render(false);}} }); // Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): each token limit as the bus's checks report it. A // limit no switched-on check counts reads "not enforced". Reset counts it again from now. Row 126 @@ -1064,9 +1071,9 @@ document.addEventListener('click',async e=>{ const path=e.target.dataset?.limitReset;if(path===undefined||!canSend||out()||settingsSaving)return; const revision=settingsSnapshot?.revision;if(!/^[a-f0-9]{64}$/.test(revision??''))return; - settingsSaving=true;resetReceipt='';render(false);let reread=false; - try{const response=await fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){resetReceipt='Sign in again.';signOut();}else{const result=await response.json();if(response.ok&&/^[a-f0-9]{64}$/.test(result?.revision??'')){settingsSnapshot=result;const prior=last.get('api:settings');if(prior)last.set('api:settings',{...prior,launch:result});reread=true;resetReceipt='Limit reset.';}else resetReceipt=result?.sent==='unknown'?'Outcome unknown. Read current settings before trying again.':`${result?.message||'Reset refused.'}`;}} - catch{resetReceipt='Outcome unknown. Read current settings before trying again.';}finally{settingsSaving=false;if(!out())render(reread);} + settingsSaving=true;resetReceipt='';render(false);let reread=false;const epoch=orgEpoch; + try{const response=await fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});if(epoch!==orgEpoch){if(response.type==='opaqueredirect')signOut();return;}if(response.type==='opaqueredirect'){resetReceipt='Sign in again.';signOut();}else{const result=await response.json();if(epoch!==orgEpoch)return;if(response.ok&&/^[a-f0-9]{64}$/.test(result?.revision??'')){settingsSnapshot=result;const prior=last.get('api:settings');if(prior)last.set('api:settings',{...prior,launch:result});reread=true;resetReceipt='Limit reset.';}else resetReceipt=result?.sent==='unknown'?'Outcome unknown. Read current settings before trying again.':`${result?.message||'Reset refused.'}`;}} + catch{if(epoch!==orgEpoch)return;resetReceipt='Outcome unknown. Read current settings before trying again.';}finally{if(epoch===orgEpoch){settingsSaving=false;if(!out())render(reread);}} }); function tokenUsage(value) { if (!value?.total) return '

    Token usage

    Usage unavailable. No count was read.

    '; @@ -1121,9 +1128,9 @@ } const unknownAssistant='Outcome unknown. Read current settings before trying again. Draft kept.'; async function assistantPost(url,payload,instance,ok,kind='tester'){ - if(kind==='tester')assistantBusy=true;else assistantFormsBusy.add(assistantKey(instance,kind));assistantReceiptKinds[instance]=kind;assistantReceipts[instance]='';if(kind!=='tester')assistantCardReceipts[assistantKey(instance,kind)]='';render(false); - try{const response=await fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){assistantReceipts[instance]='Sign in again. Draft kept.';signOut();}else{const result=await response.json();assistantReceiptKinds[instance]=kind;assistantReceipts[instance]=response.ok?ok(result):result?.sent==='unknown'?unknownAssistant:`${result?.message||'Refused.'} Draft kept.`;}} - catch{assistantReceiptKinds[instance]=kind;assistantReceipts[instance]=unknownAssistant;}finally{if(kind==='tester')assistantBusy=false;else{assistantCardReceipts[assistantKey(instance,kind)]=assistantReceipts[instance];assistantFormsBusy.delete(assistantKey(instance,kind));}if(!out())render(false);} + if(kind==='tester')assistantBusy=true;else assistantFormsBusy.add(assistantKey(instance,kind));assistantReceiptKinds[instance]=kind;assistantReceipts[instance]='';if(kind!=='tester')assistantCardReceipts[assistantKey(instance,kind)]='';render(false);const epoch=orgEpoch; + try{const response=await fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(epoch!==orgEpoch){if(response.type==='opaqueredirect')signOut();return;}if(response.type==='opaqueredirect'){assistantReceipts[instance]='Sign in again. Draft kept.';signOut();}else{const result=await response.json();if(epoch!==orgEpoch)return;assistantReceiptKinds[instance]=kind;assistantReceipts[instance]=response.ok?ok(result):result?.sent==='unknown'?unknownAssistant:`${result?.message||'Refused.'} Draft kept.`;}} + catch{if(epoch!==orgEpoch)return;assistantReceiptKinds[instance]=kind;assistantReceipts[instance]=unknownAssistant;}finally{if(epoch===orgEpoch){if(kind==='tester')assistantBusy=false;else{assistantCardReceipts[assistantKey(instance,kind)]=assistantReceipts[instance];assistantFormsBusy.delete(assistantKey(instance,kind));}if(!out())render(false);}} } const assistantForm=el=>el?.closest?.('.assistant-form'); for(const kind of ['input','change'])document.addEventListener(kind,e=>{const form=assistantForm(e.target);if(!form)return;const instance=form.dataset.instance,field=e.target.dataset.assistantField;if(field==='name')nameDrafts[instance]=e.target.value;else if(field)assistantDrafts[instance]={...Object.fromEntries(['definition','vars','tracker'].map(f=>[f,form.querySelector(`[data-assistant-field="${f}"]`).value]))};if(field){markSettingsDraft('assistant',true);form.querySelector('.settings-actions')?.classList.add('has-draft');const discard=form.querySelector('[data-assistant-discard]');if(discard)discard.hidden=false;}}); @@ -1188,11 +1195,11 @@ const form=e.target?.closest?.('.instance-form');if(!form||e.target!==form)return;e.preventDefault();if(!(canSend || isTester)||out()||!instanceView)return; const op=form.dataset.op;if(instanceBusyOps.has(op))return;instanceReceiptOp=op;instanceDrafts[op]=instanceFields(form);let payload; try{payload=instancePayload(op,instanceDrafts[op]);}catch(err){instanceReceipt=err.message;instanceReceipts[op]=instanceReceipt;render(false);return;} - instanceBusyOps.add(op);instanceReceipt='';instanceReceipts[op]='';render(false); - try{const response=await fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){instanceReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json(); + instanceBusyOps.add(op);instanceReceipt='';instanceReceipts[op]='';render(false);const epoch=orgEpoch; + try{const response=await fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(epoch!==orgEpoch){if(response.type==='opaqueredirect')signOut();return;}if(response.type==='opaqueredirect'){instanceReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();if(epoch!==orgEpoch)return; if(response.ok&&instanceAnswer(result)){instanceDrafts[op]=null;instanceView=result;const prior=last.get('instances');if(prior)last.set('instances',{...prior,rows:[result]});instanceReceipt=`${op==='add'?'Added':'Changed'} ${payload.instance}. ${result.restart?restartText():NO_RESTART_TEXT}`;} else instanceReceipt=response.ok||result?.sent==='unknown'?unknownInstance:`${result?.message||'Refused.'} Draft kept.`;}} - catch{instanceReceipt=unknownInstance;}finally{instanceBusyOps.delete(op);instanceReceiptOp=op;instanceReceipts[op]=instanceReceipt;if(!out())render(false);} + catch{if(epoch!==orgEpoch)return;instanceReceipt=unknownInstance;}finally{if(epoch===orgEpoch){instanceBusyOps.delete(op);instanceReceiptOp=op;instanceReceipts[op]=instanceReceipt;if(!out())render(false);}} }); // Row 65 (mos-0840): the routing table. One row per instance: harness, model and thinking, and // the fallback's three. Each cell offers the switched-on harnesses, the model table's keys and the @@ -1218,8 +1225,8 @@ const routesApplyMarkup=()=>routesApply?.length?`
      ${routesApply.map(a=>`
    • ${txt(a.name)}: ${txt(a.text)}${a.restart&&canSend&&!isTester?` ${window.MosaicAgents?.restartMarkup(a.name,a.thread)??''}`:''}
    • `).join('')}
    `:''; let harnessSettings=null, harnessDraft=null, harnessLoading=false, harnessReceipt='', harnessBusy=false; function readHarnesses(){ - if(harnessLoading || harnessSettings !== null || !canSend || isTester)return; harnessLoading=true; - fetch('/api/proxy/plugins',{cache:'no-store',redirect:'manual'}).then(async r=>{const v=await r.json();if(!r.ok || !/^[a-f0-9]{64}$/.test(v.revision??'') || !Array.isArray(v.available?.harness) || !v.available.harness.every(n=>typeof n==='string') || !Array.isArray(v.plugins?.harness??[]))throw Error();harnessSettings=v;}).catch(()=>{harnessSettings=false;harnessReceipt='Harnesses could not be read.';}).finally(()=>{harnessLoading=false;if(routeOf().split('?')[0]==='/settings/routing')render(false);}); + if(harnessLoading || harnessSettings !== null || !canSend || isTester)return; harnessLoading=true;const epoch=orgEpoch; + fetch('/api/proxy/plugins',{cache:'no-store',redirect:'manual'}).then(async r=>{const v=await r.json();if(epoch!==orgEpoch)return;if(!r.ok || !/^[a-f0-9]{64}$/.test(v.revision??'') || !Array.isArray(v.available?.harness) || !v.available.harness.every(n=>typeof n==='string') || !Array.isArray(v.plugins?.harness??[]))throw Error();harnessSettings=v;}).catch(()=>{if(epoch!==orgEpoch)return;harnessSettings=false;harnessReceipt='Harnesses could not be read.';}).finally(()=>{if(epoch!==orgEpoch)return;harnessLoading=false;if(routeOf().split('?')[0]==='/settings/routing')render(false);}); } const ROUTE_CELLS=[['harness','Harness'],['model','Model'],['thinking','Thinking'],['fallback.harness','Fallback harness'],['fallback.model','Fallback model'],['fallback.thinking','Fallback thinking']]; const routeCells=r=>({harness:r.harness??'',model:r.model??'',thinking:r.thinking??'','fallback.harness':r.fallback?.harness??'','fallback.model':r.fallback?.model??'','fallback.thinking':r.fallback?.thinking??''}); @@ -1296,32 +1303,32 @@ if(harnessSettings.revision!==routesView?.revision){harnessReceipt='The business changed. Reload this page before saving harnesses.';render(false);return;} const used=old.filter(n=>!selected.includes(n) && Object.values(routesView.instances).some(r=>r.harness===n || r.fallback?.harness===n)); if(used.length){harnessReceipt=`A saved route uses ${used.join(', ')}. Change the route before switching it off.`;render(false);return;} - harnessBusy=true;const button=e.target;button.disabled=true;harnessReceipt=''; - try{const response=await fetch('/api/proxy/plugins/set',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision:harnessSettings.revision,plugins:{harness:selected}}),cache:'no-store',redirect:'manual'});const result=await response.json();if(!response.ok){harnessReceipt=result.message||result.error||'Harness setting was refused.';return;}harnessSettings={...result,available:harnessSettings.available};harnessDraft=null;harnessReceipt='Harnesses saved. The next launch uses the new settings.';routesView=null;last.delete('routes');} - catch{harnessReceipt='Outcome unknown. Read the harnesses again before trying.';harnessSettings=false;} - finally{harnessBusy=false;if(!out())render(false);} + harnessBusy=true;const button=e.target;button.disabled=true;harnessReceipt='';const epoch=orgEpoch; + try{const response=await fetch('/api/proxy/plugins/set',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision:harnessSettings.revision,plugins:{harness:selected}}),cache:'no-store',redirect:'manual'});const result=await response.json();if(epoch!==orgEpoch)return;if(!response.ok){harnessReceipt=result.message||result.error||'Harness setting was refused.';return;}harnessSettings={...result,available:harnessSettings.available};harnessDraft=null;harnessReceipt='Harnesses saved. The next launch uses the new settings.';routesView=null;last.delete('routes');} + catch{if(epoch!==orgEpoch)return;harnessReceipt='Outcome unknown. Read the harnesses again before trying.';harnessSettings=false;} + finally{if(epoch===orgEpoch){harnessBusy=false;if(!out())render(false);}} }); document.addEventListener('click',async e=>{if(e.target.id!=='provider-switch'||!canSend||isTester)return; - const button=e.target,status=$('provider-switch-status');if(routesDraft){status.textContent='Save or discard the route draft first.';return;}button.disabled=true; - try {const read=await fetch('/api/proxy/plugins',{cache:'no-store'});const settings=await read.json();if(!read.ok||!/^[a-f0-9]{64}$/.test(settings.revision??''))throw Error('Could not read provider settings.'); + const button=e.target,status=$('provider-switch-status');if(routesDraft){status.textContent='Save or discard the route draft first.';return;}button.disabled=true;const epoch=orgEpoch; + try {const read=await fetch('/api/proxy/plugins',{cache:'no-store'});const settings=await read.json();if(epoch!==orgEpoch)return;if(!read.ok||!/^[a-f0-9]{64}$/.test(settings.revision??''))throw Error('Could not read provider settings.'); const names=Array.isArray(settings.plugins?.['model-provider'])?settings.plugins['model-provider']:[]; // The fresh read decides: a stale label never turns the proxy module on when it meant off. const on=names.includes('cliproxy');if(String(on)!==button.dataset.enabled){button.dataset.enabled=String(on);button.textContent=`${on?'Disable':'Enable'} model proxy catalogue`;button.disabled=false;status.textContent='The switch now shows the current setting. Press it again to change it.';return;} const next=on?names.filter(n=>n!=='cliproxy'):[...names,'cliproxy']; const response=await fetch('/api/proxy/plugins/set',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision:settings.revision,plugins:{'model-provider':next}}),cache:'no-store',redirect:'manual'}); - if(!response.ok)throw Error('Provider setting was refused.');harnessSettings=null;routesView=null;last.delete('routes');render(true); - }catch(error){status.textContent=error.message;button.disabled=false;} + if(epoch!==orgEpoch)return;if(!response.ok)throw Error('Provider setting was refused.');harnessSettings=null;routesView=null;last.delete('routes');render(true); + }catch(error){if(epoch!==orgEpoch)return;status.textContent=error.message;button.disabled=false;} }); document.addEventListener('click',e=>{if(e.target.matches?.('.fallback-toggle'))e.target.closest('tr')?.classList.add('fallback-open');}); document.addEventListener('click',e=>{if(e.target.id!=='routes-reload')return;routesDraft=null;routesView=null;routesReceipt='';routesApply=null;last.delete('routes');render(true);}); document.addEventListener('submit',async e=>{ if(e.target.id!=='routes-form')return;e.preventDefault();if(!(canSend || isTester)||out()||routesBusy||!routesView)return; routesDraft=routesFields(e.target);let payload;try{payload=routesPayload(routesDraft);}catch(err){routesReceipt=err.message;render(false);return;} - routesBusy=true;routesReceipt='';routesApply=null;render(false); - try{const response=await fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){routesReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json(); - if(response.ok&&routesAnswer(result)){routesDraft=null;routesView=result;harnessSettings=null;const prior=last.get('routes');if(prior)last.set('routes',{...prior,rows:[result]});routesReceipt=`Routes saved for ${Object.keys(payload.routes).join(', ')}. The next launch of each uses the new route.`;routesApply=await routesApplyRead(Object.keys(payload.routes));} + routesBusy=true;routesReceipt='';routesApply=null;render(false);const epoch=orgEpoch; + try{const response=await fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(epoch!==orgEpoch){if(response.type==='opaqueredirect')signOut();return;}if(response.type==='opaqueredirect'){routesReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();if(epoch!==orgEpoch)return; + if(response.ok&&routesAnswer(result)){routesDraft=null;routesView=result;harnessSettings=null;const prior=last.get('routes');if(prior)last.set('routes',{...prior,rows:[result]});routesReceipt=`Routes saved for ${Object.keys(payload.routes).join(', ')}. The next launch of each uses the new route.`;const applied=await routesApplyRead(Object.keys(payload.routes));if(epoch!==orgEpoch)return;routesApply=applied;} else routesReceipt=response.ok||result?.sent==='unknown'?unknownRoutes:`${result?.message||'Refused.'}${catalogueStep(result?.error,payload.routes)} Draft kept.`;}} - catch{routesReceipt=unknownRoutes;}finally{routesBusy=false;if(!out())render(false);} + catch{if(epoch!==orgEpoch)return;routesReceipt=unknownRoutes;}finally{if(epoch===orgEpoch){routesBusy=false;if(!out())render(false);}} }); // Row 194 (#1692, D7): the Agents section lives in agents.js (window.MosaicAgents) and draws from // the same instances read. A refresh drops that read so the next render asks the bus again. @@ -1935,11 +1942,13 @@ 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(); + const epoch = orgEpoch; let res = null, body = null; try { res = await fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' }); try { body = await res.json(); } catch { body = null; } } catch { res = null; } + if (epoch !== orgEpoch) { if (res?.type === 'opaqueredirect') signOut(); return; } // Sent: an id came back. Not sent: a 4xx (refused before the bus wrote), the server's // sent:false, or a redirect, which only the sign-in proxy gives (an expired session), so the // request never reached the Console. Anything else, no answer included, may have been sent, @@ -1999,8 +2008,16 @@ document.addEventListener('mosaic-org-changed', e => { if (window.MosaicOrg && e.detail?.org === window.MosaicOrg.id && ['/organization', '/agents'].includes(routeOf().split('?')[0])) render(false); }); // #1873 SP1: another organization opened in place (display.js, before this file's hashchange // render). Nothing read, drafted or confirmed for the old one carries over; the new one's config - // is read before the next page draws. A write already sent finishes against the old organization. + // is read before the next page draws. A write already sent finishes against the old organization, + // and its answer is not drawn on the new one. document.addEventListener('mosaic-org-switch', () => { + // Writes still in flight keep going to the old organization; their answers are dropped (orgEpoch) + // and every busy flag and receipt starts clean here. + orgEpoch++; + sending = false; receipt = null; replies = []; + hireBusy = false; fireBusy = false; settingsSaving = false; resetReceipt = ''; + assistantBusy = false; assistantFormsBusy.clear(); instanceBusyOps = new Set(); + routesBusy = false; routesApply = null; harnessBusy = false; harnessLoading = false; forget(); clearTimeout(timer); answer = null; heldErr = null; heldHtml = null; readAt = null; shown = null; projectContext = null; draft.to = ''; draft.body = ''; @@ -2011,7 +2028,8 @@ instanceView = null; instanceDrafts = { add: null, change: null }; instanceReceipt = ''; instanceReceiptOp = null; instanceReceipts = Object.create(null); routesView = null; routesDraft = null; routesReceipt = ''; routesEditVersion++; harnessSettings = null; harnessDraft = null; harnessReceipt = ''; - docKey = null; pkAll = []; pkShown = []; pkAt = 0; + // A file draft is keyed by project and path only; the page reload that used to drop it is gone. + docKey = null; docStates.clear(); pkAll = []; pkShown = []; pkAt = 0; configRead = switchRead = fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(c => applyConfig(c, { quiet: true })).catch(() => {}); }); document.addEventListener('mosaic-config', e => { if (e.detail && typeof e.detail === 'object') { applyConfig(e.detail); if (window.MosaicOrg && routeOf().split('?')[0] === '/organization') render(false); } }); diff --git a/packages/webui/src/public/display.js b/packages/webui/src/public/display.js index 880c07d00..459eaf141 100644 --- a/packages/webui/src/public/display.js +++ b/packages/webui/src/public/display.js @@ -135,9 +135,12 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date( // Capture phase: this runs before bus.js renders, so a switch is scoped before the page reads. window.addEventListener('hashchange', event => { const oldHash = event.oldURL ? new URL(event.oldURL).hash : previous; - const raw = location.hash, seg = orgOf(raw) ?? current, next = normalize(raw, seg); + const raw = location.hash; + // First run (no organization yet) keeps its own addresses: #/ is its board, not #/dashboard. + // Before the first list read settles, the settle step normalizes whatever address it finds. + if (!settled || !current) { previous = raw; return; } + const seg = orgOf(raw) ?? current, next = normalize(raw, seg); if (next !== raw) history.replaceState(null, '', next); - if (!settled || !current) { previous = next; return; } const switching = seg !== current; if (!skipGuard && next !== oldHash && (switching || window.MosaicOrgCreate?.hasDraft?.()) && dirty()) { event.stopImmediatePropagation(); diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index b5dc943b8..bfe978cba 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -230,10 +230,18 @@ // SP1: the reason an organization's bus can't be read, for its pages. `row` is its GET /api/workspace // status (state, reason, boot), or null when that read gave none. + // A reason that only restates the heading ("the bus is not running", the server's fallback) is not + // repeated; the last start time, when read, still shows. + const restates = reason => /^the bus is not (running|answering)\.?$/i.test(reason.trim()); const downWhy = row => (row && row.state !== 'ok' && typeof row.reason === 'string' && row.reason ? `${row.reason}${row.boot?.at ? `, last start ${when(row.boot.at)}` : ''}` : ''); function downBanner(org, row) { - const stopped = org.busStopped === true, why = downWhy(row); - return ``; + const stopped = org.busStopped === true; + const plain = row && row.state !== 'ok' && typeof row.reason === 'string' && row.reason && restates(row.reason); + const why = plain ? '' : downWhy(row); + const tail = plain + ? (row.boot?.at ? ` Last start ${esc(when(row.boot.at))}.` : '') + : why ? ` Reason: ${esc(why)}.` : ' No reason was read.'; + return ``; } // A side-by-side pane passes its own organization and { pane: true, idPrefix }: the pane has no bus // restart (that stays in the organization's full view) and its ids carry the pane's prefix. diff --git a/packages/webui/tests/org-switch-browser.test.mjs b/packages/webui/tests/org-switch-browser.test.mjs index 036e8ce19..64dde0657 100644 --- a/packages/webui/tests/org-switch-browser.test.mjs +++ b/packages/webui/tests/org-switch-browser.test.mjs @@ -217,15 +217,9 @@ test('organization switch isolates browser board preferences', async t => { await b.navigate(base + '#/orgs/alpha/'); await b.evaluate(`document.querySelector('#show-machine').click()`); assert.equal(await b.evaluate(`document.querySelector('#show-machine').checked`), true); - // The switch reloads the page. The navigate helper's load checks can be in flight when that - // reload commits, so set the hash in the page and poll one short check at a time. + // #1873 SP1: the switch happens in place, with no reload, and beta's own board setting applies. await b.evaluate(`location.hash = '#/orgs/beta/'`); - let loaded = false; - for (let i = 0; i < 150 && !loaded; i++) { - try { loaded = await b.evaluate(`window.MosaicOrg?.id === 'beta' && !!document.querySelector('#show-machine')`); } catch {} - if (!loaded) await new Promise(r => setTimeout(r, 30)); - } - assert.equal(loaded, true, 'beta did not load'); + await b.evaluate(`(async () => { for (let i = 0; i < 150; i++) { if (window.MosaicOrg?.id === 'beta' && !!document.querySelector('#show-machine')) return; await new Promise(r => setTimeout(r, 30)); } throw Error('beta did not load'); })()`); assert.equal(await b.evaluate(`document.querySelector('#show-machine').checked`), false); }); diff --git a/packages/webui/tests/single-pane-browser.test.mjs b/packages/webui/tests/single-pane-browser.test.mjs index 29db7545d..40e01b623 100644 --- a/packages/webui/tests/single-pane-browser.test.mjs +++ b/packages/webui/tests/single-pane-browser.test.mjs @@ -13,7 +13,7 @@ import { close } from './fixture.mjs'; const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`); const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString(); -async function fixture() { +async function fixture({ empty = false, bootless = false } = {}) { const { checkRootAgents } = await import('../../organization/src/index.mjs'); const rolesDir = fileURLToPath(new URL('../../../roles', import.meta.url)); const root = { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: {} }, { rolesDir }) }; @@ -35,6 +35,8 @@ async function fixture() { }, beta: { projects: [{ id: 'p-shop', repo: 'shop', name: 'Shop', archived: false, at: at(120) }], agents: [], inbox: [] }, }; + // Launch settings differ per organization so a value carried from one to the other shows. + const LAUNCH = id => ({ business: id, revision: (id === 'acme' ? 'a' : 'b').repeat(64), models: { opus: { provider: 'anthropic', model: 'claude-opus-5-5' } }, limits: { total: id === 'acme' ? 4 : 7 }, instances: {}, usage: { total: 0, providers: {}, models: {} } }); const value = (id, name) => Promise.resolve(DATA[id]?.[name] ?? (['usage', 'limits'].includes(name) ? {} : [])); const opts = id => ({ bus: { inbox: () => value(id, 'inbox'), agents: () => value(id, 'agents'), 'thread.claims': () => value(id, 'claims'), tasks: () => value(id, 'tasks'), usage: () => value(id, 'usage'), limits: () => value(id, 'limits'), decisions: () => value(id, 'decisions') }, @@ -42,19 +44,19 @@ async function fixture() { }); const member = org => ({ id: org.id, doc: { appearance: { theme: org.theme }, roles: ROLES[org.id], projects: {} }, - status: async () => (org.busHealthy ? { healthy: true, pid: 4242, boot: null } : { healthy: false, boot: { ok: false, at: at(90), lastGood: null, reason: 'stopped by the owner' } }), + status: async () => (org.busHealthy ? { healthy: true, pid: 4242, boot: null } : bootless ? { healthy: false, boot: null } : { healthy: false, boot: { ok: false, at: at(90), lastGood: null, reason: 'stopped by the owner' } }), options: () => opts(org.id), }); const instances = id => Object.fromEntries(Object.entries(ROLES[id]).map(([n, r]) => [n, { definition: r.definition, vars: {}, launched: true, launcher: false, protected: false, kept: false, running: n === 'coder' ? ['run-1'] : [], alive: [] }])); const organizations = { - list: async () => LIST, - members: async () => LIST.map(member), + list: async () => (empty ? [] : LIST), + members: async () => (empty ? [] : LIST.map(member)), resolve: async id => { const org = LIST.find(o => o.id === id); if (!org) return null; if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } }; return { busHealthy: true, - reads: { business: async () => ({ business: { id, human: 'jason', projects: DATA[id].projects.map(p => p.repo) } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) }, + reads: { business: async () => ({ business: { id, human: 'jason', projects: DATA[id].projects.map(p => p.repo) } }), settings: async () => ({ business: id, release: 'fixture', notifier: {}, launch: LAUNCH(id) }), queue: async () => ({ rows: [] }), busStatus: async () => ({}) }, bus: { messages: async () => ({ messages: [], unread: 0 }), agents: async () => DATA[id].agents, 'thread.claims': async () => [], inbox: async () => DATA[id].inbox.map(d => ({ ...d, business: id, raised_by_run: 'run', routed_to: 'human' })), tasks: async () => [], instances: async () => ({ business: id, revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {}, instances: instances(id) }) }, chat: { projects: async () => DATA[id].projects, threads: async () => [] }, }; @@ -63,12 +65,14 @@ async function fixture() { return { organizations, agentLevels: { loadRoot: () => root, rolesDir, ctx: { configDir: '/cfg', dataRoot: '/data', instance: 'test' } } }; } -async function setup(t, { viewport = [1440, 900], path = '/' } = {}) { - const { organizations, agentLevels } = await fixture(); +async function setup(t, { viewport = [1440, 900], path = '/', empty = false, init = null, bootless = false } = {}) { + const { organizations, agentLevels } = await fixture({ empty, bootless }); const server = await startServer({ port: 0, sso: createDevOwner(), organizations, agentLevels, log: () => {} }); t.after(() => close(server)); const b = await browser({ machine: false }); t.after(() => b.close()); await b.viewport(...viewport); + // `init` runs before the page's own scripts, under the display.js fetch wrapper, so it sees the scoped URL. + if (init) await b.call('Page.addScriptToEvaluateOnNewDocument', { source: init }); const base = `http://127.0.0.1:${server.address().port}`; await b.navigate(`${base}${path}`); return { b, base, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) }; @@ -120,7 +124,7 @@ test('an organization opens inside the same shell with a breadcrumb, and its pag assert.equal(await b.evaluate('document.querySelector(".ws-crumb a")?.getAttribute("href")'), '#/dashboard'); assert.equal(await b.evaluate('document.getElementById("current-organization")'), null); // The sidebar marks the open organization. - await wait('document.querySelector("#workspace-nav [aria-current=page]")?.closest("[data-org]")?.dataset.org === "acme" || document.querySelector("#workspace-nav a[aria-current=page]")?.getAttribute("href") === "#/orgs/acme/organization"'); + await wait('document.querySelector("#workspace-nav a[aria-current=page]")?.getAttribute("href") === "#/orgs/acme/organization"'); // The sidebar opens another organization in place; its page reads that organization's routes. await b.evaluate('performance.clearResourceTimings(); document.querySelector("#workspace-nav a[href=\'#/orgs/beta/organization\']").click()'); @@ -172,3 +176,112 @@ test('at 390 px the dashboard lists the organizations without a horizontal scrol await wait('document.querySelectorAll("[data-ws-orgrow]").length === 3'); assert.equal(await b.evaluate('document.documentElement.scrollWidth <= window.innerWidth'), true); }); + +// Review round finding 1: with no organization yet the page keeps its first-run addresses, so the +// wordmark (href "#/") opens the first-run board, not a #/dashboard the first-run page has no view for. +test('first run: the wordmark opens the first-run board, not a Not found page', async t => { + const { b, wait } = await setup(t, { path: '/#/settings/appearance', empty: true }); + await wait('document.querySelector("#bus-view h1")'); + await b.evaluate('document.querySelector(".wordmark").click()'); + // The board route empties the page view; give the router and render time to settle before checking. + await wait('location.hash !== "#/settings/appearance"'); + await new Promise(r => setTimeout(r, 800)); + assert.equal(await b.evaluate('location.hash'), '#/'); + assert.equal(await b.evaluate('!!document.querySelector("#bus-view .nf")'), false); + assert.equal(await b.evaluate('window.MosaicOrg'), undefined); +}); + +test('a switch stops following an agent request: no poll reaches the next organization and its page shows no receipt', async t => { + const ID = '11111111-2222-4333-8444-555555555555'; + const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })'; + const init = `window.__reads = []; window.__posts = []; const original = window.fetch; + window.fetch = async (url, options = {}) => { const s = String(url); let x; + if (/^\\/api\\/orgs\\/[^/]+\\/config$/.test(s)) { const r = await original(url, options), body = await r.json(); x = { ...body, send: true }; return ${json}; } + if (options.method === 'POST' && /\\/agent\\/launch$/.test(s)) { __posts.push(s); x = { request: '${ID}', at: 'x' }; return ${json}; } + if (/\\/agent\\/request\\?/.test(s)) { __reads.push(s); x = { request: '${ID}', action: 'launch', instance: 'reviewer', state: 'open', done: null, cancelled: null }; return ${json}; } + return original(url, options); };`; + const { b, wait } = await setup(t, { path: '/#/orgs/acme/settings/agents', init }); + const launch = '#agents-section [data-agent-action=launch][data-agent-name=reviewer]'; + await wait(`document.querySelector(${JSON.stringify(launch)}) && !document.querySelector(${JSON.stringify(launch)}).disabled`); + await b.evaluate(`document.querySelector(${JSON.stringify(launch)}).click()`); + await wait('window.__reads.length >= 1'); + assert.deepEqual(await b.evaluate('window.__posts'), ['/api/orgs/acme/agent/launch']); + assert.match(await b.evaluate('window.__reads[0]'), /^\/api\/orgs\/acme\/agent\/request\?id=/); + await b.evaluate('location.hash = "#/orgs/beta/settings/agents"'); + await wait('location.hash === "#/orgs/beta/settings/agents" && document.querySelector("#agents-section [data-agent-name=chief]") && !document.querySelector("#agents-section [data-agent-name=reviewer]")'); + const seen = await b.evaluate('window.__reads.length'); + // Two poll periods pass on beta's page. + await new Promise(r => setTimeout(r, 4500)); + const reads = await b.evaluate('window.__reads'); + assert.equal(reads.filter(u => u.includes('/orgs/beta/')).length, 0, reads.join(' ')); + assert.equal(reads.length, seen, 'the poll stopped at the switch'); + assert.equal(await b.evaluate('document.querySelector("#agents-receipt")?.textContent.trim() ?? ""'), ''); + assert.equal(await b.evaluate('document.querySelector("#agents-section [data-agent-action=launch][data-agent-name=chief]")?.disabled'), false); +}); + +test('a save still in flight at a switch: its answer is not drawn on the next organization and its Save is not held', async t => { + const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })'; + // acme's settings POST is held until the test releases it, then answers with acme's saved values. + const init = `window.__posts = []; const original = window.fetch; + window.fetch = async (url, options = {}) => { const s = String(url); let x; + if (/^\\/api\\/orgs\\/[^/]+\\/config$/.test(s)) { const r = await original(url, options), body = await r.json(); x = { ...body, send: true }; return ${json}; } + if (options.method === 'POST' && s === '/api/orgs/acme/settings') { __posts.push(JSON.parse(options.body)); await new Promise(r => { window.__release = r; }); + x = { business: 'acme', revision: 'c'.repeat(64), models: { opus: { provider: 'anthropic', model: 'claude-opus-5-5' } }, limits: { total: 5 }, instances: {}, usage: { total: 0, providers: {}, models: {} } }; return ${json}; } + return original(url, options); };`; + const { b, wait } = await setup(t, { path: '/#/orgs/acme/settings/limits', init }); + const total = '#launch-settings-form input[data-setting=\'["total"]\']'; + const submit = '#launch-settings-form button[type=submit]'; + await wait(`document.querySelector(${JSON.stringify(total)})?.value === "4" && !document.querySelector(${JSON.stringify(submit)}).disabled`); + await b.evaluate(`(() => { const i = document.querySelector(${JSON.stringify(total)}); i.value = "5"; i.dispatchEvent(new Event("input", { bubbles: true })); document.querySelector(${JSON.stringify(submit)}).click(); })()`); + await wait('typeof window.__release === "function"'); + assert.equal(await b.evaluate('window.__posts[0]?.revision'), 'a'.repeat(64)); + await b.evaluate('location.hash = "#/orgs/beta/settings/limits"'); + await wait(`location.hash === "#/orgs/beta/settings/limits" && document.querySelector(${JSON.stringify(total)})?.value === "7"`); + // beta's Save is its own: not held by acme's write. + assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(submit)}).disabled`), false); + await b.evaluate('window.__release()'); + await new Promise(r => setTimeout(r, 700)); + assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/settings/limits'); + assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(total)})?.value`), '7'); + assert.equal(await b.evaluate('document.getElementById("launch-settings-receipt")?.textContent.trim() ?? ""'), ''); + assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(submit)}).disabled`), false); +}); + +test('a down banner does not repeat its heading as the reason', async t => { + // No boot record: the server's reason is its fallback, "the bus is not running", the heading's own words. + const { b, wait } = await setup(t, { path: '/#/orgs/gamma/organization', bootless: true }); + await wait('document.querySelector("[data-org-dashboard] [data-org-down]")'); + const down = await b.evaluate('document.querySelector("[data-org-down]").innerText.replace(/\\s+/g, " ")'); + assert.match(down, /The bus is not running\./); + assert.equal((down.match(/the bus is not running/gi) || []).length, 1, down); + assert.doesNotMatch(down, /Reason:|No reason was read/); +}); + +test('a restart started on one organization finishes on that organization after a switch', { timeout: 60000 }, async t => { + const END = '11111111-2222-4333-8444-5555555555e1', LAUNCHED = '11111111-2222-4333-8444-5555555555a1'; + const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })'; + // Every agent request read and write is recorded, whichever organization it names; the bus answers + // are stubs (terminate, closed requests, one launch). Only acme's instances read is stubbed and + // recorded (the session gone): beta's own page reads beta's instances, which is not the chain. + const init = `window.__chain = []; const original = window.fetch; + window.fetch = async (url, options = {}) => { const s = String(url); let x; + if (options.method === 'POST' && /\\/agent\\/terminate$/.test(s)) { __chain.push(s); x = { request: '${END}' }; return ${json}; } + if (options.method === 'POST' && /\\/agent\\/launch$/.test(s)) { __chain.push(s); x = { request: '${LAUNCHED}' }; return ${json}; } + if (/\\/agent\\/request\\?/.test(s)) { __chain.push(s.split('?')[0]); const id = new URL(s, location.href).searchParams.get('id'); + x = { request: id, instance: 'coder', action: id === '${END}' ? 'end' : 'launch', state: 'closed', done: { run: id === '${END}' ? null : 'run-9' }, cancelled: null }; return ${json}; } + if (s === '/api/orgs/acme/bus/instances' && window.__restarting) { __chain.push(s); x = { rows: [{ instances: { coder: { running: [] } } }] }; return ${json}; } + return original(url, options); };`; + const { b, wait } = await setup(t, { path: '/#/orgs/acme/organization', init }); + await wait('window.MosaicAgents && document.querySelector("[data-org-dashboard] h1")'); + // The button the Routing receipt offers (agents.js restartMarkup), placed on acme's page. + await b.evaluate('window.__restarting = true; document.querySelector("[data-org-dashboard]").insertAdjacentHTML("beforeend", window.MosaicAgents.restartMarkup("coder")); document.querySelector("[data-agent-restart=coder]").click()'); + await wait('window.__chain.length >= 1'); + await b.evaluate('location.hash = "#/orgs/beta/organization"'); + await wait('location.hash === "#/orgs/beta/organization"'); + // Done when the chain has launched and read the launch request back, or as soon as any of it names beta. + await wait('window.__chain.some(u => u.includes("/orgs/beta/")) || (window.__chain.some(u => /\\/agent\\/launch$/.test(u)) && window.__chain.filter(u => /\\/agent\\/request$/.test(u)).length >= 2)', 30000); + const chain = await b.evaluate('window.__chain'); + assert.deepEqual([...new Set(chain)], ['/api/orgs/acme/agent/terminate', '/api/orgs/acme/agent/request', '/api/orgs/acme/bus/instances', '/api/orgs/acme/agent/launch'], chain.join(' ')); + // beta's page draws nothing of acme's chain. + assert.equal(await b.evaluate('!!document.querySelector("[data-agent-restart-status=coder]")'), false); +}); -- 2.54.0 From c7aecbffa9f52613e2229542bf20e361ef2dc79d Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 14 Nov 2026 23:51:59 -0600 Subject: [PATCH 3/3] fix(webui): sp1 second review round, a read or file save in flight at a switch keeps nothing (#1873, lane sp1) The second review found two paths the first fix round left open. - The project-file save captures orgEpoch before its POST. When a switch happened meanwhile, it neither paints nor announces the old file's outcome on the new organization's page. An opaqueredirect still signs out. - A live read (reader, get.api), the palette's inbox and tasks reads and the board poll keep their answer in `last` only while the organization they were sent for is still open. A later cached render on the new organization no longer draws the old one's rows. single-pane test 11 holds acme's file save across a switch to beta and asserts nothing is announced: red on the previous bus.js ("Saved as commit cccccccccccc."), green now. The kept-read guard has no browser test. --- packages/webui/src/public/bus.js | 22 +++++++++++----- .../webui/tests/single-pane-browser.test.mjs | 25 +++++++++++++++++++ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index 10a1db894..8d9843552 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -75,13 +75,17 @@ window.MosaicIcons.succeeded(); return body; } + // #1873 SP1: bumped by each in-place organization switch. A write, or a read that keeps its answer, + // captures it before its await and drops the answer when it changed, so nothing the old organization + // answered lands on the new page or in its kept answers. + let orgEpoch = 0; // A reader for one render. `live` asks the server; otherwise only kept answers are used. function reader(live) { const used = []; const get = async (verb, subject) => { const k = subject === undefined ? verb : `${verb}:${subject}`; let r; - if (live) { r = await fetchRead(verb, subject); last.set(k, r); } + if (live) { const epoch = orgEpoch; r = await fetchRead(verb, subject); if (epoch === orgEpoch) last.set(k, r); } else if (!(r = last.get(k))) throw new ReadError('not-read', 'not read before'); used.push(r.at); if (verb === 'messages') { messageCount = r.unread; return r; } @@ -90,7 +94,7 @@ get.api = async path => { const k = `api:${path}`; let r; - if (live) { r = await fetchApi(path); last.set(k, r); } + if (live) { const epoch = orgEpoch; r = await fetchApi(path); if (epoch === orgEpoch) last.set(k, r); } else if (!(r = last.get(k))) throw new ReadError('not-read', 'not read before'); used.push(r.at); return r; @@ -345,9 +349,6 @@ let configRead = Promise.resolve(); // #1873 SP1: the config read an organization switch started, until the next render has waited for it. let switchRead = null; - // #1873 SP1: bumped by each in-place organization switch. A write captures it before its await and - // drops its answer when it changed, so nothing the old organization answered lands on the new page. - let orgEpoch = 0; let canSend = false, canMessage = false, chatSend = false, sendRoles = null, sending = false, receipt = null, replies = []; const REPLIES = 50; // the broker's cap function repliesHtml() { @@ -1539,6 +1540,7 @@ if (!s || !saveable(s)) return; const sent = { project: s.project, path: s.path, base: s.commit, content: s.text }, unknown = 'Outcome unknown: the save may have gone through. Copy your text, then reload the file to check before saving again. Draft kept.'; s.saving = true; s.status = ''; + const epoch = orgEpoch; let res = null, body = null; try { paintDoc(); @@ -1550,6 +1552,9 @@ } else if (body?.sent === false) s.status = `${typeof body.message === 'string' && body.message ? body.message : 'Not saved.'} Draft kept.`; else s.status = unknown; } finally { s.saving = false; } // a throw above must not leave Save off and Reload refused + // #1873 SP1: a switch while this was in flight cleared the drafts; the old file's outcome is not + // painted or announced on the new organization's page. + if (epoch !== orgEpoch) { if (res?.type === 'opaqueredirect') signOut(); return; } paintDoc(); announce(s.status); if (res?.type === 'opaqueredirect') signOut(); @@ -1756,12 +1761,15 @@ // The board page keeps its own title; the Inbox count still follows the bus. if (shown !== null) { shown = null; unmountEditor(); $('bus-view').innerHTML = ''; document.title = 'Dashboard · Mosaic Console'; if (navigated) $('main').focus(); } // The held roles (row 95) are read with it. A refusal of either forgets both, as on the bus pages. + const epoch = orgEpoch; const [inbox, agents, messages, tasks, queue, business, projects] = await Promise.allSettled([ fetchRead('inbox'), fetchRead('agents'), fetchRead('messages'), fetchRead('tasks'), fetchApi('queue'), fetchApi('business'), fetch('/api/chat/projects', { cache: 'no-store', redirect: 'manual' }).then(async r => { if (r.type === 'opaqueredirect') { signOut(); throw signedOut(); } const body = await r.json(); if (signInRequired(r, body)) { signOutSso(); throw signedOut(); } if (!r.ok || !Array.isArray(body.rows)) throw new ReadError(body?.error, body?.message, r.status); return body; }) ]); const failed = [inbox, agents, messages, tasks].filter(s => s.status === 'rejected').map(s => s.reason); if (failed.some(err => err.code === 'signed-out')) return; + // #1873 SP1: an answer read for an organization that is no longer open is not kept. + if (epoch !== orgEpoch) return; if (inbox.status === 'fulfilled') last.set('inbox', inbox.value); if (agents.status === 'fulfilled') last.set('agents', agents.value); if (messages.status === 'fulfilled') last.set('messages', messages.value); @@ -1902,7 +1910,9 @@ if (d.open) return; pkBack = document.activeElement; pkAt = 0; $('cmdk-input').value = ''; pkItems(); pkFill(); d.showModal(); $('cmdk-input').focus(); - Promise.allSettled(['inbox', 'tasks'].map(async v => last.set(v, await fetchRead(v)))).then(rs => { + const epoch = orgEpoch; + Promise.allSettled(['inbox', 'tasks'].map(async v => { const r = await fetchRead(v); if (epoch === orgEpoch) last.set(v, r); })).then(rs => { + if (epoch !== orgEpoch) return; if (rs.some(r => r.status === 'rejected' && refusal(r.reason))) { forget(); sections(routeOf().split('?')[0]); } if (d.open) { pkItems(); pkFill(); } }); diff --git a/packages/webui/tests/single-pane-browser.test.mjs b/packages/webui/tests/single-pane-browser.test.mjs index 40e01b623..fe9a1f446 100644 --- a/packages/webui/tests/single-pane-browser.test.mjs +++ b/packages/webui/tests/single-pane-browser.test.mjs @@ -285,3 +285,28 @@ test('a restart started on one organization finishes on that organization after // beta's page draws nothing of acme's chain. assert.equal(await b.evaluate('!!document.querySelector("[data-agent-restart-status=coder]")'), false); }); + +test('a file save still in flight at a switch: its outcome is not announced on the next organization', async t => { + const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })'; + // acme's file reads are stubbed; its save POST is held until the test releases it, then answers with a commit. + const init = `window.__posts = []; const original = window.fetch; + window.fetch = async (url, options = {}) => { const s = String(url); let x; + if (/^\\/api\\/orgs\\/[^/]+\\/config$/.test(s)) { const r = await original(url, options), body = await r.json(); x = { ...body, send: true, edit: true }; return ${json}; } + if (s.startsWith('/api/orgs/acme/project-file?')) { x = { path: 'docs/a.md', commit: 'a'.repeat(40), content: '# A\\n', at: new Date().toISOString() }; return ${json}; } + if (options.method === 'POST' && s === '/api/orgs/acme/project-file') { __posts.push(JSON.parse(options.body)); await new Promise(r => { window.__release = r; }); + x = { path: 'docs/a.md', commit: 'c'.repeat(40) }; return ${json}; } + return original(url, options); };`; + const { b, wait } = await setup(t, { path: `/#/orgs/acme/docs/site/${encodeURIComponent('docs/a.md')}`, init }); + await wait('document.querySelector("#doc-form")'); + if (!await b.evaluate('!!document.querySelector("#doc-text")')) await b.evaluate('document.querySelector("#doc-mode-source").click(), true'); + await wait('document.querySelector("#doc-text")?.value === "# A\\n"'); + await b.evaluate('(() => { const a = document.querySelector("#doc-text"); a.value = "# A\\n\\nmore\\n"; a.dispatchEvent(new Event("input", { bubbles: true })); document.querySelector("#doc-form").requestSubmit(); })()'); + await wait('typeof window.__release === "function"'); + assert.equal(await b.evaluate('window.__posts[0]?.base'), 'a'.repeat(40)); + await b.evaluate('location.hash = "#/orgs/beta/organization"'); + await wait('location.hash === "#/orgs/beta/organization" && document.querySelector("[data-org-dashboard] h1")?.textContent === "Beta Works"'); + await b.evaluate('window.__release()'); + await new Promise(r => setTimeout(r, 700)); + assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/organization'); + assert.doesNotMatch(await b.evaluate('document.getElementById("announce")?.textContent ?? ""'), /Saved as commit|Draft kept/); +}); -- 2.54.0