feat(webui): one shell, a system dashboard, organization pages as drill-ins (#1873, lane sp1)
#/dashboard is the system landing route: one row per organization with its bus state, agent count (organization files), project count and needs-you count, each only when a read gave it, and active agents. Opening an organization from the sidebar or a dashboard row is a route inside the same page: no reload, a Dashboard / <organization> breadcrumb, and later /api reads scoped to it through the existing fetch wrapper, whose organization is now a variable the router sets (mosaic-org-switch event). #/orgs/<id>/... stays the address. An organization whose bus is down says so on the dashboard and on its page, with the reason the workspace read gives.
This commit is contained in:
1 parent
387f73a7a4
commit
e24e7e93a2
15 files changed
+492
-130
No files matched your search
@@ -11,8 +11,10 @@
|
||||
// Show machine sessions (#1589): off by default, kept with the page's settings. While it is off the
|
||||
// page doesn't read /api/board at all; the held roles above come from the bus (bus.js).
|
||||
let machine = false;
|
||||
const boardKey = window.MosaicOrg ? `mosaic-console-board:${window.MosaicOrg.id}` : 'mosaic-console-board';
|
||||
try { machine = JSON.parse(localStorage.getItem(boardKey))?.machine === true; } catch {}
|
||||
// #1873 SP1: kept per organization, read again when the open organization changes.
|
||||
const boardKey = () => window.MosaicOrg?.id ? `mosaic-console-board:${window.MosaicOrg.id}` : 'mosaic-console-board';
|
||||
const readMachine = () => { try { return JSON.parse(localStorage.getItem(boardKey()))?.machine === true; } catch { return false; } };
|
||||
machine = readMachine();
|
||||
// Signed out (#1589): a Console fetch answered with a redirect, so the sign-in proxy turned it away.
|
||||
// The first one sets body.signed-out (the listener below); after that no Console fetch is made.
|
||||
const out = () => document.body.classList.contains('signed-out');
|
||||
@@ -151,6 +153,16 @@
|
||||
override = readOverride();
|
||||
paint();
|
||||
document.dispatchEvent(new CustomEvent('mosaic-theme-change', {detail: {palette: current.theme, mode: current.mode, stored: true, warning: null, picked: current.theme}}));
|
||||
// The open organization's saved appearance, read again when another organization opens (#1873 SP1).
|
||||
const readBusiness = async () => {
|
||||
try {
|
||||
const { ok, body } = await getJSON('/api/business');
|
||||
if (ok && orgId(body?.business?.id)) return window.MosaicAppearance.organization(body.business.id, body.business.appearance, body.business.id);
|
||||
} catch (err) { if (err?.signedOut) return; }
|
||||
// No fresh answer: keep the organization this browser last showed for its own override, but not its stale saved value.
|
||||
if (org && !org.confirmed) { org.saved = null; showAppearance(); }
|
||||
};
|
||||
document.addEventListener('mosaic-org-switch', () => { readBusiness(); });
|
||||
window.MosaicAppearance.ready = (async () => {
|
||||
await Promise.allSettled([
|
||||
(async () => {
|
||||
@@ -161,14 +173,7 @@
|
||||
} catch (err) { if (err?.signedOut) return; themes = []; themesUnavailable = true; }
|
||||
showAppearance();
|
||||
})(),
|
||||
(async () => {
|
||||
try {
|
||||
const { ok, body } = await getJSON('/api/business');
|
||||
if (ok && orgId(body?.business?.id)) return window.MosaicAppearance.organization(body.business.id, body.business.appearance, body.business.id);
|
||||
} catch (err) { if (err?.signedOut) return; }
|
||||
// No fresh answer: keep the organization this browser last showed for its own override, but not its stale saved value.
|
||||
if (org && !org.confirmed) { org.saved = null; showAppearance(); }
|
||||
})(),
|
||||
readBusiness(),
|
||||
]);
|
||||
})();
|
||||
$('palette').onchange = e => window.MosaicAppearance.set({ theme: e.target.value });
|
||||
@@ -488,12 +493,18 @@
|
||||
const box = $('bus-down');
|
||||
// E2E5 review P2: the organization list is read first and on its own, so a failed bus-status read
|
||||
// (likely when the bus is down) still refreshes the list, its banner and the Settings Start control.
|
||||
// #1873 SP1: the dashboard lists every organization's bus on its own rows; this banner is an organization page's.
|
||||
if (window.MosaicOrg && window.MosaicOrg.route().split('?')[0] === '/dashboard') { box.hidden = true; return; }
|
||||
// An answer about an organization that is no longer open is dropped: the new one's read draws the banner.
|
||||
const asked = window.MosaicOrg?.id ?? null, stale = () => (window.MosaicOrg?.id ?? null) !== asked;
|
||||
if (window.MosaicOrg) {
|
||||
const org = (await window.MosaicOrg.readList()).find(row => row.id === window.MosaicOrg.id);
|
||||
const org = (await window.MosaicOrg.readList()).find(row => row.id === asked);
|
||||
if (stale()) return;
|
||||
if (org?.busHealthy === false) { box.textContent = 'Bus unavailable'; box.hidden = false; window.MosaicConsole?.attachBus(box, 'Restart the bus'); return; }
|
||||
}
|
||||
try {
|
||||
const s = await api('/api/bus-status');
|
||||
if (stale()) return;
|
||||
if (!s.down) { box.hidden = true; return; }
|
||||
const b = s.boot;
|
||||
box.replaceChildren(node('b', '', 'Bus down: organization file invalid. '), node('span', '', `${b.reason} `), node('span', 'small muted', `File ${b.file}. Refused at ${b.at}. Last good load: ${b.lastGood ?? 'none'}. Fix the file, then restart the bus.`));
|
||||
@@ -528,11 +539,20 @@
|
||||
function showMachine() { $('show-machine').checked = machine; document.body.classList.toggle('machine-off', !machine); }
|
||||
$('show-machine').onchange = e => {
|
||||
machine = e.target.checked; showMachine();
|
||||
try { localStorage.setItem(boardKey, JSON.stringify({ machine })); } catch {}
|
||||
try { localStorage.setItem(boardKey(), JSON.stringify({ machine })); } catch {}
|
||||
if (machine) { $('status').textContent = 'Loading board…'; fresh(); refresh(); } else hideMachine();
|
||||
};
|
||||
showMachine();
|
||||
if (!machine) hideMachine();
|
||||
// #1873 SP1: another organization opened in place. Its board setting and bus state are its own.
|
||||
const onOrgSwitch = () => {
|
||||
machine = readMachine(); showMachine();
|
||||
if (machine) { fresh(); refresh(); } else { hideMachine(); busDown(); }
|
||||
};
|
||||
document.addEventListener('mosaic-org-switch', onOrgSwitch);
|
||||
// An address with no organization picks one after the first list read; its own setting applies then.
|
||||
window.MosaicOrg?.ready?.then(() => { if (readMachine() !== machine) onOrgSwitch(); });
|
||||
window.addEventListener('hashchange', () => { if (window.MosaicOrg) busDown(); });
|
||||
$('refresh').onclick = refresh;
|
||||
$('mobile-pause').onclick = () => $('pause').click();
|
||||
$('pause').onclick = () => { paused = !paused; $('pause').querySelector('span').textContent = paused ? 'Resume' : 'Pause'; $('pause').setAttribute('aria-label', paused ? 'Resume' : 'Pause'); $('pause').title = paused ? 'Resume' : 'Pause'; $('mobile-pause').textContent = paused ? 'Resume' : 'Pause'; $('pause').setAttribute('aria-pressed', String(paused)); schedule(); if (data) render(); };
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -275,9 +275,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 +343,8 @@
|
||||
});
|
||||
|
||||
let configRead = Promise.resolve();
|
||||
// #1873 SP1: the config read an organization switch started, until the next render has waited for it.
|
||||
let switchRead = null;
|
||||
let canSend = false, canMessage = false, chatSend = false, sendRoles = null, sending = false, receipt = null, replies = [];
|
||||
const REPLIES = 50; // the broker's cap
|
||||
function repliesHtml() {
|
||||
@@ -1546,7 +1549,7 @@
|
||||
});
|
||||
|
||||
// Not found and states
|
||||
function notFound(why) { return `${head('Not found')}<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 +1604,8 @@
|
||||
const parts = path.split('/').filter(Boolean).map(p => { try { return decodeURIComponent(p); } catch { return null; } });
|
||||
if (parts.includes(null)) return notFound();
|
||||
const [a, b, c] = parts, n = parts.length;
|
||||
if (n === 0 && window.MosaicOrg) return window.MosaicWorkspace.home();
|
||||
// #1873 SP1: `#/dashboard` is the system dashboard, every organization on one page.
|
||||
if ((n === 0 || (a === 'dashboard' && n === 1)) && window.MosaicOrg) return window.MosaicWorkspace.home();
|
||||
if (a === 'projects' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.projectsPage(q);
|
||||
if (a === 'organization' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.dashboard();
|
||||
if (a === 'inbox' && n === 1 && window.MosaicOrg) return configRead.then(() => window.MosaicWorkspace.inboxPage(q, { hire: hireStartHtml }));
|
||||
@@ -1626,7 +1630,7 @@
|
||||
const paused = () => $('pause')?.getAttribute('aria-pressed') === 'true';
|
||||
// A file open in the editor is never refreshed from under its draft; Reload is the person's to press.
|
||||
// The cross-organization pages (U3) poll at the workspace read's pollMs.
|
||||
const wsPage = path => !!window.MosaicOrg && (path === '/' || path === '' || path === '/projects' || path === '/inbox');
|
||||
const wsPage = path => !!window.MosaicOrg && (path === '/' || path === '' || path === '/dashboard' || path === '/projects' || path === '/inbox');
|
||||
function schedule() { clearTimeout(timer); if (!paused() && !out() && !routeOf().startsWith('/docs/')) timer = setTimeout(() => render(false), wsPage(routeOf().split('?')[0]) ? window.MosaicWorkspace?.pollMs || 10000 : 10000); }
|
||||
// Keep focus, open <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 +1680,14 @@
|
||||
// A refresh due while this render reads would replace it: a navigation would lose its heading focus.
|
||||
clearTimeout(timer);
|
||||
if (out()) return;
|
||||
// #1873 SP1: an address with no organization waits for the first list read to pick one, and a
|
||||
// page drawn right after a switch waits for the new organization's config.
|
||||
if ((window.MosaicOrg && !window.MosaicOrg.id) || switchRead) {
|
||||
const g0 = ++gen, waiting = switchRead;
|
||||
await window.MosaicOrg?.ready; await waiting;
|
||||
if (waiting && switchRead === waiting) switchRead = null;
|
||||
if (g0 !== gen || out()) return;
|
||||
}
|
||||
const route = routeOf(), [path, query = ''] = route.split('?'), board = window.MosaicOrg ? path === '/board' : path === '/' || path === '' || path === '/board';
|
||||
// On the cross-organization pages a filter or selection changes only the query: that is a
|
||||
// repaint of the same page, not a navigation (no loading view, focus and scroll kept).
|
||||
@@ -1958,7 +1970,7 @@
|
||||
// F3: `settingsSend` lets the owner of an organization Console save Settings on the selected
|
||||
// organization's bus; messaging a role still follows `send` (canMessage) and `chatSend`.
|
||||
// A bus restart reads the config again and sends it here as a mosaic-config event.
|
||||
const applyConfig = c => {
|
||||
const applyConfig = (c, opts = {}) => {
|
||||
isTester = c?.tester === true;
|
||||
canEdit = !isTester && c?.edit === true;
|
||||
canSend = !isTester && (c?.send === true || c?.settingsSend === true);
|
||||
@@ -1975,7 +1987,7 @@
|
||||
banner.setAttribute('role', 'status'); banner.textContent = 'Tester Console. Changes are recorded as tester, never as the human.';
|
||||
$('main').prepend(banner);
|
||||
}
|
||||
if (!canSend && !isTester && !ssoOn) return;
|
||||
if (opts.quiet || (!canSend && !isTester && !ssoOn)) return;
|
||||
const path = routeOf().split('?')[0];
|
||||
if (path.startsWith('/chat')||path.startsWith('/projects')||path.startsWith('/inbox')||path.startsWith('/settings')||path.startsWith('/docs/')||wsPage(path)) render(false); else if (window.MosaicOrg ? path === '/board' : path === '/' || path === '') paintHeld(heldErr);
|
||||
};
|
||||
@@ -1985,6 +1997,23 @@
|
||||
// fresh read of the page; editors in progress are kept by holdLive.
|
||||
// Ruling 2026-11-14: an apply, hire or fire redraws the Agents page in the same response cycle, not at the next poll.
|
||||
document.addEventListener('mosaic-org-changed', e => { if (window.MosaicOrg && e.detail?.org === window.MosaicOrg.id && ['/organization', '/agents'].includes(routeOf().split('?')[0])) render(false); });
|
||||
// #1873 SP1: another organization opened in place (display.js, before this file's hashchange
|
||||
// render). Nothing read, drafted or confirmed for the old one carries over; the new one's config
|
||||
// is read before the next page draws. A write already sent finishes against the old organization.
|
||||
document.addEventListener('mosaic-org-switch', () => {
|
||||
forget(); clearTimeout(timer);
|
||||
answer = null; heldErr = null; heldHtml = null; readAt = null; shown = null; projectContext = null;
|
||||
draft.to = ''; draft.body = '';
|
||||
hireDraft = { op: 'hire', level: 'org' }; hireTemplates = []; hireStatus = ''; hireCode = '';
|
||||
fireAsk = null; fireReceipt = null;
|
||||
settingsSnapshot = null; settingsInputs = null; settingsReceipt = ''; settingInvalidKey = null;
|
||||
assistantDrafts = Object.create(null); nameDrafts = Object.create(null); assistantReceipts = Object.create(null); assistantReceiptKinds = Object.create(null); assistantCardReceipts = Object.create(null);
|
||||
instanceView = null; instanceDrafts = { add: null, change: null }; instanceReceipt = ''; instanceReceiptOp = null; instanceReceipts = Object.create(null);
|
||||
routesView = null; routesDraft = null; routesReceipt = ''; routesEditVersion++;
|
||||
harnessSettings = null; harnessDraft = null; harnessReceipt = '';
|
||||
docKey = null; pkAll = []; pkShown = []; pkAt = 0;
|
||||
configRead = switchRead = fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(c => applyConfig(c, { quiet: true })).catch(() => {});
|
||||
});
|
||||
document.addEventListener('mosaic-config', e => { if (e.detail && typeof e.detail === 'object') { applyConfig(e.detail); if (window.MosaicOrg && routeOf().split('?')[0] === '/organization') render(false); } });
|
||||
// The cross-organization pages ask for a repaint after a filter, sort or selection change,
|
||||
// and report the inbox count across organizations for the sidebar.
|
||||
|
||||
@@ -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,112 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
|
||||
banner.textContent = lines.join('; ');
|
||||
document.body.prepend(banner);
|
||||
}
|
||||
// The shell no longer names one organization in its wordmark: the breadcrumb does.
|
||||
document.getElementById('current-organization')?.remove();
|
||||
const org = rows.find(row => row.id === decodeURIComponent(id) && !row.invalid);
|
||||
const mark = document.querySelector('.wordmark');
|
||||
if (mark && org) { const name = document.createElement('span'); name.id = 'current-organization'; name.textContent = ` · ${org.label || org.id}`; mark.append(name); }
|
||||
if (org?.busHealthy === false) { const banner = document.getElementById('bus-down'); if (banner) { banner.textContent = 'Bus unavailable'; banner.hidden = false; window.MosaicConsole?.attachBus(banner, 'Restart the bus'); } }
|
||||
paintBus();
|
||||
window.MosaicBreadcrumbs?.();
|
||||
return rows;
|
||||
};
|
||||
let list = readList().then(paintHeader);
|
||||
let list;
|
||||
const refreshList = () => (list = readList().then(paintHeader));
|
||||
window.MosaicRefreshOrganizations = refreshList;
|
||||
let previous = location.hash;
|
||||
if (atLoad) {
|
||||
const first = normalize(location.hash);
|
||||
if (first !== location.hash) history.replaceState(null, '', first);
|
||||
refreshList();
|
||||
} else {
|
||||
const initial = window.MosaicInitialOrganizations = readRows();
|
||||
list = initial.then(rows => paintHeader(rows ?? []));
|
||||
initial.then(async rows => {
|
||||
const valid = Array.isArray(rows) ? rows.filter(row => !row.invalid) : [];
|
||||
if (!valid.length) return null;
|
||||
let preferred;
|
||||
try { preferred = (await (await nativeFetch('/api/config', { cache: 'no-store', redirect: 'manual' })).json()).preselect; } catch { /* first authorized organization */ }
|
||||
return encodeURIComponent((valid.find(row => row.id === preferred) ?? valid[0]).id);
|
||||
}).catch(() => null).then(seg => {
|
||||
settle(orgOf(location.hash) ?? seg);
|
||||
if (!current) return;
|
||||
const next = normalize(location.hash);
|
||||
if (next !== location.hash) history.replaceState(null, '', next);
|
||||
previous = location.hash; paintBus();
|
||||
});
|
||||
}
|
||||
// From the first-run page (no organization yet) the first one changes the page's mode, which
|
||||
// still takes one reload. Once an organization is open, a list refresh repaints in place.
|
||||
window.MosaicRefreshOrganizations = () => {
|
||||
if (current || !settled) return refreshList();
|
||||
const read = window.MosaicInitialOrganizations = readRows();
|
||||
list = read.then(rows => paintHeader(rows ?? []));
|
||||
read.then(rows => {
|
||||
const valid = Array.isArray(rows) ? rows.filter(row => !row.invalid) : [];
|
||||
if (!valid.length || orgOf(location.hash)) return;
|
||||
history.replaceState(null, '', `#/orgs/${encodeURIComponent(valid[0].id)}${tail(location.hash) === '/' ? '/organization' : tail(location.hash)}`);
|
||||
location.reload();
|
||||
});
|
||||
return read;
|
||||
};
|
||||
let previous = location.hash, skipGuard = false;
|
||||
const dirty = () => window.MosaicChat?.hasDraft?.() || window.MosaicOrgCreate?.hasDraft?.() || [...document.querySelectorAll('#reply, #conv-reply, #doc-source')].some(el => !!el.value?.trim());
|
||||
function change(next) {
|
||||
history.replaceState(null, '', next);
|
||||
location.reload();
|
||||
// A switch scopes later reads to the new organization; listeners drop what they held for the old one.
|
||||
function rescope(seg) {
|
||||
const from = current;
|
||||
current = seg;
|
||||
document.dispatchEvent(new CustomEvent('mosaic-org-switch', { detail: { from: from ? decodeURIComponent(from) : null, to: decodeURIComponent(seg) } }));
|
||||
}
|
||||
function discard() {
|
||||
window.MosaicChat?.forget?.();
|
||||
for (const el of document.querySelectorAll('#reply, #conv-reply, #doc-source')) el.value = '';
|
||||
}
|
||||
function confirmSwitch(next) {
|
||||
const dialog = document.createElement('dialog');
|
||||
dialog.setAttribute('aria-label', 'Unsaved draft');
|
||||
dialog.innerHTML = '<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, seg = orgOf(raw) ?? current, next = normalize(raw, seg);
|
||||
if (next !== raw) history.replaceState(null, '', next);
|
||||
if (!settled || !current) { previous = next; return; }
|
||||
const switching = seg !== current;
|
||||
if (!skipGuard && next !== oldHash && (switching || window.MosaicOrgCreate?.hasDraft?.()) && dirty()) {
|
||||
event.stopImmediatePropagation();
|
||||
if (dirty()) { history.replaceState(null, '', oldHash); confirmSwitch(next); } else change(next);
|
||||
history.replaceState(null, '', oldHash); confirmSwitch(next);
|
||||
return;
|
||||
}
|
||||
previous = next;
|
||||
skipGuard = false; previous = next;
|
||||
if (switching) rescope(seg);
|
||||
paintBus();
|
||||
}, true);
|
||||
window.MosaicOrg = Object.freeze({ id: decodeURIComponent(id), route, get list() { return list; }, readList, refreshList, switchTo: next => { location.hash = `#/orgs/${encodeURIComponent(next)}${route()}`; } });
|
||||
const api = Object.freeze({
|
||||
get id() { return current ? decodeURIComponent(current) : null; },
|
||||
// The open organization's name as the last list read gave it, for the breadcrumb.
|
||||
get label() { const id = current ? decodeURIComponent(current) : null, row = id && rowsSeen.find(r => r.id === id); return id ? (typeof row?.label === 'string' && row.label ? row.label : id) : null; },
|
||||
ready, route, get list() { return list; }, readList, refreshList,
|
||||
switchTo: next => { const path = route().split('?')[0]; location.hash = `#/orgs/${encodeURIComponent(next)}${path === '/dashboard' || path === '/' ? '/organization' : route()}`; },
|
||||
});
|
||||
Object.defineProperty(window, 'MosaicOrg', { configurable: true, enumerable: true, get: () => (current || !settled ? api : undefined) });
|
||||
})();
|
||||
if (document.documentElement.hasAttribute('data-settings-proxy-document') && !location.hash) location.hash = '#/settings/model-proxy';
|
||||
/* Browser-local sidebar density. No authority or project data is stored here. */
|
||||
(() => {
|
||||
const key=window.MosaicOrg ? `mosaic-sidebar-display-v1:${window.MosaicOrg.id}` : 'mosaic-sidebar-display-v1';
|
||||
// #1873 SP1: the key follows the open organization, read again when it changes.
|
||||
const key=()=>window.MosaicOrg?.id ? `mosaic-sidebar-display-v1:${window.MosaicOrg.id}` : 'mosaic-sidebar-display-v1';
|
||||
const defaults=()=>({version:1,density:'comfortable',order:'title-first'});
|
||||
const valid=x=>x&&x.version===1&&['comfortable','compact'].includes(x.density)&&['title-first','contact-first'].includes(x.order)&&Object.keys(x).sort().join(',')==='density,order,version';
|
||||
let saved=defaults();
|
||||
try {const value=JSON.parse(localStorage.getItem(key));if(valid(value))saved=value;} catch {}
|
||||
const load=()=>{saved=defaults();try {const value=JSON.parse(localStorage.getItem(key()));if(valid(value))saved=value;} catch {}};
|
||||
const apply=()=>{document.documentElement.dataset.sidebarDensity=saved.density;document.documentElement.dataset.sidebarOrder=saved.order;document.dispatchEvent(new CustomEvent('mosaic-display-change',{detail:{...saved}}));};
|
||||
apply();
|
||||
window.MosaicDisplay={get:()=>({...saved}),setDensity(density){if(!['comfortable','compact'].includes(density))return false;saved={...saved,density};apply();try{localStorage.setItem(key,JSON.stringify(saved));return true;}catch{return false;}}};
|
||||
load();apply();
|
||||
window.MosaicOrg?.ready?.then(()=>{load();apply();});
|
||||
document.addEventListener('mosaic-org-switch',()=>{load();apply();});
|
||||
window.MosaicDisplay={get:()=>({...saved}),setDensity(density){if(!['comfortable','compact'].includes(density))return false;saved={...saved,density};apply();try{localStorage.setItem(key(),JSON.stringify(saved));return true;}catch{return false;}}};
|
||||
})();
|
||||
|
||||
// P4 page: credentials and OAuth state exist only during the current operation.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,13 @@
|
||||
return Boolean(a && a.el.isConnected);
|
||||
}
|
||||
|
||||
// SP1: the reason an organization's bus can't be read, for its pages. `row` is its GET /api/workspace
|
||||
// status (state, reason, boot), or null when that read gave none.
|
||||
const downWhy = row => (row && row.state !== 'ok' && typeof row.reason === 'string' && row.reason ? `${row.reason}${row.boot?.at ? `, last start ${when(row.boot.at)}` : ''}` : '');
|
||||
function downBanner(org, row) {
|
||||
const stopped = org.busStopped === true, why = downWhy(row);
|
||||
return `<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'}.${why ? ` <span class="why">Reason: ${esc(why)}.</span>` : ' <span class="why">No reason was read.</span>'}</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 +252,8 @@
|
||||
const threads = rowsOf(value(3)), projectRows = rowsOf(value(4)), queue = rowsOf(value(5));
|
||||
const agents = rowsOf(value(6)), claims = rowsOf(value(7));
|
||||
const docRead = business?.business ? await docs(id, business.business.projects).catch(() => ({ rows: [], failed: true })) : null;
|
||||
// SP1: a bus that is down says so above everything else, with the reason the workspace read gives.
|
||||
const downRow = org.busHealthy ? null : await data.workspace({ org: id, sections: 'projects' }).then(b => arr(b?.orgs).find(r => r?.org === id) ?? null).catch(() => null);
|
||||
await window.MosaicConsole?.ready;
|
||||
const name = esc(label(org));
|
||||
const tracker = business?.business?.vars?.['tracker.kind'];
|
||||
@@ -290,8 +300,9 @@
|
||||
const stoppedPanel = (attr, title) => `<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 +331,8 @@
|
||||
// only). Every status comes from the organization's bus: its instances, and since A3 the root and
|
||||
// project agent copies it booted with. Nothing here invents a status, a model or a memory.
|
||||
const NOT_RUNNING = ['s-idle', '–', 'not running'];
|
||||
// SP1: why a bus-backed read gave nothing, in the membership list's words.
|
||||
const busWord = org => (org?.busStopped === true ? 'the bus is stopped' : 'the bus is not answering');
|
||||
// A copy's status: running or not as the bus reports its runs, or why it can't launch.
|
||||
const CANT = ['s-error', '▲', "can't launch"];
|
||||
// #1873 CB5: the bus adds `binding` to a copy that needs a credential binding: its status code and
|
||||
@@ -364,7 +377,7 @@
|
||||
} else if (inst) {
|
||||
rows.push(...Object.entries(inst).map(([name, v]) => ({ name, level: 'org', definition: v?.definition, model: null, scope: { kind: 'org' }, overrides: [], narrowed: [] })));
|
||||
}
|
||||
if (!set && inst === null) return '<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 +461,7 @@
|
||||
const on = kept.filter(a => !own(a)), replaced = kept.filter(own);
|
||||
return cell(org, `${on.map(a => copyPill(copies, agents, a.name, null, 'copy')).join('') || '<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 +562,11 @@
|
||||
// ---- main dashboard (sample 02) ----
|
||||
async function home() {
|
||||
let body;
|
||||
try { [body] = await Promise.all([data.workspace({ sections: ['projects', 'sessions', 'inbox', 'tasks', 'metrics'] }), ensureThemes()]); took(body); }
|
||||
let levels = null, listed = null;
|
||||
// SP1: the agent counts come from the organization files (GET /api/agents/levels, owner only), so a
|
||||
// stopped bus still has one; the bus state words come from the membership list. Either read may
|
||||
// fail or be absent, and its column then shows no figure.
|
||||
try { [body, levels, listed] = await Promise.all([data.workspace({ sections: ['projects', 'sessions', 'inbox', 'tasks', 'metrics'] }), data.agentLevels().catch(() => null), data.organizations().catch(() => null), ensureThemes()]); took(body); }
|
||||
catch (err) { return failedPage('ws-home', 'data-ws-home', 'Dashboard', err); }
|
||||
const o = orgsOf(body), m = body.metrics, t = m?.totals, per = arr(m?.orgs);
|
||||
const inbox = arr(body.inbox), sessions = arr(body.sessions), projectRows = arr(body.projects), tasks = arr(body.tasks);
|
||||
@@ -573,11 +590,27 @@
|
||||
<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 +643,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 +655,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);
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
// SP1 (#1873, single pane slice 1): #/dashboard is the system landing route, an organization page is a
|
||||
// drill-in route inside the same shell (no reload, the sidebar and top bar stay), and an organization
|
||||
// whose bus is down says so with its reason. Every bus is a fake: acme and beta answer, gamma's bus is
|
||||
// stopped. The real GET /api/orgs, /api/workspace and /api/agents/levels serve the page.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
|
||||
const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString();
|
||||
|
||||
async function fixture() {
|
||||
const { checkRootAgents } = await import('../../organization/src/index.mjs');
|
||||
const rolesDir = fileURLToPath(new URL('../../../roles', import.meta.url));
|
||||
const root = { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: {} }, { rolesDir }) };
|
||||
const LIST = [
|
||||
{ id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true },
|
||||
{ id: 'beta', label: 'Beta Works', theme: 'grove', busHealthy: true },
|
||||
{ id: 'gamma', label: 'Gamma Home', theme: 'rosewood', busHealthy: false, busStopped: true },
|
||||
];
|
||||
const ROLES = {
|
||||
acme: { chief: { definition: 'chief-of-staff' }, coder: { definition: 'coder' }, reviewer: { definition: 'reviewer' } },
|
||||
beta: { chief: { definition: 'chief-of-staff' } },
|
||||
gamma: { chief: { definition: 'chief-of-staff' }, researcher: { definition: 'research' } },
|
||||
};
|
||||
const DATA = {
|
||||
acme: {
|
||||
projects: [{ id: 'p-site', repo: 'site', name: 'Website', archived: false, at: at(30) }, { id: 'p-zeta', repo: 'zeta', name: 'Zeta API', archived: false, at: at(300) }],
|
||||
agents: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'working', at: at(40), working: { since: at(40) } }],
|
||||
inbox: [{ id: 'dec-acme-1', question: 'Ship the hero?', class: 'gated', blocking: true, raised_by_role: 'coder', at: at(10), options: [{ key: 'yes', text: 'Ship it' }] }],
|
||||
},
|
||||
beta: { projects: [{ id: 'p-shop', repo: 'shop', name: 'Shop', archived: false, at: at(120) }], agents: [], inbox: [] },
|
||||
};
|
||||
const value = (id, name) => Promise.resolve(DATA[id]?.[name] ?? (['usage', 'limits'].includes(name) ? {} : []));
|
||||
const opts = id => ({
|
||||
bus: { inbox: () => value(id, 'inbox'), agents: () => value(id, 'agents'), 'thread.claims': () => value(id, 'claims'), tasks: () => value(id, 'tasks'), usage: () => value(id, 'usage'), limits: () => value(id, 'limits'), decisions: () => value(id, 'decisions') },
|
||||
chat: { threads: () => value(id, 'threads'), projects: () => value(id, 'projects') },
|
||||
});
|
||||
const member = org => ({
|
||||
id: org.id, doc: { appearance: { theme: org.theme }, roles: ROLES[org.id], projects: {} },
|
||||
status: async () => (org.busHealthy ? { healthy: true, pid: 4242, boot: null } : { healthy: false, boot: { ok: false, at: at(90), lastGood: null, reason: 'stopped by the owner' } }),
|
||||
options: () => opts(org.id),
|
||||
});
|
||||
const instances = id => Object.fromEntries(Object.entries(ROLES[id]).map(([n, r]) => [n, { definition: r.definition, vars: {}, launched: true, launcher: false, protected: false, kept: false, running: n === 'coder' ? ['run-1'] : [], alive: [] }]));
|
||||
const organizations = {
|
||||
list: async () => LIST,
|
||||
members: async () => LIST.map(member),
|
||||
resolve: async id => {
|
||||
const org = LIST.find(o => o.id === id); if (!org) return null;
|
||||
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } };
|
||||
return {
|
||||
busHealthy: true,
|
||||
reads: { business: async () => ({ business: { id, human: 'jason', projects: DATA[id].projects.map(p => p.repo) } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
|
||||
bus: { messages: async () => ({ messages: [], unread: 0 }), agents: async () => DATA[id].agents, 'thread.claims': async () => [], inbox: async () => DATA[id].inbox.map(d => ({ ...d, business: id, raised_by_run: 'run', routed_to: 'human' })), tasks: async () => [], instances: async () => ({ business: id, revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {}, instances: instances(id) }) },
|
||||
chat: { projects: async () => DATA[id].projects, threads: async () => [] },
|
||||
};
|
||||
},
|
||||
};
|
||||
return { organizations, agentLevels: { loadRoot: () => root, rolesDir, ctx: { configDir: '/cfg', dataRoot: '/data', instance: 'test' } } };
|
||||
}
|
||||
|
||||
async function setup(t, { viewport = [1440, 900], path = '/' } = {}) {
|
||||
const { organizations, agentLevels } = await fixture();
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, agentLevels, log: () => {} });
|
||||
t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
await b.viewport(...viewport);
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
await b.navigate(`${base}${path}`);
|
||||
return { b, base, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) };
|
||||
}
|
||||
|
||||
const rowText = org => `document.querySelector("[data-ws-orgrow=${org}]")?.innerText.replace(/\\s+/g, " ").trim() ?? ""`;
|
||||
|
||||
test('the bare address lands on #/dashboard without a reload and lists every organization with only the figures read', async t => {
|
||||
const { b, wait, text } = await setup(t);
|
||||
await wait('location.hash === "#/dashboard" && document.querySelectorAll("[data-ws-orgrow]").length === 3');
|
||||
// One page load: the landing route is set in place, never by location.reload().
|
||||
assert.equal(await b.evaluate('performance.getEntriesByType("navigation").length'), 1);
|
||||
assert.equal(await b.evaluate('performance.getEntriesByType("navigation")[0].type'), 'navigate');
|
||||
assert.equal(await text('#bus-view h1'), 'Dashboard');
|
||||
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]")?.getAttribute("href")'), '#/dashboard');
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-orgrow]")].map(r => r.dataset.wsOrgrow)'), ['acme', 'beta', 'gamma']);
|
||||
// Agents from the levels read, projects and needs-you from the workspace read.
|
||||
await wait(`${rowText('acme')}.includes("3 agents")`);
|
||||
const acme = await b.evaluate(rowText('acme'));
|
||||
assert.match(acme, /Acme Studio/);
|
||||
assert.match(acme, /bus running/);
|
||||
assert.match(acme, /2 projects/);
|
||||
assert.match(acme, /1 needs you/);
|
||||
assert.match(await b.evaluate(rowText('beta')), /1 agent\b/);
|
||||
// gamma: down with its reason; its agent count comes from its file, the bus figures are not read.
|
||||
const gamma = await b.evaluate(rowText('gamma'));
|
||||
assert.match(gamma, /bus stopped/);
|
||||
assert.match(gamma, /stopped by the owner/);
|
||||
assert.match(gamma, /2 agents/);
|
||||
assert.doesNotMatch(gamma, /\d+ projects?|\d+ needs you/);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-ws-orgrow=beta] a")?.getAttribute("href")'), '#/orgs/beta/organization');
|
||||
// A system route marks no organization in the sidebar and shows no breadcrumb.
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#workspace-nav .sel, #workspace-nav [aria-current]").length'), 0);
|
||||
assert.equal(await b.evaluate('document.querySelector(".ws-crumb")'), null);
|
||||
// The top bar names no organization.
|
||||
assert.equal(await b.evaluate('document.getElementById("current-organization")'), null);
|
||||
});
|
||||
|
||||
test('an organization opens inside the same shell with a breadcrumb, and its pages keep reading that organization', async t => {
|
||||
const { b, wait, text } = await setup(t);
|
||||
await wait('document.querySelectorAll("[data-ws-orgrow]").length === 3');
|
||||
await b.evaluate('window.__marker = 1; window.__bar = document.querySelector(".cmdbar"); window.__side = document.getElementById("workspace-nav")');
|
||||
await b.evaluate('document.querySelector("[data-ws-orgrow=acme] a").click()');
|
||||
await wait('location.hash === "#/orgs/acme/organization" && document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"');
|
||||
assert.equal(await b.evaluate('window.__marker'), 1);
|
||||
assert.equal(await b.evaluate('window.__bar === document.querySelector(".cmdbar") && window.__side === document.getElementById("workspace-nav")'), true);
|
||||
assert.equal(await b.evaluate('performance.getEntriesByType("navigation").length'), 1);
|
||||
assert.equal(await b.evaluate('document.querySelector(".ws-crumb")?.innerText.replace(/\\s+/g, " ").trim()'), 'Dashboard › Acme Studio');
|
||||
assert.equal(await b.evaluate('document.querySelector(".ws-crumb a")?.getAttribute("href")'), '#/dashboard');
|
||||
assert.equal(await b.evaluate('document.getElementById("current-organization")'), null);
|
||||
// The sidebar marks the open organization.
|
||||
await wait('document.querySelector("#workspace-nav [aria-current=page]")?.closest("[data-org]")?.dataset.org === "acme" || document.querySelector("#workspace-nav a[aria-current=page]")?.getAttribute("href") === "#/orgs/acme/organization"');
|
||||
|
||||
// The sidebar opens another organization in place; its page reads that organization's routes.
|
||||
await b.evaluate('performance.clearResourceTimings(); document.querySelector("#workspace-nav a[href=\'#/orgs/beta/organization\']").click()');
|
||||
await wait('location.hash === "#/orgs/beta/organization" && document.querySelector("[data-org-dashboard] h1")?.textContent === "Beta Works"');
|
||||
assert.equal(await b.evaluate('window.__marker'), 1);
|
||||
// A page inside beta scopes its reads to beta.
|
||||
await b.evaluate('location.hash = "#/agents"');
|
||||
await wait('location.hash === "#/orgs/beta/agents" && document.querySelector("[data-org-agents]")');
|
||||
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'beta');
|
||||
assert.equal(await b.evaluate('window.__marker'), 1);
|
||||
// The organization's control board is named as such after the organization, not as a second Dashboard.
|
||||
await b.evaluate('location.hash = "#/orgs/beta/board"');
|
||||
await wait('/^Dashboard\\/.+\\/Control board$/.test(document.getElementById("breadcrumbs")?.textContent ?? "")');
|
||||
assert.equal(await b.evaluate('document.querySelector("#breadcrumbs [data-crumb-org]")?.getAttribute("href")'), '#/orgs/beta/organization');
|
||||
|
||||
// The breadcrumb goes back to the system dashboard, still without a reload.
|
||||
await b.evaluate('location.hash = "#/orgs/beta/organization"');
|
||||
await wait('document.querySelector(".ws-crumb a")');
|
||||
await b.evaluate('document.querySelector(".ws-crumb a").click()');
|
||||
await wait('location.hash === "#/dashboard" && document.querySelectorAll("[data-ws-orgrow]").length === 3');
|
||||
assert.equal(await b.evaluate('window.__marker'), 1);
|
||||
assert.equal(await b.evaluate('performance.getEntriesByType("navigation").length'), 1);
|
||||
assert.equal(await text('#bus-view h1'), 'Dashboard');
|
||||
});
|
||||
|
||||
test('an old #/orgs/<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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user