feat(webui): one shell, a system dashboard, organization pages as drill-ins (#1873) #1965

Merged
jason.woltje merged 3 commits from sp1-shell into refactor 2026-11-15 05:56:38 +00:00
16 changed files with 741 additions and 182 deletions

No files matched your search

+34 -15
View File
@@ -76,8 +76,11 @@
if (!usable() || busy || pending || !view) return;
const [url, payload] = plan(action, name);
busy = true; confirming = null; receipt = ''; redraw();
const gen = generation;
let answer;
try { answer = await post(url, payload); } catch { answer = { ok: false, result: { sent: 'unknown' } }; }
// #1873 SP1: an organization switch while this was in flight drops the answer; the switch already cleared busy.
if (gen !== generation) return;
busy = false;
if (answer === null) return;
const { ok, result } = answer;
@@ -122,8 +125,10 @@
const instance = create.instance.trim(), definition = create.definition;
if (!instance || !definition) { receipt = 'Name the instance and choose a definition.'; redraw(); return; }
busy = true; receipt = ''; redraw();
const gen = generation;
let answer;
try { answer = await post('/api/instances', { revision: view.revision, instance, op: 'add', definition }); } catch { answer = { ok: false, result: { sent: 'unknown' } }; }
if (gen !== generation) return;
busy = false;
if (answer === null) return;
if (answer.ok && /^[a-f0-9]{64}$/.test(answer.result?.revision ?? '')) { create = { instance: '', definition: '' }; receipt = `Created ${instance}. ${answer.result.restart ? restartText() : NO_RESTART_TEXT}`; reread(); }
@@ -154,19 +159,31 @@
document.addEventListener('submit', e => { if (e.target?.id !== 'agent-create-form') return; e.preventDefault(); createAgent(); });
// A sign-out stops any poll; nothing that was asked is retried.
document.addEventListener('mosaic-signed-out', () => { generation++; pending = null; confirming = null; });
// #1873 SP1: opening another organization stops any poll and clears what this section held for the
// old one. A request already sent finishes on the old organization's bus; its page reads it again there.
document.addEventListener('mosaic-org-switch', () => {
generation++; pending = null; receipt = ''; editing = null; confirming = null; busy = false;
create = { instance: '', definition: '' };
for (const k of Object.keys(reasons)) delete reasons[k];
});
// F3: restart an agent so a route change reaches it now, offered by the Routing receipt. One
// terminate; then reads of the instances until the run has left (up to the 60 s regenerate
// allows); then one launch. A thread definition is not launched: its next message starts it.
// A refusal, a sign-out or an unknown outcome stops the chain and says so; nothing is retried.
const restarts = Object.create(null);
let restarts = Object.create(null);
// #1873 SP1: the chain's reads and writes go to the organization it started in, which the fetch
// wrapper passes through unchanged. A switch clears what the new page shows; the old chain keeps
// its own entry and stops painting.
const pin = (org, path) => org ? `/api/orgs/${encodeURIComponent(org)}/${path.slice('/api/'.length)}` : path;
document.addEventListener('mosaic-org-switch', () => { restarts = Object.create(null); });
const RESTART_WAIT = 30;
function restartMarkup(name, thread = false) {
const r = restarts[name];
return `<span class="agent-restart"><button type="button" class="btn" data-agent-restart="${esc(name)}" data-agent-thread="${thread ? 'yes' : 'no'}"${r?.busy ? ' disabled' : ''}>${r?.busy ? 'Restarting…' : `Restart ${esc(name)} now`}</button> <span role="status" data-agent-restart-status="${esc(name)}">${esc(r?.text ?? '')}</span></span>`;
}
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
const paintRestart = name => document.querySelectorAll('.agent-restart').forEach(el => {
const paintRestart = (name, org = window.MosaicOrg?.id ?? null) => (window.MosaicOrg?.id ?? null) !== org ? undefined : document.querySelectorAll('.agent-restart').forEach(el => {
const b = el.querySelector('[data-agent-restart]');
if (b?.dataset.agentRestart === name) el.outerHTML = restartMarkup(name, b.dataset.agentThread === 'yes');
});
@@ -179,45 +196,47 @@
return { ok: res.ok, result };
}
// The request's closing read, or a reason the chain stops.
async function closing(request) {
async function closing(request, org) {
for (let tries = 0; tries < POLL_LIMIT; tries++) {
await new Promise(r => setTimeout(r, POLL_MS));
let res, r = null;
try { res = await fetch(`/api/agent/request?id=${encodeURIComponent(request)}`, { cache: 'no-store', redirect: 'manual' }); } catch { continue; }
try { res = await fetch(pin(org, `/api/agent/request?id=${encodeURIComponent(request)}`), { cache: 'no-store', redirect: 'manual' }); } catch { continue; }
if (res.type === 'opaqueredirect') return { stop: 'Sign in again, then read the agents.' };
try { r = await res.json(); } catch { continue; }
if (res.ok && r?.request === request && r.state === 'closed') return { done: r };
}
return { stop: `Request ${request} is still open. Read the agents again later.` };
}
async function runs(name) {
async function runs(name, org) {
try {
const res = await fetch('/api/bus/instances', { cache: 'no-store', redirect: 'manual' });
const res = await fetch(pin(org, '/api/bus/instances'), { cache: 'no-store', redirect: 'manual' });
if (!res.ok || res.type === 'opaqueredirect') return null;
const e = (await res.json())?.rows?.[0]?.instances?.[name];
return e ? (Array.isArray(e.running) ? e.running.length : 0) : 0;
} catch { return null; }
}
async function restartAgent(name, thread) {
const org = window.MosaicOrg?.id ?? null;
const r = restarts[name] = { busy: true, text: `Terminating ${name}…` };
const stop = text => { r.busy = false; r.text = text; paintRestart(name); };
paintRestart(name);
const asked = await send('/api/agent/terminate', { instance: name, reason: 'route change' });
const paint = () => paintRestart(name, org);
const stop = text => { r.busy = false; r.text = text; paint(); };
paint();
const asked = await send(pin(org, '/api/agent/terminate'), { instance: name, reason: 'route change' });
if (asked.signedOut) return stop('Sign in again, then retry.');
if (asked.unknown || !asked.ok || !REQUEST_ID.test(asked.result?.request ?? '')) return stop(asked.ok || asked.unknown ? UNKNOWN : refused(asked.result));
const ended = await closing(asked.result.request);
const ended = await closing(asked.result.request, org);
if (ended.stop) return stop(ended.stop);
if (ended.done.reason) return stop(`${name}: ${ended.done.reason}. It was not restarted.`);
r.text = `Terminated ${name}. Waiting for its session to end…`; paintRestart(name);
r.text = `Terminated ${name}. Waiting for its session to end…`; paint();
let left = false;
for (let i = 0; i < RESTART_WAIT && !left; i++) { await new Promise(res => setTimeout(res, POLL_MS)); left = (await runs(name)) === 0; }
for (let i = 0; i < RESTART_WAIT && !left; i++) { await new Promise(res => setTimeout(res, POLL_MS)); left = (await runs(name, org)) === 0; }
if (!left) return stop(`${name} was asked to end but its session is still running. Launch it from Agents once it has ended.`);
if (thread) return stop(`Terminated ${name} at ${new Date().toLocaleTimeString()}. Its next message starts it on the new route.`);
r.text = `Launching ${name}…`; paintRestart(name);
const launch = await send('/api/agent/launch', { instance: name });
r.text = `Launching ${name}…`; paint();
const launch = await send(pin(org, '/api/agent/launch'), { instance: name });
if (launch.signedOut) return stop('Sign in again. It was terminated and not launched.');
if (launch.unknown || !launch.ok || !REQUEST_ID.test(launch.result?.request ?? '')) return stop(launch.ok || launch.unknown ? `${UNKNOWN} It was terminated.` : `${refused(launch.result)} It was terminated and not launched.`);
const started = await closing(launch.result.request);
const started = await closing(launch.result.request, org);
if (started.stop) return stop(started.stop);
if (started.done.reason) return stop(`${name}: ${started.done.reason}. It was terminated and not launched.`);
return stop(`Restarted ${name} on the new route${started.done.done?.run ? ` (run ${started.done.done.run})` : ''}.`);
+39 -12
View File
@@ -11,8 +11,10 @@
// Show machine sessions (#1589): off by default, kept with the page's settings. While it is off the
// page doesn't read /api/board at all; the held roles above come from the bus (bus.js).
let machine = false;
const boardKey = window.MosaicOrg ? `mosaic-console-board:${window.MosaicOrg.id}` : 'mosaic-console-board';
try { machine = JSON.parse(localStorage.getItem(boardKey))?.machine === true; } catch {}
// #1873 SP1: kept per organization, read again when the open organization changes.
const boardKey = () => window.MosaicOrg?.id ? `mosaic-console-board:${window.MosaicOrg.id}` : 'mosaic-console-board';
const readMachine = () => { try { return JSON.parse(localStorage.getItem(boardKey()))?.machine === true; } catch { return false; } };
machine = readMachine();
// Signed out (#1589): a Console fetch answered with a redirect, so the sign-in proxy turned it away.
// The first one sets body.signed-out (the listener below); after that no Console fetch is made.
const out = () => document.body.classList.contains('signed-out');
@@ -151,6 +153,16 @@
override = readOverride();
paint();
document.dispatchEvent(new CustomEvent('mosaic-theme-change', {detail: {palette: current.theme, mode: current.mode, stored: true, warning: null, picked: current.theme}}));
// The open organization's saved appearance, read again when another organization opens (#1873 SP1).
const readBusiness = async () => {
try {
const { ok, body } = await getJSON('/api/business');
if (ok && orgId(body?.business?.id)) return window.MosaicAppearance.organization(body.business.id, body.business.appearance, body.business.id);
} catch (err) { if (err?.signedOut) return; }
// No fresh answer: keep the organization this browser last showed for its own override, but not its stale saved value.
if (org && !org.confirmed) { org.saved = null; showAppearance(); }
};
document.addEventListener('mosaic-org-switch', () => { readBusiness(); });
window.MosaicAppearance.ready = (async () => {
await Promise.allSettled([
(async () => {
@@ -161,14 +173,7 @@
} catch (err) { if (err?.signedOut) return; themes = []; themesUnavailable = true; }
showAppearance();
})(),
(async () => {
try {
const { ok, body } = await getJSON('/api/business');
if (ok && orgId(body?.business?.id)) return window.MosaicAppearance.organization(body.business.id, body.business.appearance, body.business.id);
} catch (err) { if (err?.signedOut) return; }
// No fresh answer: keep the organization this browser last showed for its own override, but not its stale saved value.
if (org && !org.confirmed) { org.saved = null; showAppearance(); }
})(),
readBusiness(),
]);
})();
$('palette').onchange = e => window.MosaicAppearance.set({ theme: e.target.value });
@@ -488,12 +493,18 @@
const box = $('bus-down');
// E2E5 review P2: the organization list is read first and on its own, so a failed bus-status read
// (likely when the bus is down) still refreshes the list, its banner and the Settings Start control.
// #1873 SP1: the dashboard lists every organization's bus on its own rows; this banner is an organization page's.
if (window.MosaicOrg && window.MosaicOrg.route().split('?')[0] === '/dashboard') { box.hidden = true; return; }
// An answer about an organization that is no longer open is dropped: the new one's read draws the banner.
const asked = window.MosaicOrg?.id ?? null, stale = () => (window.MosaicOrg?.id ?? null) !== asked;
if (window.MosaicOrg) {
const org = (await window.MosaicOrg.readList()).find(row => row.id === window.MosaicOrg.id);
const org = (await window.MosaicOrg.readList()).find(row => row.id === asked);
if (stale()) return;
if (org?.busHealthy === false) { box.textContent = 'Bus unavailable'; box.hidden = false; window.MosaicConsole?.attachBus(box, 'Restart the bus'); return; }
}
try {
const s = await api('/api/bus-status');
if (stale()) return;
if (!s.down) { box.hidden = true; return; }
const b = s.boot;
box.replaceChildren(node('b', '', 'Bus down: organization file invalid. '), node('span', '', `${b.reason} `), node('span', 'small muted', `File ${b.file}. Refused at ${b.at}. Last good load: ${b.lastGood ?? 'none'}. Fix the file, then restart the bus.`));
@@ -528,11 +539,27 @@
function showMachine() { $('show-machine').checked = machine; document.body.classList.toggle('machine-off', !machine); }
$('show-machine').onchange = e => {
machine = e.target.checked; showMachine();
try { localStorage.setItem(boardKey, JSON.stringify({ machine })); } catch {}
try { localStorage.setItem(boardKey(), JSON.stringify({ machine })); } catch {}
if (machine) { $('status').textContent = 'Loading board…'; fresh(); refresh(); } else hideMachine();
};
showMachine();
if (!machine) hideMachine();
// #1873 SP1: another organization opened in place. Its board setting and bus state are its own.
const onOrgSwitch = () => {
machine = readMachine(); showMachine();
if (machine) { fresh(); refresh(); } else { hideMachine(); busDown(); }
};
document.addEventListener('mosaic-org-switch', onOrgSwitch);
// An address with no organization picks one after the first list read; its own setting applies then.
window.MosaicOrg?.ready?.then(() => { if (readMachine() !== machine) onOrgSwitch(); });
// The banner hides on the system dashboard and shows on an organization's pages, so a move across
// that line redraws it. Other moves inside one organization read nothing (the switch path reads its own).
let onDashboard = null;
window.addEventListener('hashchange', () => {
if (!window.MosaicOrg) return;
const now = window.MosaicOrg.route().split('?')[0] === '/dashboard';
if (now !== onDashboard) { onDashboard = now; busDown(); }
});
$('refresh').onclick = refresh;
$('mobile-pause').onclick = () => $('pause').click();
$('pause').onclick = () => { paused = !paused; $('pause').querySelector('span').textContent = paused ? 'Resume' : 'Pause'; $('pause').setAttribute('aria-label', paused ? 'Resume' : 'Pause'); $('pause').title = paused ? 'Resume' : 'Pause'; $('mobile-pause').textContent = paused ? 'Resume' : 'Pause'; $('pause').setAttribute('aria-pressed', String(paused)); schedule(); if (data) render(); };
+14 -2
View File
@@ -181,10 +181,22 @@
menu.addEventListener('click', e => { if (e.target.closest('a')) close(); });
document.addEventListener('click', e => { if (!e.target.closest('.avatar-wrap')) close(); });
function breadcrumbs() {
const path = location.hash.slice(2).split('?')[0], parts = path.split('/').filter(Boolean), box = $('breadcrumbs');
draw();
// SP1: every page inside an organization says which one, after the dashboard: Dashboard / Acme / ...
const box = $('breadcrumbs'), org = /^#\/orgs\/[^/?]+/.test(location.hash) ? window.MosaicOrg : null;
if (!box || !org?.id || box.querySelector('[data-crumb-org]') || /^#\/orgs\/[^/?]+\/organization(?:[/?]|$)/.test(location.hash)) return;
box.insertAdjacentHTML('afterbegin', `<a href="#/dashboard">Dashboard</a><span aria-hidden="true">/</span><a href="#/orgs/${encodeURIComponent(org.id)}/organization" data-crumb-org>${escape(org.label)}</a><span aria-hidden="true">/</span>`);
}
window.MosaicBreadcrumbs = breadcrumbs;
function draw() {
// SP1: an organization's pages are routes under #/orgs/<id>; the crumb reads the route inside it.
const path = location.hash.replace(/^#\/orgs\/[^/?]+/, '#').slice(2).split('?')[0], parts = path.split('/').filter(Boolean), box = $('breadcrumbs');
if (!box || !$('bus-view')) return;
if (window.MosaicProjects?.breadcrumb?.(box, $('bus-view'))) return;
if (!parts.length || parts[0] === 'board') { box.innerHTML = '<span aria-current="page">Dashboard</span>'; return; }
// Inside an organization the board is its control board; the system dashboard is the crumb before it.
if (parts[0] === 'board' && /^#\/orgs\//.test(location.hash)) { box.innerHTML = '<span aria-current="page">Control board</span>'; return; }
if (!parts.length || parts[0] === 'board' || parts[0] === 'dashboard') { box.innerHTML = '<span aria-current="page">Dashboard</span>'; return; }
if (parts[0] === 'organization') { const h = $('bus-view').querySelector('[data-org-dashboard] h1')?.textContent?.trim(); box.innerHTML = `<a href="#/dashboard">Dashboard</a>${h ? `<span aria-hidden="true">/</span><span aria-current="page">${escape(h)}</span>` : ''}`; return; }
const names = {projects:'Projects',chat:'Projects',inbox:'Inbox',messages:'Messages',tasks:'Tasks',agents:'Agents',queue:'Queue',business:'Business',docs:'Docs',settings:'Settings'};
if (parts[0] === 'settings' && parts[1] === 'business' && $('bus-view').querySelector('#settings-page-title')?.textContent.trim() === 'Business') { box.innerHTML = '<a href="#/settings">Settings</a><span aria-hidden="true">/</span><span aria-current="page">Business</span>'; return; }
const name = names[parts[0]] || 'Dashboard', heading = $('bus-view').querySelector('h1')?.textContent?.trim();
+93 -36
View File
@@ -75,13 +75,17 @@
window.MosaicIcons.succeeded();
return body;
}
// #1873 SP1: bumped by each in-place organization switch. A write, or a read that keeps its answer,
// captures it before its await and drops the answer when it changed, so nothing the old organization
// answered lands on the new page or in its kept answers.
let orgEpoch = 0;
// A reader for one render. `live` asks the server; otherwise only kept answers are used.
function reader(live) {
const used = [];
const get = async (verb, subject) => {
const k = subject === undefined ? verb : `${verb}:${subject}`;
let r;
if (live) { r = await fetchRead(verb, subject); last.set(k, r); }
if (live) { const epoch = orgEpoch; r = await fetchRead(verb, subject); if (epoch === orgEpoch) last.set(k, r); }
else if (!(r = last.get(k))) throw new ReadError('not-read', 'not read before');
used.push(r.at);
if (verb === 'messages') { messageCount = r.unread; return r; }
@@ -90,7 +94,7 @@
get.api = async path => {
const k = `api:${path}`;
let r;
if (live) { r = await fetchApi(path); last.set(k, r); }
if (live) { const epoch = orgEpoch; r = await fetchApi(path); if (epoch === orgEpoch) last.set(k, r); }
else if (!(r = last.get(k))) throw new ReadError('not-read', 'not read before');
used.push(r.at);
return r;
@@ -275,9 +279,10 @@
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 items = [['/dashboard', 'Dashboard', '', 'board'], ['/inbox', 'Inbox', n(inboxCount?.open, 'open for you'), 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/projects', 'Projects', '', 'folder'], ['/side-by-side', 'Side by side', '', 'columns'], ['/agents', 'Agents', '', 'agents'], ['/messages', 'Assistant', n(messageCount, 'unread messages'), 'talk']];
const link = ([h, l, extra, id]) => {
const cur = h === '/' ? (route === '/' || route === '/board') : route.startsWith(h) || (h === '/projects' && route.startsWith('/chat')) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && (route.startsWith('/trail/decision') || route.startsWith('/trail/message')));
// #1873 SP1: the Dashboard item is the system dashboard, `#/dashboard`, outside any organization.
const cur = h === '/dashboard' ? (route === '/dashboard' || route === '/' || route === '/board') : route.startsWith(h) || (h === '/projects' && route.startsWith('/chat')) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && (route.startsWith('/trail/decision') || route.startsWith('/trail/message')));
return `<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
@@ -342,6 +347,8 @@
});
let configRead = Promise.resolve();
// #1873 SP1: the config read an organization switch started, until the next render has waited for it.
let switchRead = null;
let canSend = false, canMessage = false, chatSend = false, sendRoles = null, sending = false, receipt = null, replies = [];
const REPLIES = 50; // the broker's cap
function repliesHtml() {
@@ -462,7 +469,9 @@
if(!templatesOnly && d.op!=='fire' && !template){hireStatus='Read and choose a role template first.';hireCode='';paintHireStart();return;}
const thinking=d.thinking?.trim(),hire={op:d.op,level:d.level,name:d.name,reason:d.reason,...(d.level==='project'?{project:d.project}:{}),...(d.op==='fire'?{}:{role:{template:template?.name,sha256:template?.sha256},route:{harness:d.harness,model:d.model,...(thinking?{thinking}:{})}})};
hireBusy=true;hireStatus='';hireCode='';paintHireStart();
const epoch=orgEpoch;
const {res,json}=await decidePost(`/api/decisions/hire/${templatesOnly?'templates':'start'}`,{business:hireOrg() ?? d.business,...(templatesOnly?{}:{hire})});
if(epoch!==orgEpoch){if(res?.type==='opaqueredirect')signOut();return;}
hireBusy=false;
if(res?.type==='opaqueredirect'){signOut();return;}
if(res?.ok && templatesOnly && Array.isArray(json?.templates)){hireTemplates=json.templates;hireStatus=hireTemplates.length?'Templates read.':'No templates available.';}
@@ -487,7 +496,9 @@
if(fireBusy||!org||out()||!canDecide)return;
fireBusy=true;
for(const b of document.querySelectorAll('[data-agent-fire-yes],[data-agent-fire-no]'))b.disabled=true;
const epoch=orgEpoch;
const {res,json}=await decidePost('/api/decisions/hire/start',{business:org,hire:{op:'fire',level:'org',name,reason:FIRE_REASON}});
if(epoch!==orgEpoch){if(res?.type==='opaqueredirect')signOut();return;}
fireBusy=false;fireAsk=null;
if(res?.type==='opaqueredirect'){signOut();return;}
if(res?.ok && json?.op==='resolved'){
@@ -1038,9 +1049,9 @@
document.addEventListener('change',e=>{const form=e.target.closest?.('#launch-settings-form');if(form){settingsInputs=settingDraft(form);markSettingsDraft('limits',true);form.querySelector('.settings-actions')?.classList.add('has-draft');const discard=$('launch-settings-reload');if(discard)discard.hidden=false;}});
document.addEventListener('click',e=>{if(e.target.id!=='launch-settings-reload')return;settingsInputs=null;settingsSnapshot=null;settingsReceipt='';last.delete('api:settings');render(true);});
document.addEventListener('submit',async e=>{
if(e.target.id!=='launch-settings-form')return;e.preventDefault();if(!canSend||out()||settingsSaving)return;settingsInputs=settingDraft(e.target);let payload;try{payload=settingPayload();}catch(err){settingsReceipt=err.message+' Draft kept.';render(false);return;}settingsSaving=true;settingsReceipt='';render(false);
try{const response=await fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){settingsReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();if(response.ok&&/^[a-f0-9]{64}$/.test(result?.revision??'')){settingsInputs=null;settingsSnapshot=result;const prior=last.get('api:settings');if(prior)last.set('api:settings',{...prior,launch:result});settingsReceipt='Launch settings saved.';}else settingsReceipt=result?.sent==='unknown'?'Outcome unknown. Read current settings before trying again. Draft kept.':`${result?.message||'Settings refused.'} Draft kept.`;}}
catch{settingsReceipt='Outcome unknown. Read current settings before trying again. Draft kept.';}finally{settingsSaving=false;if(!out())render(false);}
if(e.target.id!=='launch-settings-form')return;e.preventDefault();if(!canSend||out()||settingsSaving)return;settingsInputs=settingDraft(e.target);let payload;try{payload=settingPayload();}catch(err){settingsReceipt=err.message+' Draft kept.';render(false);return;}settingsSaving=true;settingsReceipt='';render(false);const epoch=orgEpoch;
try{const response=await fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(epoch!==orgEpoch){if(response.type==='opaqueredirect')signOut();return;}if(response.type==='opaqueredirect'){settingsReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();if(epoch!==orgEpoch)return;if(response.ok&&/^[a-f0-9]{64}$/.test(result?.revision??'')){settingsInputs=null;settingsSnapshot=result;const prior=last.get('api:settings');if(prior)last.set('api:settings',{...prior,launch:result});settingsReceipt='Launch settings saved.';}else settingsReceipt=result?.sent==='unknown'?'Outcome unknown. Read current settings before trying again. Draft kept.':`${result?.message||'Settings refused.'} Draft kept.`;}}
catch{if(epoch!==orgEpoch)return;settingsReceipt='Outcome unknown. Read current settings before trying again. Draft kept.';}finally{if(epoch===orgEpoch){settingsSaving=false;if(!out())render(false);}}
});
// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): each token limit as the bus's checks report it. A
// limit no switched-on check counts reads "not enforced". Reset counts it again from now. Row 126
@@ -1061,9 +1072,9 @@
document.addEventListener('click',async e=>{
const path=e.target.dataset?.limitReset;if(path===undefined||!canSend||out()||settingsSaving)return;
const revision=settingsSnapshot?.revision;if(!/^[a-f0-9]{64}$/.test(revision??''))return;
settingsSaving=true;resetReceipt='';render(false);let reread=false;
try{const response=await fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){resetReceipt='Sign in again.';signOut();}else{const result=await response.json();if(response.ok&&/^[a-f0-9]{64}$/.test(result?.revision??'')){settingsSnapshot=result;const prior=last.get('api:settings');if(prior)last.set('api:settings',{...prior,launch:result});reread=true;resetReceipt='Limit reset.';}else resetReceipt=result?.sent==='unknown'?'Outcome unknown. Read current settings before trying again.':`${result?.message||'Reset refused.'}`;}}
catch{resetReceipt='Outcome unknown. Read current settings before trying again.';}finally{settingsSaving=false;if(!out())render(reread);}
settingsSaving=true;resetReceipt='';render(false);let reread=false;const epoch=orgEpoch;
try{const response=await fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});if(epoch!==orgEpoch){if(response.type==='opaqueredirect')signOut();return;}if(response.type==='opaqueredirect'){resetReceipt='Sign in again.';signOut();}else{const result=await response.json();if(epoch!==orgEpoch)return;if(response.ok&&/^[a-f0-9]{64}$/.test(result?.revision??'')){settingsSnapshot=result;const prior=last.get('api:settings');if(prior)last.set('api:settings',{...prior,launch:result});reread=true;resetReceipt='Limit reset.';}else resetReceipt=result?.sent==='unknown'?'Outcome unknown. Read current settings before trying again.':`${result?.message||'Reset refused.'}`;}}
catch{if(epoch!==orgEpoch)return;resetReceipt='Outcome unknown. Read current settings before trying again.';}finally{if(epoch===orgEpoch){settingsSaving=false;if(!out())render(reread);}}
});
function tokenUsage(value) {
if (!value?.total) return '<section class="panel" aria-labelledby="token-usage-title"><h2 id="token-usage-title">Token usage</h2><p>Usage unavailable. No count was read.</p></section>';
@@ -1118,9 +1129,9 @@
}
const unknownAssistant='Outcome unknown. Read current settings before trying again. Draft kept.';
async function assistantPost(url,payload,instance,ok,kind='tester'){
if(kind==='tester')assistantBusy=true;else assistantFormsBusy.add(assistantKey(instance,kind));assistantReceiptKinds[instance]=kind;assistantReceipts[instance]='';if(kind!=='tester')assistantCardReceipts[assistantKey(instance,kind)]='';render(false);
try{const response=await fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){assistantReceipts[instance]='Sign in again. Draft kept.';signOut();}else{const result=await response.json();assistantReceiptKinds[instance]=kind;assistantReceipts[instance]=response.ok?ok(result):result?.sent==='unknown'?unknownAssistant:`${result?.message||'Refused.'} Draft kept.`;}}
catch{assistantReceiptKinds[instance]=kind;assistantReceipts[instance]=unknownAssistant;}finally{if(kind==='tester')assistantBusy=false;else{assistantCardReceipts[assistantKey(instance,kind)]=assistantReceipts[instance];assistantFormsBusy.delete(assistantKey(instance,kind));}if(!out())render(false);}
if(kind==='tester')assistantBusy=true;else assistantFormsBusy.add(assistantKey(instance,kind));assistantReceiptKinds[instance]=kind;assistantReceipts[instance]='';if(kind!=='tester')assistantCardReceipts[assistantKey(instance,kind)]='';render(false);const epoch=orgEpoch;
try{const response=await fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(epoch!==orgEpoch){if(response.type==='opaqueredirect')signOut();return;}if(response.type==='opaqueredirect'){assistantReceipts[instance]='Sign in again. Draft kept.';signOut();}else{const result=await response.json();if(epoch!==orgEpoch)return;assistantReceiptKinds[instance]=kind;assistantReceipts[instance]=response.ok?ok(result):result?.sent==='unknown'?unknownAssistant:`${result?.message||'Refused.'} Draft kept.`;}}
catch{if(epoch!==orgEpoch)return;assistantReceiptKinds[instance]=kind;assistantReceipts[instance]=unknownAssistant;}finally{if(epoch===orgEpoch){if(kind==='tester')assistantBusy=false;else{assistantCardReceipts[assistantKey(instance,kind)]=assistantReceipts[instance];assistantFormsBusy.delete(assistantKey(instance,kind));}if(!out())render(false);}}
}
const assistantForm=el=>el?.closest?.('.assistant-form');
for(const kind of ['input','change'])document.addEventListener(kind,e=>{const form=assistantForm(e.target);if(!form)return;const instance=form.dataset.instance,field=e.target.dataset.assistantField;if(field==='name')nameDrafts[instance]=e.target.value;else if(field)assistantDrafts[instance]={...Object.fromEntries(['definition','vars','tracker'].map(f=>[f,form.querySelector(`[data-assistant-field="${f}"]`).value]))};if(field){markSettingsDraft('assistant',true);form.querySelector('.settings-actions')?.classList.add('has-draft');const discard=form.querySelector('[data-assistant-discard]');if(discard)discard.hidden=false;}});
@@ -1185,11 +1196,11 @@
const form=e.target?.closest?.('.instance-form');if(!form||e.target!==form)return;e.preventDefault();if(!(canSend || isTester)||out()||!instanceView)return;
const op=form.dataset.op;if(instanceBusyOps.has(op))return;instanceReceiptOp=op;instanceDrafts[op]=instanceFields(form);let payload;
try{payload=instancePayload(op,instanceDrafts[op]);}catch(err){instanceReceipt=err.message;instanceReceipts[op]=instanceReceipt;render(false);return;}
instanceBusyOps.add(op);instanceReceipt='';instanceReceipts[op]='';render(false);
try{const response=await fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){instanceReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();
instanceBusyOps.add(op);instanceReceipt='';instanceReceipts[op]='';render(false);const epoch=orgEpoch;
try{const response=await fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(epoch!==orgEpoch){if(response.type==='opaqueredirect')signOut();return;}if(response.type==='opaqueredirect'){instanceReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();if(epoch!==orgEpoch)return;
if(response.ok&&instanceAnswer(result)){instanceDrafts[op]=null;instanceView=result;const prior=last.get('instances');if(prior)last.set('instances',{...prior,rows:[result]});instanceReceipt=`${op==='add'?'Added':'Changed'} ${payload.instance}. ${result.restart?restartText():NO_RESTART_TEXT}`;}
else instanceReceipt=response.ok||result?.sent==='unknown'?unknownInstance:`${result?.message||'Refused.'} Draft kept.`;}}
catch{instanceReceipt=unknownInstance;}finally{instanceBusyOps.delete(op);instanceReceiptOp=op;instanceReceipts[op]=instanceReceipt;if(!out())render(false);}
catch{if(epoch!==orgEpoch)return;instanceReceipt=unknownInstance;}finally{if(epoch===orgEpoch){instanceBusyOps.delete(op);instanceReceiptOp=op;instanceReceipts[op]=instanceReceipt;if(!out())render(false);}}
});
// Row 65 (mos-0840): the routing table. One row per instance: harness, model and thinking, and
// the fallback's three. Each cell offers the switched-on harnesses, the model table's keys and the
@@ -1215,8 +1226,8 @@
const routesApplyMarkup=()=>routesApply?.length?`<ul class="routes-apply" aria-label="How the change reaches each agent">${routesApply.map(a=>`<li><b>${txt(a.name)}</b>: ${txt(a.text)}${a.restart&&canSend&&!isTester?` ${window.MosaicAgents?.restartMarkup(a.name,a.thread)??''}`:''}</li>`).join('')}</ul>`:'';
let harnessSettings=null, harnessDraft=null, harnessLoading=false, harnessReceipt='', harnessBusy=false;
function readHarnesses(){
if(harnessLoading || harnessSettings !== null || !canSend || isTester)return; harnessLoading=true;
fetch('/api/proxy/plugins',{cache:'no-store',redirect:'manual'}).then(async r=>{const v=await r.json();if(!r.ok || !/^[a-f0-9]{64}$/.test(v.revision??'') || !Array.isArray(v.available?.harness) || !v.available.harness.every(n=>typeof n==='string') || !Array.isArray(v.plugins?.harness??[]))throw Error();harnessSettings=v;}).catch(()=>{harnessSettings=false;harnessReceipt='Harnesses could not be read.';}).finally(()=>{harnessLoading=false;if(routeOf().split('?')[0]==='/settings/routing')render(false);});
if(harnessLoading || harnessSettings !== null || !canSend || isTester)return; harnessLoading=true;const epoch=orgEpoch;
fetch('/api/proxy/plugins',{cache:'no-store',redirect:'manual'}).then(async r=>{const v=await r.json();if(epoch!==orgEpoch)return;if(!r.ok || !/^[a-f0-9]{64}$/.test(v.revision??'') || !Array.isArray(v.available?.harness) || !v.available.harness.every(n=>typeof n==='string') || !Array.isArray(v.plugins?.harness??[]))throw Error();harnessSettings=v;}).catch(()=>{if(epoch!==orgEpoch)return;harnessSettings=false;harnessReceipt='Harnesses could not be read.';}).finally(()=>{if(epoch!==orgEpoch)return;harnessLoading=false;if(routeOf().split('?')[0]==='/settings/routing')render(false);});
}
const ROUTE_CELLS=[['harness','Harness'],['model','Model'],['thinking','Thinking'],['fallback.harness','Fallback harness'],['fallback.model','Fallback model'],['fallback.thinking','Fallback thinking']];
const routeCells=r=>({harness:r.harness??'',model:r.model??'',thinking:r.thinking??'','fallback.harness':r.fallback?.harness??'','fallback.model':r.fallback?.model??'','fallback.thinking':r.fallback?.thinking??''});
@@ -1293,32 +1304,32 @@
if(harnessSettings.revision!==routesView?.revision){harnessReceipt='The business changed. Reload this page before saving harnesses.';render(false);return;}
const used=old.filter(n=>!selected.includes(n) && Object.values(routesView.instances).some(r=>r.harness===n || r.fallback?.harness===n));
if(used.length){harnessReceipt=`A saved route uses ${used.join(', ')}. Change the route before switching it off.`;render(false);return;}
harnessBusy=true;const button=e.target;button.disabled=true;harnessReceipt='';
try{const response=await fetch('/api/proxy/plugins/set',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision:harnessSettings.revision,plugins:{harness:selected}}),cache:'no-store',redirect:'manual'});const result=await response.json();if(!response.ok){harnessReceipt=result.message||result.error||'Harness setting was refused.';return;}harnessSettings={...result,available:harnessSettings.available};harnessDraft=null;harnessReceipt='Harnesses saved. The next launch uses the new settings.';routesView=null;last.delete('routes');}
catch{harnessReceipt='Outcome unknown. Read the harnesses again before trying.';harnessSettings=false;}
finally{harnessBusy=false;if(!out())render(false);}
harnessBusy=true;const button=e.target;button.disabled=true;harnessReceipt='';const epoch=orgEpoch;
try{const response=await fetch('/api/proxy/plugins/set',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision:harnessSettings.revision,plugins:{harness:selected}}),cache:'no-store',redirect:'manual'});const result=await response.json();if(epoch!==orgEpoch)return;if(!response.ok){harnessReceipt=result.message||result.error||'Harness setting was refused.';return;}harnessSettings={...result,available:harnessSettings.available};harnessDraft=null;harnessReceipt='Harnesses saved. The next launch uses the new settings.';routesView=null;last.delete('routes');}
catch{if(epoch!==orgEpoch)return;harnessReceipt='Outcome unknown. Read the harnesses again before trying.';harnessSettings=false;}
finally{if(epoch===orgEpoch){harnessBusy=false;if(!out())render(false);}}
});
document.addEventListener('click',async e=>{if(e.target.id!=='provider-switch'||!canSend||isTester)return;
const button=e.target,status=$('provider-switch-status');if(routesDraft){status.textContent='Save or discard the route draft first.';return;}button.disabled=true;
try {const read=await fetch('/api/proxy/plugins',{cache:'no-store'});const settings=await read.json();if(!read.ok||!/^[a-f0-9]{64}$/.test(settings.revision??''))throw Error('Could not read provider settings.');
const button=e.target,status=$('provider-switch-status');if(routesDraft){status.textContent='Save or discard the route draft first.';return;}button.disabled=true;const epoch=orgEpoch;
try {const read=await fetch('/api/proxy/plugins',{cache:'no-store'});const settings=await read.json();if(epoch!==orgEpoch)return;if(!read.ok||!/^[a-f0-9]{64}$/.test(settings.revision??''))throw Error('Could not read provider settings.');
const names=Array.isArray(settings.plugins?.['model-provider'])?settings.plugins['model-provider']:[];
// The fresh read decides: a stale label never turns the proxy module on when it meant off.
const on=names.includes('cliproxy');if(String(on)!==button.dataset.enabled){button.dataset.enabled=String(on);button.textContent=`${on?'Disable':'Enable'} model proxy catalogue`;button.disabled=false;status.textContent='The switch now shows the current setting. Press it again to change it.';return;}
const next=on?names.filter(n=>n!=='cliproxy'):[...names,'cliproxy'];
const response=await fetch('/api/proxy/plugins/set',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision:settings.revision,plugins:{'model-provider':next}}),cache:'no-store',redirect:'manual'});
if(!response.ok)throw Error('Provider setting was refused.');harnessSettings=null;routesView=null;last.delete('routes');render(true);
}catch(error){status.textContent=error.message;button.disabled=false;}
if(epoch!==orgEpoch)return;if(!response.ok)throw Error('Provider setting was refused.');harnessSettings=null;routesView=null;last.delete('routes');render(true);
}catch(error){if(epoch!==orgEpoch)return;status.textContent=error.message;button.disabled=false;}
});
document.addEventListener('click',e=>{if(e.target.matches?.('.fallback-toggle'))e.target.closest('tr')?.classList.add('fallback-open');});
document.addEventListener('click',e=>{if(e.target.id!=='routes-reload')return;routesDraft=null;routesView=null;routesReceipt='';routesApply=null;last.delete('routes');render(true);});
document.addEventListener('submit',async e=>{
if(e.target.id!=='routes-form')return;e.preventDefault();if(!(canSend || isTester)||out()||routesBusy||!routesView)return;
routesDraft=routesFields(e.target);let payload;try{payload=routesPayload(routesDraft);}catch(err){routesReceipt=err.message;render(false);return;}
routesBusy=true;routesReceipt='';routesApply=null;render(false);
try{const response=await fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){routesReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();
if(response.ok&&routesAnswer(result)){routesDraft=null;routesView=result;harnessSettings=null;const prior=last.get('routes');if(prior)last.set('routes',{...prior,rows:[result]});routesReceipt=`Routes saved for ${Object.keys(payload.routes).join(', ')}. The next launch of each uses the new route.`;routesApply=await routesApplyRead(Object.keys(payload.routes));}
routesBusy=true;routesReceipt='';routesApply=null;render(false);const epoch=orgEpoch;
try{const response=await fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(epoch!==orgEpoch){if(response.type==='opaqueredirect')signOut();return;}if(response.type==='opaqueredirect'){routesReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();if(epoch!==orgEpoch)return;
if(response.ok&&routesAnswer(result)){routesDraft=null;routesView=result;harnessSettings=null;const prior=last.get('routes');if(prior)last.set('routes',{...prior,rows:[result]});routesReceipt=`Routes saved for ${Object.keys(payload.routes).join(', ')}. The next launch of each uses the new route.`;const applied=await routesApplyRead(Object.keys(payload.routes));if(epoch!==orgEpoch)return;routesApply=applied;}
else routesReceipt=response.ok||result?.sent==='unknown'?unknownRoutes:`${result?.message||'Refused.'}${catalogueStep(result?.error,payload.routes)} Draft kept.`;}}
catch{routesReceipt=unknownRoutes;}finally{routesBusy=false;if(!out())render(false);}
catch{if(epoch!==orgEpoch)return;routesReceipt=unknownRoutes;}finally{if(epoch===orgEpoch){routesBusy=false;if(!out())render(false);}}
});
// Row 194 (#1692, D7): the Agents section lives in agents.js (window.MosaicAgents) and draws from
// the same instances read. A refresh drops that read so the next render asks the bus again.
@@ -1529,6 +1540,7 @@
if (!s || !saveable(s)) return;
const sent = { project: s.project, path: s.path, base: s.commit, content: s.text }, unknown = 'Outcome unknown: the save may have gone through. Copy your text, then reload the file to check before saving again. Draft kept.';
s.saving = true; s.status = '';
const epoch = orgEpoch;
let res = null, body = null;
try {
paintDoc();
@@ -1540,13 +1552,16 @@
} else if (body?.sent === false) s.status = `${typeof body.message === 'string' && body.message ? body.message : 'Not saved.'} Draft kept.`;
else s.status = unknown;
} finally { s.saving = false; } // a throw above must not leave Save off and Reload refused
// #1873 SP1: a switch while this was in flight cleared the drafts; the old file's outcome is not
// painted or announced on the new organization's page.
if (epoch !== orgEpoch) { if (res?.type === 'opaqueredirect') signOut(); return; }
paintDoc();
announce(s.status);
if (res?.type === 'opaqueredirect') signOut();
});
// Not found and states
function notFound(why) { return `${head('Not found')}<div class="empty nf"><b>${why || 'No page at this address.'}</b><p>Nothing is at <code>${txt(location.hash || '#/')}</code>. <a href="#/">Go to the ${window.MosaicOrg ? 'Dashboard' : 'Board'}</a> or press <kbd>Ctrl K</kbd> to search.</p></div>`; }
function notFound(why) { return `${head('Not found')}<div class="empty nf"><b>${why || 'No page at this address.'}</b><p>Nothing is at <code>${txt(location.hash || '#/')}</code>. <a href="${window.MosaicOrg ? '#/dashboard' : '#/'}">Go to the ${window.MosaicOrg ? 'Dashboard' : 'Board'}</a> or press <kbd>Ctrl K</kbd> to search.</p></div>`; }
const loadingView = (source = null) => `${head('Reading…')}<div class="skel-rows" aria-busy="true"><span class="sr-only">${source ? `Reading the ${esc(source.toLowerCase())}.` : 'Reading from the bus.'}</span><span class="skel"></span><span class="skel"></span><span class="skel"></span><span class="skel"></span></div>`;
const retry = '<div class="actions"><button type="button" class="btn" data-retry>Read again</button></div>';
// Why a read failed, in words, with the bus code as given. Never a path or a guess.
@@ -1601,7 +1616,8 @@
const parts = path.split('/').filter(Boolean).map(p => { try { return decodeURIComponent(p); } catch { return null; } });
if (parts.includes(null)) return notFound();
const [a, b, c] = parts, n = parts.length;
if (n === 0 && window.MosaicOrg) return window.MosaicWorkspace.home();
// #1873 SP1: `#/dashboard` is the system dashboard, every organization on one page.
if ((n === 0 || (a === 'dashboard' && n === 1)) && window.MosaicOrg) return window.MosaicWorkspace.home();
if (a === 'projects' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.projectsPage(q);
if (a === 'organization' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.dashboard();
if (a === 'inbox' && n === 1 && window.MosaicOrg) return configRead.then(() => window.MosaicWorkspace.inboxPage(q, { hire: hireStartHtml }));
@@ -1626,7 +1642,7 @@
const paused = () => $('pause')?.getAttribute('aria-pressed') === 'true';
// A file open in the editor is never refreshed from under its draft; Reload is the person's to press.
// The cross-organization pages (U3) poll at the workspace read's pollMs.
const wsPage = path => !!window.MosaicOrg && (path === '/' || path === '' || path === '/projects' || path === '/inbox');
const wsPage = path => !!window.MosaicOrg && (path === '/' || path === '' || path === '/dashboard' || path === '/projects' || path === '/inbox');
function schedule() { clearTimeout(timer); if (!paused() && !out() && !routeOf().startsWith('/docs/')) timer = setTimeout(() => render(false), wsPage(routeOf().split('?')[0]) ? window.MosaicWorkspace?.pollMs || 10000 : 10000); }
// Keep focus, open <details> and the copy status across a refresh of the same page.
// In the send form, the focused field and its caret too (its values come from `draft`).
@@ -1676,6 +1692,14 @@
// A refresh due while this render reads would replace it: a navigation would lose its heading focus.
clearTimeout(timer);
if (out()) return;
// #1873 SP1: an address with no organization waits for the first list read to pick one, and a
// page drawn right after a switch waits for the new organization's config.
if ((window.MosaicOrg && !window.MosaicOrg.id) || switchRead) {
const g0 = ++gen, waiting = switchRead;
await window.MosaicOrg?.ready; await waiting;
if (waiting && switchRead === waiting) switchRead = null;
if (g0 !== gen || out()) return;
}
const route = routeOf(), [path, query = ''] = route.split('?'), board = window.MosaicOrg ? path === '/board' : path === '/' || path === '' || path === '/board';
// On the cross-organization pages a filter or selection changes only the query: that is a
// repaint of the same page, not a navigation (no loading view, focus and scroll kept).
@@ -1737,12 +1761,15 @@
// The board page keeps its own title; the Inbox count still follows the bus.
if (shown !== null) { shown = null; unmountEditor(); $('bus-view').innerHTML = ''; document.title = 'Dashboard · Mosaic Console'; if (navigated) $('main').focus(); }
// The held roles (row 95) are read with it. A refusal of either forgets both, as on the bus pages.
const epoch = orgEpoch;
const [inbox, agents, messages, tasks, queue, business, projects] = await Promise.allSettled([
fetchRead('inbox'), fetchRead('agents'), fetchRead('messages'), fetchRead('tasks'), fetchApi('queue'), fetchApi('business'),
fetch('/api/chat/projects', { cache: 'no-store', redirect: 'manual' }).then(async r => { if (r.type === 'opaqueredirect') { signOut(); throw signedOut(); } const body = await r.json(); if (signInRequired(r, body)) { signOutSso(); throw signedOut(); } if (!r.ok || !Array.isArray(body.rows)) throw new ReadError(body?.error, body?.message, r.status); return body; })
]);
const failed = [inbox, agents, messages, tasks].filter(s => s.status === 'rejected').map(s => s.reason);
if (failed.some(err => err.code === 'signed-out')) return;
// #1873 SP1: an answer read for an organization that is no longer open is not kept.
if (epoch !== orgEpoch) return;
if (inbox.status === 'fulfilled') last.set('inbox', inbox.value);
if (agents.status === 'fulfilled') last.set('agents', agents.value);
if (messages.status === 'fulfilled') last.set('messages', messages.value);
@@ -1883,7 +1910,9 @@
if (d.open) return;
pkBack = document.activeElement; pkAt = 0; $('cmdk-input').value = '';
pkItems(); pkFill(); d.showModal(); $('cmdk-input').focus();
Promise.allSettled(['inbox', 'tasks'].map(async v => last.set(v, await fetchRead(v)))).then(rs => {
const epoch = orgEpoch;
Promise.allSettled(['inbox', 'tasks'].map(async v => { const r = await fetchRead(v); if (epoch === orgEpoch) last.set(v, r); })).then(rs => {
if (epoch !== orgEpoch) return;
if (rs.some(r => r.status === 'rejected' && refusal(r.reason))) { forget(); sections(routeOf().split('?')[0]); }
if (d.open) { pkItems(); pkFill(); }
});
@@ -1923,11 +1952,13 @@
if (out()) { receipt = { kind: 'refused', to: to || 'no role', code: 'sign-in', message: 'signed out; reload the page to sign in again' }; paintSend(); announce('Not sent: ' + receipt.message); return; }
if (!to || !text.trim()) { receipt = { kind: 'refused', to: to || 'no role', code: 'invalid-request', message: to ? 'the message is empty' : 'choose a role first' }; paintSend(); announce('Not sent: ' + receipt.message); return; }
sending = true; receipt = null; paintSend();
const epoch = orgEpoch;
let res = null, body = null;
try {
res = await fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });
try { body = await res.json(); } catch { body = null; }
} catch { res = null; }
if (epoch !== orgEpoch) { if (res?.type === 'opaqueredirect') signOut(); return; }
// Sent: an id came back. Not sent: a 4xx (refused before the bus wrote), the server's
// sent:false, or a redirect, which only the sign-in proxy gives (an expired session), so the
// request never reached the Console. Anything else, no answer included, may have been sent,
@@ -1958,7 +1989,7 @@
// F3: `settingsSend` lets the owner of an organization Console save Settings on the selected
// organization's bus; messaging a role still follows `send` (canMessage) and `chatSend`.
// A bus restart reads the config again and sends it here as a mosaic-config event.
const applyConfig = c => {
const applyConfig = (c, opts = {}) => {
isTester = c?.tester === true;
canEdit = !isTester && c?.edit === true;
canSend = !isTester && (c?.send === true || c?.settingsSend === true);
@@ -1975,7 +2006,7 @@
banner.setAttribute('role', 'status'); banner.textContent = 'Tester Console. Changes are recorded as tester, never as the human.';
$('main').prepend(banner);
}
if (!canSend && !isTester && !ssoOn) return;
if (opts.quiet || (!canSend && !isTester && !ssoOn)) return;
const path = routeOf().split('?')[0];
if (path.startsWith('/chat')||path.startsWith('/projects')||path.startsWith('/inbox')||path.startsWith('/settings')||path.startsWith('/docs/')||wsPage(path)) render(false); else if (window.MosaicOrg ? path === '/board' : path === '/' || path === '') paintHeld(heldErr);
};
@@ -1985,6 +2016,32 @@
// fresh read of the page; editors in progress are kept by holdLive.
// Ruling 2026-11-14: an apply, hire or fire redraws the Agents page in the same response cycle, not at the next poll.
document.addEventListener('mosaic-org-changed', e => { if (window.MosaicOrg && e.detail?.org === window.MosaicOrg.id && ['/organization', '/agents'].includes(routeOf().split('?')[0])) render(false); });
// #1873 SP1: another organization opened in place (display.js, before this file's hashchange
// render). Nothing read, drafted or confirmed for the old one carries over; the new one's config
// is read before the next page draws. A write already sent finishes against the old organization,
// and its answer is not drawn on the new one.
document.addEventListener('mosaic-org-switch', () => {
// Writes still in flight keep going to the old organization; their answers are dropped (orgEpoch)
// and every busy flag and receipt starts clean here.
orgEpoch++;
sending = false; receipt = null; replies = [];
hireBusy = false; fireBusy = false; settingsSaving = false; resetReceipt = '';
assistantBusy = false; assistantFormsBusy.clear(); instanceBusyOps = new Set();
routesBusy = false; routesApply = null; harnessBusy = false; harnessLoading = false;
forget(); clearTimeout(timer);
answer = null; heldErr = null; heldHtml = null; readAt = null; shown = null; projectContext = null;
draft.to = ''; draft.body = '';
hireDraft = { op: 'hire', level: 'org' }; hireTemplates = []; hireStatus = ''; hireCode = '';
fireAsk = null; fireReceipt = null;
settingsSnapshot = null; settingsInputs = null; settingsReceipt = ''; settingInvalidKey = null;
assistantDrafts = Object.create(null); nameDrafts = Object.create(null); assistantReceipts = Object.create(null); assistantReceiptKinds = Object.create(null); assistantCardReceipts = Object.create(null);
instanceView = null; instanceDrafts = { add: null, change: null }; instanceReceipt = ''; instanceReceiptOp = null; instanceReceipts = Object.create(null);
routesView = null; routesDraft = null; routesReceipt = ''; routesEditVersion++;
harnessSettings = null; harnessDraft = null; harnessReceipt = '';
// A file draft is keyed by project and path only; the page reload that used to drop it is gone.
docKey = null; docStates.clear(); pkAll = []; pkShown = []; pkAt = 0;
configRead = switchRead = fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(c => applyConfig(c, { quiet: true })).catch(() => {});
});
document.addEventListener('mosaic-config', e => { if (e.detail && typeof e.detail === 'object') { applyConfig(e.detail); if (window.MosaicOrg && routeOf().split('?')[0] === '/organization') render(false); } });
// The cross-organization pages ask for a repaint after a filter, sort or selection change,
// and report the inbox count across organizations for the sidebar.
+125 -65
View File
@@ -1,60 +1,63 @@
window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(value).toLocaleString() : 'unknown';
// An organization switch reloads the page, which drops every per-page cache and
// subscription. Requests already sent keep the organization captured here.
// #1873 SP1: one shell for the whole system. `#/dashboard` is the system landing route and
// `#/orgs/<id>/...` is a route inside the same page, not a mode: opening another organization
// changes the scope of later /api reads and fires `mosaic-org-switch`. Nothing reloads.
// Requests already sent keep the organization they were sent with.
(() => {
const nativeFetch = window.fetch.bind(window);
const match = /^#\/orgs\/([^/]+)(\/.*)?$/.exec(location.hash);
const id = match?.[1] ?? null;
if (!id) {
if (!document.documentElement.hasAttribute('data-organizations')) return;
const readInitialOrganizations = () => nativeFetch('/api/orgs', { cache: 'no-store', redirect: 'manual' }).then(async r => {
if (r.ok) { window.MosaicOrganizationError = null; return r.json(); }
const body = await r.json().catch(() => ({}));
window.MosaicOrganizationError = body.message || 'Organization registry could not be read';
return null;
}).catch(() => { window.MosaicOrganizationError = 'Organization registry could not be read'; return null; });
const refreshInitial = () => {
const initialOrganizations = window.MosaicInitialOrganizations = readInitialOrganizations();
initialOrganizations.then(async rows => {
if (!Array.isArray(rows) || !rows.length || location.hash.startsWith('#/orgs/')) return;
rows = rows.filter(row => !row.invalid);
if (!rows.length) return;
let preferred;
try { preferred = (await (await nativeFetch('/api/config', { cache: 'no-store', redirect: 'manual' })).json()).preselect; } catch { /* first authorized organization */ }
const selected = rows.find(row => row.id === preferred) ?? rows[0];
const tail = location.hash.startsWith('#/') ? location.hash.slice(1) : '/';
history.replaceState(null, '', `#/orgs/${encodeURIComponent(selected.id)}${tail}`);
location.reload();
}).catch(() => {});
return initialOrganizations;
};
window.MosaicRefreshOrganizations = refreshInitial;
refreshInitial();
return;
}
const route = () => location.hash.replace(/^#\/orgs\/[^/]+/, '') || '/';
const orgOf = hash => /^#\/orgs\/([^/]+)/.exec(hash)?.[1] ?? null;
const atLoad = orgOf(location.hash);
if (!atLoad && !document.documentElement.hasAttribute('data-organizations')) return;
// current: the scoping organization as its hash segment. settled: false only while the first
// list read picks one for an address that names none. With no valid organization at all the
// page stays in its first-run mode (window.MosaicOrg reads undefined), as before.
let current = atLoad, settled = !!atLoad, markReady;
const ready = new Promise(resolve => { markReady = resolve; });
const settle = seg => { current = seg; settled = true; markReady(seg ? decodeURIComponent(seg) : null); };
// An address that names its organization is settled at load: anything waiting on ready goes on.
if (atLoad) markReady(decodeURIComponent(atLoad));
const tail = hash => { const m = /^#\/orgs\/[^/]+(.*)$/.exec(hash); return m ? (m[1] || '/') : hash.startsWith('#/') ? hash.slice(1) : '/'; };
const route = () => tail(location.hash);
// The bare address and the old per-organization dashboard address are the system dashboard;
// an organization page without the prefix gets the current organization's.
const normalize = (hash, seg = current) => {
const path = tail(hash), bare = path.split('?')[0];
if (bare === '/' || bare === '/dashboard') return `#/dashboard${path.slice(bare.length)}`;
return orgOf(hash) || !seg ? hash : `#/orgs/${seg}${path}`;
};
// /api/workspace reads and writes across organizations (U1, U4b), so it keeps its own path.
const unscoped = /^\/api\/(?:workspace(?:\/|\?|$)|orgs(?:\/|$)|themes(?:\?|$)|org-template(?:\?|$)|org-presets(?:\?|$)|org-admin(?:\?|$)|org-validate(?:\?|$)|agents\/(?:levels|root)(?:\?|$))/;
window.fetch = (input, init) => {
if (typeof input === 'string' && input.startsWith('/api/') && !/^\/api\/(?:workspace(?:\/|\?|$)|orgs(?:\/|$)|themes(?:\?|$)|org-template(?:\?|$)|org-presets(?:\?|$)|org-admin(?:\?|$)|org-validate(?:\?|$)|agents\/(?:levels|root)(?:\?|$))/.test(input))
input = `/api/orgs/${id}/${input.slice('/api/'.length)}`;
return nativeFetch(input, init);
if (typeof input !== 'string' || !input.startsWith('/api/') || unscoped.test(input)) return nativeFetch(input, init);
const send = () => nativeFetch(current ? `/api/orgs/${current}/${input.slice('/api/'.length)}` : input, init);
return settled ? send() : ready.then(send);
};
// A failed read keeps its reason, so the header can say why the list is empty.
let listError = null;
const readList = () => nativeFetch('/api/orgs', { cache: 'no-store', redirect: 'manual' }).then(async r => {
let listError = null, rowsSeen = [];
const readRows = () => nativeFetch('/api/orgs', { cache: 'no-store', redirect: 'manual' }).then(async r => {
if (r.ok) {
const body = await r.json(); listError = null;
const body = await r.json(); listError = null; window.MosaicOrganizationError = null;
const rows = Array.isArray(body) ? body : [];
// E2E5 review P2: an open panel (Settings > Organizations) follows every fresh list read, not only its own.
document.dispatchEvent(new CustomEvent('mosaic-org-list', { detail: { rows } }));
return rows;
}
const body = await r.json().catch(() => ({}));
listError = typeof body.message === 'string' && body.message ? body.message : 'Organization registry could not be read';
return [];
}).catch(() => { listError = 'Organization registry could not be read'; return []; });
// Idempotent: each read replaces the banner and the wordmark name, and removes them on recovery.
listError = window.MosaicOrganizationError = typeof body.message === 'string' && body.message ? body.message : 'Organization registry could not be read';
return null;
}).catch(() => { listError = window.MosaicOrganizationError = 'Organization registry could not be read'; return null; });
const readList = () => readRows().then(rows => rows ?? []);
// The bus banner belongs to an organization page; the dashboard lists every bus on its own rows.
const paintBus = () => {
const banner = document.getElementById('bus-down');
if (!banner) return;
const org = rowsSeen.find(row => current && row.id === decodeURIComponent(current) && !row.invalid);
if (route().split('?')[0] === '/dashboard' || (banner.dataset.orgBus && org?.busHealthy !== false)) { banner.hidden = true; delete banner.dataset.orgBus; return; }
if (org?.busHealthy === false) { banner.textContent = 'Bus unavailable'; banner.hidden = false; banner.dataset.orgBus = '1'; window.MosaicConsole?.attachBus(banner, 'Restart the bus'); }
};
// Idempotent: each read replaces the banner, and removes it on recovery.
const paintHeader = rows => {
rowsSeen = rows;
document.getElementById('org-list-banner')?.remove();
const lines = [...(listError ? [listError] : []), ...rows.filter(row => row.invalid).map(row => `${row.id}: invalid — ${row.reason}`)];
if (lines.length) {
@@ -62,58 +65,115 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
banner.textContent = lines.join('; ');
document.body.prepend(banner);
}
// The shell no longer names one organization in its wordmark: the breadcrumb does.
document.getElementById('current-organization')?.remove();
const org = rows.find(row => row.id === decodeURIComponent(id) && !row.invalid);
const mark = document.querySelector('.wordmark');
if (mark && org) { const name = document.createElement('span'); name.id = 'current-organization'; name.textContent = ` · ${org.label || org.id}`; mark.append(name); }
if (org?.busHealthy === false) { const banner = document.getElementById('bus-down'); if (banner) { banner.textContent = 'Bus unavailable'; banner.hidden = false; window.MosaicConsole?.attachBus(banner, 'Restart the bus'); } }
paintBus();
window.MosaicBreadcrumbs?.();
return rows;
};
let list = readList().then(paintHeader);
let list;
const refreshList = () => (list = readList().then(paintHeader));
window.MosaicRefreshOrganizations = refreshList;
let previous = location.hash;
if (atLoad) {
const first = normalize(location.hash);
if (first !== location.hash) history.replaceState(null, '', first);
refreshList();
} else {
const initial = window.MosaicInitialOrganizations = readRows();
list = initial.then(rows => paintHeader(rows ?? []));
initial.then(async rows => {
const valid = Array.isArray(rows) ? rows.filter(row => !row.invalid) : [];
if (!valid.length) return null;
let preferred;
try { preferred = (await (await nativeFetch('/api/config', { cache: 'no-store', redirect: 'manual' })).json()).preselect; } catch { /* first authorized organization */ }
return encodeURIComponent((valid.find(row => row.id === preferred) ?? valid[0]).id);
}).catch(() => null).then(seg => {
settle(orgOf(location.hash) ?? seg);
if (!current) return;
const next = normalize(location.hash);
if (next !== location.hash) history.replaceState(null, '', next);
previous = location.hash; paintBus();
});
}
// From the first-run page (no organization yet) the first one changes the page's mode, which
// still takes one reload. Once an organization is open, a list refresh repaints in place.
window.MosaicRefreshOrganizations = () => {
if (current || !settled) return refreshList();
const read = window.MosaicInitialOrganizations = readRows();
list = read.then(rows => paintHeader(rows ?? []));
read.then(rows => {
const valid = Array.isArray(rows) ? rows.filter(row => !row.invalid) : [];
if (!valid.length || orgOf(location.hash)) return;
history.replaceState(null, '', `#/orgs/${encodeURIComponent(valid[0].id)}${tail(location.hash) === '/' ? '/organization' : tail(location.hash)}`);
location.reload();
});
return read;
};
let previous = location.hash, skipGuard = false;
const dirty = () => window.MosaicChat?.hasDraft?.() || window.MosaicOrgCreate?.hasDraft?.() || [...document.querySelectorAll('#reply, #conv-reply, #doc-source')].some(el => !!el.value?.trim());
function change(next) {
history.replaceState(null, '', next);
location.reload();
// A switch scopes later reads to the new organization; listeners drop what they held for the old one.
function rescope(seg) {
const from = current;
current = seg;
document.dispatchEvent(new CustomEvent('mosaic-org-switch', { detail: { from: from ? decodeURIComponent(from) : null, to: decodeURIComponent(seg) } }));
}
function discard() {
window.MosaicChat?.forget?.();
for (const el of document.querySelectorAll('#reply, #conv-reply, #doc-source')) el.value = '';
}
function confirmSwitch(next) {
const dialog = document.createElement('dialog');
dialog.setAttribute('aria-label', 'Unsaved draft');
dialog.innerHTML = '<p>You have an unsaved draft. Stay here or discard it and continue.</p><button type="button" data-stay>Stay</button> <button type="button" data-discard>Discard</button>';
document.body.append(dialog);
const stay = () => { const picker = document.getElementById('org-picker'); if (picker) picker.value = decodeURIComponent(id); dialog.close(); dialog.remove(); };
const close = () => { dialog.close(); dialog.remove(); };
const stay = () => { const picker = document.getElementById('org-picker'); if (picker && current) picker.value = decodeURIComponent(current); close(); };
dialog.querySelector('[data-stay]').onclick = stay;
dialog.querySelector('[data-discard]').onclick = () => change(next);
dialog.querySelector('[data-discard]').onclick = () => { close(); discard(); skipGuard = true; location.hash = next; };
dialog.addEventListener('cancel', e => { e.preventDefault(); stay(); });
dialog.showModal(); dialog.querySelector('[data-stay]').focus();
}
// Capture phase: this runs before bus.js renders, so a switch is scoped before the page reads.
window.addEventListener('hashchange', event => {
const oldHash = event.oldURL ? new URL(event.oldURL).hash : previous;
let next = location.hash;
if (!next.startsWith('#/orgs/')) { next = `#/orgs/${id}${next.startsWith('#/') ? next.slice(1) : '/'}`; history.replaceState(null, '', next); }
const nextId = /^#\/orgs\/([^/]+)/.exec(next)?.[1];
if ((nextId && nextId !== id) || (next !== oldHash && window.MosaicOrgCreate?.hasDraft?.())) {
const raw = location.hash;
// First run (no organization yet) keeps its own addresses: #/ is its board, not #/dashboard.
// Before the first list read settles, the settle step normalizes whatever address it finds.
if (!settled || !current) { previous = raw; return; }
const seg = orgOf(raw) ?? current, next = normalize(raw, seg);
if (next !== raw) history.replaceState(null, '', next);
const switching = seg !== current;
if (!skipGuard && next !== oldHash && (switching || window.MosaicOrgCreate?.hasDraft?.()) && dirty()) {
event.stopImmediatePropagation();
if (dirty()) { history.replaceState(null, '', oldHash); confirmSwitch(next); } else change(next);
history.replaceState(null, '', oldHash); confirmSwitch(next);
return;
}
previous = next;
skipGuard = false; previous = next;
if (switching) rescope(seg);
paintBus();
}, true);
window.MosaicOrg = Object.freeze({ id: decodeURIComponent(id), route, get list() { return list; }, readList, refreshList, switchTo: next => { location.hash = `#/orgs/${encodeURIComponent(next)}${route()}`; } });
const api = Object.freeze({
get id() { return current ? decodeURIComponent(current) : null; },
// The open organization's name as the last list read gave it, for the breadcrumb.
get label() { const id = current ? decodeURIComponent(current) : null, row = id && rowsSeen.find(r => r.id === id); return id ? (typeof row?.label === 'string' && row.label ? row.label : id) : null; },
ready, route, get list() { return list; }, readList, refreshList,
switchTo: next => { const path = route().split('?')[0]; location.hash = `#/orgs/${encodeURIComponent(next)}${path === '/dashboard' || path === '/' ? '/organization' : route()}`; },
});
Object.defineProperty(window, 'MosaicOrg', { configurable: true, enumerable: true, get: () => (current || !settled ? api : undefined) });
})();
if (document.documentElement.hasAttribute('data-settings-proxy-document') && !location.hash) location.hash = '#/settings/model-proxy';
/* Browser-local sidebar density. No authority or project data is stored here. */
(() => {
const key=window.MosaicOrg ? `mosaic-sidebar-display-v1:${window.MosaicOrg.id}` : 'mosaic-sidebar-display-v1';
// #1873 SP1: the key follows the open organization, read again when it changes.
const key=()=>window.MosaicOrg?.id ? `mosaic-sidebar-display-v1:${window.MosaicOrg.id}` : 'mosaic-sidebar-display-v1';
const defaults=()=>({version:1,density:'comfortable',order:'title-first'});
const valid=x=>x&&x.version===1&&['comfortable','compact'].includes(x.density)&&['title-first','contact-first'].includes(x.order)&&Object.keys(x).sort().join(',')==='density,order,version';
let saved=defaults();
try {const value=JSON.parse(localStorage.getItem(key));if(valid(value))saved=value;} catch {}
const load=()=>{saved=defaults();try {const value=JSON.parse(localStorage.getItem(key()));if(valid(value))saved=value;} catch {}};
const apply=()=>{document.documentElement.dataset.sidebarDensity=saved.density;document.documentElement.dataset.sidebarOrder=saved.order;document.dispatchEvent(new CustomEvent('mosaic-display-change',{detail:{...saved}}));};
apply();
window.MosaicDisplay={get:()=>({...saved}),setDensity(density){if(!['comfortable','compact'].includes(density))return false;saved={...saved,density};apply();try{localStorage.setItem(key,JSON.stringify(saved));return true;}catch{return false;}}};
load();apply();
window.MosaicOrg?.ready?.then(()=>{load();apply();});
document.addEventListener('mosaic-org-switch',()=>{load();apply();});
window.MosaicDisplay={get:()=>({...saved}),setDensity(density){if(!['comfortable','compact'].includes(density))return false;saved={...saved,density};apply();try{localStorage.setItem(key(),JSON.stringify(saved));return true;}catch{return false;}}};
})();
// P4 page: credentials and OAuth state exist only during the current operation.
+7 -3
View File
@@ -27,10 +27,14 @@
}
document.addEventListener('focusin',e=>{const tab=e.target.closest?.('.project-tabs a'); if(tab) revealTab(tab.parentElement,tab);});
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const storageKey=window.MosaicOrg ? `mosaic-project-tree:${window.MosaicOrg.id}` : 'mosaic-project-tree';
// #1873 SP1: the tree and its open/closed state belong to the open organization; a switch drops both.
const storageKey=()=>window.MosaicOrg?.id ? `mosaic-project-tree:${window.MosaicOrg.id}` : 'mosaic-project-tree';
let treeRows=[], treeThreads=[], treeGeneration=0, treeState, treeRead=null, treeReadAt=0;
const treeFocus = new WeakMap();
try { treeState=JSON.parse(localStorage.getItem(storageKey) || '{}'); if (!treeState || Array.isArray(treeState) || typeof treeState !== 'object') treeState={}; } catch { treeState={}; }
const loadTreeState=()=>{ try { treeState=JSON.parse(localStorage.getItem(storageKey()) || '{}'); if (!treeState || Array.isArray(treeState) || typeof treeState !== 'object') treeState={}; } catch { treeState={}; } };
loadTreeState();
window.MosaicOrg?.ready?.then(loadTreeState);
document.addEventListener('mosaic-org-switch',()=>{ treeGeneration++; treeRows=[]; treeThreads=[]; treeRead=null; treeReadAt=0; loadTreeState(); });
// A side-by-side pane reads its own organization: its reads name that organization explicitly.
const at=(org,url)=>org && url.startsWith('/api/') ? `/api/orgs/${encodeURIComponent(org)}/${url.slice(5)}` : url;
async function read(url) {
@@ -76,7 +80,7 @@
target?.focus({preventScroll:true});
}
treeFocus.delete(container);
container.onclick=e=>{const button=e.target.closest('[data-project-disclosure]');if(!button)return;const id=button.dataset.projectDisclosure;treeState[id]=button.getAttribute('aria-expanded')!=='true';try{localStorage.setItem(storageKey,JSON.stringify(treeState));}catch{}drawTree(container,route);container.querySelector(`[data-project-disclosure="${id}"]`)?.focus();};
container.onclick=e=>{const button=e.target.closest('[data-project-disclosure]');if(!button)return;const id=button.dataset.projectDisclosure;treeState[id]=button.getAttribute('aria-expanded')!=='true';try{localStorage.setItem(storageKey(),JSON.stringify(treeState));}catch{}drawTree(container,route);container.querySelector(`[data-project-disclosure="${id}"]`)?.focus();};
} catch { if(generation===treeGeneration && container.isConnected && !document.body.classList.contains('signed-out')) {
treeRead=null;
const active=document.activeElement;
+6 -5
View File
@@ -20,7 +20,8 @@
const txt = value => esc(inert(value));
const enc = encodeURIComponent;
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/;
const STORE = window.MosaicOrg ? `mosaic-request-answer:${window.MosaicOrg.id}:` : 'mosaic-request-answer:';
// #1873 SP1: read per call, so answers stay with the organization open when they were written.
const storeOf = () => window.MosaicOrg?.id ? `mosaic-request-answer:${window.MosaicOrg.id}:` : 'mosaic-request-answer:';
const QUESTION_PHASES = ['discovery', 'prd-review', 'planning'];
const MODE_PHASES = [...QUESTION_PHASES, 'implementation', 'review', 'delivery'];
const PICKER_CODES = ['request-existing-project', 'request-attachment-selected'];
@@ -55,15 +56,15 @@
// removed once the bus has answered either way, so only a lost response leaves one behind.
const load = id => {
try {
const v = JSON.parse(sessionStorage.getItem(STORE + id));
const v = JSON.parse(sessionStorage.getItem(storeOf() + id));
return v && text(v.op) && ID.test(v.op) && text(v.text) && text(v.question) && ID.test(v.question) && positive(v.revision)
? { op: v.op, text: v.text, question: v.question, revision: v.revision } : null;
} catch { return null; }
};
const save = (id, record) => { try { sessionStorage.setItem(STORE + id, JSON.stringify(record)); } catch { /* kept in memory only */ } };
const drop = id => { try { sessionStorage.removeItem(STORE + id); } catch { /* nothing stored */ } };
const save = (id, record) => { try { sessionStorage.setItem(storeOf() + id, JSON.stringify(record)); } catch { /* kept in memory only */ } };
const drop = id => { try { sessionStorage.removeItem(storeOf() + id); } catch { /* nothing stored */ } };
const dropAll = () => {
try { for (const key of Object.keys(sessionStorage)) if (key.startsWith(STORE)) sessionStorage.removeItem(key); } catch { /* nothing stored */ }
try { for (const key of Object.keys(sessionStorage)) if (key.startsWith(storeOf())) sessionStorage.removeItem(key); } catch { /* nothing stored */ }
};
function forget() {
+10
View File
@@ -591,6 +591,16 @@ html[data-organizations] .ws-bar i{display:block;background:var(--org,var(--acti
html[data-organizations] .ws-by{display:flex;flex-wrap:wrap;gap:4px 10px;font-size:12px;color:var(--text)}
html[data-organizations] .ws-by>span{display:inline-flex;align-items:center;gap:4px;white-space:nowrap;font-variant-numeric:tabular-nums}
html[data-organizations] .ws-by .ws-av{width:14px;height:14px;border-radius:3px;font-size:7px}
html[data-organizations] .ws-orgtbl td{vertical-align:top}
html[data-organizations] .ws-orgtbl .who{display:flex;flex-wrap:wrap;align-items:center;gap:4px 10px;min-width:0}
html[data-organizations] .ws-orgtbl .who a{display:inline-flex;align-items:center;gap:8px;min-width:0;color:var(--strong);font-weight:700;text-decoration:none}
html[data-organizations] .ws-orgtbl .who a:hover .nm{text-decoration:underline}
html[data-organizations] .ws-orgtbl .who .ws-dot{font-size:12px;color:var(--muted)}
html[data-organizations] .ws-orgtbl .who .ws-dot.s-working b{color:var(--success)}
html[data-organizations] .ws-orgtbl .who .why{flex:1 0 100%;font-size:12px;color:var(--danger);overflow-wrap:anywhere}
html[data-organizations] .ws-orgtbl tr.off .ws-dot{color:var(--danger)}
html[data-organizations] .ws-dash [data-org-down]{margin:0 0 14px}
html[data-organizations] .ws-dash [data-org-down] .why{display:block;margin-top:4px;color:var(--muted)}
html[data-organizations] .ws-orgcards{list-style:none;margin:0;padding:0;display:grid;gap:8px;grid-template-columns:repeat(auto-fill,minmax(220px,1fr))}
html[data-organizations] .ws-orgcard a{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:2px 10px;align-items:center;padding:9px 12px;border:1px solid var(--line);border-radius:8px;background:var(--surface);box-shadow:inset 3px 0 0 var(--org);color:var(--text);text-decoration:none;font-size:13px;height:100%}
html[data-organizations] .ws-orgcard a:hover{background:var(--raised)}
+54 -13
View File
@@ -104,7 +104,8 @@
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;
// SP1: a system route (the dashboard) marks no organization; an organization route marks its own.
const here = route(), selected = /^#\/orgs\//.test(location.hash) ? window.MosaicOrg?.id : null;
const rows = orgs.map(org => {
const [cls, glyph, word] = state(org), mine = org.id === selected, current = mine && here === '/organization';
return `<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>`;
@@ -227,6 +228,21 @@
return Boolean(a && a.el.isConnected);
}
// SP1: the reason an organization's bus can't be read, for its pages. `row` is its GET /api/workspace
// status (state, reason, boot), or null when that read gave none.
// A reason that only restates the heading ("the bus is not running", the server's fallback) is not
// repeated; the last start time, when read, still shows.
const restates = reason => /^the bus is not (running|answering)\.?$/i.test(reason.trim());
const downWhy = row => (row && row.state !== 'ok' && typeof row.reason === 'string' && row.reason ? `${row.reason}${row.boot?.at ? `, last start ${when(row.boot.at)}` : ''}` : '');
function downBanner(org, row) {
const stopped = org.busStopped === true;
const plain = row && row.state !== 'ok' && typeof row.reason === 'string' && row.reason && restates(row.reason);
const why = plain ? '' : downWhy(row);
const tail = plain
? (row.boot?.at ? ` <span class="why">Last start ${esc(when(row.boot.at))}.</span>` : '')
: why ? ` <span class="why">Reason: ${esc(why)}.</span>` : ' <span class="why">No reason was read.</span>';
return `<div class="banner err" role="status" data-org-down><div><b>${stopped ? 'The bus is not running.' : 'The bus is not answering.'}</b> Nothing about ${esc(label(org))}'s agents or packs can be read until it ${stopped ? 'starts' : 'answers'}.${tail}</div></div>`;
}
// A side-by-side pane passes its own organization and { pane: true, idPrefix }: the pane has no bus
// restart (that stays in the organization's full view) and its ids carry the pane's prefix.
async function dashboard(id = window.MosaicOrg.id, opts = {}) {
@@ -244,6 +260,8 @@
const threads = rowsOf(value(3)), projectRows = rowsOf(value(4)), queue = rowsOf(value(5));
const agents = rowsOf(value(6)), claims = rowsOf(value(7));
const docRead = business?.business ? await docs(id, business.business.projects).catch(() => ({ rows: [], failed: true })) : null;
// SP1: a bus that is down says so above everything else, with the reason the workspace read gives.
const downRow = org.busHealthy ? null : await data.workspace({ org: id, sections: 'projects' }).then(b => arr(b?.orgs).find(r => r?.org === id) ?? null).catch(() => null);
await window.MosaicConsole?.ready;
const name = esc(label(org));
const tracker = business?.business?.vars?.['tracker.kind'];
@@ -290,8 +308,9 @@
const stoppedPanel = (attr, title) => `<section class="panel" ${attr} data-bus-stopped><h3>${title}</h3><p class="note">Not read: the bus is stopped.</p></section>`;
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>
<nav class="ws-crumb" aria-label="Breadcrumb"><a href="#/dashboard">Dashboard</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>
${org.busHealthy ? '' : downBanner(org, downRow)}
${docrow}
<div class="ws-split">
<section aria-label="Measures and sessions">
@@ -320,6 +339,8 @@
// only). Every status comes from the organization's bus: its instances, and since A3 the root and
// project agent copies it booted with. Nothing here invents a status, a model or a memory.
const NOT_RUNNING = ['s-idle', '–', 'not running'];
// SP1: why a bus-backed read gave nothing, in the membership list's words.
const busWord = org => (org?.busStopped === true ? 'the bus is stopped' : 'the bus is not answering');
// A copy's status: running or not as the bus reports its runs, or why it can't launch.
const CANT = ['s-error', '▲', "can't launch"];
// #1873 CB5: the bus adds `binding` to a copy that needs a credential binding: its status code and
@@ -364,7 +385,7 @@
} else if (inst) {
rows.push(...Object.entries(inst).map(([name, v]) => ({ name, level: 'org', definition: v?.definition, model: null, scope: { kind: 'org' }, overrides: [], narrowed: [] })));
}
if (!set && inst === null) return '<p class="ws-panel tight note">Agents unavailable. The bus did not answer the instance read.</p>';
if (!set && inst === null) return `<p class="ws-panel tight note" data-agents-why>${selOrg?.busHealthy === false ? `Agents not read: ${busWord(selOrg)}.` : 'Agents unavailable. The bus did not answer the instance read.'}</p>`;
if (!rows.length) return '<p class="ws-panel tight note">No agents configured</p>';
const where = a => (a.level === 'root' ? 'Your root configuration' : a.level === 'org' ? `${label(selOrg)} file` : `${names(a.project)} project`);
const sub = a => [a.definition && a.definition !== a.name ? a.definition : '', a.level === 'org' && typeof inst?.[a.name]?.pack?.id === 'string' ? `from pack ${inst[a.name].pack.id}` : '', a.overrides?.length ? `replaces the ${a.overrides.map(o => (o.level === 'org' ? 'organization' : o.level)).join(' and ')} ${a.overrides.length > 1 ? 'definitions' : 'definition'}` : '', a.narrowed?.length ? `narrowed here: ${a.narrowed.join(', ')}` : ''].filter(Boolean).join(' · ');
@@ -448,7 +469,7 @@
const on = kept.filter(a => !own(a)), replaced = kept.filter(own);
return cell(org, `${on.map(a => copyPill(copies, agents, a.name, null, 'copy')).join('') || '<span class="muted">none</span>'}${replaced.length ? `<span class="note ws-off">${esc(replaced.map(a => a.name).join(', '))} replaced by ${esc(label(org))}'s own</span>` : ''}${off.length ? `<span class="note ws-off">${esc(off.map(a => a.name).join(', '))} turned off for ${esc(label(org))}</span>` : ''}`, endc(i));
}).join('');
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(org, k, v, agents)).join('') : '<span class="muted">No agents configured</span>', endc(i))).join('');
const orgRow = results.map(({ org, inst, agents }, i) => cell(org, inst === null ? `<span class="muted" data-agents-why>${org.busHealthy === false ? `Agents not read: ${busWord(org)}` : 'Agents unavailable'}</span>` : Object.keys(inst).length ? Object.entries(inst).map(([k, v]) => pill(org, k, v, agents)).join('') : '<span class="muted">No agents configured</span>', endc(i))).join('');
// Project: the agents a project defines itself. Narrowing and exclusion show in the table.
const projRow = results.map(({ org, lv, rows, copies, agents }, i) => {
if (lv?.state !== 'ok') return cell(org, `<span class="muted">${lv?.state === 'invalid' ? 'Project agents unavailable: the organization file was refused' : 'Project agents unavailable'}</span>`, ` last${endc(i)}`);
@@ -549,7 +570,11 @@
// ---- main dashboard (sample 02) ----
async function home() {
let body;
try { [body] = await Promise.all([data.workspace({ sections: ['projects', 'sessions', 'inbox', 'tasks', 'metrics'] }), ensureThemes()]); took(body); }
let levels = null, listed = null;
// SP1: the agent counts come from the organization files (GET /api/agents/levels, owner only), so a
// stopped bus still has one; the bus state words come from the membership list. Either read may
// fail or be absent, and its column then shows no figure.
try { [body, levels, listed] = await Promise.all([data.workspace({ sections: ['projects', 'sessions', 'inbox', 'tasks', 'metrics'] }), data.agentLevels().catch(() => null), data.organizations().catch(() => null), ensureThemes()]); took(body); }
catch (err) { return failedPage('ws-home', 'data-ws-home', 'Dashboard', err); }
const o = orgsOf(body), m = body.metrics, t = m?.totals, per = arr(m?.orgs);
const inbox = arr(body.inbox), sessions = arr(body.sessions), projectRows = arr(body.projects), tasks = arr(body.tasks);
@@ -573,11 +598,27 @@
<div class="ws-tile quiet" data-ws-tile="spend"><span class="ws-label">Model spend</span><span class="ws-empty-t">No spend figures yet</span><span class="ws-delta">No model prices are read yet, so today's use has no cost figure.</span></div>
</div>` : '<p class="ws-panel tight note" data-ws-tiles>No organization answered, so there are no totals yet.</p>';
const cards = o.rows.map(r => {
const [cls, glyph, word] = orgState(r), id = r.org;
const extra = r.state === 'ok' ? '' : `<span class="why">${esc(r.reason || '')}${r.boot?.at ? `${r.reason ? ' · ' : ''}last start ${esc(when(r.boot.at))}` : ''}</span>`;
return `<li class="ws-orgcard ws-o${r.state === 'ok' ? '' : ' off'}" style="${o.style(id)}" data-ws-orgcard="${esc(id)}" data-state="${esc(r.state)}"><a href="${esc(orgHref(id))}/organization" data-focus-key="${esc(`orgcard:${id}`)}">${av(o.meta(id), 'md')}<span class="nm">${esc(o.name(id))}</span>${dot(cls, glyph, word, true)}<span class="at">read ${esc(hm(r.at))}</span>${extra}</a></li>`;
// SP1 (sample r2-01): one row per organization. A figure shows only when its read gave it; an
// organization that did not answer shows its state and reason instead of figures.
const listRow = id => (Array.isArray(listed) ? listed.find(x => x?.id === id) : null);
const projectsRead = readers(o, SRC.projects), inboxRead = readers(o, SRC.needs), sessionsRead = readers(o, SRC.sessions);
const none = '<span class="muted" title="not read">–</span>';
// The bus state as the workspace read found it; "stopped" only when the membership list says the owner stopped it.
const ROW_STATE = { ok: ['s-working', '●', 'bus running'], starting: ['s-waiting', '◐', 'bus starting'], timeout: ['s-error', '▲', 'bus not answering'], invalid: ['s-error', '▲', 'invalid'] };
const rowState = (r, fromList) => (r.state === 'down' ? ['s-offline', '○', fromList?.busStopped === true ? 'bus stopped' : 'bus down'] : ROW_STATE[r.state] || orgState(r));
const orgTable = o.rows.map(r => {
const id = r.org, fromList = listRow(id), x = per.find(p => p.org === id);
const [cls, glyph, word] = rowState(r, fromList);
const lv = levels?.orgs?.find(v => v?.id === id);
const count = lv?.state === 'ok' && Array.isArray(lv.base?.agents) ? lv.base.agents.length : null;
const working = x && sessionsRead.has(id) ? num(x.sessions?.working) : null;
const agentsCell = count === null ? none : `${plural(count, 'agent')}${working === null ? '' : ` <small class="muted">(${working} working)</small>`}`;
const projectsCell = x && projectsRead.has(id) ? plural(num(x.projects?.active), 'project') : none;
const needsCell = x && inboxRead.has(id) ? (num(x.needsYou) ? `${num(x.needsYou)} needs you` : 'none waiting') : none;
const why = r.state === 'ok' ? '' : `<span class="why">${esc(r.reason || '')}${r.boot?.at ? `${r.reason ? ' · ' : ''}last start ${esc(when(r.boot.at))}` : ''}</span>`;
return `<tr class="ws-o${r.state === 'ok' ? '' : ' off'}" style="${o.style(id)}" data-ws-orgrow="${esc(id)}" data-state="${esc(r.state)}"><td class="who"><a href="${esc(orgHref(id))}/organization" data-focus-key="${esc(`orgrow:${id}`)}">${av(o.meta(id))}<span class="nm">${esc(o.name(id))}</span></a>${dot(cls, glyph, word, true)}${why}</td><td>${agentsCell}</td><td class="hide-m">${projectsCell}</td><td>${needsCell}</td></tr>`;
}).join('');
const orgList = o.rows.length ? `<div class="ws-tbl"><table class="ws-orgtbl" data-ws-orgs><thead><tr><th scope="col">Organization</th><th scope="col">Agents</th><th scope="col" class="hide-m">Projects</th><th scope="col">Needs you</th></tr></thead><tbody>${orgTable}</tbody></table></div>` : '<p class="ws-panel tight note" data-ws-orgs>No organizations yet</p>';
const recent = projectRows.filter(p => p.lastActivity).slice(0, 6);
const STATE_WORD = { working: 'has a session working', waiting: 'is waiting on you', idle: 'has an idle session', none: 'no live session' };
@@ -610,10 +651,10 @@
return `<article class="ws ws-home" data-ws-home>
<div class="ws-vh"><h1 tabindex="-1">Dashboard</h1><p class="sub">${sub}</p></div>
${tiles}
${secH('Organizations', `read ${esc(hm(body.readAt))}`)}
<ul class="ws-orgcards" data-ws-orgs>${cards || '<li class="ws-line">No organizations yet</li>'}</ul>
<div class="ws-split ws-home-split">
<section aria-label="Projects and tasks">
<section aria-label="Organizations, projects and tasks">
${secH('Organizations', `read ${esc(hm(body.readAt))}`)}
${orgList}
${secH('Recent project activity', 'latest change per project; there is no activity feed yet', `<a href="${esc(selHref('/projects'))}" data-focus-key="home-projects">All projects</a>`)}
${trail}${missLine(o, ['projects'], 'recent')}
${secH('Tasks by state', 'open tasks, every tracker', `<a href="${esc(selHref('/tasks'))}" data-focus-key="home-tasks">Open tasks</a>`)}
@@ -622,7 +663,7 @@
<section aria-label="Needs you and sessions">
${secH('Needs you', '', `<a href="${esc(selHref('/inbox'))}" data-focus-key="home-inbox">Inbox</a>`)}
${needsList}${missLine(o, ['inbox'], 'needs')}
${secH('Sessions now', '', `<a href="${esc(selHref('/agents'))}" data-focus-key="home-agents">Agents</a>`)}
${secH('Active agents', 'sessions now', `<a href="${esc(selHref('/agents'))}" data-focus-key="home-agents">Agents</a>`)}
${sessTable}${missLine(o, ['agents', 'claims'], 'sessions')}
</section>
</div>
@@ -81,7 +81,8 @@ test('archive does not announce success for an unexpected response', { timeout:
test('archiving the current organization switches to the remaining one', { timeout: 45000 }, async t => {
const f = await fixture(t); await f.open('alpha');
await f.b.evaluate(`document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-final').click()`);
await f.wait(`window.MosaicOrg?.id === 'beta' && document.querySelector('header')?.textContent.includes('beta')`);
// #1873 SP1: the breadcrumb, not the header, names the open organization.
await f.wait(`window.MosaicOrg?.id === 'beta' && /beta/i.test(document.querySelector('#breadcrumbs [data-crumb-org]')?.textContent ?? '')`);
assert.equal((await registry.listOrgs(f.ctx)).find(e => e.id === 'alpha').state, 'archived');
});
@@ -188,6 +189,8 @@ test('fresh archived organization page offers owner restore while writes stay re
const f = await fixture(t); await f.open('beta');
await f.b.evaluate(`document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-final').click()`);
await f.wait(`!document.querySelector('#org-archived').hidden`);
// #1873 SP1: another organization's address no longer reloads the page, so a fresh page is loaded on purpose.
await f.b.navigate('about:blank');
await f.b.navigate(`http://127.0.0.1:${f.port}/#/orgs/beta/settings/organizations?org=beta`);
await f.wait(`document.querySelector('#org-archived') && !document.querySelector('#org-archived').hidden`);
assert.equal(await f.b.evaluate(`document.querySelector('#org-restore').disabled`), false);
@@ -200,6 +203,16 @@ test('fresh archived organization page offers owner restore while writes stay re
assert.equal((await registry.listOrgs(f.ctx)).find(e => e.id === 'beta').state, 'active');
});
// #1873 SP1: opening the archived organization's page in place (no reload) shows its restore command.
test('an archived organization opened in place shows its restore command', { timeout: 45000 }, async t => {
const f = await fixture(t); await f.open('beta');
await f.b.evaluate(`document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-final').click()`);
await f.wait(`!document.querySelector('#org-archived').hidden`);
await f.b.evaluate(`window.__sameDocument = true; location.hash = '#/orgs/beta/settings/organizations?org=beta'`);
await f.wait(`window.MosaicOrg?.id === 'beta' && /mosaic org restore beta --if-revision \\d+/.test(document.querySelector('#org-archived')?.innerText ?? '')`);
assert.equal(await f.b.evaluate(`window.__sameDocument`), true);
});
test('owner can start a down bus after setup recorded done and sees recovery guidance on refusal', { timeout: 45000 }, async t => {
const f = await fixture(t);
await f.b.evaluate(`(()=>{const old=window.fetch;window.fetch=async (input,init={})=>{const u=String(input);if(u.endsWith('/setup') && (!init.method || init.method==='GET'))return new Response(JSON.stringify({notifications:{state:'done'},queue:{state:'done'},projects:[],bus:{state:'done',verb:'mosaic bus start alpha'}}),{status:200});if(u.endsWith('/setup/bus') && init.method==='POST')return new Response(JSON.stringify({error:'stale-lock',message:'stale bus host lock'}),{status:409});return old(input,init)}})()`);
@@ -233,6 +246,8 @@ for (const capability of [{ user:true, orgSetup:false }, { tester:true, orgSetup
await f.b.evaluate(`document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-final').click()`);
await f.wait(`!document.querySelector('#org-archived').hidden`);
await f.b.call('Page.addScriptToEvaluateOnNewDocument', {source:`{const old=window.fetch;window.fetch=(input,init)=>input instanceof URL && input.pathname==='/api/config'?Promise.resolve(new Response(JSON.stringify(${JSON.stringify(capability)}),{status:200})):old(input,init)}`});
// #1873 SP1: the stub applies to a new document; another organization's address alone no longer reloads.
await f.b.navigate('about:blank');
await f.b.navigate(`http://127.0.0.1:${f.port}/#/orgs/beta/settings/organizations?org=beta`);
await f.wait(`document.querySelector('#org-archived') && !document.querySelector('#org-archived').hidden`);
assert.equal(await f.b.evaluate(`document.querySelector('#org-restore').disabled`),true);
@@ -78,8 +78,9 @@ test('deleting the current organization switches the Console to a remaining orga
await b.evaluate(`document.querySelector('#org-open-form button').click()`);
await wait(`document.querySelector('#org-delete') && !document.querySelector('#org-delete').hidden`);
await b.evaluate(`document.querySelector('#org-delete-open').click();document.querySelector('#org-delete-continue').click();const i=document.querySelector('#org-delete-confirm');i.value='alpha';i.dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('#org-delete-final').click()`);
// #1873 SP1: the breadcrumb, not the header, names the open organization.
let switched = false;
for (let i = 0; i < 300 && !switched; i++) { try { switched = await b.evaluate(`window.MosaicOrg?.id === 'beta' && document.querySelector('header')?.textContent.includes('beta')`); } catch {} if (!switched) await new Promise(r => setTimeout(r, 30)); }
for (let i = 0; i < 300 && !switched; i++) { try { switched = await b.evaluate(`window.MosaicOrg?.id === 'beta' && (document.querySelector('#breadcrumbs [data-crumb-org]')?.textContent ?? '').includes('beta')`); } catch {} if (!switched) await new Promise(r => setTimeout(r, 30)); }
assert.equal(switched, true); assert.deepEqual((await registry.listOrgs(ctx)).map(e => e.id), ['beta']);
});
@@ -1,5 +1,6 @@
// On a selected org's page, the header shows why the org list failed or which org is broken,
// and Retry re-reads the list and clears the banner once it recovers (polish review F3, P19 #868).
// #1873 SP1: the breadcrumb names the open organization; the header no longer does.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as fs from 'node:fs/promises';
@@ -35,9 +36,9 @@ test('a registry error shows on a selected org page and Retry recovers', async t
await wait(f.b, `(${bannerText}).includes('Organization registry could not be read') && document.querySelector('#held [data-retry]')`);
const before = f.requests(); await fs.writeFile(file, original);
await f.b.evaluate("document.querySelector('#held [data-retry]').click()");
await wait(f.b, "!!document.querySelector('#current-organization') && !document.getElementById('org-list-banner')");
await wait(f.b, "!!document.querySelector('#breadcrumbs [data-crumb-org]') && !document.getElementById('org-list-banner')");
assert.ok(f.requests() > before, 'Retry re-read the organization list');
assert.equal(await f.b.evaluate("document.querySelectorAll('#current-organization').length"), 1);
assert.equal(await f.b.evaluate("document.querySelectorAll('#breadcrumbs [data-crumb-org]').length"), 1);
});
test('a broken org banner is replaced, not stacked, and clears after repair', async t => {
@@ -52,5 +53,5 @@ test('a broken org banner is replaced, not stacked, and clears after repair', as
await fs.writeFile(file, original);
await f.b.evaluate("document.querySelector('#held [data-retry]').click()");
await wait(f.b, "!document.getElementById('org-list-banner')");
assert.equal(await f.b.evaluate("document.querySelectorAll('#current-organization').length"), 1);
assert.equal(await f.b.evaluate("document.querySelectorAll('#breadcrumbs [data-crumb-org]').length"), 1);
});
@@ -92,7 +92,8 @@ test('creating another organization opens its setup, receipt, header and theme',
assert.ok(switched, 'new organization setup loaded after the switch');
await b.evaluate('window.MosaicAppearance.ready');
assert.match(await b.evaluate('location.hash'), /^#\/orgs\/beta\/settings\/organizations\?org=beta&step=setup/);
assert.match(await b.evaluate('document.querySelector("header").textContent'), /beta/);
// SP1: the shell no longer names the organization in its header; the breadcrumb does.
assert.match(await b.evaluate('document.querySelector("#breadcrumbs [data-crumb-org]").textContent'), /beta/);
assert.equal(await b.evaluate('document.documentElement.dataset.palette'), 'cobalt');
assert.match(await b.evaluate('document.querySelector("#org-receipt").textContent'), /Organization created.*beta/s);
});
@@ -142,17 +143,21 @@ test('sidebar lists organizations and navigating changes the route and header',
await b.viewport(390, 900);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/settings/business`);
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelectorAll('[data-workspace-org]').length===2 && document.querySelector('#settings-page-title'))return;await new Promise(r=>setTimeout(r,30))}throw Error('Organizations did not appear')})()`);
assert.match(await b.evaluate(`document.querySelector('header').textContent`), /Alpha/);
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('#breadcrumbs [data-crumb-org]')?.textContent==='Alpha')return;await new Promise(r=>setTimeout(r,30))}throw Error('organization crumb missing')})()`);
assert.equal(await b.evaluate(`document.querySelector('#current-organization')`), null, 'the header does not name an organization');
assert.equal(await b.evaluate(`document.querySelector('#settings-page-title').textContent`), 'Organization');
await b.evaluate('window.__sameDocument = true');
assert.equal(await b.evaluate(`!!document.querySelector('#org-picker')`), false);
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-workspace-org]')].map(o=>o.dataset.workspaceOrg)`), ['alpha', 'beta']);
await b.evaluate(`document.querySelector('[data-workspace-org=beta] a').click()`);
let switched = false;
for (let i = 0; i < 150 && !switched; i++) {
try { switched = await b.evaluate(`location.hash.startsWith('#/orgs/beta/') && document.querySelector('header')?.textContent.includes('Beta')`); } catch { /* reload replaced the page */ }
// The sidebar opens the organization's own page: its breadcrumb is Dashboard / Beta.
try { switched = await b.evaluate(`location.hash.startsWith('#/orgs/beta/') && document.querySelector('#breadcrumbs')?.textContent === 'Dashboard/Beta'`); } catch { /* not ready */ }
if (!switched) await new Promise(r => setTimeout(r, 30));
}
assert.equal(switched, true, 'switch completed after reload');
assert.equal(switched, true, 'switch completed in place');
assert.equal(await b.evaluate('window.__sameDocument'), true, 'the switch did not reload the page');
assert.match(await b.evaluate(`document.querySelector('body').textContent`), /Bus unavailable/);
await new Promise(r => setTimeout(r, 400));
assert.equal(await b.evaluate(`document.querySelector('#bus-down').hidden`), false, 'offline banner remains visible after the boot-status read');
@@ -193,7 +198,7 @@ test('a chat draft is kept on Stay and discarded on an organization switch', asy
await b.evaluate(`document.querySelector('[data-discard]').click()`).catch(() => {});
let switched = false;
for (let i = 0; i < 150 && !switched; i++) {
try { switched = await b.evaluate(`location.hash.startsWith('#/orgs/beta/') && document.querySelector('header')?.textContent.includes('Beta')`); } catch {}
try { switched = await b.evaluate(`location.hash.startsWith('#/orgs/beta/') && document.querySelector('#breadcrumbs [data-crumb-org]')?.textContent === 'Beta'`); } catch {}
if (!switched) await new Promise(r => setTimeout(r, 30));
}
assert.equal(switched, true);
@@ -212,15 +217,9 @@ test('organization switch isolates browser board preferences', async t => {
await b.navigate(base + '#/orgs/alpha/');
await b.evaluate(`document.querySelector('#show-machine').click()`);
assert.equal(await b.evaluate(`document.querySelector('#show-machine').checked`), true);
// The switch reloads the page. The navigate helper's load checks can be in flight when that
// reload commits, so set the hash in the page and poll one short check at a time.
// #1873 SP1: the switch happens in place, with no reload, and beta's own board setting applies.
await b.evaluate(`location.hash = '#/orgs/beta/'`);
let loaded = false;
for (let i = 0; i < 150 && !loaded; i++) {
try { loaded = await b.evaluate(`window.MosaicOrg?.id === 'beta' && !!document.querySelector('#show-machine')`); } catch {}
if (!loaded) await new Promise(r => setTimeout(r, 30));
}
assert.equal(loaded, true, 'beta did not load');
await b.evaluate(`(async () => { for (let i = 0; i < 150; i++) { if (window.MosaicOrg?.id === 'beta' && !!document.querySelector('#show-machine')) return; await new Promise(r => setTimeout(r, 30)); } throw Error('beta did not load'); })()`);
assert.equal(await b.evaluate(`document.querySelector('#show-machine').checked`), false);
});
@@ -0,0 +1,312 @@
// SP1 (#1873, single pane slice 1): #/dashboard is the system landing route, an organization page is a
// drill-in route inside the same shell (no reload, the sidebar and top bar stay), and an organization
// whose bus is down says so with its reason. Every bus is a fake: acme and beta answer, gamma's bus is
// stopped. The real GET /api/orgs, /api/workspace and /api/agents/levels serve the page.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString();
async function fixture({ empty = false, bootless = false } = {}) {
const { checkRootAgents } = await import('../../organization/src/index.mjs');
const rolesDir = fileURLToPath(new URL('../../../roles', import.meta.url));
const root = { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: {} }, { rolesDir }) };
const LIST = [
{ id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true },
{ id: 'beta', label: 'Beta Works', theme: 'grove', busHealthy: true },
{ id: 'gamma', label: 'Gamma Home', theme: 'rosewood', busHealthy: false, busStopped: true },
];
const ROLES = {
acme: { chief: { definition: 'chief-of-staff' }, coder: { definition: 'coder' }, reviewer: { definition: 'reviewer' } },
beta: { chief: { definition: 'chief-of-staff' } },
gamma: { chief: { definition: 'chief-of-staff' }, researcher: { definition: 'research' } },
};
const DATA = {
acme: {
projects: [{ id: 'p-site', repo: 'site', name: 'Website', archived: false, at: at(30) }, { id: 'p-zeta', repo: 'zeta', name: 'Zeta API', archived: false, at: at(300) }],
agents: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'working', at: at(40), working: { since: at(40) } }],
inbox: [{ id: 'dec-acme-1', question: 'Ship the hero?', class: 'gated', blocking: true, raised_by_role: 'coder', at: at(10), options: [{ key: 'yes', text: 'Ship it' }] }],
},
beta: { projects: [{ id: 'p-shop', repo: 'shop', name: 'Shop', archived: false, at: at(120) }], agents: [], inbox: [] },
};
// Launch settings differ per organization so a value carried from one to the other shows.
const LAUNCH = id => ({ business: id, revision: (id === 'acme' ? 'a' : 'b').repeat(64), models: { opus: { provider: 'anthropic', model: 'claude-opus-5-5' } }, limits: { total: id === 'acme' ? 4 : 7 }, instances: {}, usage: { total: 0, providers: {}, models: {} } });
const value = (id, name) => Promise.resolve(DATA[id]?.[name] ?? (['usage', 'limits'].includes(name) ? {} : []));
const opts = id => ({
bus: { inbox: () => value(id, 'inbox'), agents: () => value(id, 'agents'), 'thread.claims': () => value(id, 'claims'), tasks: () => value(id, 'tasks'), usage: () => value(id, 'usage'), limits: () => value(id, 'limits'), decisions: () => value(id, 'decisions') },
chat: { threads: () => value(id, 'threads'), projects: () => value(id, 'projects') },
});
const member = org => ({
id: org.id, doc: { appearance: { theme: org.theme }, roles: ROLES[org.id], projects: {} },
status: async () => (org.busHealthy ? { healthy: true, pid: 4242, boot: null } : bootless ? { healthy: false, boot: null } : { healthy: false, boot: { ok: false, at: at(90), lastGood: null, reason: 'stopped by the owner' } }),
options: () => opts(org.id),
});
const instances = id => Object.fromEntries(Object.entries(ROLES[id]).map(([n, r]) => [n, { definition: r.definition, vars: {}, launched: true, launcher: false, protected: false, kept: false, running: n === 'coder' ? ['run-1'] : [], alive: [] }]));
const organizations = {
list: async () => (empty ? [] : LIST),
members: async () => (empty ? [] : LIST.map(member)),
resolve: async id => {
const org = LIST.find(o => o.id === id); if (!org) return null;
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } };
return {
busHealthy: true,
reads: { business: async () => ({ business: { id, human: 'jason', projects: DATA[id].projects.map(p => p.repo) } }), settings: async () => ({ business: id, release: 'fixture', notifier: {}, launch: LAUNCH(id) }), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
bus: { messages: async () => ({ messages: [], unread: 0 }), agents: async () => DATA[id].agents, 'thread.claims': async () => [], inbox: async () => DATA[id].inbox.map(d => ({ ...d, business: id, raised_by_run: 'run', routed_to: 'human' })), tasks: async () => [], instances: async () => ({ business: id, revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {}, instances: instances(id) }) },
chat: { projects: async () => DATA[id].projects, threads: async () => [] },
};
},
};
return { organizations, agentLevels: { loadRoot: () => root, rolesDir, ctx: { configDir: '/cfg', dataRoot: '/data', instance: 'test' } } };
}
async function setup(t, { viewport = [1440, 900], path = '/', empty = false, init = null, bootless = false } = {}) {
const { organizations, agentLevels } = await fixture({ empty, bootless });
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, agentLevels, log: () => {} });
t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(...viewport);
// `init` runs before the page's own scripts, under the display.js fetch wrapper, so it sees the scoped URL.
if (init) await b.call('Page.addScriptToEvaluateOnNewDocument', { source: init });
const base = `http://127.0.0.1:${server.address().port}`;
await b.navigate(`${base}${path}`);
return { b, base, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) };
}
const rowText = org => `document.querySelector("[data-ws-orgrow=${org}]")?.innerText.replace(/\\s+/g, " ").trim() ?? ""`;
test('the bare address lands on #/dashboard without a reload and lists every organization with only the figures read', async t => {
const { b, wait, text } = await setup(t);
await wait('location.hash === "#/dashboard" && document.querySelectorAll("[data-ws-orgrow]").length === 3');
// One page load: the landing route is set in place, never by location.reload().
assert.equal(await b.evaluate('performance.getEntriesByType("navigation").length'), 1);
assert.equal(await b.evaluate('performance.getEntriesByType("navigation")[0].type'), 'navigate');
assert.equal(await text('#bus-view h1'), 'Dashboard');
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]")?.getAttribute("href")'), '#/dashboard');
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-orgrow]")].map(r => r.dataset.wsOrgrow)'), ['acme', 'beta', 'gamma']);
// Agents from the levels read, projects and needs-you from the workspace read.
await wait(`${rowText('acme')}.includes("3 agents")`);
const acme = await b.evaluate(rowText('acme'));
assert.match(acme, /Acme Studio/);
assert.match(acme, /bus running/);
assert.match(acme, /2 projects/);
assert.match(acme, /1 needs you/);
assert.match(await b.evaluate(rowText('beta')), /1 agent\b/);
// gamma: down with its reason; its agent count comes from its file, the bus figures are not read.
const gamma = await b.evaluate(rowText('gamma'));
assert.match(gamma, /bus stopped/);
assert.match(gamma, /stopped by the owner/);
assert.match(gamma, /2 agents/);
assert.doesNotMatch(gamma, /\d+ projects?|\d+ needs you/);
assert.equal(await b.evaluate('document.querySelector("[data-ws-orgrow=beta] a")?.getAttribute("href")'), '#/orgs/beta/organization');
// A system route marks no organization in the sidebar and shows no breadcrumb.
assert.equal(await b.evaluate('document.querySelectorAll("#workspace-nav .sel, #workspace-nav [aria-current]").length'), 0);
assert.equal(await b.evaluate('document.querySelector(".ws-crumb")'), null);
// The top bar names no organization.
assert.equal(await b.evaluate('document.getElementById("current-organization")'), null);
});
test('an organization opens inside the same shell with a breadcrumb, and its pages keep reading that organization', async t => {
const { b, wait, text } = await setup(t);
await wait('document.querySelectorAll("[data-ws-orgrow]").length === 3');
await b.evaluate('window.__marker = 1; window.__bar = document.querySelector(".cmdbar"); window.__side = document.getElementById("workspace-nav")');
await b.evaluate('document.querySelector("[data-ws-orgrow=acme] a").click()');
await wait('location.hash === "#/orgs/acme/organization" && document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"');
assert.equal(await b.evaluate('window.__marker'), 1);
assert.equal(await b.evaluate('window.__bar === document.querySelector(".cmdbar") && window.__side === document.getElementById("workspace-nav")'), true);
assert.equal(await b.evaluate('performance.getEntriesByType("navigation").length'), 1);
assert.equal(await b.evaluate('document.querySelector(".ws-crumb")?.innerText.replace(/\\s+/g, " ").trim()'), 'Dashboard › Acme Studio');
assert.equal(await b.evaluate('document.querySelector(".ws-crumb a")?.getAttribute("href")'), '#/dashboard');
assert.equal(await b.evaluate('document.getElementById("current-organization")'), null);
// The sidebar marks the open organization.
await wait('document.querySelector("#workspace-nav a[aria-current=page]")?.getAttribute("href") === "#/orgs/acme/organization"');
// The sidebar opens another organization in place; its page reads that organization's routes.
await b.evaluate('performance.clearResourceTimings(); document.querySelector("#workspace-nav a[href=\'#/orgs/beta/organization\']").click()');
await wait('location.hash === "#/orgs/beta/organization" && document.querySelector("[data-org-dashboard] h1")?.textContent === "Beta Works"');
assert.equal(await b.evaluate('window.__marker'), 1);
// A page inside beta scopes its reads to beta.
await b.evaluate('location.hash = "#/agents"');
await wait('location.hash === "#/orgs/beta/agents" && document.querySelector("[data-org-agents]")');
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'beta');
assert.equal(await b.evaluate('window.__marker'), 1);
// The organization's control board is named as such after the organization, not as a second Dashboard.
await b.evaluate('location.hash = "#/orgs/beta/board"');
await wait('/^Dashboard\\/.+\\/Control board$/.test(document.getElementById("breadcrumbs")?.textContent ?? "")');
assert.equal(await b.evaluate('document.querySelector("#breadcrumbs [data-crumb-org]")?.getAttribute("href")'), '#/orgs/beta/organization');
// The breadcrumb goes back to the system dashboard, still without a reload.
await b.evaluate('location.hash = "#/orgs/beta/organization"');
await wait('document.querySelector(".ws-crumb a")');
await b.evaluate('document.querySelector(".ws-crumb a").click()');
await wait('location.hash === "#/dashboard" && document.querySelectorAll("[data-ws-orgrow]").length === 3');
assert.equal(await b.evaluate('window.__marker'), 1);
assert.equal(await b.evaluate('performance.getEntriesByType("navigation").length'), 1);
assert.equal(await text('#bus-view h1'), 'Dashboard');
});
test('an old #/orgs/<id>/... link still opens that page', async t => {
const { b, wait } = await setup(t, { path: '/#/orgs/beta/agents' });
await wait('document.querySelector("[data-org-agents]")');
assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/agents');
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'beta');
assert.equal(await b.evaluate('performance.getEntriesByType("navigation").length'), 1);
});
test('a stopped organization says so on its page and on its agents, with the reason', async t => {
const { b, wait } = await setup(t, { path: '/#/orgs/gamma/organization' });
await wait('document.querySelector("[data-org-dashboard] [data-org-down]")');
const down = await b.evaluate('document.querySelector("[data-org-down]").innerText.replace(/\\s+/g, " ")');
assert.match(down, /The bus is not running/);
assert.match(down, /stopped by the owner/);
await b.evaluate('location.hash = "#/orgs/gamma/agents"');
await wait('document.querySelector("[data-org-agents] [data-org=gamma] [data-agents-why]")');
assert.equal(await b.evaluate('document.querySelector("[data-org=gamma] [data-agents-why]").innerText.trim()'), 'Agents not read: the bus is stopped');
const agents = await b.evaluate('document.querySelector("[data-org-agents]").innerText.replace(/\\s+/g, " ")');
assert.doesNotMatch(agents, /Agents unavailable/);
});
test('at 390 px the dashboard lists the organizations without a horizontal scroll', async t => {
const { b, wait } = await setup(t, { viewport: [390, 844] });
await wait('document.querySelectorAll("[data-ws-orgrow]").length === 3');
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= window.innerWidth'), true);
});
// Review round finding 1: with no organization yet the page keeps its first-run addresses, so the
// wordmark (href "#/") opens the first-run board, not a #/dashboard the first-run page has no view for.
test('first run: the wordmark opens the first-run board, not a Not found page', async t => {
const { b, wait } = await setup(t, { path: '/#/settings/appearance', empty: true });
await wait('document.querySelector("#bus-view h1")');
await b.evaluate('document.querySelector(".wordmark").click()');
// The board route empties the page view; give the router and render time to settle before checking.
await wait('location.hash !== "#/settings/appearance"');
await new Promise(r => setTimeout(r, 800));
assert.equal(await b.evaluate('location.hash'), '#/');
assert.equal(await b.evaluate('!!document.querySelector("#bus-view .nf")'), false);
assert.equal(await b.evaluate('window.MosaicOrg'), undefined);
});
test('a switch stops following an agent request: no poll reaches the next organization and its page shows no receipt', async t => {
const ID = '11111111-2222-4333-8444-555555555555';
const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })';
const init = `window.__reads = []; window.__posts = []; const original = window.fetch;
window.fetch = async (url, options = {}) => { const s = String(url); let x;
if (/^\\/api\\/orgs\\/[^/]+\\/config$/.test(s)) { const r = await original(url, options), body = await r.json(); x = { ...body, send: true }; return ${json}; }
if (options.method === 'POST' && /\\/agent\\/launch$/.test(s)) { __posts.push(s); x = { request: '${ID}', at: 'x' }; return ${json}; }
if (/\\/agent\\/request\\?/.test(s)) { __reads.push(s); x = { request: '${ID}', action: 'launch', instance: 'reviewer', state: 'open', done: null, cancelled: null }; return ${json}; }
return original(url, options); };`;
const { b, wait } = await setup(t, { path: '/#/orgs/acme/settings/agents', init });
const launch = '#agents-section [data-agent-action=launch][data-agent-name=reviewer]';
await wait(`document.querySelector(${JSON.stringify(launch)}) && !document.querySelector(${JSON.stringify(launch)}).disabled`);
await b.evaluate(`document.querySelector(${JSON.stringify(launch)}).click()`);
await wait('window.__reads.length >= 1');
assert.deepEqual(await b.evaluate('window.__posts'), ['/api/orgs/acme/agent/launch']);
assert.match(await b.evaluate('window.__reads[0]'), /^\/api\/orgs\/acme\/agent\/request\?id=/);
await b.evaluate('location.hash = "#/orgs/beta/settings/agents"');
await wait('location.hash === "#/orgs/beta/settings/agents" && document.querySelector("#agents-section [data-agent-name=chief]") && !document.querySelector("#agents-section [data-agent-name=reviewer]")');
const seen = await b.evaluate('window.__reads.length');
// Two poll periods pass on beta's page.
await new Promise(r => setTimeout(r, 4500));
const reads = await b.evaluate('window.__reads');
assert.equal(reads.filter(u => u.includes('/orgs/beta/')).length, 0, reads.join(' '));
assert.equal(reads.length, seen, 'the poll stopped at the switch');
assert.equal(await b.evaluate('document.querySelector("#agents-receipt")?.textContent.trim() ?? ""'), '');
assert.equal(await b.evaluate('document.querySelector("#agents-section [data-agent-action=launch][data-agent-name=chief]")?.disabled'), false);
});
test('a save still in flight at a switch: its answer is not drawn on the next organization and its Save is not held', async t => {
const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })';
// acme's settings POST is held until the test releases it, then answers with acme's saved values.
const init = `window.__posts = []; const original = window.fetch;
window.fetch = async (url, options = {}) => { const s = String(url); let x;
if (/^\\/api\\/orgs\\/[^/]+\\/config$/.test(s)) { const r = await original(url, options), body = await r.json(); x = { ...body, send: true }; return ${json}; }
if (options.method === 'POST' && s === '/api/orgs/acme/settings') { __posts.push(JSON.parse(options.body)); await new Promise(r => { window.__release = r; });
x = { business: 'acme', revision: 'c'.repeat(64), models: { opus: { provider: 'anthropic', model: 'claude-opus-5-5' } }, limits: { total: 5 }, instances: {}, usage: { total: 0, providers: {}, models: {} } }; return ${json}; }
return original(url, options); };`;
const { b, wait } = await setup(t, { path: '/#/orgs/acme/settings/limits', init });
const total = '#launch-settings-form input[data-setting=\'["total"]\']';
const submit = '#launch-settings-form button[type=submit]';
await wait(`document.querySelector(${JSON.stringify(total)})?.value === "4" && !document.querySelector(${JSON.stringify(submit)}).disabled`);
await b.evaluate(`(() => { const i = document.querySelector(${JSON.stringify(total)}); i.value = "5"; i.dispatchEvent(new Event("input", { bubbles: true })); document.querySelector(${JSON.stringify(submit)}).click(); })()`);
await wait('typeof window.__release === "function"');
assert.equal(await b.evaluate('window.__posts[0]?.revision'), 'a'.repeat(64));
await b.evaluate('location.hash = "#/orgs/beta/settings/limits"');
await wait(`location.hash === "#/orgs/beta/settings/limits" && document.querySelector(${JSON.stringify(total)})?.value === "7"`);
// beta's Save is its own: not held by acme's write.
assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(submit)}).disabled`), false);
await b.evaluate('window.__release()');
await new Promise(r => setTimeout(r, 700));
assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/settings/limits');
assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(total)})?.value`), '7');
assert.equal(await b.evaluate('document.getElementById("launch-settings-receipt")?.textContent.trim() ?? ""'), '');
assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(submit)}).disabled`), false);
});
test('a down banner does not repeat its heading as the reason', async t => {
// No boot record: the server's reason is its fallback, "the bus is not running", the heading's own words.
const { b, wait } = await setup(t, { path: '/#/orgs/gamma/organization', bootless: true });
await wait('document.querySelector("[data-org-dashboard] [data-org-down]")');
const down = await b.evaluate('document.querySelector("[data-org-down]").innerText.replace(/\\s+/g, " ")');
assert.match(down, /The bus is not running\./);
assert.equal((down.match(/the bus is not running/gi) || []).length, 1, down);
assert.doesNotMatch(down, /Reason:|No reason was read/);
});
test('a restart started on one organization finishes on that organization after a switch', { timeout: 60000 }, async t => {
const END = '11111111-2222-4333-8444-5555555555e1', LAUNCHED = '11111111-2222-4333-8444-5555555555a1';
const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })';
// Every agent request read and write is recorded, whichever organization it names; the bus answers
// are stubs (terminate, closed requests, one launch). Only acme's instances read is stubbed and
// recorded (the session gone): beta's own page reads beta's instances, which is not the chain.
const init = `window.__chain = []; const original = window.fetch;
window.fetch = async (url, options = {}) => { const s = String(url); let x;
if (options.method === 'POST' && /\\/agent\\/terminate$/.test(s)) { __chain.push(s); x = { request: '${END}' }; return ${json}; }
if (options.method === 'POST' && /\\/agent\\/launch$/.test(s)) { __chain.push(s); x = { request: '${LAUNCHED}' }; return ${json}; }
if (/\\/agent\\/request\\?/.test(s)) { __chain.push(s.split('?')[0]); const id = new URL(s, location.href).searchParams.get('id');
x = { request: id, instance: 'coder', action: id === '${END}' ? 'end' : 'launch', state: 'closed', done: { run: id === '${END}' ? null : 'run-9' }, cancelled: null }; return ${json}; }
if (s === '/api/orgs/acme/bus/instances' && window.__restarting) { __chain.push(s); x = { rows: [{ instances: { coder: { running: [] } } }] }; return ${json}; }
return original(url, options); };`;
const { b, wait } = await setup(t, { path: '/#/orgs/acme/organization', init });
await wait('window.MosaicAgents && document.querySelector("[data-org-dashboard] h1")');
// The button the Routing receipt offers (agents.js restartMarkup), placed on acme's page.
await b.evaluate('window.__restarting = true; document.querySelector("[data-org-dashboard]").insertAdjacentHTML("beforeend", window.MosaicAgents.restartMarkup("coder")); document.querySelector("[data-agent-restart=coder]").click()');
await wait('window.__chain.length >= 1');
await b.evaluate('location.hash = "#/orgs/beta/organization"');
await wait('location.hash === "#/orgs/beta/organization"');
// Done when the chain has launched and read the launch request back, or as soon as any of it names beta.
await wait('window.__chain.some(u => u.includes("/orgs/beta/")) || (window.__chain.some(u => /\\/agent\\/launch$/.test(u)) && window.__chain.filter(u => /\\/agent\\/request$/.test(u)).length >= 2)', 30000);
const chain = await b.evaluate('window.__chain');
assert.deepEqual([...new Set(chain)], ['/api/orgs/acme/agent/terminate', '/api/orgs/acme/agent/request', '/api/orgs/acme/bus/instances', '/api/orgs/acme/agent/launch'], chain.join(' '));
// beta's page draws nothing of acme's chain.
assert.equal(await b.evaluate('!!document.querySelector("[data-agent-restart-status=coder]")'), false);
});
test('a file save still in flight at a switch: its outcome is not announced on the next organization', async t => {
const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })';
// acme's file reads are stubbed; its save POST is held until the test releases it, then answers with a commit.
const init = `window.__posts = []; const original = window.fetch;
window.fetch = async (url, options = {}) => { const s = String(url); let x;
if (/^\\/api\\/orgs\\/[^/]+\\/config$/.test(s)) { const r = await original(url, options), body = await r.json(); x = { ...body, send: true, edit: true }; return ${json}; }
if (s.startsWith('/api/orgs/acme/project-file?')) { x = { path: 'docs/a.md', commit: 'a'.repeat(40), content: '# A\\n', at: new Date().toISOString() }; return ${json}; }
if (options.method === 'POST' && s === '/api/orgs/acme/project-file') { __posts.push(JSON.parse(options.body)); await new Promise(r => { window.__release = r; });
x = { path: 'docs/a.md', commit: 'c'.repeat(40) }; return ${json}; }
return original(url, options); };`;
const { b, wait } = await setup(t, { path: `/#/orgs/acme/docs/site/${encodeURIComponent('docs/a.md')}`, init });
await wait('document.querySelector("#doc-form")');
if (!await b.evaluate('!!document.querySelector("#doc-text")')) await b.evaluate('document.querySelector("#doc-mode-source").click(), true');
await wait('document.querySelector("#doc-text")?.value === "# A\\n"');
await b.evaluate('(() => { const a = document.querySelector("#doc-text"); a.value = "# A\\n\\nmore\\n"; a.dispatchEvent(new Event("input", { bubbles: true })); document.querySelector("#doc-form").requestSubmit(); })()');
await wait('typeof window.__release === "function"');
assert.equal(await b.evaluate('window.__posts[0]?.base'), 'a'.repeat(40));
await b.evaluate('location.hash = "#/orgs/beta/organization"');
await wait('location.hash === "#/orgs/beta/organization" && document.querySelector("[data-org-dashboard] h1")?.textContent === "Beta Works"');
await b.evaluate('window.__release()');
await new Promise(r => setTimeout(r, 700));
assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/organization');
assert.doesNotMatch(await b.evaluate('document.getElementById("announce")?.textContent ?? ""'), /Saved as commit|Draft kept/);
});
@@ -370,7 +370,7 @@ test('an unavailable selected bus does not hide other organizations on Agents',
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/beta/agents`);
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('[data-org-agents]'))return;await new Promise(r=>setTimeout(r,30))}throw Error('organization agent grid missing')})()`);
const text=await b.evaluate('document.querySelector("[data-org-agents]").innerText');
assert.match(text,/Alpha/); assert.match(text,/chief/); assert.match(text,/Beta/); assert.match(text,/Agents unavailable/);
assert.match(text,/Alpha/); assert.match(text,/chief/); assert.match(text,/Beta/); assert.match(text,/Agents not read: the bus is not answering/); assert.doesNotMatch(text,/Agents unavailable/);
});
test('a failed conversation read says unavailable, never that none exists', async t => {
@@ -81,10 +81,10 @@ async function setup(t, { viewport = [1440, 900], hash = '/', workspaceTimeoutMs
test('Dashboard renders every section across organizations, and a stopped one shows its state while the others render', async t => {
const { b, wait, text } = await setup(t);
await wait('document.querySelector("[data-ws-home] [data-ws-tiles]") && document.querySelectorAll("[data-ws-orgcard]").length === 3');
await wait('document.querySelector("[data-ws-home] [data-ws-tiles]") && document.querySelectorAll("[data-ws-orgrow]").length === 3');
// The Dashboard nav item is this page.
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page] .t")?.textContent'), 'Dashboard');
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]").getAttribute("href")'), '#/');
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]").getAttribute("href")'), '#/dashboard');
assert.equal(await text('#bus-view h1'), 'Dashboard');
// Tiles: needs you, sessions, open tasks, projects, and an honest line for spend.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-tile]")].map(e => e.dataset.wsTile)'), ['needs', 'sessions', 'tasks', 'projects', 'spend']);
@@ -92,12 +92,12 @@ test('Dashboard renders every section across organizations, and a stopped one sh
assert.match(await text('[data-ws-tile=projects]'), /Projects\s*4\s*3 active/i);
assert.match(await text('[data-ws-tile=spend]'), /No spend figures yet/);
assert.doesNotMatch(await text('[data-ws-tile=spend]'), /\$|\d/);
// Organization cards: two running with their read, the stopped one with its state and the time it was read.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-orgcard]")].map(e => [e.dataset.wsOrgcard, e.dataset.state])'), [['acme', 'ok'], ['beta', 'ok'], ['gamma', 'down']]);
assert.match(await text('[data-ws-orgcard=gamma]'), /Gamma Home/);
assert.match(await text('[data-ws-orgcard=gamma]'), /down/);
assert.match(await text('[data-ws-orgcard=gamma]'), /stopped by the owner/);
assert.equal(await b.evaluate('document.querySelector("[data-ws-orgcard=beta] a").getAttribute("href")'), '#/orgs/beta/organization');
// SP1: one organization row each: two running, the stopped one with its state and reason.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-orgrow]")].map(e => [e.dataset.wsOrgrow, e.dataset.state])'), [['acme', 'ok'], ['beta', 'ok'], ['gamma', 'down']]);
assert.match(await text('[data-ws-orgrow=gamma]'), /Gamma Home/);
assert.match(await text('[data-ws-orgrow=gamma]'), /bus down/);
assert.match(await text('[data-ws-orgrow=gamma]'), /stopped by the owner/);
assert.equal(await b.evaluate('document.querySelector("[data-ws-orgrow=beta] a").getAttribute("href")'), '#/orgs/beta/organization');
assert.match(await text('[data-ws-home] .sub'), /2 of 3 organizations answered/);
assert.match(await text('[data-ws-home] .sub'), /Gamma Home down, read \d\d:\d\d/);
// Recent project activity across both running organizations, newest first, each linking into its org.
@@ -243,18 +243,18 @@ test('A poll repaints in place: focus, caret and scroll stay where they were', a
assert.deepEqual(await b.evaluate('[document.activeElement?.dataset.focusKey, document.activeElement.selectionStart]'), ['proj-name', 2]);
// The Dashboard and the Inbox repaint the same way.
await b.evaluate('location.hash = "#/orgs/acme/"');
await wait('document.querySelectorAll("[data-ws-orgcard]").length === 3');
await b.evaluate('document.querySelector("[data-ws-orgcard=beta] a").focus()');
await wait('document.querySelectorAll("[data-ws-orgrow]").length === 3');
await b.evaluate('document.querySelector("[data-ws-orgrow=beta] a").focus()');
await b.evaluate('window.__marker = document.querySelector("[data-ws-orgs]"); document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))');
await wait('document.querySelector("[data-ws-orgs]") !== window.__marker');
assert.equal(await b.evaluate('document.activeElement?.dataset.focusKey'), 'orgcard:beta');
assert.equal(await b.evaluate('document.activeElement?.dataset.focusKey'), 'orgrow:beta');
// No loading view replaced the page during that repaint.
assert.equal(await b.evaluate('document.querySelector("#bus-view .loading")'), null);
});
test('Phone width: no horizontal scroll on any of the three pages, and the drawer opens and closes', async t => {
const { b, wait } = await setup(t, { viewport: [390, 844] });
for (const [hash, ready] of [['/', '[data-ws-orgcard]'], ['/projects', '[data-ws-project]'], ['/inbox', '[data-ws-item]']]) {
for (const [hash, ready] of [['/', '[data-ws-orgrow]'], ['/projects', '[data-ws-project]'], ['/inbox', '[data-ws-item]']]) {
await b.evaluate(`location.hash = "#/orgs/acme${hash}"`);
await wait(`document.querySelector(${JSON.stringify(ready)})`);
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, hash);