Compare commits

...
3 Commits
Author SHA1 Message Date
jason.woltje 8630b8dfc0 test(webui): wait for both sidebar lists after the reload (#1873) 2026-11-10 12:01:55 -06:00
jason.woltje 5eb6dda049 fix(webui): U2b review fixes for the workspace dashboard and agents (#1873)
- Agent activity comes from the bus agents read (the state for the run
  holding the role); with no reported state a live agent shows "running",
  never working or idle.
- A session's project and task is the thread its live run claims
  (thread.claims), not the agent's first listed conversation.
- The dashboard reads an organization only after /api/orgs lists it as
  valid; otherwise it shows "Organization unavailable" and reads nothing.
  Agents reads the selected business only for a listed organization.
- A failed conversation read says "Conversation unavailable", not that
  none exists.
- A reported model gets a Change link to Settings, Routing, in the
  dashboard and agents tables.
2026-11-10 11:32:19 -06:00
jason.woltje 8710cc0e1e feat(webui): U2 unified workspace sidebar, org dashboard and agents grid (#1873)
Finishes the U2 WIP (37b0891a3) to samples 01, 03 and 08 of the
2026-11-09 unified workspace set:
- workspace.js: sidebar Organizations and Projects groups (collapsible,
  state remembered), the org dashboard, the Agents levels.
- workspace-data.js: every read these screens make, GET no-store,
  redirect manual; a refused read shows unavailable for that part only.
- shell.css: the html[data-organizations] .ws-* block from the sample.
- bus.js: org-mode sidebar order, pinned Settings, no project tree under
  Projects, focus kept across repaint.
- Honest empty states where the Console has no data (KPIs, metrics, kind).
- Tests: unified-workspace-browser (10), picker/delete/switch browser tests
  follow the sidebar, browser.test script pin.
2026-11-10 11:32:19 -06:00
11 changed files with 987 additions and 32 deletions

No files matched your search

+53 -8
View File
@@ -264,9 +264,32 @@
// ---------------------------------------------------------------- views
let inboxCount = null, messageCount = null;
// Organization mode (sample 01): Dashboard, Inbox, Tasks, Projects, Side by side, Agents and
// Assistant, with Settings pinned to the foot of the sidebar. Queue opens from the organization
// dashboard and Docs from its Docs row, so neither is a sidebar item here.
function orgSections(route) {
const nav = $('console-nav');
let foot = $('sections-foot');
if (!foot) { foot = document.createElement('div'); foot.id = 'sections-foot'; foot.className = 'ws-foot'; nav.append(foot); }
const active = document.activeElement;
const focused = $('sections').contains(active) || foot.contains(active) ? active.getAttribute('href') : null;
const icon = id => window.MosaicWorkspace?.icon?.(id) ?? `<svg class="i" aria-hidden="true"><use href="/icons.svg#i-${id}"/></svg>`;
const n = (count, what) => count > 0 ? `<span class="ws-n hot" aria-label="${count} ${what}">${count}</span>` : '';
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 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')));
return `<a href="#${h}" class="s1-section ws-nav"${cur ? ' aria-current="page"' : ''}>${icon(id)}<span class="t">${l}</span>${extra}</a>`;
};
// No project tree under Projects here: the sidebar's Projects group lists every organization's
// projects and opens the same project page, which lists its conversations.
$('sections').innerHTML = items.map(item => `<li>${link(item)}</li>`).join('');
foot.innerHTML = link(['/settings', 'Settings', '', 'settings']);
if (focused) [...$('sections').querySelectorAll('a[href]'), ...foot.querySelectorAll('a[href]')].find(a => a.getAttribute('href') === focused)?.focus({ preventScroll: true });
}
function sections(route) {
if (window.MosaicOrg) return orgSections(route);
const focused = $('sections').contains(document.activeElement) ? document.activeElement.getAttribute('href') : null;
const items = [['/', 'Dashboard', '', 'board'], ['/projects', 'Projects', '', 'inbox'], ['/inbox', 'Inbox', inboxCount?.open > 0 ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/messages', 'Messages', messageCount > 0 ? `<span class="count" aria-label="${messageCount} unread messages">${messageCount}</span>` : '', 'talk'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents'], ['/queue', 'Queue', '', 'queue'], ['/docs', 'Docs', '', 'library'], ['/settings', 'Settings', '', 'settings']];
const items = [['/', 'Dashboard', '', 'board'], ['/projects', 'Projects', '', 'inbox'], ['/inbox', 'Inbox', inboxCount?.open > 0 ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/messages', 'Assistant', messageCount > 0 ? `<span class="count" aria-label="${messageCount} unread messages">${messageCount}</span>` : '', 'talk'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents'], ['/queue', 'Queue', '', 'queue'], ['/docs', 'Docs', '', 'library'], ['/settings', 'Settings', '', 'settings']];
$('sections').innerHTML = items.map(([h, l, extra, icon]) => {
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')));
return `<li><a href="#${h}" class="s1-section"${cur ? ' aria-current="page"' : ''}><svg class="i" aria-hidden="true">${h === '/projects' ? '<path d="M2 5h6l2 2h8v11H2z"/>' : `<use href="/icons.svg${spriteVersion ? `?retry=${spriteVersion}` : ''}#i-${icon}"/>`}</svg>${l}${extra ? ` ${extra}` : ''}</a>${h === '/projects' ? '<div id="project-tree" aria-label="Project conversations"></div>' : ''}</li>`;
@@ -668,7 +691,12 @@
}
}
async function agentsView(get) {
const claims = await get('agents');
let claims;
try { claims = await get('agents'); } catch (err) {
if (!window.MosaicOrg || err.code === 'signed-out') throw err;
if (refusal(err)) forget();
return `${await window.MosaicWorkspace.agentGrid()}<div class="banner err" role="alert">The selected organization's held roles could not be read. ${why(err)}</div>`;
}
let instances = null, threads = [];
if (!isTester) {
const reads = await Promise.allSettled([get('instances'), get.api('chat/threads')]);
@@ -683,7 +711,9 @@
for (const claim of claims) if (claim.holder_run && claim.role && !holders.has(claim.holder_run)) holders.set(claim.holder_run, claim.role);
const threadSection = await threadClaimsSection(get, holders, threads, !!instances, running);
const rows = claims.map(c => `<tr><th scope="row">${txt(c.role)}</th><td>${stateMark(c.state)}${c.working ? `<span class="source">${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}</span>` : ''}</td><td>${attention(c)}</td><td>${txt(holders.get(c.holder_run) || c.role)}<span class="source">run ${txt(c.holder_run)}${!instances ? ', running status unavailable' : running.has(c.holder_run) ? '' : ', not running'}</span></td><td>${txt(c.harness)}${c.address ? `<span class="source">${txt(c.address)}</span>` : ''}</td><td>${when(c.at)}</td></tr>`).join('');
return `${head('Agents', readNote(get))}
// Organization mode (sample 08): the levels grid carries the page heading; held roles follow.
const top = window.MosaicOrg ? await window.MosaicWorkspace.agentGrid(readNote(get)) : head('Agents', readNote(get));
return `${top}
<section aria-labelledby="b-claims"><h2 id="b-claims">Held roles <span class="count">${claims.length}</span></h2><p class="small muted">The current holder of each claimed role. A role nobody holds does not appear. Working: the holder has messages it received and hasn't answered yet. Waiting: a decision the role raised is open in your inbox. Otherwise idle.</p>
${claims.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Held roles, scroll for all columns"><table class="s1-table s1-agents"><thead><tr>${['Role', 'State', 'Attention', 'Holder', 'Harness', 'Held since'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div>` : empty('No role is held right now.', 'A role shows here while a run holds its claim on the bus.')}</section>
${threadSection}
@@ -753,7 +783,7 @@
const b = (await get.api('business')).business || {};
const orgs = window.MosaicOrg ? await window.MosaicOrg.list : null;
if (orgs) await configRead; // orgSetup decides the "New organization" link
const picker = orgs ? `<section class="panel" aria-labelledby="org-picker-title"><h2 id="org-picker-title">Organization</h2><label for="org-picker">Current organization</label><select id="org-picker">${orgs.map(org => `<option value="${esc(org.id)}"${org.id === window.MosaicOrg.id ? ' selected' : ''}${org.invalid ? ' disabled' : ''}>${txt(org.label || org.id)}${org.invalid ? ' · Invalid' : org.busHealthy ? '' : ' · Bus unavailable'}</option>`).join('')}</select>${orgSetup ? `<p><a href="${NEW_ORG}">New organization</a></p>` : ''}</section>` : '';
const picker = '';
const instances = Array.isArray(b.instances) ? b.instances : [], creds = Array.isArray(b.credentials) ? b.credentials : [], actions = Array.isArray(b.actions) ? b.actions : [];
const launch = b.launch || {};
const authKnown = instances.some(i => i.authority);
@@ -1172,7 +1202,7 @@
const label=pages.find(([id])=>id===page)?.[1] || 'Appearance';
const isDirty=id=>(id==='routing'&&routesDraft)||(id==='limits'&&settingsInputs)||(id==='assistant'&&(Object.keys(assistantDrafts).length||Object.keys(nameDrafts).length))||(id==='agents'&&(instanceDrafts.add||instanceDrafts.change));
const dot='<span class="settings-dot"><span class="sr-only">unsaved changes</span></span>';
const links=pages.map(([id,name],i)=>`${i===0?'<div class="settings-group">Your browser</div>':i===1?'<div class="settings-group">This instance</div>':''}<a href="#/settings/${id}"${id===page?' aria-current="page"':''}>${name}${isDirty(id)?dot:''}</a>`).join('');
const links=pages.map(([id,name],i)=>`${i===0?'<div class="settings-group">Your browser</div>':i===1?'<div class="settings-group">This instance</div>':''}<a href="#/settings/${id}"${id===page?' aria-current="page"':''}>${name}${isDirty(id)?dot:''}</a>`).join('') + (!isTester ? '<div class="settings-group">Members and invites <span class="muted">not built</span></div>' : '');
return `${head('Settings','Your browser and this instance.')}<div class="settings-layout"><div><button type="button" id="settings-disclosure" aria-expanded="false" aria-controls="settings-nav"><small>Section</small><strong>${label}${isDirty(page)?dot:''}</strong><span aria-hidden="true">⌄</span></button><nav id="settings-nav" aria-label="Settings sections">${links}</nav></div><div class="settings-content settings-${page}${readonlyPage(page)?' readonly':''}"><h2 id="settings-page-title" tabindex="-1">${label}</h2><p class="settings-helper">${helper}</p>${readonlyPage(page)?'<p class="settings-readonly">Read only. Saving requires signed-in send mode.</p>':''}${body}</div></div>`;
}
async function settingsView(get, page='appearance') {
@@ -1412,10 +1442,20 @@
// ---------------------------------------------------------------- router
// #/ and anything that is not a #/ route is the control board; the rest is this file's.
const routeOf = () => window.MosaicOrg?.route() ?? (location.hash.startsWith('#/') ? location.hash.slice(1) : '/');
// A one-line scope note right under a view's page heading.
function afterHead(html, note) {
const start = html.indexOf('<div class="page-head">'), end = start < 0 ? -1 : html.indexOf('</div></div>', start);
const line = `<p class="small muted ws-scope" data-not-built>${esc(note)}</p>`;
return end < 0 ? `${line}${html}` : `${html.slice(0, end + 12)}${line}${html.slice(end + 12)}`;
}
function view(get, path, q, opts, current) {
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 (a === 'organization' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.dashboard();
if (a === 'side-by-side' && n === 1 && window.MosaicOrg) return `${head('Side by side', 'Not built yet.')}${empty('Side by side is not built yet.', 'It arrives with a later unified workspace update. Projects and Agents work today.')}`;
if (a === 'inbox' && n === 1 && window.MosaicOrg) return inboxView(get).then(html => afterHead(html, 'The inbox across all organizations is not built yet. This is the selected organization\'s inbox.'));
if (a === 'tasks' && n === 1 && window.MosaicOrg) return tasksView(get).then(html => afterHead(html, 'Tasks across all organizations are not built yet. These are the selected organization\'s tasks.'));
if (a === 'inbox' && n === 1) return inboxView(get);
if (a === 'inbox' && n === 2) return SAFE.test(b) ? decisionView(get, b, current) : notFound(`${txt(b)} is not a decision id.`);
if (a === 'messages' && n === 1) return messagesView(get, q);
@@ -1441,6 +1481,7 @@
function keep() {
const a = document.activeElement, view = $('bus-view');
return {
focusKey: view.contains(a) ? a.dataset?.focusKey || (a.hasAttribute?.('data-bus-restart') ? 'bus-restart' : null) : null,
href: view.contains(a) ? a.getAttribute('href') : null, copy: view.contains(a) ? a.dataset?.copy : null, retry: view.contains(a) && a.hasAttribute?.('data-retry'), mirror: view.contains(a) ? a.dataset?.mirror : null, h1: view.contains(a) && a.tagName === 'H1',
settingsChoice: view.contains(a) && a.type==='radio' ? [a.name,a.value] : null,
settingsField: view.contains(a) && a.closest('.settings-content') && (a.dataset?.instanceField || a.dataset?.assistantField || a.dataset?.route || a.dataset?.setting) ? {form:a.closest('form')?.id || null, instance:a.closest('[data-instance]')?.dataset.instance || null, key:a.dataset.instanceField?'instanceField':a.dataset.assistantField?'assistantField':a.dataset.route?'route':'setting', value:a.dataset.instanceField || a.dataset.assistantField || a.dataset.route || a.dataset.setting} : null,
@@ -1459,7 +1500,10 @@
const dec = k.decide ? $('bus-view').querySelector(k.decide === 'status' ? '#decide-status' : `[data-decide="${k.decide}"]`) : null;
const choice=k.settingsChoice && [...$('bus-view').querySelectorAll('input[type=radio]')].find(x=>x.name===k.settingsChoice[0]&&x.value===k.settingsChoice[1]);
const setting=k.settingsField && [...$('bus-view').querySelectorAll('[data-instance-field],[data-assistant-field],[data-route],[data-setting]')].find(e=>e.dataset[k.settingsField.key]===k.settingsField.value && (!k.settingsField.form || e.closest('form')?.id===k.settingsField.form) && (!k.settingsField.instance || e.closest('[data-instance]')?.dataset.instance===k.settingsField.instance));
if (setting) { setting.focus({preventScroll:true}); if(k.caret && typeof setting.setSelectionRange==='function'){setting.setSelectionRange(k.caret[0],k.caret[1]);setting.scrollTop=k.caret[2];} }
// The workspace screens mark their controls with data-focus-key: several Open links share an href.
const keyed = k.focusKey ? $('bus-view').querySelector(k.focusKey === 'bus-restart' ? '[data-bus-restart]' : `[data-focus-key="${CSS.escape(k.focusKey)}"]`) : null;
if (keyed) keyed.focus({ preventScroll: true });
else if (setting) { setting.focus({preventScroll:true}); if(k.caret && typeof setting.setSelectionRange==='function'){setting.setSelectionRange(k.caret[0],k.caret[1]);setting.scrollTop=k.caret[2];} }
else if (choice) choice.focus({preventScroll:true});
else if (dec) dec.focus({ preventScroll: true });
else if (el && (k.href || k.copy || k.retry || k.mirror || k.field || k.send)) {
@@ -1557,7 +1601,7 @@
let html, state = 'fresh';
const live = reader(true);
try {
if (path !== '/messages' && !['/settings','/settings/appearance','/settings/model-proxy','/settings/organizations'].includes(path)) {
if (path !== '/organization' && !(path === '/agents' && window.MosaicOrg) && path !== '/messages' && !['/settings','/settings/appearance','/settings/model-proxy','/settings/organizations'].includes(path)) {
try { await live('messages'); }
catch (err) { messageCount = null; if (err.status === 403 || err.code === 'signed-out') throw err; }
}
@@ -1578,6 +1622,7 @@
$('bus-view').innerHTML = html; shown = route;
syncAppearance(true);
if (path.startsWith('/docs/')) paintDoc();
if (path === '/organization') window.MosaicConsole?.attachBus(document.querySelector('[data-dashboard-bus]'));
if (path === '/settings/model-proxy') proxyCleanup=window.MosaicProxy.mount();
if (path === '/settings/organizations') orgCleanup=window.MosaicOrgCreate.mount();
sections(path);
@@ -1639,7 +1684,7 @@
// Ctrl+K opens it. It lists the views, and the decisions and tasks from the last inbox and
// tasks reads (opening it reads both again, GET only). Enter goes; Esc closes and puts focus
// back where it was. It only navigates.
const VIEWS = [['#/', 'Dashboard'], ['#/projects', 'Projects'], ['#/inbox', 'Inbox'], ['#/messages', 'Messages'], ['#/tasks', 'Tasks'], ['#/agents', 'Agents'], ['#/queue', 'Queue'], ['#/settings/business', 'Business'], ['#/docs', 'Docs'], ['#/settings', 'Settings'], ['#/settings/display', 'Sidebar display']];
const VIEWS = [['#/', 'Dashboard'], ['#/projects', 'Projects'], ['#/inbox', 'Inbox'], ['#/messages', 'Assistant'], ['#/tasks', 'Tasks'], ['#/agents', 'Agents'], ['#/queue', 'Queue'], ['#/settings/business', 'Business'], ['#/docs', 'Docs'], ['#/settings', 'Settings'], ['#/settings/display', 'Sidebar display']];
let pkAll = [], pkShown = [], pkAt = 0, pkBack = null;
function pkItems() {
const decisions = (last.get('inbox')?.rows || []).map(d => ({ href: `#/inbox/${enc(d.id)}`, label: first(d.question), group: `decision ${short(d.id)}` }));
+2 -2
View File
@@ -7,7 +7,7 @@
<link rel="stylesheet" href="/processing.css"><script src="/processing.js" defer></script>
<link rel="stylesheet" href="/org-create.css"><script src="/org-create.js" defer></script>
<link rel="stylesheet" href="/onboarding.css"><script src="/onboarding.js" defer></script>
<script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
<script src="/workspace-data.js" defer></script><script src="/workspace.js" defer></script><script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
<body>
<div hidden aria-hidden="true"><select id="palette"></select><select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></div>
<a class="skip" href="#main">Skip to content</a>
@@ -19,7 +19,7 @@
<div id="processing-mobile"></div>
<div class="frame">
<div id="nav-scrim" hidden></div><nav class="tree" id="console-nav" aria-label="Console"><div class="drawer-head"><strong>Navigation</strong><button type="button" class="btn" id="nav-close">Close</button></div><p id="nav-overflow" class="small muted" hidden>Scroll for more destinations</p><div id="project-context" aria-label="Current project and conversation"></div>
<ul class="s1-sections" id="sections"></ul><div id="board-tree"><h2 class="tree-title">Board project filter</h2><div id="projects"><p class="muted">Loading projects…</p></div><p class="small muted">Select a project to filter sessions. Waiting on you always shows all projects.</p></div></nav>
<ul class="s1-sections" id="sections"></ul><div id="workspace-nav" aria-label="Organizations and projects"></div><div id="board-tree"><h2 class="tree-title">Board project filter</h2><div id="projects"><p class="muted">Loading projects…</p></div><p class="small muted">Select a project to filter sessions. Waiting on you always shows all projects.</p></div></nav>
<main id="main" class="content" tabindex="-1"><div id="signed-out" class="banner ref" role="alert" tabindex="-1" hidden></div><div id="bus-down" class="banner err" role="alert" hidden></div><div id="theme-warning" class="banner ref" role="status" hidden><p></p><div class="actions"><button id="theme-clear" type="button" class="btn" hidden>Use the default theme</button></div></div><p id="org-cue" class="org-cue" hidden></p><nav class="breadcrumbs" id="breadcrumbs" aria-label="Breadcrumb"><span aria-current="page">Dashboard</span></nav><div class="page-head" id="board-head"><div><h1>Dashboard</h1><p id="dashboard-business" class="muted">Business overview</p><p id="status" class="small muted" role="status">Loading board…</p></div><div class="filters"><label><input id="show-machine" type="checkbox">Show machine sessions</label></div></div>
<section id="dashboard-summary" aria-label="Dashboard metrics"><div id="dashboard-kpis" class="kpi-grid"></div><p id="dashboard-updated" class="kpi-footer">Not read yet</p></section>
<section id="held-section" aria-labelledby="held-title"><h2 id="held-title">Held roles <span id="held-count" class="count">–</span></h2><p class="small muted">The stack's seats: each role a run holds on the bus, from the bus <code>agents</code> read. Machine sessions are under Show machine sessions.</p><div id="held"><div class="skel-rows" aria-busy="true"><span class="sr-only">Reading the held roles.</span><span class="skel"></span><span class="skel"></span></div></div></section>
+203
View File
@@ -351,3 +351,206 @@ body:not(:has(.project-chat)) .content { padding-bottom:calc(22px + var(--foot-h
.settings-content:has(.route-actions){padding-bottom:100px}
}
.assistant-form textarea[data-assistant-field="vars"] { field-sizing:content; min-height:6em; max-height:16em; overflow-y:auto; }
/* Unified workspace, organization mode (PRD 1.20, U2). The sample 01, 03 and 08 layouts, ported
from docs/design/samples/2026-11-09-unified-workspace/workspace.css with ws- names, so nothing
collides with the Console's own .btn, .chip, .panel, .split, .label or .cfg. */
html[data-organizations]{--glyph:"DejaVu Sans","Segoe UI Symbol","Apple Symbols","Noto Sans Symbols 2",var(--font)}
@media (min-width:960px){html[data-organizations]{--sidebar-width:240px}}
/* An organization's colour for the page's mode: workspace.js sets the three pairs inline. */
html[data-organizations] .ws-o{--org:var(--org-l);--org-on:var(--org-on-l)}
@media (prefers-color-scheme:dark){html[data-organizations]:not([data-mode="light"]):not([data-mode="dim"]) .ws-o{--org:var(--org-d);--org-on:var(--org-on-d)}}
html[data-organizations][data-mode="dark"] .ws-o{--org:var(--org-d);--org-on:var(--org-on-d)}
html[data-organizations][data-mode="dim"] .ws-o{--org:var(--org-m);--org-on:var(--org-on-m)}
html[data-organizations] .ws-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
html[data-organizations] .ws-label{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}
html[data-organizations] .ws-line{margin:0;padding:3px 8px 3px 10px;font-size:12px;color:var(--muted);list-style:none}
/* Sidebar (sample 01). */
html[data-organizations] .tree{padding:10px 8px 14px;gap:2px}
html[data-organizations] #project-tree{border-bottom:0;padding-bottom:0}
html[data-organizations] .drawer-head{background:var(--nav-surface,var(--surface))}
html[data-organizations] #workspace-nav{display:flex;flex-direction:column;gap:2px;min-width:0;border:0;padding:0}
html[data-organizations] .s1-sections{display:grid;gap:2px;flex-basis:auto;overflow:visible;padding:0}
html[data-organizations] .s1-sections>li{width:100%;flex:none}
html[data-organizations] .ws-nav,html[data-organizations] .s1-section.ws-nav{display:flex;align-items:center;gap:9px;padding:5px 8px;min-height:0;min-width:0;border:0;border-radius:var(--r);box-shadow:none;background:transparent;color:var(--text);text-decoration:none;font:600 13.5px/1.5 var(--font);flex-wrap:nowrap;white-space:nowrap}
html[data-organizations] .ws-nav:hover{background:var(--raised)}
html[data-organizations] .ws-nav[aria-current=page],html[data-organizations] .s1-section.ws-nav[aria-current=page]{background:color-mix(in srgb,var(--action) 13%,var(--surface));box-shadow:none;color:var(--strong)}
html[data-organizations] .ws-nav svg.i{width:18px;height:18px;stroke-width:1.75;color:inherit}
html[data-organizations] .ws-nav[aria-current=page] svg.i{color:var(--action)}
html[data-organizations] .ws-nav .t{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
html[data-organizations] .ws-nav.sel{color:var(--strong)}
html[data-organizations] .ws-n{margin-left:auto;font:600 11px var(--mono);color:var(--muted);font-variant-numeric:tabular-nums}
html[data-organizations] .ws-n.hot{color:var(--surface);background:var(--danger);border-radius:999px;padding:0 6px}
html[data-organizations] .ws-hr{border:0;border-top:1px solid var(--line);margin:6px 4px;width:auto}
html[data-organizations] .ws-foot{margin-top:auto;padding-top:8px;border-top:1px solid var(--line)}
html[data-organizations] .ws-grp{display:flex;flex-direction:column;gap:1px}
html[data-organizations] .ws-grp-h{display:flex;align-items:center;gap:6px;padding:10px 8px 4px;color:var(--muted)}
html[data-organizations] .ws-grp-t{display:flex;align-items:center;gap:6px;flex:1;min-width:0;padding:0;margin:0;border:0;background:none;color:inherit;font:inherit;cursor:pointer;text-align:left}
html[data-organizations] .ws-grp-t .ws-label{flex:1}
html[data-organizations] .ws-grp-h>.ws-label{flex:1}
html[data-organizations] .ws-grp-h svg.i{width:14px;height:14px;transition:transform .15s}
html[data-organizations] .ws-grp-t[aria-expanded=false] svg.chev{transform:rotate(-90deg)}
html[data-organizations] .ws-grp-h .ws-n{margin-left:0}
html[data-organizations] .ws-add{display:inline-grid;place-items:center;flex:none;width:22px;height:22px;border-radius:var(--r);color:var(--muted);border:1px solid var(--line)}
html[data-organizations] .ws-add:hover{background:var(--raised);color:var(--strong)}
html[data-organizations] .ws-marks{display:flex;gap:4px;padding:0 8px 6px 28px}
html[data-organizations] .ws-orgs{display:flex;flex-direction:column;gap:1px;list-style:none;margin:0;padding:0}
html[data-organizations] .ws-orgs[hidden]{display:none}
html[data-organizations] .ws-project-list[hidden]{display:none}
html[data-organizations] .ws-seg{display:inline-flex;margin-left:auto;border:1px solid var(--line);border-radius:var(--r);overflow:hidden;background:var(--surface)}
html[data-organizations] .ws-seg>button{border:0;background:none;padding:2px 8px;color:var(--muted);font:600 12px/1.5 var(--font);display:inline-flex;align-items:center;white-space:nowrap;cursor:pointer;min-height:0}
html[data-organizations] .ws-seg.xs>button{padding:0 6px;font-size:11px}
html[data-organizations] .ws-seg [aria-pressed=true]{background:var(--action);color:var(--onAction)}
html[data-organizations] .ws-pgroup{display:flex;flex-direction:column;gap:1px;margin-bottom:4px}
html[data-organizations] .ws-pgroup-h{display:flex;align-items:center;gap:7px;padding:4px 8px 2px;font-size:12px;font-weight:700;color:var(--strong)}
html[data-organizations] .ws-pgroup-h .t{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
html[data-organizations] .ws-projs{margin-left:17px;border-left:2px solid var(--org);display:flex;flex-direction:column;gap:1px}
html[data-organizations] .ws-proj{padding:3px 8px 3px 10px;font-weight:500;font-size:13px}
html[data-organizations] .ws-proj.flat{padding-left:8px}
html[data-organizations] .ws-flat{display:flex;flex-direction:column;gap:1px}
/* Avatars, status marks, chips, buttons. */
html[data-organizations] .ws-av{display:inline-grid;place-items:center;flex:none;width:20px;height:20px;border-radius:5px;background:var(--org,var(--muted));color:var(--org-on,var(--surface));font:700 9.5px/1 var(--font);letter-spacing:.02em}
html[data-organizations] .ws-av.xs{width:16px;height:16px;border-radius:4px;font-size:8.5px;letter-spacing:0}
html[data-organizations] .ws-av.md{width:28px;height:28px;border-radius:7px;font-size:12px}
html[data-organizations] .ws-av.lg{width:44px;height:44px;border-radius:10px;font-size:17px}
html[data-organizations] .ws-av.all{background:var(--raised);color:var(--strong)}
html[data-organizations] .ws-av.all .mosaic-logo{width:18px;height:18px;fill:currentColor}
html[data-organizations] .ws-dot{display:inline-flex;align-items:center;gap:6px;flex:none}
html[data-organizations] .ws-dot b{font:400 13px/1 var(--glyph);width:14px;text-align:center}
html[data-organizations] .ws-dot.s-working{color:var(--success)}
html[data-organizations] .ws-dot.s-waiting{color:var(--warning)}
html[data-organizations] .ws-dot.s-error{color:var(--danger)}
html[data-organizations] .ws-dot.s-offline,html[data-organizations] .ws-dot.s-idle,html[data-organizations] .ws-dot.s-unknown{color:var(--muted)}
html[data-organizations] .ws-nav .ws-dot{margin-left:auto}
html[data-organizations] .ws-org-row .ws-dot{font-size:12.5px;font-weight:500}
html[data-organizations] .ws-org-row .ws-dot.s-offline,html[data-organizations] .ws-org-row .ws-dot.s-error{color:var(--text)}
html[data-organizations] .ws-org-row .ws-dot.s-offline b{color:var(--muted)}
html[data-organizations] .ws-org-row .ws-dot.s-error b{color:var(--danger)}
html[data-organizations] .ws-btn,html[data-organizations] .ws-busctl [data-bus-restart]{display:inline-flex;align-items:center;gap:6px;min-height:0;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:4px 10px;font:600 13px/1.4 var(--font);color:var(--strong);text-decoration:none;white-space:nowrap;cursor:pointer;box-shadow:none}
html[data-organizations] .ws-btn:hover:not(:disabled),html[data-organizations] .ws-busctl [data-bus-restart]:hover:not(:disabled){background:var(--raised)}
html[data-organizations] .ws-btn:disabled,html[data-organizations] .ws-busctl [data-bus-restart]:disabled{color:var(--muted);cursor:not-allowed;opacity:1}
html[data-organizations] .ws-btn svg.i{width:15px;height:15px}
/* The live bus restart is console-restart.js's own button; its icon sits beside it, outside the
part that file repaints. */
html[data-organizations] .ws-busctl{position:relative;display:inline-flex;flex-direction:column;align-items:flex-end}
html[data-organizations] .ws-busctl .bus-restart{display:inline-flex;flex-direction:column;align-items:flex-end;gap:4px}
html[data-organizations] .ws-busctl [data-bus-restart]{padding-left:31px}
html[data-organizations] .ws-busctl .ws-busicon{position:absolute;left:11px;top:7px;width:15px;height:15px;pointer-events:none;color:var(--strong);z-index:1}
html[data-organizations] .ws-busctl [data-bus-restart-receipt]{font-size:12px;color:var(--muted);max-width:28em;text-align:right}
html[data-organizations] .ws-busctl [data-bus-restart-receipt]:empty{display:none}
html[data-organizations] .ws-chips{display:flex;flex-wrap:wrap;align-items:center;gap:6px}
/* Page frame (sample main column). */
html[data-organizations] .content:has(.ws){padding-top:18px;padding-inline:24px}
body:has([data-org-dashboard]) #breadcrumbs,body:has([data-org-agents]) #breadcrumbs{display:none}
html[data-organizations] .ws{font-size:14px;line-height:1.5;color:var(--text);min-width:0}
html[data-organizations] .ws h1{margin:0;font-size:28px;font-weight:800;letter-spacing:-.02em;line-height:1.15;color:var(--strong)}
html[data-organizations] .ws h1:focus,html[data-organizations] .ws h1:focus-visible{outline:none}
html[data-organizations] .ws h2{margin:0;font-size:15px;font-weight:700;color:var(--strong)}
html[data-organizations] .ws code,html[data-organizations] .ws .mono{font-family:var(--mono);font-size:12.5px}
html[data-organizations] .ws .muted,html[data-organizations] .ws .note{color:var(--muted)}
html[data-organizations] .ws .note{font-size:13px}
html[data-organizations] .ws-crumb{display:flex;gap:6px;align-items:center;font-size:12.5px;color:var(--muted);margin-bottom:4px}
html[data-organizations] .ws-crumb a{color:var(--muted);text-decoration:none}
html[data-organizations] .ws-crumb a:hover{color:var(--action);text-decoration:underline}
html[data-organizations] .ws-vh{display:flex;flex-wrap:wrap;align-items:center;gap:8px 14px;margin-bottom:14px}
html[data-organizations] .ws-vh .sub{flex-basis:100%;margin:0;font-size:13.5px;color:var(--muted)}
html[data-organizations] .ws-right{margin-left:auto;display:flex;flex-wrap:wrap;align-items:flex-start;gap:8px}
html[data-organizations] .ws-sec-h{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 10px;margin:22px 0 8px}
html[data-organizations] .ws-sec-h .note{flex:1}
html[data-organizations] .ws-sec-h a{margin-left:auto;font-size:13px;font-weight:600;color:var(--action);text-decoration:none}
html[data-organizations] .ws-sec-h a:hover{text-decoration:underline}
html[data-organizations] .ws-panel{background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:16px;margin:0}
html[data-organizations] .ws-panel.tight{padding:12px 14px}
html[data-organizations] .ws-split{display:grid;grid-template-columns:minmax(0,1.45fr) minmax(0,1fr);gap:16px;align-items:start}
html[data-organizations] .ws-split{margin-top:22px}
html[data-organizations] .ws-split>section>.ws-sec-h:first-child{margin-top:0}
/* Organization dashboard (sample 03). */
html[data-organizations] .ws-orghead{display:flex;flex-wrap:wrap;align-items:center;gap:14px;margin-bottom:14px}
html[data-organizations] .ws-orghead h1{display:flex;align-items:center;gap:10px}
html[data-organizations] .ws-orghead h1 svg.i{width:20px;height:20px;color:var(--muted)}
html[data-organizations] .ws-meta{flex-basis:100%;display:flex;flex-wrap:wrap;align-items:center;gap:4px 14px;margin:-6px 0 0;padding-left:58px;font-size:13px;color:var(--muted)}
html[data-organizations] .ws-meta code{color:var(--text)}
html[data-organizations] .ws-meta .ws-dot{color:var(--muted)}
html[data-organizations] .ws-meta .ws-dot.s-working b{color:var(--success)}
html[data-organizations] .ws-meta .ws-dot.s-offline b,html[data-organizations] .ws-meta .ws-dot.s-error b{color:var(--danger)}
html[data-organizations] .ws-typechips,html[data-organizations] .ws-docrow{margin-top:8px}
html[data-organizations] .ws-typechips .ws-label,html[data-organizations] .ws-docrow .ws-label{min-width:104px}
html[data-organizations] .ws-doc{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:2px 8px;font-size:12.5px;font-weight:600;color:var(--strong);text-decoration:none}
html[data-organizations] .ws-doc:hover{background:var(--raised)}
html[data-organizations] .ws-doc svg.i{width:14px;height:14px;color:var(--muted)}
html[data-organizations] .ws-doc small{font-weight:500;color:var(--muted);font-size:11px}
html[data-organizations] .ws-more{font-size:13px;font-weight:600;margin-left:4px;color:var(--action);text-decoration:none}
html[data-organizations] .ws-tiles{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px}
html[data-organizations] .ws-tile{background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:12px 14px;display:grid;gap:6px;align-content:start}
html[data-organizations] .ws-tile.is-empty{grid-column:1/-1;border-style:dashed;background:transparent}
html[data-organizations] .ws-empty-t{font-size:13.5px;font-weight:600;color:var(--strong)}
html[data-organizations] .ws-tbl{overflow-x:auto;border:1px solid var(--line);border-radius:8px;background:var(--surface)}
html[data-organizations] .ws-tbl table{width:100%;border-collapse:collapse;font-size:13px;margin:0}
html[data-organizations] .ws-tbl th{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);text-align:left;padding:7px 10px;border-bottom:1px solid var(--line);background:none;white-space:nowrap}
html[data-organizations] .ws-tbl td{padding:7px 10px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent);vertical-align:middle}
html[data-organizations] .ws-tbl tr:last-child td{border-bottom:0}
html[data-organizations] .ws-tbl .t1{color:var(--strong)}
html[data-organizations] .ws-tbl .t2{display:block;font-size:12px;color:var(--muted)}
html[data-organizations] .ws-tbl td.mono{font-family:var(--mono);font-size:12.5px;white-space:nowrap}
html[data-organizations] .ws-cfg{display:grid;list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--surface)}
html[data-organizations] .ws-cfg li{display:grid;align-items:center;grid-template-columns:18px 132px minmax(0,1fr) auto auto;gap:0 10px;padding:7px 12px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent)}
html[data-organizations] .ws-cfg li:last-child{border-bottom:0}
html[data-organizations] .ws-cfg svg.i{width:16px;height:16px;color:var(--muted)}
html[data-organizations] .ws-cfg .k{font-size:13px;font-weight:600;color:var(--strong)}
html[data-organizations] .ws-cfg .v{font-size:12px;color:var(--muted);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
html[data-organizations] .ws-cfg .src-tag{font:500 11px var(--mono);color:var(--accent);white-space:nowrap}
html[data-organizations] .ws-cfg .go{font-size:12.5px;font-weight:600;color:var(--action);text-decoration:none;min-width:32px;text-align:right}
html[data-organizations] .ws-cfg a.go:hover{text-decoration:underline}
html[data-organizations] .ws-cfg li.danger .k,html[data-organizations] .ws-cfg li.danger svg.i,html[data-organizations] .ws-cfg li.danger .go,html[data-organizations] .ws-cfg li.danger .src-tag{color:var(--danger)}
/* Agents (sample 08). */
html[data-organizations] .ws-lvl{display:inline-block;font:700 10.5px/1.5 var(--font);letter-spacing:.05em;text-transform:uppercase;padding:1px 6px;border-radius:3px;margin-right:6px;vertical-align:1px}
html[data-organizations] .ws-lvl.root{background:var(--strong);color:var(--surface)}
html[data-organizations] .ws-lvl.org{background:color-mix(in srgb,var(--org,var(--action)) 18%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--org,var(--action)) 50%,transparent);color:var(--strong)}
html[data-organizations] .ws-lvl.proj{box-shadow:inset 0 0 0 1px var(--line);color:var(--text)}
html[data-organizations] .ws-span-band{display:flex;flex-wrap:wrap;align-items:center;gap:8px 10px;padding:8px 12px;margin-bottom:14px;border-radius:8px;border:1px solid var(--action);background:color-mix(in srgb,var(--action) 8%,var(--surface));font-size:13px;color:var(--strong)}
html[data-organizations] .ws-span-band .orgs{display:inline-flex;gap:4px}
html[data-organizations] .ws-band-note{flex-basis:100%;font-size:12.5px}
html[data-organizations] .ws-levels{display:grid;border:1px solid var(--line);border-radius:10px;overflow:hidden;background:var(--surface)}
html[data-organizations] .ws-levels>div{padding:10px 12px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent);border-right:1px solid color-mix(in srgb,var(--line) 60%,transparent);min-width:0}
html[data-organizations] .ws-levels>.endc{border-right:0}
html[data-organizations] .ws-levels>.last{border-bottom:0}
html[data-organizations] .ws-levels>.lh{background:color-mix(in srgb,var(--raised) 45%,transparent);display:grid;gap:4px;align-content:start}
html[data-organizations] .ws-levels>.lh .ws-lvl{justify-self:start}
html[data-organizations] .ws-levels>.lh small{font-size:11.5px;color:var(--muted);line-height:1.4}
html[data-organizations] .ws-levels>.oh{display:flex;align-items:center;gap:8px;font-weight:700;color:var(--strong);background:color-mix(in srgb,var(--raised) 45%,transparent)}
html[data-organizations] .ws-levels>.span{grid-column:2/-1;display:flex;align-items:center}
html[data-organizations] .ws-levels .mlabel{display:none;align-items:center;gap:6px;font-size:12px;font-weight:700;color:var(--strong);margin-bottom:4px}
html[data-organizations] .ws-agent{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);border-radius:999px;padding:2px 10px 2px 8px;font-size:12.5px;font-weight:600;background:var(--canvas);margin:2px 4px 2px 0;color:var(--strong)}
html[data-organizations] .ws-agent small{color:var(--muted);font-weight:500;font-size:11px}
html[data-organizations] .ws-agent .ws-dot b{font-size:11px;width:10px}
html[data-organizations] .ws-rules{display:grid;gap:10px;font-size:13px}
html[data-organizations] .ws-rules p{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px;margin:0}
html[data-organizations] .ws-rules .ws-lvl{margin-right:0}
html[data-organizations] .ws-scope{margin:-8px 0 14px}
@media (pointer:coarse){html[data-organizations] .ws-nav,html[data-organizations] .s1-section.ws-nav{min-height:40px}}
@media (max-width:759px){
html[data-organizations] .content:has(.ws){padding:14px 16px 24px}
html[data-organizations] .tree{padding:10px 12px 20px}
html[data-organizations] .ws h1{font-size:24px}
html[data-organizations] .ws-split,html[data-organizations] .ws-tiles{grid-template-columns:minmax(0,1fr)}
html[data-organizations] .ws-tile.is-empty{grid-column:auto}
html[data-organizations] .ws-sec-h .note{flex-basis:100%;order:3}
html[data-organizations] .ws-cfg li{grid-template-columns:18px minmax(0,1fr) auto auto}
html[data-organizations] .ws-cfg .v{grid-column:2/-1;grid-row:2}
html[data-organizations] .ws-meta{padding-left:0;margin-top:0}
html[data-organizations] .ws-orghead .ws-right{margin-left:0}
html[data-organizations] .ws-busctl,html[data-organizations] .ws-busctl .bus-restart{align-items:flex-start}
html[data-organizations] .ws-busctl [data-bus-restart-receipt]{text-align:left}
html[data-organizations] .ws-typechips .ws-label,html[data-organizations] .ws-docrow .ws-label{min-width:0}
html[data-organizations] .ws-levels{grid-template-columns:minmax(0,1fr)!important}
html[data-organizations] .ws-levels>div{border-right:0}
html[data-organizations] .ws-levels>.hide-m,html[data-organizations] .ws .hide-m{display:none}
html[data-organizations] .ws-levels>.span{grid-column:auto}
html[data-organizations] .ws-levels>.last.lh{border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent)}
html[data-organizations] .ws-levels .mlabel{display:flex}
}
@@ -0,0 +1,24 @@
// Every read the unified workspace screens make: the organization list, the theme table and the
// per-organization routes under /api/orgs/<id>/. Nothing here writes. A read that fails throws, so
// each caller says "unavailable" for that one part instead of guessing.
(() => {
const read = async path => {
const response = await fetch(path, { cache: 'no-store', redirect: 'manual' });
if (!response.ok || response.type === 'opaqueredirect') throw new Error('Read unavailable');
return response.json();
};
const orgPath = (id, path) => `/api/orgs/${encodeURIComponent(id)}/${path}`;
const organizations = () => read('/api/orgs');
const themes = () => read('/api/themes');
const projects = id => read(orgPath(id, 'chat/projects'));
const threads = id => read(orgPath(id, 'chat/threads'));
const instances = id => read(orgPath(id, 'bus/instances'));
// Held roles with the state the bus derives (working, waiting, idle), and which run holds which thread.
const agents = id => read(orgPath(id, 'bus/agents'));
const claims = id => read(orgPath(id, 'bus/thread.claims'));
const queue = id => read(orgPath(id, 'queue'));
const business = id => read(orgPath(id, 'business'));
const settings = id => read(orgPath(id, 'settings'));
const projectFiles = (id, project) => read(orgPath(id, `project-files?project=${encodeURIComponent(project)}`));
window.MosaicWorkspaceData = Object.freeze({ organizations, themes, projects, threads, instances, agents, claims, queue, business, settings, projectFiles });
})();
+299
View File
@@ -0,0 +1,299 @@
// The unified workspace screens in organization mode: the sidebar's Organizations and Projects
// groups (sample 01), the organization dashboard (sample 03) and the Agents levels (sample 08).
// Every read goes through MosaicWorkspaceData (workspace-data.js). Nothing here writes; the bus
// restart button is console-restart.js's own control, with its gating and trail unchanged.
(() => {
'use strict';
if (!document.documentElement.hasAttribute('data-organizations')) return;
const data = window.MosaicWorkspaceData;
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const enc = encodeURIComponent;
const GROUPING = 'mosaic-workspace-project-grouping-v1', ORGS_OPEN = 'mosaic-workspace-orgs-open-v1', PROJECTS_OPEN = 'mosaic-workspace-projects-open-v1';
const load = key => { try { return localStorage.getItem(key); } catch { return null; } };
const save = (key, value) => { try { localStorage.setItem(key, value); } catch {} };
let grouping = load(GROUPING) === 'flat' ? 'flat' : 'org';
let expanded = load(ORGS_OPEN) !== 'closed';
let projectsOpen = load(PROJECTS_OPEN) !== 'closed';
let orgs = null, orgError = false, themes = null, projects = new Map();
// Icons: the Console sprite where it has one, inline paths from the U0 sample where it does not.
const PATHS = {
folder: '<path d="M3.5 6.5a1 1 0 0 1 1-1h4.5l2 2h8.5a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1z"/>',
columns: '<rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M12 4.5v15"/>',
chevron: '<path d="m6 9 6 6 6-6"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
restart: '<path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/><path d="M19.5 4.5v4h-4"/>',
doc: '<path d="M6.5 3.5h7l4 4v13h-11z"/><path d="M13.5 3.5v4h4M9 12h6M9 15.5h6"/>',
people: '<circle cx="9" cy="8.5" r="3"/><path d="M3.5 19.5a5.5 5.5 0 0 1 11 0M15.5 5.8a3 3 0 0 1 0 5.4M17.5 19.5a5.5 5.5 0 0 0-2.5-4.6"/>',
cube: '<path d="M12 3.5 20 8v8l-8 4.5L4 16V8z"/><path d="m4 8 8 4.5L20 8M12 12.5v8"/>',
};
const icon = (id, cls = '') => `<svg class="i${cls ? ` ${cls}` : ''}"${PATHS[id] ? ' viewBox="0 0 24 24"' : ''} aria-hidden="true" focusable="false">${PATHS[id] || `<use href="/icons.svg#i-${id}"/>`}</svg>`;
const LOGO = '<svg class="mosaic-logo" viewBox="0 0 36 36" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg>';
// An organization's colour is its theme's action colour, per mode, with that mode's onAction for
// the initials. The CSS picks the pair for the page's mode (.ws-o in shell.css).
const HEX = /^#[0-9a-f]{6}$/i;
const themeOf = org => (themes || []).find(t => t.id === (org.theme || 'harbor')) || (themes || []).find(t => t.id === 'harbor');
function colours(org) {
const tokens = themeOf(org)?.tokens || {};
const pick = (mode, name, fallback) => (HEX.test(tokens[mode]?.[name] || '') ? tokens[mode][name] : fallback);
const l = [pick('light', '--action', '#2266bf'), pick('light', '--onAction', '#ffffff')];
const d = [pick('dark', '--action', '#74a6e7'), pick('dark', '--onAction', '#10141a')];
const m = [pick('dim', '--action', d[0]), pick('dim', '--onAction', d[1])];
return `--org-l:${l[0]};--org-on-l:${l[1]};--org-d:${d[0]};--org-on-d:${d[1]};--org-m:${m[0]};--org-on-m:${m[1]}`;
}
// Two words give their first letters (Distinctly Digital, DD); one word gives its first letter and
// its last consonant (Forge, FG; Home, HM), as the U0 sample marks them.
function initials(org) {
const words = String(org.label || org.id || '?').trim().split(/[\s_.-]+/).filter(Boolean);
if (words.length > 1) return (words[0][0] + words[1][0]).toUpperCase();
const word = words[0] || '?', rest = word.slice(1).replace(/[^a-z0-9]/gi, ''), consonants = rest.match(/[bcdfghjklmnpqrstvwxz]/gi);
return (word[0] + (consonants ? consonants[consonants.length - 1] : rest[0] || '')).toUpperCase();
}
const av = (org, size = '') => `<span class="ws-av ws-o${size ? ` ${size}` : ''}" style="${colours(org)}" aria-hidden="true">${esc(initials(org))}</span>`;
const label = org => org.label || org.id;
const state = org => (org.invalid ? ['s-error', '▲', 'invalid'] : org.busHealthy ? ['s-working', '●', 'bus running'] : ['s-offline', '○', 'bus unavailable']);
// A status mark: a glyph plus its word, shown or read aloud only.
const dot = (cls, glyph, word, shown = false) => `<span class="ws-dot ${cls}" title="${esc(word)}"><b aria-hidden="true">${glyph}</b>${shown ? esc(word) : `<span class="ws-sr">${esc(word)}</span>`}</span>`;
const orgHref = id => `#/orgs/${enc(id)}`;
const route = () => location.hash.replace(/^#\/orgs\/[^/]+/, '') || '/';
const ensureThemes = async () => { if (!themes) themes = await data.themes().then(b => (Array.isArray(b?.themes) ? b.themes : [])).catch(() => []); return themes; };
const instancesOf = value => { const v = value?.rows?.[0]?.instances ?? value?.instances; return v && typeof v === 'object' && !Array.isArray(v) ? v : null; };
const running = item => (Array.isArray(item?.running) ? item.running.length : 0);
const rowsOf = v => (Array.isArray(v) ? v : Array.isArray(v?.rows) ? v.rows : null);
// An agent's activity is the state the bus derives for the run holding its role. A live process
// alone says only running, never working or idle.
const ACTIVITY = { working: ['s-working', '●', 'working'], waiting: ['s-waiting', '◐', 'waiting on you'], idle: ['s-idle', '–', 'idle'] };
const activity = (name, item, agents) => {
const on = running(item) > 0;
const held = on && agents ? agents.find(a => a?.role === name && item.running.includes(a.holder_run)) : null;
return ACTIVITY[held?.state] || (on ? ['s-working', '●', 'running'] : ['s-idle', '–', 'not running']);
};
// The inline model control: the change itself happens on Settings, Routing, with its own checks.
const modelCell = (agent, model) => (model ? `${esc(model)} <a class="ws-change" href="#/settings/routing" data-focus-key="${esc(`model:${agent}`)}" aria-label="${esc(`Change the model for ${agent}`)}">Change</a>` : '<span class="muted">not reported</span>');
// Focus survives a repaint: the focused control's data-focus-key is found again afterwards.
const focusKey = root => { const a = document.activeElement; return root && a && root.contains(a) ? a.dataset?.focusKey || null : null; };
const refocus = (root, key) => { if (key) root.querySelector(`[data-focus-key="${CSS.escape(key)}"]`)?.focus({ preventScroll: true }); };
// ---------- sidebar (sample 01) ----------
function projectLinks(org, flat) {
const rows = projects.get(org.id);
if (rows === undefined) return flat ? '' : '<p class="ws-line">Reading projects…</p>';
if (rows === null) return `<p class="ws-line">${flat ? `${esc(label(org))}: projects unavailable` : 'Projects unavailable'}</p>`;
const live = rows.filter(p => p && !p.archived);
if (!live.length) return flat ? '' : '<p class="ws-line">No projects yet</p>';
const hash = location.hash;
return live.map(p => {
const href = `${orgHref(org.id)}/projects/${enc(p.id)}`, current = hash === href || hash.startsWith(`${href}/`);
return `<a class="ws-nav ws-proj${flat ? ' flat' : ''}" href="${esc(href)}" data-focus-key="${esc(`proj:${org.id}:${p.id}`)}"${current ? ' aria-current="page"' : ''}>${flat ? av(org, 'xs') : ''}<span class="t">${esc(p.name || p.id)}</span></a>`;
}).join('');
}
function projectGroups(list) {
if (grouping === 'flat') {
const body = list.map(org => projectLinks(org, true)).join('');
if (body) return `<div class="ws-flat">${body}</div>`;
return list.some(org => projects.get(org.id) === undefined) ? '<p class="ws-line">Reading projects…</p>' : '<p class="ws-line">No projects yet</p>';
}
return list.map(org => `<div class="ws-pgroup ws-o" style="${colours(org)}" data-org="${esc(org.id)}"><div class="ws-pgroup-h">${av(org, 'xs')}<span class="t">${esc(label(org))}</span></div><div class="ws-projs">${projectLinks(org, false)}</div></div>`).join('') || '<p class="ws-line">No projects yet</p>';
}
function paint() {
const root = document.getElementById('workspace-nav'); if (!root) return;
const key = focusKey(root);
if (orgError || !orgs) { root.innerHTML = `<hr class="ws-hr"><p class="ws-line">${orgError ? 'Organizations unavailable' : 'Reading organizations…'}</p>`; return; }
const here = route(), selected = window.MosaicOrg?.id;
const rows = orgs.map(org => {
const [cls, glyph, word] = state(org), mine = org.id === selected, current = mine && here === '/organization';
return `<li data-workspace-org="${esc(org.id)}"><a class="ws-nav ws-org-row${mine && !current ? ' sel' : ''}" href="${esc(orgHref(org.id))}/organization" data-focus-key="${esc(`org:${org.id}`)}"${current ? ' aria-current="page"' : ''}>${av(org)}<span class="t">${esc(label(org))}</span>${dot(cls, glyph, word, cls !== 's-working')}</a></li>`;
}).join('');
const marks = expanded || !orgs.length ? '' : `<div class="ws-marks" aria-hidden="true">${orgs.map(org => av(org, 'xs')).join('')}</div>`;
root.innerHTML = `<hr class="ws-hr"><div class="ws-grp"><div class="ws-grp-h"><button type="button" class="ws-grp-t" data-org-toggle data-focus-key="org-toggle" aria-expanded="${expanded}" aria-controls="ws-org-list">${icon('chevron', 'chev')}<span class="ws-label">Organizations</span><span class="ws-n">${orgs.length}</span></button><a class="ws-add" href="#/settings/organizations?new=1" data-focus-key="org-new" aria-label="New organization" title="New organization">${icon('plus')}</a></div>${marks}<ul class="ws-orgs" id="ws-org-list" data-org-list${expanded ? '' : ' hidden'}>${rows || '<li class="ws-line">No organizations yet</li>'}</ul></div><hr class="ws-hr"><div class="ws-grp"><div class="ws-grp-h"><button type="button" class="ws-grp-t" data-projects-toggle data-focus-key="projects-toggle" aria-expanded="${projectsOpen}" aria-controls="ws-project-list">${icon('chevron', 'chev')}<span class="ws-label">Projects</span></button><span class="ws-seg xs" role="group" aria-label="Project layout"><button type="button" data-group="org" data-focus-key="group-org" aria-pressed="${grouping === 'org'}">By org</button><button type="button" data-group="flat" data-focus-key="group-flat" aria-pressed="${grouping === 'flat'}">Flat</button></span></div><div class="ws-project-list" id="ws-project-list"${projectsOpen ? '' : ' hidden'}>${projectGroups(orgs.filter(org => !org.invalid))}</div></div>`;
refocus(root, key);
}
let reading = 0;
async function refresh(rows) {
const turn = ++reading;
try {
const [list] = await Promise.all([Array.isArray(rows) ? rows : data.organizations(), ensureThemes()]);
if (turn !== reading) return;
orgs = Array.isArray(list) ? list : []; orgError = false;
} catch { if (turn === reading) { orgError = true; paint(); } return; }
paint();
const results = await Promise.all(orgs.filter(org => !org.invalid).map(org => data.projects(org.id).then(b => [org.id, Array.isArray(b?.rows) ? b.rows : null]).catch(() => [org.id, null])));
if (turn !== reading) return;
projects = new Map(results); paint();
}
document.addEventListener('click', e => {
const toggle = e.target.closest?.('[data-org-toggle]');
if (toggle) { expanded = !expanded; save(ORGS_OPEN, expanded ? 'open' : 'closed'); paint(); document.querySelector('[data-org-toggle]')?.focus(); return; }
if (e.target.closest?.('[data-projects-toggle]')) { projectsOpen = !projectsOpen; save(PROJECTS_OPEN, projectsOpen ? 'open' : 'closed'); paint(); document.querySelector('[data-projects-toggle]')?.focus(); return; }
const group = e.target.closest?.('#workspace-nav [data-group]');
if (group) { grouping = group.dataset.group === 'flat' ? 'flat' : 'org'; save(GROUPING, grouping); paint(); document.querySelector(`#workspace-nav [data-group="${grouping}"]`)?.focus(); }
});
window.addEventListener('hashchange', () => { if (orgs) paint(); });
document.addEventListener('mosaic-org-list', e => refresh(e.detail?.rows));
// ---------- organization dashboard (sample 03) ----------
const SAFE = /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]{0,159}$/;
const DOC_PATH = /^(spec\/north-star\.md|docs\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.md)$/;
const docCache = new Map();
// Docs are read at most once a minute per organization: the dashboard repaints every 10 s.
async function docs(id, names) {
const hit = docCache.get(id);
if (hit && Date.now() - hit.at < 60000) return hit.value;
const list = (Array.isArray(names) ? names : []).filter(p => typeof p === 'string' && SAFE.test(p)).slice(0, 6);
const read = await Promise.allSettled(list.map(p => data.projectFiles(id, p).then(body => (Array.isArray(body?.files) ? body.files : []).filter(f => typeof f?.path === 'string' && DOC_PATH.test(f.path)).map(f => ({ project: p, path: f.path })))));
const value = { rows: read.flatMap(r => (r.status === 'fulfilled' ? r.value : [])), failed: read.length > 0 && read.every(r => r.status === 'rejected') };
docCache.set(id, { at: Date.now(), value });
return value;
}
const docName = path => (path === 'spec/north-star.md' ? 'North star' : path.replace(/^.*\//, '').replace(/\.md$/, '').replace(/[-_]+/g, ' ').replace(/^./, c => c.toUpperCase()));
const titleCase = s => String(s || '').replace(/(^|[-_\s])(\w)/g, (_, sep, c) => `${sep ? ' ' : ''}${c.toUpperCase()}`);
const clock = () => new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
const secH = (title, note = '', link = '') => `<div class="ws-sec-h"><h2>${title}</h2>${note ? `<span class="note">${note}</span>` : ''}${link}</div>`;
function settingsList(org, ctx) {
const { business, settings, instances, orgCount } = ctx;
const b = business?.business, s = settings;
const unread = 'Could not be read';
const names = instances ? Object.keys(instances) : null;
const models = b && Array.isArray(b.instances) ? [...new Set(b.instances.map(i => i?.vars?.model).filter(m => typeof m === 'string' && m))] : null;
const arbiters = b?.arbiters && typeof b.arbiters === 'object' ? Object.entries(b.arbiters).filter(([, v]) => typeof v === 'string').map(([k, v]) => `${v} (${k})`).join(' · ') : null;
const total = s?.launch && !s.launch.refused ? s.launch.limits?.total : undefined;
const notifier = s?.notifier;
const theme = b?.appearance?.theme || org.theme || 'harbor';
const rows = [
['business', 'Identity', b ? `${label(org)} · ${org.id} · kind not recorded` : unread, 'org file', 'business'],
['flag', 'Appearance', `${titleCase(theme)} theme · ${initials(org)} avatar`, b?.appearance?.theme || org.theme ? 'org file' : 'system', 'appearance'],
['cube', 'System types', 'Not built yet', 'not built', null],
['folder', 'Projects', b ? (Array.isArray(b.projects) && b.projects.length ? plural(b.projects.length, 'project') : 'No projects') : unread, 'org file', '#/projects'],
['people', 'Agents and roles', names ? (names.length ? `${plural(names.length, 'agent')} · ${Object.values(instances).filter(running).length} running` : 'No agents') : unread, 'org file', 'agents'],
['term', 'Models and routing', models ? (models.length ? models.join(' · ') : 'No models set') : unread, 'org file', 'routing'],
['queue', 'Arbiters', b ? arbiters || 'None set' : unread, 'org file', 'business'],
['tasks', 'Task tracker', b ? (b.vars?.['tracker.kind'] ? titleCase(b.vars['tracker.kind']) : 'Not set') : unread, 'org file', 'business'],
['talk', 'Channels', !s ? unread : typeof notifier?.binding === 'string' ? `Notifier ${notifier.binding}` : notifier?.refused ? unread : 'No notifier set', 'system', 'about'],
['ledger', 'Launch limits', !s ? unread : s.launch?.refused ? unread : Number.isFinite(total) ? `${plural(total, 'session')} at once` : 'No session limit', 'org file', 'limits'],
['talk', 'Assistant', 'The assistant agent and its seat names', 'org file', 'assistant'],
['ledger', 'Token use', 'Usage and limits per provider and model', 'system', 'tokens'],
['term', 'Model proxy', 'Accounts, priority and client keys', 'system', 'model-proxy'],
['restart', 'Console', 'Console restart', 'system', 'console'],
['business', 'Organizations', orgCount === null ? unread : plural(orgCount, 'organization'), 'system', 'organizations'],
['flag', 'About', typeof s?.release === 'string' ? `Release ${s.release}` : 'Address, release and notifier', 'system', 'about'],
['people', 'Members and invites', 'Not built yet', 'not built', null],
['restart', 'Bus', `${org.busHealthy ? 'Running' : 'Unavailable'} · restart from this page's header`, 'system', null],
['close', 'Archive or delete', "Archive or delete this organization's file", 'danger', 'organizations'],
];
return `<ul class="ws-cfg one" data-org-settings>${rows.map(([ic, name, value, tag, page]) => {
const href = page === null ? null : page.startsWith('#') ? page : `#/settings/${page}`;
const go = href ? `<a class="go" href="${esc(href)}" data-focus-key="${esc(`set:${name}`)}" aria-label="Open ${esc(name)}">Open</a>` : '<span class="go" aria-hidden="true"></span>';
return `<li${tag === 'danger' ? ' class="danger"' : ''} data-org-setting="${esc(name)}">${icon(ic)}<span class="k">${esc(name)}</span><span class="v" title="${esc(value)}">${esc(value)}</span><span class="src-tag">${esc(tag)}</span>${go}</li>`;
}).join('')}</ul>`;
}
async function dashboard() {
const id = window.MosaicOrg.id;
// Only an organization the membership list names is read; anything else reads nothing more.
const [listRead] = await Promise.all([data.organizations().catch(() => null), ensureThemes()]);
const list = Array.isArray(listRead) ? listRead : null;
const org = list?.find(o => o.id === id && !o.invalid);
if (!org) return `<article class="ws ws-dash" data-org-dashboard data-org-unavailable><div class="ws-vh"><h1 tabindex="-1">Organization unavailable</h1><p class="sub">${list ? 'This organization is not on your list.' : 'The organization list could not be read.'}</p></div></article>`;
const reads = await Promise.allSettled([data.business(id), data.settings(id), data.instances(id), data.threads(id), data.projects(id), data.queue(id), data.agents(id), data.claims(id)]);
const value = i => (reads[i].status === 'fulfilled' ? reads[i].value : null);
const business = value(0), settings = value(1), instances = instancesOf(value(2));
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;
await window.MosaicConsole?.ready;
const name = esc(label(org));
const tracker = business?.business?.vars?.['tracker.kind'];
// Header: the bus restart is console-restart.js's button where the Console allows it, otherwise
// the same control, present and disabled, with the reason.
const restart = window.MosaicConsole?.busButton?.('Restart bus') || '';
const bus = restart ? `<span class="ws-busctl" data-dashboard-bus>${icon('restart', 'ws-busicon')}${restart}</span>` : `<span data-dashboard-bus><button type="button" class="ws-btn" data-bus-restart-off data-focus-key="bus-restart-off" disabled title="Restarting the bus is the owner's trailed action, on a Console started with send and the action trail">${icon('restart')}Restart bus</button></span>`;
const reload = `<button type="button" class="ws-btn" data-org-reload data-focus-key="org-reload" disabled aria-describedby="ws-reload-why" title="Reload arrives with the cross-org update">${icon('doc')}Reload organization file</button>`;
const [bcls, bglyph] = state(org);
const meta = `<p class="ws-meta"><span>Kind not recorded</span><span>id <code>${esc(id)}</code></span><span>Tracker: ${tracker ? esc(titleCase(tracker)) : 'not set'}</span><span>${dot(bcls, bglyph, org.busHealthy ? `bus running, read ${clock()}` : `bus unavailable, read ${clock()}`, true)}</span><span id="ws-reload-why">Restart is a trailed human action; reload arrives with the cross-org update.</span></p>`;
const projectName = pid => projectRows?.find(p => p.id === pid)?.name || pid;
const typechips = '<div class="ws-typechips ws-chips"><span class="ws-label">System types</span><span class="note">No system types yet. They arrive with the system types update.</span></div>';
const docChips = !docRead ? '<span class="note">Docs unavailable</span>' : docRead.failed ? '<span class="note">Docs unavailable</span>' : docRead.rows.length ? docRead.rows.slice(0, 6).map(d => `<a class="ws-doc" href="#/docs/${enc(d.project)}/${enc(d.path)}" data-focus-key="${esc(`doc:${d.project}:${d.path}`)}" title="${esc(`${d.project}/${d.path}`)}">${icon('doc')}${esc(docName(d.path))}<small>${esc(projectName(d.project))}</small></a>`).join('') : '<span class="note">No docs yet</span>';
const docrow = `<div class="ws-docrow ws-chips"><span class="ws-label">Docs</span>${docChips}<a class="ws-more" href="#/docs" data-focus-key="docs-all">All docs</a></div>`;
const tiles = (title, note, empty) => `${secH(title, note)}<div class="ws-tiles"><div class="ws-tile is-empty"><span class="ws-empty-t">${empty}</span></div></div>`;
const modelOf = inst => business?.business?.instances?.find(i => i?.instance === inst)?.vars?.model;
const live = instances ? Object.entries(instances).filter(([, item]) => running(item)) : null;
// A session's project and task is the thread its live run holds a claim on (thread.claims), not
// any older conversation of the same agent. A failed read says unavailable, never "none".
const sessionRows = (live || []).map(([agent, item]) => {
const held = claims ? claims.filter(c => item.running.includes(c?.holder_run)) : null;
const first = held?.[0], thread = first && threads ? threads.find(t => t?.id === first.thread) : null;
const model = modelOf(agent), count = running(item), [acls, aglyph, aword] = activity(agent, item, agents);
const more = held && held.length > 1 ? ` · ${held.length - 1} more` : '';
const where = held === null ? '<span class="muted">Conversation unavailable</span>'
: !first ? '<span class="muted">No conversation held right now</span>'
: thread ? `${esc(projectName(thread.project))}<span class="t2">${esc(thread.name || thread.id)}${more}</span>`
: threads ? `<span class="muted">Conversation</span><span class="t2">${esc(first.thread)}${more}</span>` : '<span class="muted">Conversation unavailable</span>';
return `<tr><td>${esc(agent)}</td><td>${where}</td><td class="hide-m mono">${modelCell(agent, model)}</td><td>${dot(acls, aglyph, aword, true)}${count > 1 ? ` <span class="muted">${count} sessions</span>` : ''}</td></tr>`;
}).join('');
const sessions = live === null ? '<p class="ws-panel tight note">Sessions unavailable. The bus did not answer the instance read.</p>' : live.length ? `<div class="ws-tbl"><table><thead><tr><th scope="col">Agent</th><th scope="col">Project and task</th><th scope="col" class="hide-m">Model</th><th scope="col">Status</th></tr></thead><tbody>${sessionRows}</tbody></table></div>` : '<p class="ws-panel tight note">No active agent sessions</p>';
const open = queue ? queue.filter(r => r?.state !== 'done').length : null;
const queueNote = queue === null ? 'unavailable' : queue.length ? `${plural(queue.length, 'row')}, ${open} not done` : 'no rows';
return `<article class="ws ws-dash" data-org-dashboard>
<nav class="ws-crumb" aria-label="Breadcrumb"><a href="#/settings/organizations">Organizations</a><span aria-hidden="true">›</span><span>${name}</span></nav>
<div class="ws-orghead ws-o" style="${colours(org)}">${av(org, 'lg')}<h1 tabindex="-1">${name}</h1><div class="ws-right">${bus}${reload}</div>${meta}</div>
${typechips}${docrow}
<div class="ws-split">
<section aria-label="Measures and sessions">
${tiles('KPIs', 'defined in the organization file', 'No KPIs defined in the organization file')}
${tiles('Metrics', 'measured by the bus', 'No metrics recorded yet')}
${secH('Active agent sessions', live ? (live.length ? `${live.length} running` : 'none running') : '', '<a href="#/agents" data-focus-key="agents-all">All agents</a>')}
${sessions}
${secH('Queue', queueNote, '<a href="#/queue" data-org-queue data-focus-key="queue-open">Open queue</a>')}
</section>
<section aria-label="Settings">
${secH('Settings', 'every setting, with where its value comes from')}
${settingsList(org, { business, settings, instances, orgCount: list ? list.length : null })}
</section>
</div>
</article>`;
}
// ---------- agents levels (sample 08) ----------
async function agentGrid(note = '') {
const [list] = await Promise.all([data.organizations().catch(() => null), ensureThemes()]);
const selected = window.MosaicOrg?.id;
const head = `<div class="ws-vh"><h1 tabindex="-1">Agents</h1><div class="ws-right"><a class="ws-btn" href="#/settings/agents" data-focus-key="agents-edit">${icon('settings')}Edit agents</a></div><p class="sub">Agents are defined at three levels: root, organization and project. Only organization agents can be read today.${note ? ` ${note}` : ''}</p></div>`;
if (!Array.isArray(list)) return `<section class="ws ws-agents" data-org-agents>${head}<p class="ws-panel tight note">Organizations unavailable.</p></section>`;
const valid = list.filter(org => !org.invalid);
const admitted = valid.some(org => org.id === selected);
const [results, business] = await Promise.all([
Promise.all(valid.map(org => Promise.all([data.instances(org.id).then(instancesOf).catch(() => null), data.agents(org.id).then(rowsOf).catch(() => null)]).then(([inst, agents]) => ({ org, inst, agents })))),
admitted ? data.business(selected).catch(() => null) : null,
]);
const band = `<div class="ws-span-band"><span class="ws-av all md" aria-hidden="true">${LOGO}</span><b>assistant</b><span class="ws-lvl root">root</span>${dot('s-unknown', '?', 'status not reported', true)}<span>Works across</span><span class="orgs">${valid.map(org => av(org)).join('')}</span><span class="muted ws-band-note">The assistant talks with you across organizations. Its status is not reported on this page; set it up under Settings, Assistant.</span></div>`;
const n = Math.max(valid.length, 1);
const cell = (org, inner, cls = '') => `<div class="ws-o${cls}" style="${colours(org)}" data-org="${esc(org.id)}"><span class="mlabel">${av(org, 'xs')}${esc(label(org))}</span>${inner}</div>`;
const pill = (name, item, agents) => { const cos = item?.definition === 'chief-of-staff' || name === 'chief'; const [acls, aglyph, aword] = activity(name, item, agents); return `<span class="ws-agent${cos ? ' cos' : ''}">${dot(acls, aglyph, aword)}${esc(name)}${cos && name !== 'chief' ? ' <small>chief</small>' : ''}</span>`; };
const orgRow = results.map(({ org, inst, agents }, i) => cell(org, inst === null ? '<span class="muted">Agents unavailable</span>' : Object.keys(inst).length ? Object.entries(inst).map(([k, v]) => pill(k, v, agents)).join('') : '<span class="muted">No agents configured</span>', i === results.length - 1 ? ' endc' : '')).join('');
const levels = valid.length ? `<div class="ws-levels" style="grid-template-columns:200px repeat(${n},minmax(0,1fr))">
<div class="lh hide-m"></div>${valid.map((org, i) => `<div class="oh hide-m ws-o${i === valid.length - 1 ? ' endc' : ''}" style="${colours(org)}">${av(org)}${esc(label(org))}</div>`).join('')}
<div class="lh"><span class="ws-lvl root">root</span><small>Your root configuration, shared by every organization.</small></div><div class="span endc"><span class="muted">Root agents are not available yet.</span></div>
<div class="lh"><span class="ws-lvl org">org</span><small>Defined in the organization file. Each organization's agents work only inside it.</small></div>${orgRow}
<div class="lh last"><span class="ws-lvl proj">project</span><small>Defined in one project and available only there.</small></div><div class="span endc last"><span class="muted">Project agents are not available yet.</span></div>
</div>` : '<p class="ws-panel tight note">No organizations yet.</p>';
const mine = results.find(r => r.org.id === selected);
const selOrg = list.find(o => o.id === selected) || { id: selected, label: selected };
const modelOf = inst => business?.business?.instances?.find(i => i?.instance === inst)?.vars?.model;
const table = !mine ? '<p class="ws-panel tight note">The selected organization is not on the list.</p>' : mine.inst === null ? '<p class="ws-panel tight note">Agents unavailable. The bus did not answer the instance read.</p>' : !Object.keys(mine.inst).length ? '<p class="ws-panel tight note">No agents configured</p>' : `<div class="ws-tbl"><table><thead><tr><th scope="col">Agent</th><th scope="col">Level</th><th scope="col" class="hide-m">Defined in</th><th scope="col">Status</th><th scope="col" class="hide-m">Model</th></tr></thead><tbody>${Object.entries(mine.inst).map(([k, v]) => { const [acls, aglyph, aword] = activity(k, v, mine.agents), model = modelOf(k); return `<tr><td class="t1">${esc(k)}${v?.definition && v.definition !== k ? `<span class="t2">${esc(v.definition)}</span>` : ''}</td><td><span class="ws-lvl org ws-o" style="${colours(selOrg)}">org</span></td><td class="hide-m">${esc(label(selOrg))} file</td><td>${dot(acls, aglyph, aword, true)}</td><td class="hide-m mono">${modelCell(k, model)}</td></tr>`; }).join('')}</tbody></table></div>`;
const rules = `<div class="ws-panel tight ws-rules"><p><span class="ws-lvl org">org</span>Agents come from the organization file. Add, change or remove them under Settings, Agents.</p><p><span class="ws-lvl proj">project</span><span class="ws-lvl root">root</span>Not built yet, so nothing can be defined at these levels.</p><p class="muted">An agent's harness, model and thinking are set under Settings, Routing.</p></div>`;
return `<section class="ws ws-agents" data-org-agents>${head}${band}${levels}
<div class="ws-split ws-split-agents"><section>${secH(`Agents in ${esc(label(selOrg))}`, 'organization level', '')}${table}</section><section>${secH('Who changes what')}${rules}</section></div>
</section>`;
}
window.MosaicWorkspace = Object.freeze({ dashboard, agentGrid, icon, refresh: () => refresh() });
refresh();
})();
+1 -1
View File
@@ -58,7 +58,7 @@ const files = new Map([
['/', ['index.html', 'text/html; charset=utf-8']],
['/proxy.js', ['proxy.js', 'text/javascript; charset=utf-8']],
['/project-remote.js', ['project-remote.js', 'text/javascript; charset=utf-8']],
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js', 'org-create.js', 'console-restart.js', 'onboarding.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js', 'org-create.js', 'console-restart.js', 'onboarding.js', 'workspace-data.js', 'workspace.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
['/files.js', ['files.js', 'text/javascript; charset=utf-8']],
['/projects.js', ['projects.js', 'text/javascript; charset=utf-8']],
['/projects.css', ['projects.css', 'text/css; charset=utf-8']],
+2 -2
View File
@@ -18,8 +18,8 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4');
await b.evaluate('document.querySelector("#hide-offline").click()');
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4);
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/bus.js', '/requests.js']);
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it. U2 (#1873) adds workspace-data.js and workspace.js before console-restart.js.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/workspace-data.js', '/workspace.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/bus.js', '/requests.js']);
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/processing.js"]').length`), 1);
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1);
assert.equal(await b.evaluate('!!window.injected'), false);
@@ -137,14 +137,15 @@ for (const [status, body] of [[201, { outcome: 'done', step: 'delete' }], [503,
});
}
test('review: deleting a non-current org refreshes the organization switcher', async t => {
// U2 (#1873): the sidebar's Organizations group replaced the organization switcher.
test('review: deleting a non-current org refreshes the sidebar organization list', async t => {
const f = await reviewFixture(t);
await f.confirm('beta');
assert.deepEqual((await registry.listOrgs(f.ctx)).map(e=>e.id), ['alpha']);
await f.b.evaluate(`location.hash='#/orgs/alpha/settings/business'`);
await f.wait(`!!document.querySelector('#org-picker')`);
assert.deepEqual(await f.b.evaluate(`[...document.querySelector('#org-picker').options].map(e=>e.value)`), ['alpha']);
assert.equal(await f.b.evaluate(`document.querySelector('#org-picker').value`), 'alpha');
await f.b.evaluate(`location.hash='#/orgs/alpha/organization'`);
await f.wait(`document.querySelectorAll('[data-workspace-org]').length === 1`);
assert.deepEqual(await f.b.evaluate(`[...document.querySelectorAll('[data-workspace-org]')].map(e=>e.dataset.workspaceOrg)`), ['alpha']);
assert.equal(await f.b.evaluate(`document.querySelector('[data-workspace-org="alpha"] a').getAttribute('aria-current')`), 'page');
assert.deepEqual(await f.b.evaluate(`window.MosaicOrg.list.then(rows => rows.map(row => row.id))`), ['alpha']);
});
@@ -60,15 +60,16 @@ for (const [width, height] of [[1400, 900], [390, 844]]) {
});
}
test('the organization picker offers New organization', { timeout: 60000 }, async t => {
// U2 (#1873): the sidebar's Organizations group replaced the organization picker.
test('the sidebar Organizations group offers New organization', { timeout: 60000 }, async t => {
const { base, b, wait, formFocused } = await consoleWith(t, ['alpha', 'beta']);
await b.viewport(1400, 900);
await b.navigate(`${base}#/orgs/alpha/settings/business`);
const link = `document.querySelector('section[aria-labelledby="org-picker-title"] a[href="#/settings/organizations?new=1"]')`;
await wait(`document.querySelector('#org-picker') && ${link}`, 'picker link');
assert.equal(await b.evaluate(`${link}.textContent`), 'New organization');
const link = `document.querySelector('#workspace-nav a.ws-add[href="#/settings/organizations?new=1"]')`;
await wait(`document.querySelectorAll('[data-workspace-org]').length === 2 && ${link}`, 'sidebar link');
assert.equal(await b.evaluate(`${link}.getAttribute('aria-label')`), 'New organization');
await b.evaluate(`${link}.click()`);
await formFocused('#/orgs/alpha', 'picker');
await formFocused('#/orgs/alpha', 'sidebar');
});
test('the no-organization empty state offers New organization next to its Settings link', { timeout: 60000 }, async t => {
@@ -128,7 +128,7 @@ test('project creation offers repositories from the scoped business read', async
assert.equal(await b.evaluate(`document.querySelector('#chat-repo option[value=work]').value`), 'work');
});
test('Settings lists organizations and switching changes the route and header', async t => {
test('sidebar lists organizations and navigating changes the route and header', async t => {
const organizations = {
list: async () => [{ id: 'alpha', label: 'Alpha', theme: null, busHealthy: true }, { id: 'beta', label: 'Beta', theme: null, busHealthy: false }],
resolve: async id => ['alpha', 'beta'].includes(id) ? {
@@ -141,11 +141,12 @@ test('Settings lists organizations and switching changes the route and header',
const b = await browser({ machine: false }); t.after(() => b.close());
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.querySelector('#org-picker'))return;await new Promise(r=>setTimeout(r,30))}throw Error('Organization picker did not appear')})()`);
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/);
assert.equal(await b.evaluate(`document.querySelector('#settings-page-title').textContent`), 'Organization');
assert.deepEqual(await b.evaluate(`[...document.querySelector('#org-picker').options].map(o=>o.value)`), ['alpha', 'beta']);
await b.evaluate(`(()=>{const e=document.querySelector('#org-picker');e.value='beta';e.dispatchEvent(new Event('change',{bubbles: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 */ }
@@ -178,12 +179,12 @@ test('a chat draft is kept on Stay and discarded on an organization switch', asy
assert.equal(await b.evaluate(`!!document.querySelector('#chat-session-form, #chat-share-form, #chat-clear')`), false, 'unmapped session and sharing controls are unavailable');
await b.evaluate(`(()=>{const el=document.querySelector('#chat-body');el.value='unsent alpha';el.dispatchEvent(new Event('input',{bubbles:true}))})()`);
await b.evaluate(`location.hash='#/settings/business'`);
await wait('!!document.querySelector("#org-picker")');
await b.evaluate(`(()=>{const el=document.querySelector('#org-picker');el.value='beta';el.dispatchEvent(new Event('change',{bubbles:true}))})()`);
await wait('!!document.querySelector("[data-workspace-org=beta] a")');
await b.evaluate(`document.querySelector('[data-workspace-org=beta] a').click()`);
await wait('!!document.querySelector("dialog[open] [data-stay]")');
await b.evaluate(`document.querySelector('[data-stay]').click()`);
assert.match(await b.evaluate('location.hash'), /^#\/orgs\/alpha\//);
assert.equal(await b.evaluate(`document.querySelector('#org-picker').value`), 'alpha');
assert.equal(await b.evaluate(`window.MosaicOrg.id`), 'alpha');
await b.evaluate(`location.hash='#/chat/threads/${thread}'`);
await wait('!!document.querySelector("#chat-body")');
assert.equal(await b.evaluate(`document.querySelector('#chat-body').value`), 'unsent alpha');
@@ -210,8 +211,16 @@ test('organization switch isolates browser board preferences', async t => {
const base = `http://127.0.0.1:${server.address().port}/`;
await b.navigate(base + '#/orgs/alpha/');
await b.evaluate(`document.querySelector('#show-machine').click()`);
await b.navigate(base + '#/orgs/beta/');
await b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(window.MosaicOrg?.id === 'beta')return;await new Promise(r=>setTimeout(r,30))}throw Error('beta did not load')})()`);
assert.equal(await b.evaluate(`document.querySelector('#show-machine').checked`), true);
// The switch reloads the page. The navigate helper's load checks can be in flight when that
// reload commits, so set the hash in the page and poll one short check at a time.
await b.evaluate(`location.hash = '#/orgs/beta/'`);
let loaded = false;
for (let i = 0; i < 150 && !loaded; i++) {
try { loaded = await b.evaluate(`window.MosaicOrg?.id === 'beta' && !!document.querySelector('#show-machine')`); } catch {}
if (!loaded) await new Promise(r => setTimeout(r, 30));
}
assert.equal(loaded, true, 'beta did not load');
assert.equal(await b.evaluate(`document.querySelector('#show-machine').checked`), false);
});
@@ -0,0 +1,373 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { createOrgAdmin } from '../src/org-admin.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
// Three organizations as sample 01 and 03 show them: two running buses and one stopped.
function workspaceOrgs({ failThreads = false, seen = null } = {}) {
const ORGS = [
{ id: 'ddigital', label: 'Distinctly Digital', theme: 'rosewood', busHealthy: true },
{ id: 'forge', label: 'Forge', theme: 'cobalt', busHealthy: true },
{ id: 'home', label: 'Home', theme: 'grove', busHealthy: false },
];
const PROJECTS = { ddigital: [{ id: 'spring-launch', name: 'Spring launch' }, { id: 'brand-refresh', name: 'Brand refresh' }], forge: [{ id: 'api-gateway', name: 'API gateway' }], home: [] };
const INSTANCES = { ddigital: { chief: ['chief-of-staff', ['r1']], copywriter: ['copywriter', ['r2']], 'montage-producer': ['producer', ['r3', 'r4']], editor: ['editor', []] }, forge: { chief: ['chief-of-staff', []] } };
const THREADS = { ddigital: [{ id: 't0', name: 'Kickoff notes', project: 'spring-launch', instance: 'chief' }, { id: 't1', name: 'V-112 Launch email, draft 2', project: 'spring-launch', instance: 'copywriter' }, { id: 't2', name: 'V-118 Teaser reel', project: 'spring-launch', instance: 'montage-producer' }, { id: 't3', name: 'Weekly plan', project: 'brand-refresh', instance: 'chief' }], forge: [] };
// The bus's derived state per held role: chief waits on the human, copywriter works, montage-producer
// reports none, so its activity is not known and it shows only as running.
const AGENTS = { ddigital: [{ role: 'chief', holder_run: 'r1', state: 'waiting' }, { role: 'copywriter', holder_run: 'r2', state: 'working' }], forge: [] };
const claim = (thread, holder_run) => ({ thread, holder_run, harness: 'claude', at: '2026-11-09T12:00:00.000Z' });
const CLAIMS = { ddigital: [claim('t3', 'r1'), claim('t1', 'r2'), claim('t2', 'r3')], forge: [] };
const FILES = { 'spring-launch': ['docs/north-star.md', 'docs/launch-plan.md', 'src/index.js'], 'brand-refresh': ['docs/brand-voice-guide.md'], 'api-gateway': [] };
const instances = id => ({ business: id, revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {},
instances: Object.fromEntries(Object.entries(INSTANCES[id]).map(([n, [definition, running]]) => [n, { definition, vars: {}, launched: true, launcher: false, protected: false, kept: false, running, alive: [] }])) });
const business = id => ({ business: { id, human: 'jason', arbiters: ['chief'], projects: PROJECTS[id].map(p => p.id), vars: id === 'ddigital' ? { 'tracker.kind': 'vikunja' } : {},
instances: Object.keys(INSTANCES[id]).map(n => ({ instance: n, vars: { model: 'sonnet-5.5' } })), appearance: { theme: ORGS.find(o => o.id === id).theme } } });
return {
list: async () => ORGS,
resolve: async id => {
seen?.push(id);
const org = ORGS.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), settings: async () => ({ release: '0.0.42', notifier: { binding: 'discord' }, launch: { limits: { total: 4 } } }),
queue: async () => ({ rows: id === 'ddigital' ? [{ state: 'done' }, { state: 'in-progress' }, { state: 'queued' }] : [] }), busStatus: async () => ({}) },
bus: { instances: async () => instances(id), messages: async () => ({ messages: [], unread: 0 }), agents: async () => AGENTS[id], 'thread.claims': async () => CLAIMS[id], inbox: async () => [], tasks: async () => [] },
chat: { projects: async () => PROJECTS[id], threads: async () => { if (failThreads) throw new Error('threads down'); return THREADS[id]; } },
projectFiles: async ({ project }) => ({ head: 'a'.repeat(40), files: (FILES[project] || []).map(path => ({ path })) }),
};
},
};
}
// No bus or setup work runs in these tests: every restart below is refused before the admin is used.
const refusingAdmin = () => createOrgAdmin({ service: {}, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) });
const SETTINGS = ['Identity', 'Appearance', 'System types', 'Projects', 'Agents and roles', 'Models and routing', 'Arbiters', 'Task tracker', 'Channels', 'Launch limits', 'Assistant', 'Token use', 'Model proxy', 'Console', 'Organizations', 'About', 'Members and invites', 'Bus', 'Archive or delete'];
const TAGS = ['org file', 'override', 'system', 'root', 'not built', 'danger'];
// Every page the Settings view has for the owner (bus.js settingsPages).
const SETTINGS_PAGES = ['appearance', 'business', 'limits', 'organizations', 'routing', 'agents', 'assistant', 'tokens', 'model-proxy', 'console', 'about'];
test('workspace navigation exposes all organizations and remembers project grouping', async t => {
const organizations = {
list: async () => [{ id:'alpha', label:'Alpha', theme:null, busHealthy:true }, { id:'beta', label:'Beta', theme:'cobalt', busHealthy:false }],
resolve: async id => ({ busHealthy:id === 'alpha', reads:{ business:async()=>({ business:{ id, projects:[] } }) }, bus:{}, chat:{projects:async()=>[],threads:async()=>[]} }),
};
const server = await startServer({port:0, sso:createDevOwner(), organizations, log:()=>{}}); t.after(()=>close(server));
const b=await browser({machine:false}); t.after(()=>b.close());
const wait = waitFor(b);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/`);
await wait('document.querySelectorAll("[data-workspace-org]").length === 2');
assert.match(await b.evaluate('document.querySelector("[data-workspace-org=beta]").textContent'), /Beta.*bus unavailable/is);
// An organization's colour is its theme's action colour; no theme is the harbor default.
assert.equal(await b.evaluate('document.querySelector("[data-workspace-org=alpha] .ws-av").style.getPropertyValue("--org-l")'), '#2266bf');
await b.evaluate('document.querySelector("[data-org-toggle]").click()');
assert.equal(await b.evaluate('document.querySelector("[data-org-list]").hidden'),true);
await b.evaluate('document.querySelector("[data-org-toggle]").click(); document.querySelector("[data-group=flat]").click()');
// The Projects group collapses too, and each group's open state is remembered (sample 01).
await b.evaluate('document.querySelector("[data-projects-toggle]").click()');
assert.deepEqual(await b.evaluate('[document.querySelector("#ws-project-list").hidden, document.querySelector("[data-projects-toggle]").getAttribute("aria-expanded"), document.activeElement?.dataset.focusKey]'), [true, 'false', 'projects-toggle']);
await b.evaluate('location.reload()');
// The reload repaints the sidebar in stages; wait for both lists before reading them.
await wait('document.querySelector("[data-group=flat]")?.getAttribute("aria-pressed") === "true" && !!document.querySelector("#ws-project-list") && !!document.querySelector("[data-org-list]")');
assert.deepEqual(await b.evaluate('[document.querySelector("#ws-project-list").hidden, document.querySelector("[data-org-list]").hidden]'), [true, false]);
await b.evaluate('document.querySelector("[data-projects-toggle]").click()');
assert.equal(await b.evaluate('document.querySelector("#ws-project-list").hidden'), false);
await b.viewport(390,844);
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'),true);
});
test('sidebar follows sample 01 and Queue opens from the organization dashboard, not the sidebar', async t => {
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: workspaceOrgs(), log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
const wait = waitFor(b);
await b.viewport(1440, 900);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/organization`);
await wait('document.querySelector("[data-org-dashboard] [data-org-settings]") && document.querySelectorAll("#workspace-nav .ws-proj").length === 3');
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#sections > li > a .t")].map(e => e.textContent)'), ['Dashboard', 'Inbox', 'Tasks', 'Projects', 'Side by side', 'Agents', 'Assistant']);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#sections-foot a")].map(e => e.textContent.trim())'), ['Settings']);
assert.match(await b.evaluate('document.querySelector("[data-org-toggle]").textContent.replace(/\\s+/g, " ").trim()'), /^Organizations\s*3$/);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-workspace-org]")].map(li => [li.dataset.workspaceOrg, !!li.querySelector(".ws-av"), [...li.querySelector(".ws-dot").classList].find(c => c.startsWith("s-"))])'),
[['ddigital', true, 's-working'], ['forge', true, 's-working'], ['home', true, 's-offline']]);
assert.equal(await b.evaluate('document.querySelector("#workspace-nav [data-group=org]").getAttribute("aria-pressed")'), 'true');
// The four-tile logo stays.
assert.equal(await b.evaluate('document.querySelectorAll(".wordmark .mosaic-logo rect").length'), 4);
// No organization switcher remains anywhere on the page.
assert.equal(await b.evaluate('document.querySelector("#org-picker, select[name=org], [data-org-switch]")'), null);
// Projects has no conversation tree under it; the Projects group links to each project page.
assert.equal(await b.evaluate('document.querySelector("#sections #project-tree, #sections .project-tree-list")'), null);
assert.match(await b.evaluate('document.querySelector("#workspace-nav .ws-proj").getAttribute("href")'), /^#\/orgs\/[a-z0-9-]+\/projects\/[^/]+$/);
// Queue and Docs are not sidebar items.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#console-nav a[href]")].filter(a => /\\/(queue|docs)$/.test(a.getAttribute("href"))).length'), 0);
// Settings stays pinned to the foot of the sidebar.
assert.equal(await b.evaluate('(() => { const nav = document.getElementById("console-nav").getBoundingClientRect(), foot = document.getElementById("sections-foot").getBoundingClientRect(); return foot.bottom >= nav.bottom - 24; })()'), true);
const queue = await b.evaluate('document.querySelector("[data-org-dashboard] [data-org-queue]").getAttribute("href")');
assert.equal(queue, '#/queue');
await b.evaluate('document.querySelector("[data-org-queue]").click()');
await wait('location.hash === "#/orgs/ddigital/queue" && [...document.querySelectorAll("#bus-view h1")].some(h => h.textContent.trim() === "Queue")');
// Views not built yet open and say so.
for (const [route, title] of [['inbox', 'Inbox'], ['tasks', 'Tasks'], ['side-by-side', 'Side by side']]) {
await b.evaluate(`location.hash = "#/orgs/ddigital/${route}"`);
await wait(`document.getElementById("bus-view").innerText.includes("not built yet") && [...document.querySelectorAll("#bus-view h1")].some(h => h.textContent.trim() === ${JSON.stringify(title)})`);
assert.doesNotMatch(await b.evaluate('document.getElementById("bus-view").innerText'), /error|failed/i, route);
}
});
test('organization dashboard renders every section from the per-organization reads', async t => {
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations: workspaceOrgs(), log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
const wait = waitFor(b);
await b.viewport(1440, 900);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/organization`);
await wait('document.querySelectorAll("[data-org-dashboard] .ws-doc").length > 0');
const head = await b.evaluate(`(() => { const h = document.querySelector('[data-org-dashboard] .ws-orghead'); return { avatar: h.querySelector('.ws-av')?.textContent, title: h.querySelector('h1').textContent, meta: h.querySelector('.ws-meta').innerText, colour: h.style.getPropertyValue('--org-l') }; })()`);
assert.equal(head.avatar, 'DD'); assert.equal(head.title, 'Distinctly Digital');
assert.match(head.meta, /Kind not recorded/); assert.match(head.meta, /id ddigital/); assert.match(head.meta, /Tracker: Vikunja/); assert.match(head.meta, /bus running, read \d/);
assert.match(head.colour, /^#[0-9a-f]{6}$/i);
assert.match(await b.evaluate('document.querySelector(".ws-typechips").innerText'), /System types\s+No system types yet/i);
// Docs come from the project files reads: only documents, named by project.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".ws-doc")].map(a => a.innerText.replace(/\\s+/g, " ").trim())'), ['North star Spring launch', 'Launch plan Spring launch', 'Brand voice guide Brand refresh']);
// KPIs and metrics: one honest empty tile each, with no invented number.
const tiles = await b.evaluate('[...document.querySelectorAll(".ws-tiles")].map(t => [t.querySelectorAll(".ws-tile").length, t.querySelector(".ws-tile").classList.contains("is-empty"), t.innerText.replace(/\\s+/g, " ").trim()])');
assert.deepEqual(tiles, [[1, true, 'No KPIs defined in the organization file'], [1, true, 'No metrics recorded yet']]);
// Active agent sessions: the running instances, the thread their live run holds (chief's older
// Kickoff notes is not it), the model with its Change link, and the state the bus reports. With no
// reported state (montage-producer) the row says only running.
const rows = await b.evaluate('[...document.querySelectorAll(".ws-tbl tbody tr")].map(tr => [...tr.cells].map(c => c.innerText.replace(/\\s+/g, " ").trim()))');
assert.deepEqual(rows, [
['chief', 'Brand refresh Weekly plan', 'sonnet-5.5 Change', '◐ waiting on you'],
['copywriter', 'Spring launch V-112 Launch email, draft 2', 'sonnet-5.5 Change', '● working'],
['montage-producer', 'Spring launch V-118 Teaser reel', 'sonnet-5.5 Change', '● running 2 sessions'],
]);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".ws-tbl a.ws-change")].map(a => [a.getAttribute("href"), a.getAttribute("aria-label")])'),
['chief', 'copywriter', 'montage-producer'].map(n => ['#/settings/routing', `Change the model for ${n}`]));
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".ws-tbl thead th")].map(th => th.textContent)'), ['Agent', 'Project and task', 'Model', 'Status']);
assert.match(await b.evaluate('document.querySelector("[data-org-dashboard]").innerText'), /Queue\s+3 rows, 2 not done\s+Open queue/);
// Settings: one row per setting, each with an icon, a value, a source tag and, where a page exists, Open.
const settings = await b.evaluate(`[...document.querySelectorAll('[data-org-settings] > li')].map(li => ({ name: li.dataset.orgSetting, icon: !!li.querySelector('svg'), value: li.querySelector('.v').textContent, tag: li.querySelector('.src-tag').textContent, href: li.querySelector('a.go')?.getAttribute('href') ?? null, danger: li.classList.contains('danger') }))`);
assert.deepEqual(settings.map(s => s.name), SETTINGS);
for (const s of settings) { assert.ok(s.icon, s.name); assert.ok(s.value, s.name); assert.ok(TAGS.includes(s.tag), `${s.name}: ${s.tag}`); }
const by = Object.fromEntries(settings.map(s => [s.name, s]));
assert.deepEqual([by['Members and invites'].tag, by['Members and invites'].href], ['not built', null]);
assert.deepEqual([by['System types'].tag, by['System types'].href], ['not built', null]);
assert.deepEqual([by['Archive or delete'].tag, by['Archive or delete'].danger, by['Archive or delete'].href], ['danger', true, '#/settings/organizations']);
assert.equal(by.Identity.value, 'Distinctly Digital · ddigital · kind not recorded');
assert.equal(by['Task tracker'].value, 'Vikunja');
assert.equal(by['Launch limits'].value, '4 sessions at once');
assert.equal(by['Agents and roles'].value, '4 agents · 3 running');
for (const page of SETTINGS_PAGES) assert.ok(settings.some(s => s.href === `#/settings/${page}`), `no row opens settings/${page}`);
});
test('organization dashboard empty states say so in one short line', async t => {
const organizations = { list: async () => [{ id: 'alpha', label: 'Alpha', busHealthy: true }], resolve: async () => ({ busHealthy: true,
reads: { business: async () => ({ business: { id: 'alpha', projects: [] } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
bus: { instances: async () => ({ business: 'alpha', revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {}, instances: {} }), messages: async () => ({ messages: [], unread: 0 }), agents: async () => [] },
chat: { projects: async () => [], threads: async () => [] } }) };
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/organization`);
await waitFor(b)('document.querySelector("[data-org-dashboard] [data-org-settings]")');
const text = await b.evaluate('document.querySelector("[data-org-dashboard]").innerText');
// Section labels are uppercased by CSS, so innerText carries the uppercase form.
for (const line of [/System types\s+No system types yet\. They arrive with the system types update\./i, /Docs\s+No docs yet/i, /No KPIs defined in the organization file/, /No metrics recorded yet/, /Active agent sessions\s+none running/i, /No active agent sessions/, /Queue\s+no rows/i, /Tracker: not set/])
assert.match(text, line);
assert.equal(await b.evaluate('document.querySelectorAll("[data-org-settings] > li").length'), SETTINGS.length);
});
test('organization dashboard presents sections and honest unavailable controls', async t => {
const organizations = { list:async()=>[{id:'alpha',label:'Alpha',busHealthy:false}], resolve:async()=>({busHealthy:false,reads:{business:async()=>({business:{id:'alpha',projects:[]}})},bus:{},chat:{projects:async()=>[],threads:async()=>[]}}) };
const server=await startServer({port:0,sso:createDevOwner(),organizations,log:()=>{}}); t.after(()=>close(server));
const b=await browser({machine:false}); t.after(()=>b.close());
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/organization`);
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('[data-org-dashboard]'))return;await new Promise(r=>setTimeout(r,30))}throw Error('dashboard missing')})()`);
const text=await b.evaluate('document.querySelector("[data-org-dashboard]").innerText');
for(const label of ['Docs','KPIs','Metrics','Active agent sessions','Settings','Queue']) assert.match(text,new RegExp(label,'i'));
assert.match(text,/reload organization file/i);
assert.match(text,/no metrics recorded yet/i);
assert.match(text,/bus unavailable, read \d/);
assert.match(text,/Sessions unavailable/);
assert.equal(await b.evaluate('document.querySelector("[data-org-reload]").disabled'),true);
});
test('each header control is present, reload is disabled, and restart is refused without the owner', async t => {
const owner = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations: workspaceOrgs(), orgAdmin: refusingAdmin(), log: () => {} }); t.after(() => close(owner));
const readOnly = await startServer({ port: 0, sso: createDevOwner(), organizations: workspaceOrgs(), orgAdmin: refusingAdmin(), log: () => {} }); t.after(() => close(readOnly));
const signedOut = await startServer({ port: 0, organizations: workspaceOrgs(), orgAdmin: refusingAdmin(), log: () => {} }); t.after(() => close(signedOut));
const b = await browser({ machine: false }); t.after(() => b.close());
const wait = waitFor(b);
const header = server => b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/organization`).then(() => wait('document.querySelector("[data-org-dashboard] [data-org-settings]")')).then(() => b.evaluate(`(() => {
const h = document.querySelector('[data-org-dashboard] .ws-orghead'), live = h.querySelector('[data-bus-restart]'), off = h.querySelector('[data-bus-restart-off]'), reload = h.querySelector('[data-org-reload]');
return { live: live ? [live.textContent, live.disabled] : null, off: off ? [off.textContent, off.disabled, !!off.title] : null,
reload: [reload.textContent, reload.disabled, reload.title, document.getElementById(reload.getAttribute('aria-describedby'))?.textContent] };
})()`));
const RELOAD = ['Reload organization file', true, 'Reload arrives with the cross-org update', 'Restart is a trailed human action; reload arrives with the cross-org update.'];
// The owner on a Console started with --send gets console-restart.js's restart button.
assert.deepEqual(await header(owner), { live: ['Restart bus', false], off: null, reload: RELOAD });
// Without --send, or with nobody signed in, the control is present and disabled, and the route refuses.
const post = 'fetch("/api/orgs/ddigital/restart/bus", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }).then(async r => [r.status, (await r.json()).error])';
assert.deepEqual(await header(readOnly), { live: null, off: ['Restart bus', true, true], reload: RELOAD });
assert.deepEqual(await b.evaluate(post), [403, 'read-only']);
// With nobody signed in the organization list is not readable, so the dashboard reads nothing and
// shows no controls at all.
await b.navigate(`http://127.0.0.1:${signedOut.address().port}/#/orgs/ddigital/organization`);
await wait('document.querySelector("[data-org-unavailable]")');
assert.equal(await b.evaluate('document.querySelector("[data-bus-restart], [data-bus-restart-off], [data-org-reload]")'), null);
// With nobody signed in, the server refuses every per-org route before the admin hooks run.
assert.deepEqual(await b.evaluate(post), [404, 'org-unavailable']);
// There is no reload route.
assert.equal(await b.evaluate('fetch("/api/orgs/ddigital/reload", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }).then(r => r.ok)'), false);
});
// Tab from the first sidebar link through the dashboard and return each stop's region and box.
async function tabStops(b, start) {
await b.evaluate(`(${start}).focus()`);
const stops = [];
for (let i = 0; i < 90; i++) {
const at = await b.evaluate(`(() => { const a = document.activeElement, sec = a.closest('[data-org-dashboard] section'); return { i: [...document.querySelectorAll('*')].indexOf(a), region: a.closest('#console-nav') ? 'nav' : sec ? sec.getAttribute('aria-label') : a.closest('[data-org-dashboard]') ? 'head' : 'other', key: a.dataset.focusKey || a.getAttribute('href') || a.textContent.trim() }; })()`);
if (stops.length && at.region === 'other' && stops.at(-1).region !== 'other') break;
stops.push(at);
await b.key('Tab');
}
const boxes = await b.evaluate(`(${JSON.stringify(stops.map(s => s.i))}).map(i => { const r = document.querySelectorAll('*')[i].getBoundingClientRect(); return { top: r.top, bottom: r.bottom, left: r.left }; })`);
return stops.map((s, n) => ({ ...s, ...boxes[n] }));
}
function assertVisualOrder(stops, regions, label) {
for (let n = 1; n < stops.length; n++) {
const a = stops[n - 1], z = stops[n], where = `${label}: ${a.key} then ${z.key}`;
assert.ok(z.i > a.i, `tab order leaves DOM order at ${where}`);
assert.ok(regions.indexOf(z.region) >= regions.indexOf(a.region), `region order at ${where}`);
if (z.region !== a.region) continue;
const sameRow = z.top < a.bottom && z.bottom > a.top;
assert.ok(sameRow ? z.left > a.left : z.top >= a.top, `visual order at ${where}`);
}
}
test('keyboard focus follows visual order and survives a data refresh', async t => {
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations: workspaceOrgs(), log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
const wait = waitFor(b);
await b.viewport(1440, 900);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/organization`);
await wait('document.querySelectorAll("[data-org-dashboard] .ws-doc").length > 0 && document.querySelectorAll("#workspace-nav .ws-proj").length === 3');
const wide = await tabStops(b, 'document.querySelector("#sections a")');
const regions = ['nav', 'head', 'Measures and sessions', 'Settings'];
assert.deepEqual([...new Set(wide.map(s => s.region))].filter(r => r !== 'other'), regions);
assertVisualOrder(wide.filter(s => s.region !== 'other'), regions, '1440');
const keys = wide.map(s => s.key);
for (const key of ['org:forge', 'proj:ddigital:spring-launch', 'doc:spring-launch:docs/north-star.md', 'docs-all', 'agents-all', 'queue-open', 'set:Identity', 'set:Archive or delete']) assert.ok(keys.includes(key), `Tab never reaches ${key}`);
// The disabled reload is not a tab stop; the restart button is.
assert.ok(!keys.includes('org-reload')); assert.ok(keys.includes('Restart bus'));
await b.viewport(390, 844);
await wait('document.querySelector("[data-org-dashboard]")');
const narrow = (await tabStops(b, 'document.querySelector("[data-org-dashboard] .ws-crumb a")')).filter(s => s.region !== 'other');
assertVisualOrder(narrow, regions.slice(1), '390');
for (let n = 1; n < narrow.length; n++) assert.ok(narrow[n].top >= narrow[n - 1].top - 1 || narrow[n].region === narrow[n - 1].region, `stacked order at 390: ${narrow[n - 1].key} then ${narrow[n].key}`);
await b.viewport(1440, 900);
// A refresh repaints the dashboard, the sidebar and the restart button; focus stays on the same control.
for (const key of ['set:Projects', 'queue-open', 'doc:spring-launch:docs/launch-plan.md']) {
await wait(`document.querySelector('[data-focus-key="${key}"]')`);
await b.evaluate(`window.oldDashboard = document.querySelector('[data-org-dashboard]'); document.querySelector('[data-focus-key="${key}"]').focus(); document.getElementById('refresh').click()`);
await wait('document.querySelector("[data-org-dashboard]") && document.querySelector("[data-org-dashboard]") !== window.oldDashboard');
assert.equal(await b.evaluate('document.activeElement.dataset.focusKey'), key);
}
await b.evaluate(`window.oldDashboard = document.querySelector('[data-org-dashboard]'); document.querySelector('[data-bus-restart]').focus(); document.getElementById('refresh').click()`);
await wait('document.querySelector("[data-org-dashboard]") !== window.oldDashboard');
assert.equal(await b.evaluate('document.activeElement.hasAttribute("data-bus-restart")'), true);
await b.evaluate(`window.oldRow = document.querySelector('[data-workspace-org=forge]'); document.querySelector('[data-focus-key="org:forge"]').focus(); window.MosaicOrg.refreshList()`);
await wait('document.querySelector("[data-workspace-org=forge]") !== window.oldRow');
assert.equal(await b.evaluate('document.activeElement.dataset.focusKey'), 'org:forge');
});
test('phone drawer opens over the dashboard with no horizontal scroll', async t => {
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: workspaceOrgs(), log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
const wait = waitFor(b);
await b.viewport(390, 844);
for (const route of ['organization', 'agents']) {
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/${route}`);
await wait(route === 'organization' ? 'document.querySelector("[data-org-dashboard] [data-org-settings]")' : 'document.querySelector("[data-org-agents] .ws-levels")');
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, route);
}
assert.equal(await b.evaluate('getComputedStyle(document.getElementById("console-nav")).visibility === "hidden" || document.getElementById("console-nav").getBoundingClientRect().right <= 0'), true);
await b.evaluate('document.getElementById("nav-open").click()');
await wait('document.getElementById("nav-open").getAttribute("aria-expanded") === "true"');
await wait('document.querySelectorAll("[data-workspace-org]").length === 3');
const drawer = await b.evaluate('(() => { const r = document.getElementById("console-nav").getBoundingClientRect(); return { left: r.left, right: r.right, sw: document.documentElement.scrollWidth, cw: document.documentElement.clientWidth }; })()');
assert.ok(drawer.left >= 0 && drawer.right <= 390 && drawer.right > 200, JSON.stringify(drawer));
assert.ok(drawer.sw <= drawer.cw, JSON.stringify(drawer));
// The open drawer itself does not scroll sideways; the only clipped children are screen-reader text.
assert.deepEqual(await b.evaluate('(() => { const nav = document.getElementById("console-nav"); return [nav.scrollWidth <= nav.clientWidth, [...nav.querySelectorAll("*")].filter(e => e.scrollWidth > e.clientWidth + 1 && getComputedStyle(e).overflowX !== "visible" && !e.classList.contains("ws-sr")).map(e => e.className)]; })()'), [true, []]);
});
test('agents view includes organization-scoped instances without inventing root or project agents', async t => {
const organizations={list:async()=>[{id:'alpha',label:'Alpha',busHealthy:true},{id:'beta',label:'Beta',busHealthy:false}],resolve:async id=>({busHealthy:id==='alpha',reads:{business:async()=>({business:{id}})},bus:{messages:async()=>({messages:[],unread:0}),agents:async()=>[],instances:async()=>({business:id,revision:'a'.repeat(64),launch:true,noHire:[],retired:[],instances:{chief:{definition:'chief-of-staff',vars:{},launched:true,launcher:false,protected:false,kept:false,running:['run-1'],alive:[]}},definitions:{'chief-of-staff':{services:[],accepted:true}}})},chat:{threads:async()=>[],projects:async()=>[]}})};
const server=await startServer({port:0,sso:createDevOwner(),organizations,log:()=>{}});t.after(()=>close(server));
const b=await browser({machine:false});t.after(()=>b.close());
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/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,/Root agents are not available yet/); assert.match(text,/Project agents are not available yet/);
});
test('an unavailable selected bus does not hide other organizations on Agents', async t => {
const organizations={list:async()=>[{id:'alpha',label:'Alpha',busHealthy:true},{id:'beta',label:'Beta',busHealthy:false}],resolve:async id=>({busHealthy:id==='alpha',reads:{business:async()=>({business:{id}})},bus:id==='alpha'?{agents:async()=>[],messages:async()=>({messages:[],unread:0}),instances:async()=>({business:id,revision:'a'.repeat(64),launch:true,noHire:[],retired:[],instances:{chief:{definition:'chief-of-staff',vars:{},launched:true,launcher:false,protected:false,kept:false,running:['run-1'],alive:[]}},definitions:{'chief-of-staff':{services:[],accepted:true}}})}:{},chat:{threads:async()=>[],projects:async()=>[]}})};
const server=await startServer({port:0,sso:createDevOwner(),organizations,log:()=>{}});t.after(()=>close(server));
const b=await browser({machine:false});t.after(()=>b.close());
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/);
});
test('a failed conversation read says unavailable, never that none exists', async t => {
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations: workspaceOrgs({ failThreads: true }), log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(1440, 900);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/organization`);
await waitFor(b)('document.querySelectorAll("[data-org-dashboard] .ws-tbl tbody tr").length === 3');
const where = await b.evaluate('[...document.querySelectorAll(".ws-tbl tbody tr")].map(tr => tr.cells[1].innerText.trim())');
assert.deepEqual(where, ['Conversation unavailable', 'Conversation unavailable', 'Conversation unavailable']);
assert.doesNotMatch(await b.evaluate('document.querySelector("[data-org-dashboard]").innerText'), /No conversation/);
});
test('an organization missing from the list, or an unreadable list, is never read', async t => {
const seen = [];
const orgs = workspaceOrgs({ seen });
for (const [label, organizations, message] of [['not listed', orgs, /not on your list/], ['list fails', { ...orgs, list: async () => { throw new Error('list down'); } }, /could not be read/]]) {
seen.length = 0;
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations, log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
const target = label === 'not listed' ? 'denied' : 'ddigital';
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/${target}/organization`);
await waitFor(b)('document.querySelector("[data-org-unavailable]")');
const view = await b.evaluate('document.querySelector("[data-org-unavailable]").innerText');
assert.match(view, /Organization unavailable/, label); assert.match(view, message, label);
// None of the dashboard's per-organization reads went out for the target.
const asked = await b.evaluate(`performance.getEntriesByType('resource').map(e => new URL(e.name).pathname).filter(p => p.startsWith('/api/orgs/${target}/'))`);
// The app shell's own boot (app.js, unchanged here) reads /business once; the dashboard adds none.
const dashboardReads = /\/(settings|queue|chat\/threads|chat\/projects|bus\/instances|bus\/agents|bus\/thread\.claims)$/;
assert.deepEqual(asked.filter(p => dashboardReads.test(p)), [], `${label}: ${asked.join(' ')}`);
assert.ok(asked.filter(p => p.endsWith('/business')).length <= 1, `${label}: ${asked.join(' ')}`);
}
});
test('agents grid shows the reported state and a model Change link', async t => {
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations: workspaceOrgs(), log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(1440, 900);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/agents`);
await waitFor(b)('document.querySelectorAll("[data-org-agents] .ws-tbl tbody tr").length === 4');
const rows = await b.evaluate('[...document.querySelectorAll("[data-org-agents] .ws-tbl tbody tr")].map(tr => [tr.cells[0].innerText.split("\\n")[0].trim(), tr.cells[3].innerText.replace(/\\s+/g, " ").trim(), tr.cells[4].innerText.replace(/\\s+/g, " ").trim()])');
assert.deepEqual(rows, [['chief', '◐ waiting on you', 'sonnet-5.5 Change'], ['copywriter', '● working', 'sonnet-5.5 Change'], ['montage-producer', '● running', 'sonnet-5.5 Change'], ['editor', '– not running', 'sonnet-5.5 Change']]);
const pills = await b.evaluate('[...document.querySelectorAll("[data-org=ddigital] .ws-agent")].map(p => p.querySelector(".ws-dot").title)');
assert.deepEqual(pills, ['waiting on you', 'working', 'running', 'not running']);
// Keyboard reaches the Change links.
const label = await b.evaluate('(() => { const a = document.querySelector("[data-org-agents] a.ws-change"); a.focus(); return document.activeElement === a && a.getAttribute("aria-label"); })()');
assert.equal(label, 'Change the model for chief');
});