You have an unsaved draft. Stay here or discard it and continue.
- Organizations › ${name}
+ Dashboard › ${name}
${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 spend No spend figures yet No 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 ? `Organization Agents Projects Needs you ${orgTable}
` : '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 `
${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);