From e24e7e93a24728af395fb28cedf85f0c21c94eb5 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 14 Nov 2026 22:35:00 -0600 Subject: [PATCH] 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);