Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c26879637a | ||
|
|
2fc97a3edb | ||
|
|
68df55130e | ||
|
|
6ad1d33dd1 | ||
|
|
5fba4726dc | ||
|
|
69ce460f91 | ||
|
|
307f88be3e | ||
|
|
bfd3ed6811 | ||
|
|
5b6b39f080 | ||
|
|
2d7d7be5ca | ||
|
|
f73c761ad3 | ||
|
|
8b4a26dd2b | ||
|
|
11d2b43b3a | ||
|
|
8aefc0f7a9 | ||
|
|
9cff3255e6 | ||
|
|
d50d79cddd | ||
|
|
fb3bd16052 | ||
|
|
13615e12b9 | ||
|
|
bd29e756a5 | ||
|
|
6bd9be78ac | ||
|
|
3a84f46b6e | ||
|
|
208439814a | ||
|
|
7c6571768f | ||
|
|
3597e0daf6 | ||
|
|
3116f43e65 | ||
|
|
c66f1ec00c | ||
|
|
9400e0d94d | ||
|
|
a9912983e6 | ||
|
|
626321608f | ||
|
|
5f965cdb30 | ||
|
|
8296d33100 | ||
|
|
69ac78b97e | ||
|
|
a2a7df73df | ||
|
|
789e2bcdd4 | ||
|
|
02df2a3a25 | ||
|
|
b982404963 | ||
|
|
5dd940165d | ||
|
|
ed4f45ed23 | ||
|
|
79df01f1de | ||
|
|
d7b98b4698 | ||
|
|
0f29fe0d92 | ||
|
|
32be3a01bc | ||
|
|
f97e0d3aeb | ||
|
|
eebdb2f0ce | ||
|
|
5373b110ac | ||
|
|
3885f4628b | ||
|
|
ba9933309b | ||
|
|
0785af8129 | ||
|
|
d1b01329bc | ||
|
|
c7aecbffa9 | ||
|
|
bb2f79f3d9 | ||
|
|
e24e7e93a2 |
No files matched your search
@@ -76,8 +76,11 @@
|
||||
if (!usable() || busy || pending || !view) return;
|
||||
const [url, payload] = plan(action, name);
|
||||
busy = true; confirming = null; receipt = ''; redraw();
|
||||
const gen = generation;
|
||||
let answer;
|
||||
try { answer = await post(url, payload); } catch { answer = { ok: false, result: { sent: 'unknown' } }; }
|
||||
// #1873 SP1: an organization switch while this was in flight drops the answer; the switch already cleared busy.
|
||||
if (gen !== generation) return;
|
||||
busy = false;
|
||||
if (answer === null) return;
|
||||
const { ok, result } = answer;
|
||||
@@ -122,8 +125,10 @@
|
||||
const instance = create.instance.trim(), definition = create.definition;
|
||||
if (!instance || !definition) { receipt = 'Name the instance and choose a definition.'; redraw(); return; }
|
||||
busy = true; receipt = ''; redraw();
|
||||
const gen = generation;
|
||||
let answer;
|
||||
try { answer = await post('/api/instances', { revision: view.revision, instance, op: 'add', definition }); } catch { answer = { ok: false, result: { sent: 'unknown' } }; }
|
||||
if (gen !== generation) return;
|
||||
busy = false;
|
||||
if (answer === null) return;
|
||||
if (answer.ok && /^[a-f0-9]{64}$/.test(answer.result?.revision ?? '')) { create = { instance: '', definition: '' }; receipt = `Created ${instance}. ${answer.result.restart ? restartText() : NO_RESTART_TEXT}`; reread(); }
|
||||
@@ -154,19 +159,31 @@
|
||||
document.addEventListener('submit', e => { if (e.target?.id !== 'agent-create-form') return; e.preventDefault(); createAgent(); });
|
||||
// A sign-out stops any poll; nothing that was asked is retried.
|
||||
document.addEventListener('mosaic-signed-out', () => { generation++; pending = null; confirming = null; });
|
||||
// #1873 SP1: opening another organization stops any poll and clears what this section held for the
|
||||
// old one. A request already sent finishes on the old organization's bus; its page reads it again there.
|
||||
document.addEventListener('mosaic-org-switch', () => {
|
||||
generation++; pending = null; receipt = ''; editing = null; confirming = null; busy = false;
|
||||
create = { instance: '', definition: '' };
|
||||
for (const k of Object.keys(reasons)) delete reasons[k];
|
||||
});
|
||||
|
||||
// F3: restart an agent so a route change reaches it now, offered by the Routing receipt. One
|
||||
// terminate; then reads of the instances until the run has left (up to the 60 s regenerate
|
||||
// allows); then one launch. A thread definition is not launched: its next message starts it.
|
||||
// A refusal, a sign-out or an unknown outcome stops the chain and says so; nothing is retried.
|
||||
const restarts = Object.create(null);
|
||||
let restarts = Object.create(null);
|
||||
// #1873 SP1: the chain's reads and writes go to the organization it started in, which the fetch
|
||||
// wrapper passes through unchanged. A switch clears what the new page shows; the old chain keeps
|
||||
// its own entry and stops painting.
|
||||
const pin = (org, path) => org ? `/api/orgs/${encodeURIComponent(org)}/${path.slice('/api/'.length)}` : path;
|
||||
document.addEventListener('mosaic-org-switch', () => { restarts = Object.create(null); });
|
||||
const RESTART_WAIT = 30;
|
||||
function restartMarkup(name, thread = false) {
|
||||
const r = restarts[name];
|
||||
return `<span class="agent-restart"><button type="button" class="btn" data-agent-restart="${esc(name)}" data-agent-thread="${thread ? 'yes' : 'no'}"${r?.busy ? ' disabled' : ''}>${r?.busy ? 'Restarting…' : `Restart ${esc(name)} now`}</button> <span role="status" data-agent-restart-status="${esc(name)}">${esc(r?.text ?? '')}</span></span>`;
|
||||
}
|
||||
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
||||
const paintRestart = name => document.querySelectorAll('.agent-restart').forEach(el => {
|
||||
const paintRestart = (name, org = window.MosaicOrg?.id ?? null) => (window.MosaicOrg?.id ?? null) !== org ? undefined : document.querySelectorAll('.agent-restart').forEach(el => {
|
||||
const b = el.querySelector('[data-agent-restart]');
|
||||
if (b?.dataset.agentRestart === name) el.outerHTML = restartMarkup(name, b.dataset.agentThread === 'yes');
|
||||
});
|
||||
@@ -179,45 +196,47 @@
|
||||
return { ok: res.ok, result };
|
||||
}
|
||||
// The request's closing read, or a reason the chain stops.
|
||||
async function closing(request) {
|
||||
async function closing(request, org) {
|
||||
for (let tries = 0; tries < POLL_LIMIT; tries++) {
|
||||
await new Promise(r => setTimeout(r, POLL_MS));
|
||||
let res, r = null;
|
||||
try { res = await fetch(`/api/agent/request?id=${encodeURIComponent(request)}`, { cache: 'no-store', redirect: 'manual' }); } catch { continue; }
|
||||
try { res = await fetch(pin(org, `/api/agent/request?id=${encodeURIComponent(request)}`), { cache: 'no-store', redirect: 'manual' }); } catch { continue; }
|
||||
if (res.type === 'opaqueredirect') return { stop: 'Sign in again, then read the agents.' };
|
||||
try { r = await res.json(); } catch { continue; }
|
||||
if (res.ok && r?.request === request && r.state === 'closed') return { done: r };
|
||||
}
|
||||
return { stop: `Request ${request} is still open. Read the agents again later.` };
|
||||
}
|
||||
async function runs(name) {
|
||||
async function runs(name, org) {
|
||||
try {
|
||||
const res = await fetch('/api/bus/instances', { cache: 'no-store', redirect: 'manual' });
|
||||
const res = await fetch(pin(org, '/api/bus/instances'), { cache: 'no-store', redirect: 'manual' });
|
||||
if (!res.ok || res.type === 'opaqueredirect') return null;
|
||||
const e = (await res.json())?.rows?.[0]?.instances?.[name];
|
||||
return e ? (Array.isArray(e.running) ? e.running.length : 0) : 0;
|
||||
} catch { return null; }
|
||||
}
|
||||
async function restartAgent(name, thread) {
|
||||
const org = window.MosaicOrg?.id ?? null;
|
||||
const r = restarts[name] = { busy: true, text: `Terminating ${name}…` };
|
||||
const stop = text => { r.busy = false; r.text = text; paintRestart(name); };
|
||||
paintRestart(name);
|
||||
const asked = await send('/api/agent/terminate', { instance: name, reason: 'route change' });
|
||||
const paint = () => paintRestart(name, org);
|
||||
const stop = text => { r.busy = false; r.text = text; paint(); };
|
||||
paint();
|
||||
const asked = await send(pin(org, '/api/agent/terminate'), { instance: name, reason: 'route change' });
|
||||
if (asked.signedOut) return stop('Sign in again, then retry.');
|
||||
if (asked.unknown || !asked.ok || !REQUEST_ID.test(asked.result?.request ?? '')) return stop(asked.ok || asked.unknown ? UNKNOWN : refused(asked.result));
|
||||
const ended = await closing(asked.result.request);
|
||||
const ended = await closing(asked.result.request, org);
|
||||
if (ended.stop) return stop(ended.stop);
|
||||
if (ended.done.reason) return stop(`${name}: ${ended.done.reason}. It was not restarted.`);
|
||||
r.text = `Terminated ${name}. Waiting for its session to end…`; paintRestart(name);
|
||||
r.text = `Terminated ${name}. Waiting for its session to end…`; paint();
|
||||
let left = false;
|
||||
for (let i = 0; i < RESTART_WAIT && !left; i++) { await new Promise(res => setTimeout(res, POLL_MS)); left = (await runs(name)) === 0; }
|
||||
for (let i = 0; i < RESTART_WAIT && !left; i++) { await new Promise(res => setTimeout(res, POLL_MS)); left = (await runs(name, org)) === 0; }
|
||||
if (!left) return stop(`${name} was asked to end but its session is still running. Launch it from Agents once it has ended.`);
|
||||
if (thread) return stop(`Terminated ${name} at ${new Date().toLocaleTimeString()}. Its next message starts it on the new route.`);
|
||||
r.text = `Launching ${name}…`; paintRestart(name);
|
||||
const launch = await send('/api/agent/launch', { instance: name });
|
||||
r.text = `Launching ${name}…`; paint();
|
||||
const launch = await send(pin(org, '/api/agent/launch'), { instance: name });
|
||||
if (launch.signedOut) return stop('Sign in again. It was terminated and not launched.');
|
||||
if (launch.unknown || !launch.ok || !REQUEST_ID.test(launch.result?.request ?? '')) return stop(launch.ok || launch.unknown ? `${UNKNOWN} It was terminated.` : `${refused(launch.result)} It was terminated and not launched.`);
|
||||
const started = await closing(launch.result.request);
|
||||
const started = await closing(launch.result.request, org);
|
||||
if (started.stop) return stop(started.stop);
|
||||
if (started.done.reason) return stop(`${name}: ${started.done.reason}. It was terminated and not launched.`);
|
||||
return stop(`Restarted ${name} on the new route${started.done.done?.run ? ` (run ${started.done.done.run})` : ''}.`);
|
||||
|
||||
@@ -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');
|
||||
@@ -58,6 +60,11 @@
|
||||
const darkPreference = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const APPEARANCE_KEY = 'mosaic-console-appearance', LAST_KEY = 'mosaic-console-appearance-last';
|
||||
const orgKey = id => `${APPEARANCE_KEY}:${id}`;
|
||||
// #1873 SP2: the system dashboard is no organization's page. It shows the system theme: this browser's
|
||||
// own choice made there, else the default, never the theme of the organization opened last.
|
||||
const SYSTEM_KEY = 'mosaic-console-appearance-system';
|
||||
// SP3: display.js owns which addresses are system pages (the dashboard and #/inbox); the bare address is one before it normalizes.
|
||||
const onSystem = () => !!window.MosaicOrg && ((!/^#\/orgs\//.test(location.hash) && ['', '/'].includes(location.hash.replace(/^#/, '').split('?')[0])) || window.MosaicOrg.system?.() === true);
|
||||
const store = {
|
||||
read(k) { try { const v = JSON.parse(localStorage.getItem(k)); return v && typeof v === 'object' && !Array.isArray(v) ? v : null; } catch { return null; } },
|
||||
write(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); return true; } catch { return false; } },
|
||||
@@ -66,7 +73,10 @@
|
||||
const orgId = v => typeof v === 'string' && v.length > 0 && v.length <= 64;
|
||||
// org: null until the Console knows which organization it shows. Then the override lives under that organization's own key;
|
||||
// before that, and for a Console with no organization, it is the browser-wide record this page has always kept.
|
||||
let org = null, override = null, themes = null, themesUnavailable = false, current = null, applied = [];
|
||||
let org = null, override = null, themes = null, themesUnavailable = false, current = null, applied = [], system = false;
|
||||
const shown = () => (system ? null : org);
|
||||
// Kept in memory too: a choice storage refused still shows until reload, as an organization's does.
|
||||
let systemOverride = Brand.appearanceOf(store.read(SYSTEM_KEY));
|
||||
const readOverride = () => {
|
||||
if (org) return Brand.appearanceOf(store.read(orgKey(org.id)));
|
||||
const v = store.read(APPEARANCE_KEY);
|
||||
@@ -78,7 +88,7 @@
|
||||
$('theme-switch').setAttribute('aria-checked', String(dark));
|
||||
}
|
||||
function paintNotices() {
|
||||
const w = current.warning, banner = $('theme-warning'), cue = $('org-cue'), label = org?.label;
|
||||
const w = current.warning, banner = $('theme-warning'), cue = $('org-cue'), label = shown()?.label;
|
||||
banner.hidden = !w;
|
||||
if (w) {
|
||||
const asked = String(w.theme).slice(0, 64), kept = w.source === 'browser' ? 'The choice is kept.' : 'The saved value is unchanged.';
|
||||
@@ -91,11 +101,12 @@
|
||||
$('theme-clear').textContent = label ? `Use ${label}’s theme` : 'Use the default theme';
|
||||
}
|
||||
// Colour is never the only cue to which organization this is: whenever the theme is not the built-in one, the name is written out.
|
||||
cue.hidden = !(org && (current.theme !== Brand.DEFAULT_THEME || w));
|
||||
cue.hidden = !(shown() && (current.theme !== Brand.DEFAULT_THEME || w));
|
||||
if (!cue.hidden) cue.textContent = w ? `${label} · Harbor shown instead of “${String(w.theme).slice(0, 64)}”` : `${label} · ${nameOf(current.theme)} theme · ${current.themeSource === 'browser' ? 'your choice in this browser' : 'set by the organization'}`;
|
||||
}
|
||||
function paint() {
|
||||
current = Brand.resolveAppearance({ themes, unavailable: themesUnavailable, saved: org?.saved ?? null, override });
|
||||
system = onSystem();
|
||||
current = Brand.resolveAppearance({ themes, unavailable: themesUnavailable, saved: shown()?.saved ?? null, override: system ? systemOverride : override });
|
||||
const root = document.documentElement;
|
||||
root.dataset.palette = current.theme;
|
||||
if (current.mode === 'system') delete root.dataset.mode; else root.dataset.mode = current.mode;
|
||||
@@ -126,15 +137,16 @@
|
||||
ready: null,
|
||||
// The installed, valid themes as the server listed them, or null until /api/themes has answered.
|
||||
themes: () => themes && themes.map(t => ({ id: t.id, displayName: t.displayName, tokens: t.tokens })),
|
||||
state: () => ({ theme: current.theme, mode: current.mode, requested: current.requested, themeSource: current.themeSource, modeSource: current.modeSource, warning: current.warning, provisional: current.provisional, organization: org && { id: org.id, label: org.label }, saved: org?.saved ?? null, override: override && { ...override } }),
|
||||
state: () => { const o = shown(), v = system ? systemOverride : override; return { theme: current.theme, mode: current.mode, requested: current.requested, themeSource: current.themeSource, modeSource: current.modeSource, warning: current.warning, provisional: current.provisional, organization: o && { id: o.id, label: o.label }, saved: o?.saved ?? null, override: v && { ...v } }; },
|
||||
// Writes this browser's override for the organization (a theme, a mode, or both). false when storage refused it; the page still shows it until reload.
|
||||
set(patch) {
|
||||
if (onSystem()) { const next = Brand.appearanceOf({ ...(systemOverride || {}), ...patch }), ok = next ? store.write(SYSTEM_KEY, next) : true; systemOverride = next; showAppearance(ok); return ok; }
|
||||
const next = Brand.appearanceOf({ ...(override || {}), ...patch });
|
||||
const ok = next ? (org ? store.write(orgKey(org.id), next) : store.write(APPEARANCE_KEY, next)) : true;
|
||||
override = next; showAppearance(ok); return ok;
|
||||
},
|
||||
// Removes the override: the organization's saved value, or else the default, shows again.
|
||||
clear() { const ok = org ? store.remove(orgKey(org.id)) : store.remove(APPEARANCE_KEY); override = null; showAppearance(ok); return ok; },
|
||||
clear() { if (onSystem()) { const ok = store.remove(SYSTEM_KEY); systemOverride = null; showAppearance(ok); return ok; } const ok = org ? store.remove(orgKey(org.id)) : store.remove(APPEARANCE_KEY); override = null; showAppearance(ok); return ok; },
|
||||
// The seam for the organization switcher (stream S5): call it with the organization now shown and its saved appearance.
|
||||
organization(id, saved, label) {
|
||||
if (!orgId(id)) return;
|
||||
@@ -151,6 +163,20 @@
|
||||
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(); });
|
||||
// The dashboard and an organization page share the shell: moving between them repaints the theme.
|
||||
const repaintScope = () => { if (onSystem() !== system) showAppearance(); };
|
||||
window.addEventListener('hashchange', repaintScope);
|
||||
window.MosaicOrg?.ready?.then(repaintScope);
|
||||
window.MosaicAppearance.ready = (async () => {
|
||||
await Promise.allSettled([
|
||||
(async () => {
|
||||
@@ -161,14 +187,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 +507,22 @@
|
||||
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.
|
||||
// So is one drawn for a page left while it was read (#1873 SP2: the organization page draws none).
|
||||
const where = () => window.MosaicOrg?.route().split('?')[0] === '/organization';
|
||||
const asked = window.MosaicOrg?.id ?? null, onPage = where(), stale = () => (window.MosaicOrg?.id ?? null) !== asked || where() !== onPage;
|
||||
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;
|
||||
// #1873 SP2: the organization's own page says it in its down banner, with the restart action: one banner, not two.
|
||||
if (org?.busHealthy === false && onPage) { box.hidden = true; 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 +557,28 @@
|
||||
function showMachine() { $('show-machine').checked = machine; document.body.classList.toggle('machine-off', !machine); }
|
||||
$('show-machine').onchange = e => {
|
||||
machine = e.target.checked; showMachine();
|
||||
try { localStorage.setItem(boardKey, JSON.stringify({ machine })); } catch {}
|
||||
try { localStorage.setItem(boardKey(), JSON.stringify({ machine })); } catch {}
|
||||
if (machine) { $('status').textContent = 'Loading board…'; fresh(); refresh(); } else hideMachine();
|
||||
};
|
||||
showMachine();
|
||||
if (!machine) hideMachine();
|
||||
// #1873 SP1: another organization opened in place. Its board setting and bus state are its own.
|
||||
const onOrgSwitch = () => {
|
||||
machine = readMachine(); showMachine();
|
||||
if (machine) { fresh(); refresh(); } else { hideMachine(); busDown(); }
|
||||
};
|
||||
document.addEventListener('mosaic-org-switch', onOrgSwitch);
|
||||
// An address with no organization picks one after the first list read; its own setting applies then.
|
||||
window.MosaicOrg?.ready?.then(() => { if (readMachine() !== machine) onOrgSwitch(); });
|
||||
// The banner hides on the system dashboard, gives way to the organization page's own down banner,
|
||||
// and shows on the organization's other pages, so a move across those lines redraws it. Other moves
|
||||
// inside one organization read nothing (the switch path reads its own).
|
||||
let busPlace = null;
|
||||
window.addEventListener('hashchange', () => {
|
||||
if (!window.MosaicOrg) return;
|
||||
const path = window.MosaicOrg.route().split('?')[0], now = ['/dashboard', '/organization'].includes(path) ? path : 'page';
|
||||
if (now !== busPlace) { busPlace = now; busDown(); }
|
||||
});
|
||||
$('refresh').onclick = refresh;
|
||||
$('mobile-pause').onclick = () => $('pause').click();
|
||||
$('pause').onclick = () => { paused = !paused; $('pause').querySelector('span').textContent = paused ? 'Resume' : 'Pause'; $('pause').setAttribute('aria-label', paused ? 'Resume' : 'Pause'); $('pause').title = paused ? 'Resume' : 'Pause'; $('mobile-pause').textContent = paused ? 'Resume' : 'Pause'; $('pause').setAttribute('aria-pressed', String(paused)); schedule(); if (data) render(); };
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/* #1873 SP6: the Assistant page (r2-13). A bar with the page heading and "Open thread", then up to
|
||||
three thread panes as cards side by side, each with its own header (title, organization and
|
||||
project chips, close), its own scroll and its own composer. One pane and a tab strip on a phone.
|
||||
Loaded after shell.css and chat.css; every rule is scoped to the Assistant page. */
|
||||
html[data-organizations] .assist{display:flex;flex-direction:column;gap:10px;min-width:0}
|
||||
html[data-organizations] .assist-bar{display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap}
|
||||
html[data-organizations] .assist-vh{flex:1 1 320px;min-width:0}
|
||||
html[data-organizations] .assist-vh h1{margin:0}
|
||||
html[data-organizations] .assist-vh h1:focus{outline:none}
|
||||
html[data-organizations] .assist-vh .sub{margin:4px 0 0;color:var(--muted);font-size:14px}
|
||||
html[data-organizations] .assist-tools{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
html[data-organizations] .assist-open{min-width:220px;max-width:100%;font:500 14px/1.4 var(--font);color:var(--text);background:var(--surface);border:1px solid var(--line);border-radius:var(--r);padding:6px 8px}
|
||||
html[data-organizations] .assist-open:disabled{color:var(--muted);cursor:not-allowed}
|
||||
html[data-organizations] .assist [data-assist-unread]:empty,html[data-organizations] .assist [data-assist-note]:empty{display:none}
|
||||
html[data-organizations] .assist [data-assist-unread] .note{margin:0}
|
||||
html[data-organizations] .assist [data-assist-note]{margin:0;font-size:13px;color:var(--muted)}
|
||||
html[data-organizations] .assist [data-assist-note][data-refused]{color:var(--warning);font-weight:600}
|
||||
|
||||
html[data-organizations] .assist-new{border:1px solid var(--line);border-radius:var(--r);background:var(--surface);padding:12px 14px;display:grid;gap:10px}
|
||||
html[data-organizations] .assist-new[hidden]{display:none}
|
||||
html[data-organizations] .assist-new h2{margin:0;font-size:16px}
|
||||
html[data-organizations] .assist-new h2:focus{outline:none}
|
||||
html[data-organizations] .assist-new-fields{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}
|
||||
html[data-organizations] .assist-new-fields label{display:grid;gap:4px;font-size:13px;color:var(--muted);min-width:0}
|
||||
html[data-organizations] .assist-new-fields select,html[data-organizations] .assist-new-fields input{font:500 14px/1.4 var(--font);color:var(--text);background:var(--canvas);border:1px solid var(--line);border-radius:var(--r);padding:6px 8px;min-width:0}
|
||||
html[data-organizations] .assist-new-actions{display:flex;gap:8px;flex-wrap:wrap}
|
||||
html[data-organizations] .assist-new .ws-btn.primary{background:var(--action);color:var(--onAction);border-color:var(--action)}
|
||||
html[data-organizations] .assist-new .ws-btn.primary:hover:not(:disabled){background:color-mix(in srgb,var(--action) 88%,var(--strong))}
|
||||
html[data-organizations] .assist-new [data-assist-new-status]{margin:0;font-size:13px}
|
||||
html[data-organizations] .assist-new [data-assist-new-status]:empty{display:none}
|
||||
|
||||
html[data-organizations] .assist-tabs{display:none;margin-left:0;max-width:100%;flex-wrap:wrap}
|
||||
html[data-organizations] .assist-tabs>button{max-width:60vw;overflow:hidden;text-overflow:ellipsis;min-height:32px;padding:2px 10px}
|
||||
|
||||
html[data-organizations] .assist-panes{display:grid;grid-template-columns:repeat(var(--assist-n,1),minmax(0,1fr));gap:12px;min-height:0}
|
||||
html[data-organizations] .assist-panes[hidden]{display:none}
|
||||
html[data-organizations] .assist-pane{display:flex;flex-direction:column;min-width:0;min-height:0;border:1px solid var(--line);border-radius:var(--r);background:var(--surface);overflow:hidden}
|
||||
html[data-organizations] .assist-pane-h{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid var(--line);box-shadow:inset 0 3px 0 var(--org,var(--line));min-width:0;flex-wrap:wrap}
|
||||
html[data-organizations] .assist-pane-h h2{margin:0;font-size:15px;font-weight:700;color:var(--strong,var(--text));overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:5em;flex:1 1 0}
|
||||
html[data-organizations] .assist-pane-h h2:focus{outline:none}
|
||||
html[data-organizations] .assist-chips{display:inline-flex;gap:6px;flex-wrap:wrap;min-width:0;order:1;flex:1 0 100%}
|
||||
html[data-organizations] .assist-chips:empty{display:none}
|
||||
/* SP7: a pane's tools (Full view, Messages, close) stay together on the title's row; the chips take the row under it.
|
||||
A pane too narrow for them wraps the tools, never pushes one past the pane's edge. */
|
||||
html[data-organizations] .assist-pane-tools{display:inline-flex;align-items:center;justify-content:flex-end;gap:2px;flex:0 1 auto;flex-wrap:wrap;max-width:100%;margin-left:auto}
|
||||
html[data-organizations] .assist-chips .ws-chip{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
html[data-organizations] .assist .pane-tool{display:inline-grid;place-items:center;width:28px;height:28px;border-radius:var(--r);color:var(--muted);border:0;background:none;padding:0;min-height:0;cursor:pointer;flex:none}
|
||||
html[data-organizations] .assist .pane-tool:hover{background:var(--raised);color:var(--text)}
|
||||
/* SP7: each pane's own organization's Messages page, a text link among the pane's tools. */
|
||||
html[data-organizations] .assist .pane-tool.assist-msgs{width:auto;padding:0 8px;font-size:12px;font-weight:600;text-decoration:none}
|
||||
/* SP7: "Resume assistant" under a pane's composer is a small secondary control, not a second primary button. */
|
||||
html[data-organizations] .assist-body .btn.pane-resume{align-self:flex-start;min-height:0;padding:2px 8px;margin-top:4px;font-size:12px;font-weight:500;background:none;border-color:transparent;color:var(--muted)}
|
||||
html[data-organizations] .assist-body .btn.pane-resume:hover:not(:disabled){background:var(--raised);color:var(--text)}
|
||||
html[data-organizations] .assist .pane-tool svg.i{width:15px;height:15px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
|
||||
html[data-organizations] .assist-line{margin:0;padding:6px 12px;border-bottom:1px solid var(--line);font-size:13px;color:var(--text);background:color-mix(in srgb,var(--warn,#b7791f) 12%,var(--surface))}
|
||||
html[data-organizations] .assist-line[hidden]{display:none}
|
||||
html[data-organizations] .assist-body{flex:1;min-height:0;overflow:auto;padding:12px;display:flex;flex-direction:column}
|
||||
/* The chat thread view in a pane: the log scrolls, the composer stays at the foot of the card.
|
||||
Messages read as the project conversation's bubbles (projects.css scopes those to .project-chat,
|
||||
whose page-level layout a pane must not take), sized for a third of the screen. */
|
||||
html[data-organizations] .assist-body .chat-log{flex:1 1 0;min-height:120px;max-height:none;overflow:auto;margin:8px 0 0;padding:0;border:0}
|
||||
html[data-organizations] .assist-body .chat-log>li{border:0;display:flex;flex-direction:column;align-items:flex-start;gap:4px;padding-block:6px}
|
||||
html[data-organizations] .assist-body .chat-log>li.chat-message-human{align-items:flex-end}
|
||||
html[data-organizations] .assist-body .chat-log>li>.chat-body{background:var(--raised);border:1px solid var(--line);border-radius:12px;padding:8px 12px;max-width:100%;box-sizing:border-box;overflow-wrap:anywhere}
|
||||
html[data-organizations] .assist-body .chat-log>li.chat-message-human>.chat-body{background:var(--accent-fill);color:var(--on-accent);border-color:transparent}
|
||||
html[data-organizations] .assist-body .chat-message-meta{display:flex;align-items:center;gap:6px;opacity:1;font-size:12px;color:var(--muted)}
|
||||
html[data-organizations] .assist-body .chat-message-meta strong{color:var(--text);font-weight:600}
|
||||
html[data-organizations] .assist-body .chat-avatar{display:grid;place-items:center;flex:none;width:24px;height:24px;border-radius:50%;background:var(--raised);color:var(--action);font-weight:700;font-size:12px}
|
||||
html[data-organizations] .assist-body .chat-message-human .chat-avatar{background:var(--accent-fill);color:var(--on-accent)}
|
||||
html[data-organizations] .assist-body>.chat-area{max-width:none;margin:0;flex:1 1 auto;min-height:0;display:flex;flex-direction:column}
|
||||
html[data-organizations] .assist-body .chat-conversation{flex:1 1 auto;min-height:0;display:flex;flex-direction:column}
|
||||
html[data-organizations] .assist-body .chat-conversation>p.small{margin:0;font-size:12px}
|
||||
html[data-organizations] .assist-body form{margin-top:auto;padding-top:10px}
|
||||
html[data-organizations] .assist-body .chat-conversation>button,html[data-organizations] .assist-body form>button{align-self:flex-start;justify-self:start;width:auto}
|
||||
html[data-organizations] .assist-body textarea{width:100%;box-sizing:border-box;min-height:56px;height:56px}
|
||||
html[data-organizations] .assist-empty{margin:24px 0;color:var(--muted)}
|
||||
html[data-organizations] .assist-empty[hidden]{display:none}
|
||||
|
||||
@media (max-width:760px){
|
||||
html[data-organizations] .assist-tabs:not([hidden]){display:inline-flex}
|
||||
html[data-organizations] .assist-panes{grid-template-columns:minmax(0,1fr);height:auto}
|
||||
html[data-organizations] .assist-pane.alt{display:none}
|
||||
html[data-organizations] .assist-body{overflow:visible}
|
||||
html[data-organizations] .assist-body .chat-log{flex:none;min-height:0;overflow:visible}
|
||||
html[data-organizations] .assist-open{flex:1 1 100%;min-width:0}
|
||||
html[data-organizations] .assist-tools{flex:1 1 100%}
|
||||
html[data-organizations] .assist-new-fields{grid-template-columns:minmax(0,1fr)}
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
// #1873 SP6: the Assistant page (#/assistant), one system page with no organization prefix. Up to
|
||||
// three chat threads, from any organization on the list, open next to each other. Each pane is the
|
||||
// chat thread view (chat.js) in its own instance pinned to the pane's organization, so its reads and
|
||||
// its sends name that organization in the address whichever organization the page opened last.
|
||||
// A phone shows one pane at a time with a tab strip.
|
||||
//
|
||||
// The open set, its order and the phone's shown pane live in the address
|
||||
// (#/assistant?open=<org>:<thread>,…&show=<org>:<thread>), checked before use and rewritten with
|
||||
// replaceState. A bare #/assistant opens the set this browser had last.
|
||||
//
|
||||
// SP7: a plain click on a thread link anywhere in the Console (a project's conversation rows, the
|
||||
// sidebar's thread links) opens that thread as a pane here, added to the open set; #/assistant?add=<org>:<thread>
|
||||
// carries it across the page change. A link marked data-full-view (a pane's Full view, a requirement's
|
||||
// "Open conversation") and a per-organization chat address typed or pasted open that organization's own chat view.
|
||||
(() => {
|
||||
'use strict';
|
||||
if (!document.documentElement.hasAttribute('data-organizations')) return;
|
||||
const data = window.MosaicWorkspaceData, ws = () => window.MosaicWorkspace;
|
||||
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const enc = encodeURIComponent;
|
||||
const MAX = 3, POLL = 10000, LIST_TTL = 3000, INFO_TTL = 30000, STORE = 'mosaic-assistant-open-v1';
|
||||
const ORG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const paused = () => document.getElementById('pause')?.getAttribute('aria-pressed') === 'true';
|
||||
const load = () => { try { return localStorage.getItem(STORE); } catch { return null; } };
|
||||
const save = value => { try { localStorage.setItem(STORE, value); } catch {} };
|
||||
const svg = d => `<svg class="i sm" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${d}</svg>`;
|
||||
const CLOSE = svg('<path d="M6 6l12 12M18 6 6 18"/>');
|
||||
const setHtml = (el, html) => { if (el && el.innerHTML !== html) el.innerHTML = html; };
|
||||
const setText = (el, text) => { if (el && el.textContent !== text) el.textContent = text; };
|
||||
const rowsOf = body => (Array.isArray(body?.rows) ? body.rows : null);
|
||||
|
||||
// "<org>:<thread>,…" from the address: valid selectors only, each once, at most three.
|
||||
function parse(query) {
|
||||
const q = new URLSearchParams(query || ''), keys = [];
|
||||
for (const part of (q.get('open') || '').split(',')) {
|
||||
const cut = part.indexOf(':');
|
||||
if (cut < 1) continue;
|
||||
const org = part.slice(0, cut), id = part.slice(cut + 1).toLowerCase();
|
||||
if (!ORG.test(org) || !UUID.test(id)) continue;
|
||||
const key = `${org}:${id}`;
|
||||
if (!keys.includes(key) && keys.length < MAX) keys.push(key);
|
||||
}
|
||||
const want = q.get('show') || '', at = want.indexOf(':');
|
||||
const show = at > 0 ? `${want.slice(0, at)}:${want.slice(at + 1).toLowerCase()}` : null;
|
||||
return { keys, show: keys.includes(show) ? show : null, named: q.has('open') };
|
||||
}
|
||||
const split = key => { const cut = key.indexOf(':'); return { org: key.slice(0, cut), id: key.slice(cut + 1) }; };
|
||||
|
||||
// ---------- reads ----------
|
||||
// The organization list, and per organization its threads, projects and whether this Console may
|
||||
// send there (its config's chatSend). An organization whose bus is down is not read.
|
||||
let listAt = 0, listPromise = null;
|
||||
const infos = new Map();
|
||||
function orgList(fresh = false) {
|
||||
if (!listPromise || fresh || Date.now() - listAt > LIST_TTL) {
|
||||
listAt = Date.now();
|
||||
listPromise = Promise.all([data.organizations(), ws()?.themes?.()]).then(([rows]) => (Array.isArray(rows) ? rows : null)).catch(() => null);
|
||||
}
|
||||
return listPromise;
|
||||
}
|
||||
const readConfig = org => fetch(`/api/orgs/${enc(org)}/config`, { cache: 'no-store', redirect: 'manual' })
|
||||
.then(r => (r.ok ? r.json() : null)).then(c => c?.chatSend === true).catch(() => false);
|
||||
function info(org, fresh = false) {
|
||||
if (!org || org.invalid || org.busHealthy === false) return Promise.resolve({ threads: null, projects: null, chatSend: false, read: false });
|
||||
const hit = infos.get(org.id);
|
||||
if (hit && !fresh && Date.now() - hit.at < INFO_TTL) return hit.promise;
|
||||
const promise = Promise.all([
|
||||
data.threads(org.id).then(rowsOf).catch(() => null),
|
||||
data.projects(org.id).then(rowsOf).catch(() => null),
|
||||
readConfig(org.id),
|
||||
]).then(([threads, projects, chatSend]) => ({
|
||||
threads: threads && threads.filter(t => t && !t.shared && typeof t.id === 'string' && UUID.test(t.id)),
|
||||
projects: projects && projects.filter(p => p && typeof p.id === 'string'),
|
||||
chatSend, read: true,
|
||||
}));
|
||||
infos.set(org.id, { at: Date.now(), promise });
|
||||
return promise;
|
||||
}
|
||||
// The rows ws().orgsOf reads: an organization not ok is named at the top of the page.
|
||||
function orgRows(list, got) {
|
||||
return list.map(o => {
|
||||
const why = o.invalid ? ['invalid', o.reason || ''] : o.busHealthy === false ? ['down', ws().state(o)[2]] : got.get(o.id)?.threads === null ? ['timeout', 'the thread list did not answer'] : ['ok', ''];
|
||||
return { org: o.id, label: o.label, theme: o.theme, state: why[0], reason: why[1] };
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- state ----------
|
||||
let mounted = null; // { root, panes: [pane], show, turn, timer, list, got, o, form }
|
||||
let mountGen = 0, paneSeq = 0;
|
||||
|
||||
function writeUrl() {
|
||||
if (!mounted) return;
|
||||
const keys = mounted.panes.map(p => p.key);
|
||||
const qs = keys.length ? `open=${keys.join(',')}${mounted.show && mounted.show !== keys[0] ? `&show=${mounted.show}` : ''}` : '';
|
||||
save(keys.join(','));
|
||||
const next = qs ? `#/assistant?${qs}` : '#/assistant';
|
||||
if (location.hash !== next) history.replaceState(null, '', next);
|
||||
}
|
||||
|
||||
// ---------- markup ----------
|
||||
function frame() {
|
||||
return `<section class="assist" data-assistant aria-labelledby="assist-title">
|
||||
<div class="assist-bar">
|
||||
<div class="assist-vh"><h1 id="assist-title" tabindex="-1">Assistant</h1>
|
||||
<p class="sub">Threads from any organization, side by side. Each pane reads and sends through its own organization.</p></div>
|
||||
<div class="assist-tools">
|
||||
<label class="ws-sr" for="assist-open">Open thread</label>
|
||||
<select id="assist-open" class="assist-open" data-assist-open><option value="">Open thread…</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<div data-assist-unread></div>
|
||||
<p class="note" data-assist-note role="status"></p>
|
||||
<form class="assist-new" data-assist-new hidden aria-labelledby="assist-new-title">
|
||||
<h2 id="assist-new-title" tabindex="-1">New thread in <span data-assist-new-org></span></h2>
|
||||
<div class="assist-new-fields">
|
||||
<label><span>Project</span><select name="project" required></select></label>
|
||||
<label><span>Agent</span><select name="instance" required></select></label>
|
||||
<label><span>Name</span><input name="name" required maxlength="200" autocomplete="off"></label>
|
||||
</div>
|
||||
<div class="assist-new-actions"><button class="ws-btn primary" type="submit">Start thread</button><button class="ws-btn" type="button" data-assist-new-cancel>Cancel</button></div>
|
||||
<p class="note" data-assist-new-status role="status"></p>
|
||||
</form>
|
||||
<div class="ws-seg assist-tabs" role="group" aria-label="Shown thread" data-assist-tabs></div>
|
||||
<div class="assist-panes" data-assist-panes></div>
|
||||
<p class="assist-empty" data-assist-empty hidden>No thread is open. Choose one under Open thread.</p>
|
||||
</section>`;
|
||||
}
|
||||
function paneShell(p) {
|
||||
const n = p.n;
|
||||
return `<section class="assist-pane ws-o" data-assist-pane="${esc(p.key)}" id="assist-p${n}" aria-labelledby="assist-p${n}-title">
|
||||
<div class="assist-pane-h">
|
||||
<h2 id="assist-p${n}-title" data-assist-title>Reading…</h2>
|
||||
<span class="assist-chips" data-assist-chips></span>
|
||||
<span class="assist-pane-tools">
|
||||
<a class="pane-tool assist-msgs" data-assist-pane-full data-full-view href="#/orgs/${enc(p.org)}/projects/threads/${enc(p.id)}">Full view</a>
|
||||
<a class="pane-tool assist-msgs" data-assist-pane-messages href="#/orgs/${enc(p.org)}/messages" aria-label="Messages in ${esc(p.org)}">Messages</a>
|
||||
<button type="button" class="pane-tool" data-assist-close aria-label="Close thread">${CLOSE}</button>
|
||||
</span>
|
||||
</div>
|
||||
<p class="assist-line" data-assist-line role="status" hidden></p>
|
||||
<div class="assist-body" data-assist-body></div>
|
||||
</section>`;
|
||||
}
|
||||
const els = p => ({
|
||||
title: p.el.querySelector('[data-assist-title]'), chips: p.el.querySelector('[data-assist-chips]'),
|
||||
close: p.el.querySelector('[data-assist-close]'), full: p.el.querySelector('[data-assist-pane-full]'), messages: p.el.querySelector('[data-assist-pane-messages]'), line: p.el.querySelector('[data-assist-line]'), body: p.el.querySelector('[data-assist-body]'),
|
||||
});
|
||||
|
||||
// The picker: every organization on the list, its threads, and "New thread in …" where this
|
||||
// Console may send. A stopped, invalid or silent organization is named and offers nothing.
|
||||
function pickerHtml(list, got) {
|
||||
const open = new Set(mounted.panes.map(p => p.key));
|
||||
const groups = (list || []).filter(o => o && typeof o.id === 'string' && ORG.test(o.id)).map(o => {
|
||||
const name = ws().label(o), i = got.get(o.id);
|
||||
if (o.invalid) return `<optgroup label="${esc(name)} · invalid"><option disabled value="">Organization invalid</option></optgroup>`;
|
||||
if (o.busHealthy === false) return `<optgroup label="${esc(name)} · ${esc(ws().state(o)[2])}"><option disabled value="">No threads while its bus is down</option></optgroup>`;
|
||||
if (!i?.threads) return `<optgroup label="${esc(name)} · not answering"><option disabled value="">Threads unavailable</option></optgroup>`;
|
||||
const project = id => i.projects?.find(p => p.id === id)?.name;
|
||||
const rows = i.threads.map(t => {
|
||||
const key = `${o.id}:${t.id.toLowerCase()}`, where = project(t.project);
|
||||
return `<option value="${esc(key)}"${open.has(key) ? ' disabled' : ''}>${esc(t.name || t.id)}${where ? ` · ${esc(where)}` : ''}${open.has(key) ? ' (open)' : ''}</option>`;
|
||||
}).join('');
|
||||
const fresh = i.chatSend ? `<option value="new:${esc(o.id)}">New thread in ${esc(name)}…</option>` : '';
|
||||
return `<optgroup label="${esc(name)}">${rows || (fresh ? '' : '<option disabled value="">No threads</option>')}${fresh}</optgroup>`;
|
||||
});
|
||||
return `<option value="">Open thread…</option>${groups.join('')}`;
|
||||
}
|
||||
|
||||
// ---------- drawing ----------
|
||||
async function draw({ fresh = false, navigated = false } = {}) {
|
||||
if (!mounted) return;
|
||||
const gen = mountGen, turn = ++mounted.turn;
|
||||
const live = () => mounted && gen === mountGen && turn === mounted.turn;
|
||||
clearTimeout(mounted.timer);
|
||||
const list = await orgList(fresh);
|
||||
if (!live()) return;
|
||||
const openOrgs = new Set(mounted.panes.map(p => p.org));
|
||||
const valid = (list || []).filter(o => o && typeof o.id === 'string' && ORG.test(o.id));
|
||||
const got = new Map(await Promise.all(valid.map(async o => [o.id, await info(o, fresh || (!navigated && openOrgs.has(o.id)))])));
|
||||
if (!live()) return;
|
||||
mounted.list = list; mounted.got = got;
|
||||
mounted.o = ws().orgsOf({ orgs: orgRows(valid, got) });
|
||||
paintTop();
|
||||
for (const p of mounted.panes) drawPane(p);
|
||||
paintTabs(); layout();
|
||||
mounted.timer = setTimeout(tick, POLL);
|
||||
}
|
||||
function tick() {
|
||||
if (!mounted) return;
|
||||
if (paused()) { mounted.timer = setTimeout(tick, POLL); return; }
|
||||
void draw();
|
||||
}
|
||||
// The line under the lead: how many panes are open, or why a thread was not opened (drawn as a warning).
|
||||
function paintNote() {
|
||||
const el = mounted.root.querySelector('[data-assist-note]');
|
||||
setText(el, mounted.notice || (mounted.panes.length ? `${mounted.panes.length} open, up to ${MAX}` : ''));
|
||||
el?.toggleAttribute('data-refused', !!mounted.notice);
|
||||
}
|
||||
function paintTop() {
|
||||
const { root, list, o, panes } = mounted;
|
||||
setHtml(root.querySelector('[data-assist-unread]'), list ? ws().unreadNote(o, [], 'assistant') : '<p class="note ws-unread-note">The organization list could not be read. Open panes only read.</p>');
|
||||
paintNote();
|
||||
const pick = root.querySelector('[data-assist-open]');
|
||||
if (document.activeElement !== pick) { setHtml(pick, pickerHtml(list, mounted.got)); pick.value = ''; }
|
||||
const full = panes.length >= MAX;
|
||||
pick.disabled = full;
|
||||
pick.title = full ? `${MAX} threads are open. Close one to open another.` : '';
|
||||
root.querySelector('[data-assist-empty]').hidden = panes.length > 0;
|
||||
}
|
||||
function drawPane(p) {
|
||||
if (!mounted || !p.el.isConnected) return;
|
||||
const { list, got, o } = mounted, e = els(p);
|
||||
const org = list?.find(x => x?.id === p.org) || null, i = got.get(p.org);
|
||||
const name = org ? ws().label(org) : p.org;
|
||||
const thread = i?.threads?.find(t => t.id.toLowerCase() === p.id) || null;
|
||||
const title = thread?.name || `Thread ${p.id.slice(0, 8)}`;
|
||||
p.title = title;
|
||||
setText(e.title, title);
|
||||
e.close.setAttribute('aria-label', `Close ${title}, ${name}`);
|
||||
e.full.setAttribute('aria-label', `Full view of ${title}, ${name}`);
|
||||
e.messages.setAttribute('aria-label', `Messages in ${name}`);
|
||||
if (org) p.el.setAttribute('style', ws().colours(org)); else p.el.removeAttribute('style');
|
||||
const project = thread && i.projects?.find(x => x.id === thread.project)?.name;
|
||||
setHtml(e.chips, `${ws().orgChip(o, p.org)}${ws().projChip(project || '')}`);
|
||||
let line = '', readable = true, why = 'Nothing is read for this thread.', back = false;
|
||||
if (!list) line = 'The organization list could not be read, so sending is off here until it reads again.';
|
||||
else if (!org) { line = `${p.org} is not on your organization list.`; readable = false; }
|
||||
else if (org.invalid) { line = `${name}: organization invalid. Fix its file under Settings, Organizations.`; readable = false; }
|
||||
else if (org.busHealthy === false) {
|
||||
const how = org.busStopped ? 'stopped' : 'unavailable';
|
||||
line = `${name}: bus is ${how}. This pane cannot read or send until it starts again; the other panes are not affected.`;
|
||||
why = `Nothing is read for this thread while ${name}'s bus is ${how}.`; readable = false; back = true;
|
||||
}
|
||||
else if (i?.threads && !thread) line = `This thread is not in ${name}'s thread list.`;
|
||||
// A held message the last render had no composer for stays in the line between polls, so the
|
||||
// status line neither flickers nor is announced again on every read.
|
||||
const withHeld = () => {
|
||||
const cps = [...p.draft.replace(/\s+/g, ' ').trim()];
|
||||
return `${line ? `${line} ` : ''}Your unsent message is kept until this thread can send: “${cps.length > 200 ? `${cps.slice(0, 200).join('')}…` : cps.join('')}”`;
|
||||
};
|
||||
const shown = readable && p.held && p.draft ? withHeld() : line;
|
||||
setText(e.line, shown); e.line.hidden = !shown;
|
||||
if (!readable) {
|
||||
p.held = false;
|
||||
// An unsent message outlives the chat view: it is held on the pane and put back when the
|
||||
// thread reads again.
|
||||
const typed = p.chat && e.body.querySelector('form textarea')?.value;
|
||||
if (typed?.trim()) p.draft = typed;
|
||||
p.chat?.dispose(); p.chat = null;
|
||||
const kept = !p.draft ? '' : back ? ' Your unsent message is kept and comes back here when it reads again.' : ' Your unsent message is kept while this pane stays open.';
|
||||
setHtml(e.body, `<p class="ws-line">${esc(why)}${kept}</p>`);
|
||||
return;
|
||||
}
|
||||
if (!p.chat) { p.chat = window.MosaicChat.pane(p.org); p.first = true; }
|
||||
const first = p.first, chat = p.chat, seq = ++p.seq; p.first = false;
|
||||
void chat.render({ path: `/chat/threads/${p.id}`, container: e.body, canSend: !!list && i?.chatSend === true, navigated: first }).then(() => {
|
||||
// Only the latest render of this pane decides: an earlier one that a newer render cut short
|
||||
// has no composer yet and must not claim the thread cannot send.
|
||||
if (seq !== p.seq || p.chat !== chat || !p.el.isConnected) return;
|
||||
const field = p.draft ? e.body.querySelector('form textarea') : null;
|
||||
if (p.draft && !field) {
|
||||
// The thread reads but this render drew no composer (it cannot send now): the held message is
|
||||
// quoted in the pane's status line until a composer is back to take it.
|
||||
p.held = true; const text = withHeld(); setText(e.line, text); e.line.hidden = false;
|
||||
return;
|
||||
}
|
||||
// Put the held message back only into an empty composer; text typed meanwhile is never overwritten.
|
||||
if (field && !field.value) { field.value = p.draft; field.dispatchEvent(new Event('input', { bubbles: true })); }
|
||||
if (field && field.value === p.draft) p.draft = '';
|
||||
if (p.held && (field || !p.draft)) { p.held = false; setText(e.line, line); e.line.hidden = !line; }
|
||||
}, () => {});
|
||||
}
|
||||
function paintTabs() {
|
||||
const { root, panes, show } = mounted;
|
||||
const tabs = root.querySelector('[data-assist-tabs]');
|
||||
const focused = tabs.contains(document.activeElement) ? document.activeElement.dataset.assistShow : null;
|
||||
const html = panes.map(p => `<button type="button" data-assist-show="${esc(p.key)}" aria-pressed="${show === p.key}" aria-controls="assist-p${p.n}">${esc(p.title || 'Reading…')}</button>`).join('');
|
||||
if (tabs.innerHTML !== html) { tabs.innerHTML = html; if (focused) tabs.querySelector(`[data-assist-show="${CSS.escape(focused)}"]`)?.focus({ preventScroll: true }); }
|
||||
tabs.hidden = panes.length < 2;
|
||||
}
|
||||
function layout() {
|
||||
const { root, panes, show } = mounted;
|
||||
const box = root.querySelector('[data-assist-panes]');
|
||||
box.style.setProperty('--assist-n', String(Math.max(1, panes.length)));
|
||||
box.hidden = !panes.length;
|
||||
for (const p of panes) p.el.classList.toggle('alt', show !== p.key);
|
||||
fit();
|
||||
}
|
||||
// Wide screens: the panes fill the window below the bar and each scrolls on its own.
|
||||
const wide = window.matchMedia('(min-width: 761px)');
|
||||
function fit() {
|
||||
if (!mounted) return;
|
||||
const box = mounted.root.querySelector('[data-assist-panes]');
|
||||
if (!wide.matches) { box.style.height = ''; return; }
|
||||
const foot = document.querySelector('body > .foot, .foot')?.getBoundingClientRect().height || 0;
|
||||
const top = box.getBoundingClientRect().top;
|
||||
box.style.height = `${Math.max(360, Math.floor(window.innerHeight - Math.max(top, 0) - foot - 16))}px`;
|
||||
}
|
||||
|
||||
// ---------- panes ----------
|
||||
// SP11: a pane's unsent message is kept per thread for the life of the browser tab, so leaving the
|
||||
// Assistant page (a Full view link included) and coming back puts it in the composer again.
|
||||
// sessionStorage can be missing or refuse a write; the Map still keeps it while the page lives.
|
||||
// SP14: the kept messages belong to the person who typed them. They are stored with the viewer the
|
||||
// page's /api/config read names (bus.js, window.MosaicViewer), and read back only by a page that
|
||||
// read the same viewer. An entry stored under another viewer, or under none (an older entry, a
|
||||
// failed read), is removed unread, so a tab that never saw a sign-out (another tab signed out, the
|
||||
// session expired) does not hand one person's text to the next. A Console with no sign-in has one
|
||||
// fixed viewer. Until the viewer is read nothing is written; with none, messages live only in the page.
|
||||
const DRAFTS = 'mosaic-assistant-drafts';
|
||||
const kept = new Map();
|
||||
let viewer = null, known = false, viewerRead = null;
|
||||
const saveKept = () => {
|
||||
if (!known) return;
|
||||
try { if (kept.size && viewer) sessionStorage.setItem(DRAFTS, JSON.stringify({ viewer, drafts: Object.fromEntries(kept) })); else sessionStorage.removeItem(DRAFTS); } catch { /* the Map still holds it */ }
|
||||
};
|
||||
const ready = () => (viewerRead ??= (async () => {
|
||||
let v = null, stored = null;
|
||||
try { v = await window.MosaicViewer; } catch { /* no viewer */ }
|
||||
try { stored = JSON.parse(sessionStorage.getItem(DRAFTS) || 'null'); } catch { /* none kept */ }
|
||||
viewer = typeof v === 'string' && v ? v : null;
|
||||
known = true;
|
||||
const own = !draftsOff && viewer && stored && typeof stored === 'object' && stored.viewer === viewer && stored.drafts && typeof stored.drafts === 'object' && !Array.isArray(stored.drafts);
|
||||
if (own) for (const [k, t] of Object.entries(stored.drafts)) if (!kept.has(k) && typeof t === 'string' && t.trim() && parse(`open=${k}`).keys[0] === k) kept.set(k, t);
|
||||
saveKept();
|
||||
})());
|
||||
const typedIn = p => p.chat?.draft?.() || p.draft || p.el?.querySelector('form textarea')?.value || '';
|
||||
// Signed out, by an expired session or by the Sign out form: the kept drafts go and nothing is kept
|
||||
// again, not even by the pagehide the form's own submit causes, so the next person to sign in on
|
||||
// this tab never sees text typed before.
|
||||
let draftsOff = false;
|
||||
function keep(p) { if (draftsOff) return; const text = typedIn(p); if (text.trim()) kept.set(p.key, text); else kept.delete(p.key); saveKept(); }
|
||||
function drop(p) { if (kept.delete(p.key)) saveKept(); }
|
||||
window.addEventListener('pagehide', () => { if (mounted) for (const p of mounted.panes) keep(p); });
|
||||
function forgetDrafts() {
|
||||
draftsOff = true; kept.clear(); saveKept();
|
||||
try { sessionStorage.removeItem(DRAFTS); } catch { /* nothing to remove */ }
|
||||
if (mounted) for (const p of mounted.panes) { p.draft = ''; const t = p.el?.querySelector('form textarea'); if (t) t.value = ''; }
|
||||
}
|
||||
document.addEventListener('mosaic-signed-out', forgetDrafts);
|
||||
document.addEventListener('submit', e => { if (e.target?.id === 'sign-out') forgetDrafts(); }, true);
|
||||
function makePane(key) {
|
||||
const { org, id } = split(key);
|
||||
const p = { key, org, id, n: ++paneSeq, el: null, chat: null, first: true, title: null, draft: kept.get(key) || '', held: false, seq: 0 };
|
||||
const box = mounted.root.querySelector('[data-assist-panes]');
|
||||
box.insertAdjacentHTML('beforeend', paneShell(p));
|
||||
p.el = box.lastElementChild;
|
||||
return p;
|
||||
}
|
||||
function addPane(key, { focus = false } = {}) {
|
||||
if (!mounted) return;
|
||||
const had = mounted.panes.find(p => p.key === key);
|
||||
if (had) { mounted.show = key; paintTabs(); layout(); writeUrl(); return; }
|
||||
if (mounted.panes.length >= MAX) return;
|
||||
const p = makePane(key);
|
||||
mounted.panes.push(p); mounted.show = key;
|
||||
writeUrl();
|
||||
if (mounted.got) { paintTop(); drawPane(p); paintTabs(); layout(); }
|
||||
if (focus) els(p).title.setAttribute('tabindex', '-1'), els(p).title.focus({ preventScroll: true });
|
||||
}
|
||||
// SP7: a thread opened from a link takes a free slot or, with all three taken, the place of the
|
||||
// first pane that holds no unsent message. When every pane holds one, nothing is replaced.
|
||||
// SP11: a pane whose send is in flight, or whose sent message the conversation does not show yet,
|
||||
// is held too, until its receipt is in.
|
||||
const holds = p => !!p.draft.trim() || !!p.chat?.hasDraft() || !!p.chat?.busy?.();
|
||||
function place(key) {
|
||||
const had = mounted.panes.find(p => p.key === key);
|
||||
if (had) return had;
|
||||
if (mounted.panes.length < MAX) { const p = makePane(key); mounted.panes.push(p); return p; }
|
||||
const old = mounted.panes.find(p => !holds(p));
|
||||
if (!old) return null;
|
||||
const p = makePane(key);
|
||||
drop(old); old.chat?.dispose(); old.chat = null; old.el.replaceWith(p.el);
|
||||
mounted.panes[mounted.panes.indexOf(old)] = p;
|
||||
return p;
|
||||
}
|
||||
const REFUSED = `That thread was not opened: all ${MAX} panes hold an unsent message or a message waiting to show. Send or clear one, wait for a sent message to show, or close a pane, then open the thread again.`;
|
||||
function add(key) {
|
||||
if (!mounted) return false;
|
||||
const p = place(key);
|
||||
if (!p) { mounted.notice = REFUSED; paintNote(); return false; }
|
||||
mounted.notice = ''; mounted.show = key;
|
||||
writeUrl();
|
||||
if (mounted.got) { paintTop(); drawPane(p); paintTabs(); layout(); }
|
||||
els(p).title.setAttribute('tabindex', '-1'); els(p).title.focus({ preventScroll: true });
|
||||
return true;
|
||||
}
|
||||
const addOf = query => { const v = new URLSearchParams(query || '').get('add'); return v ? parse(new URLSearchParams({ open: v }).toString()).keys[0] || null : null; };
|
||||
const THREAD_LINK = /^#\/(?:orgs\/([^/?#]+)\/)?(?:projects|chat)\/threads\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
|
||||
document.addEventListener('click', e => {
|
||||
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
const a = e.target.closest?.('a[href]');
|
||||
// A link marked data-full-view (a pane's Full view, a requirement's "Open conversation") goes to the
|
||||
// organization's own thread view, which has Request details, sessions and sharing.
|
||||
if (!a || a.hasAttribute('download') || a.hasAttribute('data-full-view') || (a.target && a.target !== '_self')) return;
|
||||
const m = THREAD_LINK.exec(a.getAttribute('href') || '');
|
||||
const org = m && (m[1] || window.MosaicOrg?.id);
|
||||
if (!m || !org || !ORG.test(org)) return;
|
||||
e.preventDefault();
|
||||
const key = `${org}:${m[2].toLowerCase()}`;
|
||||
if (mounted && /^#\/assistant(?:\?|$)/.test(location.hash)) add(key);
|
||||
else location.hash = `#/assistant?add=${key}`;
|
||||
});
|
||||
|
||||
// SP11: closing a pane that holds unsent text asks first; a close drops the kept text.
|
||||
function closePane(p) {
|
||||
if (!mounted || !p) return;
|
||||
const at = mounted.panes.indexOf(p);
|
||||
if (at < 0) return;
|
||||
if (typedIn(p).trim() && !window.confirm(`Close ${p.title || 'this thread'}? Its unsent message is lost.`)) return;
|
||||
drop(p);
|
||||
mounted.notice = '';
|
||||
p.chat?.dispose(); p.chat = null; p.el.remove();
|
||||
mounted.panes.splice(at, 1);
|
||||
const next = mounted.panes[Math.min(at, mounted.panes.length - 1)];
|
||||
if (mounted.show === p.key) mounted.show = next?.key ?? null;
|
||||
writeUrl(); paintTop(); paintTabs(); layout();
|
||||
// The close button just left the page: focus the next pane's close control, or the picker.
|
||||
(next ? els(next).close : mounted.root.querySelector('[data-assist-open]')).focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
// ---------- a new thread ----------
|
||||
async function openForm(orgId) {
|
||||
if (!mounted) return;
|
||||
const org = mounted.list?.find(o => o?.id === orgId);
|
||||
if (!org || org.busHealthy === false || mounted.got.get(orgId)?.chatSend !== true) return;
|
||||
const f = mounted.root.querySelector('[data-assist-new]'), formTurn = ++mounted.formTurn;
|
||||
mounted.form = { org: orgId, busy: false };
|
||||
f.hidden = false; f.reset();
|
||||
setText(f.querySelector('[data-assist-new-org]'), ws().label(org));
|
||||
setText(f.querySelector('[data-assist-new-status]'), 'Reading projects and agents…');
|
||||
const project = f.elements.project, instance = f.elements.instance;
|
||||
project.innerHTML = ''; instance.innerHTML = ''; project.disabled = true; instance.disabled = true;
|
||||
f.querySelector('h2').focus({ preventScroll: true });
|
||||
const [i, agents] = await Promise.all([
|
||||
info(org, true),
|
||||
fetch(`/api/orgs/${enc(orgId)}/chat/instances`, { cache: 'no-store', redirect: 'manual' }).then(r => (r.ok ? r.json() : null)).then(rowsOf).catch(() => null),
|
||||
]);
|
||||
if (!mounted || mounted.formTurn !== formTurn || f.hidden) return;
|
||||
const projects = (i.projects || []).filter(p => !p.archived);
|
||||
project.innerHTML = projects.map(p => `<option value="${esc(p.id)}">${esc(p.name || p.id)}</option>`).join('');
|
||||
instance.innerHTML = (agents || []).filter(a => a && typeof a.instance === 'string').map(a => `<option value="${esc(a.instance)}">${esc(a.name || a.instance)}</option>`).join('');
|
||||
project.disabled = !projects.length; instance.disabled = !agents?.length;
|
||||
const missing = [i.projects === null ? 'Projects could not be read.' : !projects.length ? 'This organization has no open project.' : '', agents === null ? 'Agents could not be read.' : !agents.length ? 'No agent takes threads here.' : ''].filter(Boolean).join(' ');
|
||||
setText(f.querySelector('[data-assist-new-status]'), missing);
|
||||
}
|
||||
function closeForm() {
|
||||
if (!mounted) return;
|
||||
mounted.form = null; mounted.formTurn++;
|
||||
mounted.root.querySelector('[data-assist-new]').hidden = true;
|
||||
}
|
||||
async function submitForm(f) {
|
||||
const form = mounted?.form;
|
||||
if (!form || form.busy) return;
|
||||
const status = f.querySelector('[data-assist-new-status]');
|
||||
const body = { project: f.elements.project.value, name: f.elements.name.value.trim(), instance: f.elements.instance.value };
|
||||
if (!body.project || !body.instance || !body.name) { setText(status, 'Choose a project and an agent, and name the thread.'); return; }
|
||||
form.busy = true;
|
||||
const submit = f.querySelector('[type=submit]'); submit.disabled = true;
|
||||
setText(status, 'Starting the thread…');
|
||||
let result = null, why = '';
|
||||
try {
|
||||
const r = await fetch(`/api/orgs/${enc(form.org)}/chat/threads`, { method: 'POST', cache: 'no-store', redirect: 'manual', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
||||
const v = await r.json().catch(() => null);
|
||||
if (r.ok && typeof v?.id === 'string' && UUID.test(v.id)) result = v.id.toLowerCase();
|
||||
else why = r.type === 'opaqueredirect' || r.status === 401 ? 'You are signed out. Reload the page to sign in again.' : `The thread was not started${typeof v?.error === 'string' ? ` (${v.error})` : ''}.`;
|
||||
} catch { why = 'Outcome unknown. Look under Open thread before trying again.'; }
|
||||
if (mounted?.form !== form) return;
|
||||
form.busy = false; submit.disabled = false;
|
||||
if (!result) { setText(status, why); return; }
|
||||
infos.delete(form.org);
|
||||
const key = `${form.org}:${result}`;
|
||||
if (mounted.panes.length >= MAX && !mounted.panes.some(p => p.key === key)) { setText(status, `Started. ${MAX} threads are open; close one to open it.`); return; }
|
||||
closeForm();
|
||||
addPane(key, { focus: true });
|
||||
void draw({ fresh: false });
|
||||
}
|
||||
|
||||
// ---------- events (bound once to the mount root) ----------
|
||||
function bind(root) {
|
||||
root.addEventListener('change', e => {
|
||||
const pick = e.target.closest?.('[data-assist-open]');
|
||||
if (!pick || !mounted) return;
|
||||
const value = pick.value;
|
||||
pick.value = '';
|
||||
if (value.startsWith('new:')) { void openForm(value.slice(4)); return; }
|
||||
const want = parse(`open=${value}`).keys[0];
|
||||
if (want) { mounted.notice = ''; addPane(want); }
|
||||
});
|
||||
root.addEventListener('click', e => {
|
||||
if (!mounted) return;
|
||||
const show = e.target.closest?.('[data-assist-show]');
|
||||
if (show) { const p = mounted.panes.find(x => x.key === show.dataset.assistShow); if (p) { mounted.show = p.key; paintTabs(); layout(); writeUrl(); } return; }
|
||||
const close = e.target.closest?.('[data-assist-close]');
|
||||
if (close) { closePane(mounted.panes.find(x => x.el.contains(close))); return; }
|
||||
if (e.target.closest?.('[data-assist-new-cancel]')) { closeForm(); root.querySelector('[data-assist-open]').focus({ preventScroll: true }); }
|
||||
});
|
||||
root.addEventListener('submit', e => {
|
||||
const f = e.target.closest?.('[data-assist-new]');
|
||||
if (!f) return;
|
||||
e.preventDefault();
|
||||
void submitForm(f);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- public ----------
|
||||
async function mount(container, query, { navigated = false } = {}) {
|
||||
unmount();
|
||||
const gen = ++mountGen;
|
||||
// SP14: the kept messages are known only once the viewer is; a page left meanwhile mounts nothing.
|
||||
await ready();
|
||||
if (gen !== mountGen) return;
|
||||
container.innerHTML = frame();
|
||||
const root = container.querySelector('[data-assistant]');
|
||||
mounted = { root, panes: [], show: null, turn: 0, timer: null, list: null, got: null, o: null, form: null, formTurn: 0, notice: '' };
|
||||
bind(root);
|
||||
window.addEventListener('resize', fit);
|
||||
document.title = 'Assistant · Mosaic Console';
|
||||
let want = parse(query);
|
||||
if (!want.named) want = parse(`open=${load() || ''}`);
|
||||
for (const key of want.keys) mounted.panes.push(makePane(key));
|
||||
mounted.show = want.show || want.keys[0] || null;
|
||||
// A thread link followed from another page: a fresh set holds no unsent message, so it always lands.
|
||||
const extra = addOf(query), added = extra && place(extra);
|
||||
if (added) mounted.show = extra;
|
||||
if (added) { els(added).title.setAttribute('tabindex', '-1'); els(added).title.focus({ preventScroll: true }); }
|
||||
else if (navigated) root.querySelector('h1').focus({ preventScroll: true });
|
||||
writeUrl(); paintTabs(); layout();
|
||||
root.querySelector('[data-assist-empty]').hidden = mounted.panes.length > 0;
|
||||
// SP14: a full set offers no fourth from the start, not only once the organization reads answer.
|
||||
root.querySelector('[data-assist-open]').disabled = mounted.panes.length >= MAX;
|
||||
await draw({ navigated: true });
|
||||
}
|
||||
function unmount() {
|
||||
if (!mounted) { mountGen++; return; }
|
||||
clearTimeout(mounted.timer);
|
||||
for (const p of mounted.panes) { keep(p); p.chat?.dispose(); }
|
||||
window.removeEventListener('resize', fit);
|
||||
mounted = null; mountGen++;
|
||||
}
|
||||
// The Console's Refresh, and a redraw of the same page: every pane reads again now. A query that
|
||||
// names a different open set (an address edited in place) opens that set.
|
||||
function refresh(query) {
|
||||
if (!mounted) return;
|
||||
if (typeof query === 'string') {
|
||||
const want = parse(query);
|
||||
if (want.named && want.keys.join(',') !== mounted.panes.map(p => p.key).join(',')) {
|
||||
for (const p of [...mounted.panes]) if (!want.keys.includes(p.key)) { keep(p); p.chat?.dispose(); p.el.remove(); }
|
||||
const kept = new Map(mounted.panes.map(p => [p.key, p]));
|
||||
mounted.panes = want.keys.map(k => kept.get(k) || makePane(k));
|
||||
const box = mounted.root.querySelector('[data-assist-panes]');
|
||||
for (const p of mounted.panes) box.append(p.el);
|
||||
mounted.show = want.show || want.keys[0] || null;
|
||||
writeUrl();
|
||||
}
|
||||
const extra = addOf(query);
|
||||
if (extra) add(extra);
|
||||
}
|
||||
infos.clear();
|
||||
void draw({ fresh: true });
|
||||
}
|
||||
const hasDraft = () => !!mounted && mounted.panes.some(p => !!p.draft.trim() || !!p.chat?.hasDraft());
|
||||
// A write that changed an organization reads it again.
|
||||
document.addEventListener('mosaic-org-changed', e => { if (mounted && mounted.panes.some(p => p.org === e.detail?.org)) { infos.delete(e.detail.org); void draw(); } });
|
||||
|
||||
window.MosaicAssistant = Object.freeze({ mount, unmount, refresh, hasDraft, add });
|
||||
})();
|
||||
@@ -181,11 +181,27 @@
|
||||
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)) 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>`);
|
||||
// SP7: a trail with nothing above the page only repeats the page's heading, so the heading names it once.
|
||||
if (!box) return;
|
||||
const heading = [...$('main').querySelectorAll('h1')].find(h => h.getClientRects().length)?.textContent.trim();
|
||||
const echo = !box.querySelector('a') && !!heading && box.textContent.trim() === heading;
|
||||
if (box.hidden !== echo) box.hidden = echo;
|
||||
}
|
||||
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; }
|
||||
const names = {projects:'Projects',chat:'Projects',inbox:'Inbox',messages:'Messages',tasks:'Tasks',agents:'Agents',queue:'Queue',business:'Business',docs:'Docs',settings:'Settings'};
|
||||
// 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',assistant:'Assistant',tasks:'Tasks',agents:'Agents',workflows:'Workflows',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();
|
||||
const project = $('bus-view').querySelector('.chat-conversation > p:first-child a, .chat-room > p:first-child a');
|
||||
|
||||
@@ -75,13 +75,17 @@
|
||||
window.MosaicIcons.succeeded();
|
||||
return body;
|
||||
}
|
||||
// #1873 SP1: bumped by each in-place organization switch. A write, or a read that keeps its answer,
|
||||
// captures it before its await and drops the answer when it changed, so nothing the old organization
|
||||
// answered lands on the new page or in its kept answers.
|
||||
let orgEpoch = 0;
|
||||
// A reader for one render. `live` asks the server; otherwise only kept answers are used.
|
||||
function reader(live) {
|
||||
const used = [];
|
||||
const get = async (verb, subject) => {
|
||||
const k = subject === undefined ? verb : `${verb}:${subject}`;
|
||||
let r;
|
||||
if (live) { r = await fetchRead(verb, subject); last.set(k, r); }
|
||||
if (live) { const epoch = orgEpoch; r = await fetchRead(verb, subject); if (epoch === orgEpoch) last.set(k, r); }
|
||||
else if (!(r = last.get(k))) throw new ReadError('not-read', 'not read before');
|
||||
used.push(r.at);
|
||||
if (verb === 'messages') { messageCount = r.unread; return r; }
|
||||
@@ -90,7 +94,7 @@
|
||||
get.api = async path => {
|
||||
const k = `api:${path}`;
|
||||
let r;
|
||||
if (live) { r = await fetchApi(path); last.set(k, r); }
|
||||
if (live) { const epoch = orgEpoch; r = await fetchApi(path); if (epoch === orgEpoch) last.set(k, r); }
|
||||
else if (!(r = last.get(k))) throw new ReadError('not-read', 'not read before');
|
||||
used.push(r.at);
|
||||
return r;
|
||||
@@ -204,7 +208,7 @@
|
||||
out.groupAs = m?.from_role === 'human' ? 'request' : m?.decision ? 'decision' : 'other';
|
||||
} else if (r.table === 'decisions') {
|
||||
out.kind = 'decision.raised'; out.decision = r.id; out.actor = { role: r.raised_by_role, run: r.raised_by_run };
|
||||
out.text = `Raised a ${r.class} decision for ${r.route_to}: ${String(r.question).split('\n')[0]}`;
|
||||
out.text = `Raised a ${String(classWord(r.class)).toLowerCase()} decision for ${r.route_to}: ${String(r.question).split('\n')[0]}`;
|
||||
} else if (r.table === 'decision_events') {
|
||||
// The human answers from the CLI (via cli) or, since row 252 (#1753), a grant request from the
|
||||
// signed-in Console (via console); every other row is the broker's.
|
||||
@@ -239,7 +243,9 @@
|
||||
const badge = (text, kind = 'muted') => `<span class="badge badge-${kind}">${txt(text)}</span>`;
|
||||
// Class chip: the decision class, the action in mono. BLOCKING is its own chip (CSS capitalises it; the text stays lowercase).
|
||||
const CLASSES = ['routine', 'within-role', 'cross-role', 'gated'];
|
||||
const chip = (cls, action) => `<span class="tag${CLASSES.includes(cls) ? ` tag-${cls}` : ''}">${txt(cls)} · <code>${txt(action)}</code></span>`;
|
||||
// SP11: the class in the Inbox's words, from workspace.js's map.
|
||||
const classWord = cls => window.MosaicWorkspace?.classWord?.(cls) ?? cls;
|
||||
const chip = (cls, action) => `<span class="tag${CLASSES.includes(cls) ? ` tag-${cls}` : ''}">${txt(classWord(cls))} · <code>${txt(action)}</code></span>`;
|
||||
const blockChip = text => `<span class="tag tag-block">${txt(text)}</span>`;
|
||||
// An empty state says what would appear and where it comes from.
|
||||
const empty = (what, from) => `<div class="empty"><b>${what}</b>${from ? `<p>${from}</p>` : ''}</div>`;
|
||||
@@ -264,8 +270,8 @@
|
||||
|
||||
// ---------------------------------------------------------------- views
|
||||
let inboxCount = null, messageCount = null;
|
||||
// Organization mode (sample 01): Dashboard, Inbox, Tasks, Projects, Side by side, Agents and
|
||||
// Assistant, with Settings pinned to the foot of the sidebar. Queue opens from the organization
|
||||
// Organization mode (sample 01): Dashboard, Inbox, Tasks, Projects, Agents and Assistant (SP6:
|
||||
// `#/assistant`, threads of any organization side by side), with Settings pinned to the foot of the sidebar. Queue opens from the organization
|
||||
// dashboard and Docs from its Docs row, so neither is a sidebar item here.
|
||||
function orgSections(route) {
|
||||
const nav = $('console-nav');
|
||||
@@ -275,9 +281,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'], ['/agents', 'Agents', '', 'agents'], ['/assistant', 'Assistant', '', '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,12 +349,14 @@
|
||||
});
|
||||
|
||||
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() {
|
||||
if (replies === null) return '<p class="small warn">The replies to it could not be read. Read again to retry; <code>mosaic inbox</code> in a terminal lists them.</p>';
|
||||
if (!replies.length) return '<p class="small muted">No reply yet. One shows here when the role answers, as the page refreshes.</p>';
|
||||
return `<h3 class="small">Replies <span class="count">${replies.length}</span></h3><ol class="s1-plain s1-replies">${replies.map(m => `<li><p class="small muted">${txt(m.from_role)} ${when(m.at)} · ${txt(m.class)} · <a href="#/trail/message/${enc(m.id)}">trail</a></p><p class="s1-q">${txt(m.body)}</p></li>`).join('')}</ol>${replies.length >= REPLIES ? `<p class="small muted">The first ${REPLIES} replies show here.</p>` : ''}`;
|
||||
return `<h3 class="small">Replies <span class="count">${replies.length}</span></h3><ol class="s1-plain s1-replies">${replies.map(m => `<li><p class="small muted">${txt(m.from_role)} ${when(m.at)} · ${txt(classWord(m.class))} · <a href="#/trail/message/${enc(m.id)}">trail</a></p><p class="s1-q">${txt(m.body)}</p></li>`).join('')}</ol>${replies.length >= REPLIES ? `<p class="small muted">The first ${REPLIES} replies show here.</p>` : ''}`;
|
||||
}
|
||||
const draft = { to: '', body: '' };
|
||||
function composerHtml() {
|
||||
@@ -462,7 +471,9 @@
|
||||
if(!templatesOnly && d.op!=='fire' && !template){hireStatus='Read and choose a role template first.';hireCode='';paintHireStart();return;}
|
||||
const thinking=d.thinking?.trim(),hire={op:d.op,level:d.level,name:d.name,reason:d.reason,...(d.level==='project'?{project:d.project}:{}),...(d.op==='fire'?{}:{role:{template:template?.name,sha256:template?.sha256},route:{harness:d.harness,model:d.model,...(thinking?{thinking}:{})}})};
|
||||
hireBusy=true;hireStatus='';hireCode='';paintHireStart();
|
||||
const epoch=orgEpoch;
|
||||
const {res,json}=await decidePost(`/api/decisions/hire/${templatesOnly?'templates':'start'}`,{business:hireOrg() ?? d.business,...(templatesOnly?{}:{hire})});
|
||||
if(epoch!==orgEpoch){if(res?.type==='opaqueredirect')signOut();return;}
|
||||
hireBusy=false;
|
||||
if(res?.type==='opaqueredirect'){signOut();return;}
|
||||
if(res?.ok && templatesOnly && Array.isArray(json?.templates)){hireTemplates=json.templates;hireStatus=hireTemplates.length?'Templates read.':'No templates available.';}
|
||||
@@ -479,44 +490,65 @@
|
||||
const FIRE_REASON='Fired from the Agents page.';
|
||||
let fireAsk=null,fireBusy=false,fireReceipt=null;
|
||||
const fireKey=(org,name)=>`${org}\u0000${name}`;
|
||||
function fireNote(){const org=hireOrg();const r=fireReceipt&&fireReceipt.org===org?fireReceipt:null;
|
||||
// #1873 SP5: the system Agents page lists every organization's agents, so its controls carry their
|
||||
// organization (data-agent-fire-org) and post to that organization's own bus, never to the one
|
||||
// opened last. Its receipt box is `data-agents-receipt="*"` and shows a receipt from any of them.
|
||||
// The agent cards keep the old shape: no attribute, the opened organization, canDecide.
|
||||
function fireNote(scope=hireOrg()){const r=fireReceipt&&(scope==='*'?fireReceipt.system:fireReceipt.org===scope)?fireReceipt:null;
|
||||
return r?`<p class="receipt${r.failed?' failed':''}" data-agents-receipt-text>${txt(r.text)}${r.code?` (<code>${txt(r.code)}</code>)`:''}</p>`:'';}
|
||||
function paintFireNote(){for(const e of document.querySelectorAll('[data-agents-receipt]'))e.innerHTML=fireNote();}
|
||||
async function fireAgent(name){
|
||||
const org=hireOrg();
|
||||
if(fireBusy||!org||out()||!canDecide)return;
|
||||
function paintFireNote(){for(const e of document.querySelectorAll('[data-agents-receipt]'))e.innerHTML=fireNote(e.dataset.agentsReceipt||hireOrg());}
|
||||
const fireSel=(attr,name,org)=>`[${attr}="${CSS.escape(name)}"]${org?`[data-agent-fire-org="${CSS.escape(org)}"]`:':not([data-agent-fire-org])'}`;
|
||||
async function fireAgent(name,named=null,orgName=named){
|
||||
const org=named??hireOrg();
|
||||
// A named organization was checked against its own config when the control was drawn (decidesIn).
|
||||
if(fireBusy||!org||out()||(!named&&!canDecide)||isTester)return;
|
||||
fireBusy=true;
|
||||
for(const b of document.querySelectorAll('[data-agent-fire-yes],[data-agent-fire-no]'))b.disabled=true;
|
||||
const {res,json}=await decidePost('/api/decisions/hire/start',{business:org,hire:{op:'fire',level:'org',name,reason:FIRE_REASON}});
|
||||
fireBusy=false;fireAsk=null;
|
||||
const epoch=orgEpoch;
|
||||
const {res,json}=await decidePost(`${decideBase({ org: named })}/decisions/hire/start`,{business:org,hire:{op:'fire',level:'org',name,reason:FIRE_REASON}});
|
||||
// SP5 review F7: a named organization's Fire went to that organization, so its outcome still
|
||||
// stands after the opened organization changes.
|
||||
// After a switch, busy and confirm state belong to whatever Fire started since; leave them alone.
|
||||
const stale=epoch!==orgEpoch;
|
||||
if(stale&&!named){if(res?.type==='opaqueredirect')signOut();return;}
|
||||
if(!stale){fireBusy=false;fireAsk=null;}
|
||||
if(res?.type==='opaqueredirect'){signOut();return;}
|
||||
const where=named?` in ${orgName}`:'';
|
||||
if(res?.ok && json?.op==='resolved'){
|
||||
for(const c of document.querySelectorAll(`[data-org="${CSS.escape(org)}"] [data-agent-card="${CSS.escape(name)}"]`))c.remove();
|
||||
for(const r of document.querySelectorAll(`[data-agents-org="${CSS.escape(org)}"] tr[data-agent="${CSS.escape(name)}"]`))r.remove();
|
||||
fireReceipt={org,text:`Fired ${name}. Recorded decision ${json.id}.`};
|
||||
for(const r of document.querySelectorAll(`[data-ws-agent="${CSS.escape(`${org}:${name}`)}"]`))r.remove();
|
||||
fireReceipt={org,system:!!named,text:`Fired ${name}${where}. Recorded decision ${json.id}.`};
|
||||
paintFireNote();announce(fireReceipt.text);
|
||||
document.dispatchEvent(new CustomEvent('mosaic-org-changed',{detail:{org}}));
|
||||
} else {
|
||||
const r=decideRefusal(res,json,'outcome-unknown');
|
||||
fireReceipt={org,failed:true,text:`${name} was not fired: ${r.message}`,code:r.code};
|
||||
fireReceipt={org,system:!!named,failed:true,text:`${name}${where} was not fired: ${r.message}`,code:r.code};
|
||||
paintFireNote();
|
||||
for(const c of document.querySelectorAll(`[data-agent-fire-confirm="${CSS.escape(name)}"]`))c.outerHTML=fireButton(name);
|
||||
for(const c of document.querySelectorAll(fireSel('data-agent-fire-confirm',name,named)))c.outerHTML=fireButton(name,named,orgName);
|
||||
}
|
||||
}
|
||||
const fireButton=name=>`<button type="button" class="btn small" data-agent-fire="${esc(name)}" data-focus-key="${esc(`fire:${name}`)}" aria-label="Fire ${esc(name)}">Fire</button>`;
|
||||
const fireConfirm=name=>`<span class="ws-fire-confirm" role="group" aria-label="Fire ${esc(name)}?" data-agent-fire-confirm="${esc(name)}">Fire ${txt(name)}? <button type="button" class="btn small primary" data-agent-fire-yes="${esc(name)}" data-focus-key="${esc(`fire-yes:${name}`)}"${fireBusy?' disabled':''}>Confirm fire</button><button type="button" class="btn small" data-agent-fire-no="${esc(name)}"${fireBusy?' disabled':''}>Cancel</button></span>`;
|
||||
// What the agent card shows for one agent: nothing without the decide right or outside the page's own organization.
|
||||
function fireControl(org,name){
|
||||
if(!canDecide||isTester||org!==hireOrg())return '';
|
||||
return fireAsk===fireKey(org,name)?fireConfirm(name):fireButton(name);
|
||||
const fireOrgAttr=org=>org?` data-agent-fire-org="${esc(org)}"`:'';
|
||||
const fireFocus=(kind,name,org)=>esc(org?`${kind}:${org}:${name}`:`${kind}:${name}`);
|
||||
// SP5 review F5: the words name the organization by its label (`orgName`); its id addresses the control.
|
||||
const fireNameAttr=(org,orgName)=>org&&orgName&&orgName!==org?` data-agent-fire-name="${esc(orgName)}"`:'';
|
||||
const fireButton=(name,org=null,orgName=org)=>`<button type="button" class="btn small" data-agent-fire="${esc(name)}"${fireOrgAttr(org)}${fireNameAttr(org,orgName)} data-focus-key="${fireFocus('fire',name,org)}" aria-label="Fire ${esc(name)}${org?` in ${esc(orgName)}`:''}">Fire</button>`;
|
||||
const fireConfirm=(name,org=null,orgName=org)=>`<span class="ws-fire-confirm" role="group" aria-label="Fire ${esc(name)}${org?` in ${esc(orgName)}`:''}?" data-agent-fire-confirm="${esc(name)}"${fireOrgAttr(org)}${fireNameAttr(org,orgName)}>Fire ${txt(name)}? <button type="button" class="btn small primary" data-agent-fire-yes="${esc(name)}"${fireOrgAttr(org)} data-focus-key="${fireFocus('fire-yes',name,org)}"${fireBusy?' disabled':''}>Confirm fire</button><button type="button" class="btn small" data-agent-fire-no="${esc(name)}"${fireOrgAttr(org)}${fireBusy?' disabled':''}>Cancel</button></span>`;
|
||||
// What the agent card shows for one agent: nothing without the decide right or outside the page's own
|
||||
// organization. SP5: `allowed` given means the caller read that organization's own config (decidesIn);
|
||||
// the control is then drawn for that organization, whichever one was opened last.
|
||||
function fireControl(org,name,allowed,orgName=org){
|
||||
const named=typeof allowed==='boolean';
|
||||
if(isTester||(named?!allowed:(!canDecide||org!==hireOrg())))return '';
|
||||
return fireAsk===fireKey(org,name)?fireConfirm(name,named?org:null,named?orgName:null):fireButton(name,named?org:null,named?orgName:null);
|
||||
}
|
||||
document.addEventListener('click',e=>{
|
||||
const t=e.target.closest?.('[data-agent-fire],[data-agent-fire-yes],[data-agent-fire-no]');
|
||||
if(!t)return;
|
||||
const org=hireOrg();
|
||||
if(t.hasAttribute('data-agent-fire')){const name=t.dataset.agentFire;fireAsk=fireKey(org,name);t.outerHTML=fireConfirm(name);document.querySelector(`[data-agent-fire-yes="${CSS.escape(name)}"]`)?.focus();}
|
||||
else if(t.hasAttribute('data-agent-fire-no')){const name=t.dataset.agentFireNo;fireAsk=null;t.closest('[data-agent-fire-confirm]').outerHTML=fireButton(name);announce('Cancelled. Nothing was fired.');}
|
||||
else void fireAgent(t.dataset.agentFireYes);
|
||||
const named=t.dataset.agentFireOrg??null,org=named??hireOrg(),orgName=t.closest('[data-agent-fire-name]')?.dataset.agentFireName??named;
|
||||
if(t.hasAttribute('data-agent-fire')){const name=t.dataset.agentFire;fireAsk=fireKey(org,name);t.outerHTML=fireConfirm(name,named,orgName);document.querySelector(fireSel('data-agent-fire-yes',name,named))?.focus();}
|
||||
else if(t.hasAttribute('data-agent-fire-no')){const name=t.dataset.agentFireNo;fireAsk=null;t.closest('[data-agent-fire-confirm]').outerHTML=fireButton(name,named,orgName);document.querySelector(fireSel('data-agent-fire',name,named))?.focus();announce('Cancelled. Nothing was fired.');}
|
||||
else void fireAgent(t.dataset.agentFireYes,named,orgName);
|
||||
});
|
||||
window.MosaicDecide=Object.freeze({fireControl,fireNote});
|
||||
document.addEventListener('click',e=>{if(e.target.closest?.('[data-hire-load]'))hireSubmit(true);});
|
||||
@@ -572,6 +604,9 @@
|
||||
};
|
||||
const decideRefusal = (res, json, fallback) => res && !res.ok && res.status !== 408 && typeof json?.error === 'string' && typeof json?.message === 'string'
|
||||
? { code: json.error, message: json.message } : { code: fallback, message: DECIDE_FALLBACK[fallback] };
|
||||
// SP3: an answer started from the system Inbox names its organization and goes there, whichever
|
||||
// organization was opened last. One started on a decision page keeps the page's own scope.
|
||||
const decideBase = a => a.org ? `/api/orgs/${enc(a.org)}` : '/api';
|
||||
async function decide(step) {
|
||||
const a = answer;
|
||||
if (!a || a.pending || a.result) return;
|
||||
@@ -582,7 +617,7 @@
|
||||
const choice = selected.key, ordinary = ['decision','hire'].includes(a.console.kind);
|
||||
const adjust=choice==='yes'?collectHireAdjustment(a.console):undefined;
|
||||
a.pending = step; a.error = null; a.confirm = null; paintDecide();
|
||||
const { res, json } = await decidePost(`/api/decisions/${enc(a.id)}/confirmation`, { choice,...(adjust===undefined?{}:{adjust}) });
|
||||
const { res, json } = await decidePost(`${decideBase(a)}/decisions/${enc(a.id)}/confirmation`, { choice,...(adjust===undefined?{}:{adjust}) });
|
||||
a.pending = null;
|
||||
// The page moved to another decision meanwhile: its confirmation is dropped, never shown.
|
||||
if (answer !== a) { if (res?.type === 'opaqueredirect') signOut(); return; }
|
||||
@@ -596,7 +631,7 @@
|
||||
if (step !== 'confirm' || !a.confirm) return;
|
||||
const c = a.confirm;
|
||||
a.pending = 'confirm'; a.error = null; paintDecide();
|
||||
const { res, json } = await decidePost(`/api/decisions/${enc(a.id)}/resolve`, { choice: c.choice, confirmation: c.confirmation });
|
||||
const { res, json } = await decidePost(`${decideBase(a)}/decisions/${enc(a.id)}/resolve`, { choice: c.choice, confirmation: c.confirmation });
|
||||
// One use, whatever came back.
|
||||
a.pending = null; a.confirm = null;
|
||||
if (answer !== a) { if (res?.type === 'opaqueredirect') signOut(); else render(false); return; }
|
||||
@@ -606,6 +641,53 @@
|
||||
paintDecide('#decide-status'); announce(['decision','hire'].includes(c.kind) ? 'Recorded' : c.action === 'approve' ? 'Approved' : 'Denied'); render(false);
|
||||
} else { a.error = decideRefusal(res, json, 'outcome-unknown'); paintDecide('#decide-status'); announce(`Not answered: ${a.error.message}`); }
|
||||
}
|
||||
// Whether this Console decides in one organization, from that organization's own config: the system
|
||||
// Inbox's hire links must not follow the organization opened last, whose bus may be the stopped one.
|
||||
const orgConfig = id => (isTester ? Promise.resolve(null) : fetch(`/api/orgs/${enc(id)}/config`, { cache: 'no-store', redirect: 'manual' })
|
||||
.then(r => (r.ok ? r.json() : null)).catch(() => null));
|
||||
const decidesIn = id => orgConfig(id).then(c => c?.decide === true && c?.tester !== true);
|
||||
// SP4: whether this Console takes task changes in one organization, from that organization's own
|
||||
// config (orgSetup, as the per-organization board read it), so the system task board offers a move
|
||||
// only where the row's organization takes it.
|
||||
const tasksIn = id => orgConfig(id).then(c => c?.orgSetup === true && c?.tester !== true);
|
||||
// SP3: the answer panel for the row selected in the Inbox, in that row's organization. That
|
||||
// organization's config says whether this Console answers decisions there, and its bus inbox says
|
||||
// whether this decision is eligible (its `console` field), so neither comes from the organization
|
||||
// opened last. The state follows the same keep rules as the decision page's. `current` is the
|
||||
// render's own check: a read that returns after the page moved on changes nothing.
|
||||
async function inboxAnswer(d, current) {
|
||||
if (!d || isTester || !d.console || typeof d.org !== 'string') return inboxKeep(d);
|
||||
const base = `/api/orgs/${enc(d.org)}`;
|
||||
let eligible = null, failed = false, seen = null;
|
||||
try {
|
||||
const [c, i] = await Promise.all([`${base}/config`, `${base}/bus/inbox`].map(u => fetch(u, { cache: 'no-store', redirect: 'manual' })));
|
||||
if (c.type === 'opaqueredirect' || i.type === 'opaqueredirect') { if (current()) signOut(); return ''; }
|
||||
if (!c.ok || !i.ok) throw new Error('not read');
|
||||
const [cfg, box] = await Promise.all([c.json(), i.json()]);
|
||||
const row = (Array.isArray(box?.rows) ? box.rows : []).find(r => r?.id === d.id);
|
||||
// The broker's inbox row is the decisions row (`route_to`) plus `authorization`; the confirm
|
||||
// step shows who raised it and what it affects from it, as the decision page does from the trail.
|
||||
eligible = cfg?.decide === true && cfg?.tester !== true && row?.route_to === 'human' && row.console && typeof row.console === 'object' ? row.console : null;
|
||||
if (eligible) seen = { at: row.at, raisedBy: { role: row.raised_by_role, run: row.raised_by_run }, routeTo: row.route_to, authorization: row.authorization ?? null };
|
||||
} catch { failed = true; }
|
||||
if (!current()) return '';
|
||||
const same = answer?.org === d.org && answer.id === d.id;
|
||||
if (!same) answer = eligible ? { id: d.id, org: d.org, console: eligible, pending: null, confirm: null, error: null, result: null } : null;
|
||||
else if (eligible && !answer.pending) {
|
||||
if (answer.console.digest !== eligible.digest) answer.confirm = null;
|
||||
answer.console = eligible;
|
||||
}
|
||||
else if (!eligible && !failed && !answer.pending && !answer.result) answer = null;
|
||||
if (answer?.org === d.org && answer.id === d.id) { if (seen) answer.decision = seen; return decideHtml(); }
|
||||
return failed ? '<p class="note" data-ws-answer-why>Whether this Console can answer it here was not read: the organization did not answer. Open it in its organization, or use a terminal.</p>' : '';
|
||||
}
|
||||
// A row this Console cannot answer drops any answer held for another row, unless one is in flight.
|
||||
function inboxKeep(d) {
|
||||
if (answer?.org && !answer.pending && !(d && answer.org === d.org && answer.id === d.id && answer.result)) answer = null;
|
||||
return answer?.org && d && answer.org === d.org && answer.id === d.id ? decideHtml() : '';
|
||||
}
|
||||
// The receipt stays on screen after the answered row leaves the list.
|
||||
const inboxAnswered = key => answer?.org && answer.result && `${answer.org}:${answer.id}` === key ? decideHtml() : '';
|
||||
async function inboxView(get) {
|
||||
await configRead;
|
||||
if(!hireDraft.business && window.MosaicOrg?.id)hireDraft.business=window.MosaicOrg.id;
|
||||
@@ -627,7 +709,7 @@
|
||||
if (after !== null && !SAFE.test(after)) return notFound('The message cursor is not a message id.');
|
||||
const page = await get('messages', after === null ? undefined : after);
|
||||
const body = page.rows.length ? `<ul id="message-list" class="waiting s1-replies">${page.rows.map(m =>
|
||||
`<li class="wait-item"><p class="small muted">${txt(m.from_role)} · ${txt(m.class)} · ${when(m.at)} · ${m.read ? 'Read' : 'Unread'} · <a href="#/trail/message/${enc(m.id)}">trail</a></p><p class="s1-message">${messageText(m)}</p></li>`).join('')}</ul>`
|
||||
`<li class="wait-item"><p class="small muted">${txt(m.from_role)} · ${txt(classWord(m.class))} · ${when(m.at)} · ${m.read ? 'Read' : 'Unread'} · <a href="#/trail/message/${enc(m.id)}">trail</a></p><p class="s1-message">${messageText(m)}</p></li>`).join('')}</ul>`
|
||||
: empty('No messages to you.', 'Messages appear here as the bus is read.');
|
||||
return `${head('Messages', `${readNote(get)} Every message to you, newest first. Reading here marks nothing read.`)}
|
||||
<p class="small muted">${page.total} messages · ${page.unread} unread</p>${body}
|
||||
@@ -820,12 +902,15 @@
|
||||
return `<section aria-labelledby="b-thread-claims">${title}<div class="banner err" role="alert"><p>${why(err)}</p>${retry}</div></section>`;
|
||||
}
|
||||
}
|
||||
async function agentsView(get) {
|
||||
// SP5: an organization's scoped page leads with its rows of the system Agents table (which owns the
|
||||
// heading), then the levels grid under its own h2, then held roles and threads.
|
||||
const scopedTop = async (q, org, note) => `${await window.MosaicWorkspace.agentsPage(q, { scope: org })}${await window.MosaicWorkspace.agentGrid(note, undefined, { under: true })}`;
|
||||
async function agentsView(get, q = new URLSearchParams(), org = null) {
|
||||
let claims;
|
||||
try { claims = await get('agents'); } catch (err) {
|
||||
if (!window.MosaicOrg || err.code === 'signed-out') throw err;
|
||||
if (refusal(err)) forget();
|
||||
return `${await window.MosaicWorkspace.agentGrid()}<div class="banner err" role="alert">The selected organization's held roles could not be read. ${why(err)}</div>`;
|
||||
return `${org ? await scopedTop(q, org) : await window.MosaicWorkspace.agentGrid()}<div class="banner err" role="alert">The selected organization's held roles could not be read. ${why(err)}</div>`;
|
||||
}
|
||||
let instances = null, threads = [];
|
||||
if (!isTester) {
|
||||
@@ -842,7 +927,7 @@
|
||||
const threadSection = await threadClaimsSection(get, holders, threads, !!instances, running);
|
||||
const rows = claims.map(c => `<tr><th scope="row">${txt(c.role)}</th><td>${stateMark(c.state)}${c.working ? `<span class="source">${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}</span>` : ''}</td><td>${attention(c)}</td><td>${txt(holders.get(c.holder_run) || c.role)}<span class="source">run ${txt(c.holder_run)}${!instances ? ', running status unavailable' : running.has(c.holder_run) ? '' : ', not running'}</span></td><td>${txt(c.harness)}${c.address ? `<span class="source">${txt(c.address)}</span>` : ''}</td><td>${when(c.at)}</td></tr>`).join('');
|
||||
// Organization mode (sample 08): the levels grid carries the page heading; held roles follow.
|
||||
const top = window.MosaicOrg ? await window.MosaicWorkspace.agentGrid(readNote(get)) : head('Agents', readNote(get));
|
||||
const top = !window.MosaicOrg ? head('Agents', readNote(get)) : org ? await scopedTop(q, org, readNote(get)) : await window.MosaicWorkspace.agentGrid(readNote(get));
|
||||
return `${top}
|
||||
<section aria-labelledby="b-claims"><h2 id="b-claims">Held roles <span class="count">${claims.length}</span></h2><p class="small muted">The current holder of each claimed role. A role nobody holds does not appear. Working: the holder has messages it received and hasn't answered yet. Waiting: a decision the role raised is open in your inbox. Otherwise idle.</p>
|
||||
${claims.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Held roles, scroll for all columns"><table class="s1-table s1-agents"><thead><tr>${['Role', 'State', 'Attention', 'Holder', 'Harness', 'Held since'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div>` : empty('No role is held right now.', 'A role shows here while a run holds its claim on the bus.')}</section>
|
||||
@@ -1038,9 +1123,9 @@
|
||||
document.addEventListener('change',e=>{const form=e.target.closest?.('#launch-settings-form');if(form){settingsInputs=settingDraft(form);markSettingsDraft('limits',true);form.querySelector('.settings-actions')?.classList.add('has-draft');const discard=$('launch-settings-reload');if(discard)discard.hidden=false;}});
|
||||
document.addEventListener('click',e=>{if(e.target.id!=='launch-settings-reload')return;settingsInputs=null;settingsSnapshot=null;settingsReceipt='';last.delete('api:settings');render(true);});
|
||||
document.addEventListener('submit',async e=>{
|
||||
if(e.target.id!=='launch-settings-form')return;e.preventDefault();if(!canSend||out()||settingsSaving)return;settingsInputs=settingDraft(e.target);let payload;try{payload=settingPayload();}catch(err){settingsReceipt=err.message+' Draft kept.';render(false);return;}settingsSaving=true;settingsReceipt='';render(false);
|
||||
try{const response=await fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){settingsReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();if(response.ok&&/^[a-f0-9]{64}$/.test(result?.revision??'')){settingsInputs=null;settingsSnapshot=result;const prior=last.get('api:settings');if(prior)last.set('api:settings',{...prior,launch:result});settingsReceipt='Launch settings saved.';}else settingsReceipt=result?.sent==='unknown'?'Outcome unknown. Read current settings before trying again. Draft kept.':`${result?.message||'Settings refused.'} Draft kept.`;}}
|
||||
catch{settingsReceipt='Outcome unknown. Read current settings before trying again. Draft kept.';}finally{settingsSaving=false;if(!out())render(false);}
|
||||
if(e.target.id!=='launch-settings-form')return;e.preventDefault();if(!canSend||out()||settingsSaving)return;settingsInputs=settingDraft(e.target);let payload;try{payload=settingPayload();}catch(err){settingsReceipt=err.message+' Draft kept.';render(false);return;}settingsSaving=true;settingsReceipt='';render(false);const epoch=orgEpoch;
|
||||
try{const response=await fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(epoch!==orgEpoch){if(response.type==='opaqueredirect')signOut();return;}if(response.type==='opaqueredirect'){settingsReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();if(epoch!==orgEpoch)return;if(response.ok&&/^[a-f0-9]{64}$/.test(result?.revision??'')){settingsInputs=null;settingsSnapshot=result;const prior=last.get('api:settings');if(prior)last.set('api:settings',{...prior,launch:result});settingsReceipt='Launch settings saved.';}else settingsReceipt=result?.sent==='unknown'?'Outcome unknown. Read current settings before trying again. Draft kept.':`${result?.message||'Settings refused.'} Draft kept.`;}}
|
||||
catch{if(epoch!==orgEpoch)return;settingsReceipt='Outcome unknown. Read current settings before trying again. Draft kept.';}finally{if(epoch===orgEpoch){settingsSaving=false;if(!out())render(false);}}
|
||||
});
|
||||
// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): each token limit as the bus's checks report it. A
|
||||
// limit no switched-on check counts reads "not enforced". Reset counts it again from now. Row 126
|
||||
@@ -1061,9 +1146,9 @@
|
||||
document.addEventListener('click',async e=>{
|
||||
const path=e.target.dataset?.limitReset;if(path===undefined||!canSend||out()||settingsSaving)return;
|
||||
const revision=settingsSnapshot?.revision;if(!/^[a-f0-9]{64}$/.test(revision??''))return;
|
||||
settingsSaving=true;resetReceipt='';render(false);let reread=false;
|
||||
try{const response=await fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){resetReceipt='Sign in again.';signOut();}else{const result=await response.json();if(response.ok&&/^[a-f0-9]{64}$/.test(result?.revision??'')){settingsSnapshot=result;const prior=last.get('api:settings');if(prior)last.set('api:settings',{...prior,launch:result});reread=true;resetReceipt='Limit reset.';}else resetReceipt=result?.sent==='unknown'?'Outcome unknown. Read current settings before trying again.':`${result?.message||'Reset refused.'}`;}}
|
||||
catch{resetReceipt='Outcome unknown. Read current settings before trying again.';}finally{settingsSaving=false;if(!out())render(reread);}
|
||||
settingsSaving=true;resetReceipt='';render(false);let reread=false;const epoch=orgEpoch;
|
||||
try{const response=await fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});if(epoch!==orgEpoch){if(response.type==='opaqueredirect')signOut();return;}if(response.type==='opaqueredirect'){resetReceipt='Sign in again.';signOut();}else{const result=await response.json();if(epoch!==orgEpoch)return;if(response.ok&&/^[a-f0-9]{64}$/.test(result?.revision??'')){settingsSnapshot=result;const prior=last.get('api:settings');if(prior)last.set('api:settings',{...prior,launch:result});reread=true;resetReceipt='Limit reset.';}else resetReceipt=result?.sent==='unknown'?'Outcome unknown. Read current settings before trying again.':`${result?.message||'Reset refused.'}`;}}
|
||||
catch{if(epoch!==orgEpoch)return;resetReceipt='Outcome unknown. Read current settings before trying again.';}finally{if(epoch===orgEpoch){settingsSaving=false;if(!out())render(reread);}}
|
||||
});
|
||||
function tokenUsage(value) {
|
||||
if (!value?.total) return '<section class="panel" aria-labelledby="token-usage-title"><h2 id="token-usage-title">Token usage</h2><p>Usage unavailable. No count was read.</p></section>';
|
||||
@@ -1118,9 +1203,9 @@
|
||||
}
|
||||
const unknownAssistant='Outcome unknown. Read current settings before trying again. Draft kept.';
|
||||
async function assistantPost(url,payload,instance,ok,kind='tester'){
|
||||
if(kind==='tester')assistantBusy=true;else assistantFormsBusy.add(assistantKey(instance,kind));assistantReceiptKinds[instance]=kind;assistantReceipts[instance]='';if(kind!=='tester')assistantCardReceipts[assistantKey(instance,kind)]='';render(false);
|
||||
try{const response=await fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){assistantReceipts[instance]='Sign in again. Draft kept.';signOut();}else{const result=await response.json();assistantReceiptKinds[instance]=kind;assistantReceipts[instance]=response.ok?ok(result):result?.sent==='unknown'?unknownAssistant:`${result?.message||'Refused.'} Draft kept.`;}}
|
||||
catch{assistantReceiptKinds[instance]=kind;assistantReceipts[instance]=unknownAssistant;}finally{if(kind==='tester')assistantBusy=false;else{assistantCardReceipts[assistantKey(instance,kind)]=assistantReceipts[instance];assistantFormsBusy.delete(assistantKey(instance,kind));}if(!out())render(false);}
|
||||
if(kind==='tester')assistantBusy=true;else assistantFormsBusy.add(assistantKey(instance,kind));assistantReceiptKinds[instance]=kind;assistantReceipts[instance]='';if(kind!=='tester')assistantCardReceipts[assistantKey(instance,kind)]='';render(false);const epoch=orgEpoch;
|
||||
try{const response=await fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(epoch!==orgEpoch){if(response.type==='opaqueredirect')signOut();return;}if(response.type==='opaqueredirect'){assistantReceipts[instance]='Sign in again. Draft kept.';signOut();}else{const result=await response.json();if(epoch!==orgEpoch)return;assistantReceiptKinds[instance]=kind;assistantReceipts[instance]=response.ok?ok(result):result?.sent==='unknown'?unknownAssistant:`${result?.message||'Refused.'} Draft kept.`;}}
|
||||
catch{if(epoch!==orgEpoch)return;assistantReceiptKinds[instance]=kind;assistantReceipts[instance]=unknownAssistant;}finally{if(epoch===orgEpoch){if(kind==='tester')assistantBusy=false;else{assistantCardReceipts[assistantKey(instance,kind)]=assistantReceipts[instance];assistantFormsBusy.delete(assistantKey(instance,kind));}if(!out())render(false);}}
|
||||
}
|
||||
const assistantForm=el=>el?.closest?.('.assistant-form');
|
||||
for(const kind of ['input','change'])document.addEventListener(kind,e=>{const form=assistantForm(e.target);if(!form)return;const instance=form.dataset.instance,field=e.target.dataset.assistantField;if(field==='name')nameDrafts[instance]=e.target.value;else if(field)assistantDrafts[instance]={...Object.fromEntries(['definition','vars','tracker'].map(f=>[f,form.querySelector(`[data-assistant-field="${f}"]`).value]))};if(field){markSettingsDraft('assistant',true);form.querySelector('.settings-actions')?.classList.add('has-draft');const discard=form.querySelector('[data-assistant-discard]');if(discard)discard.hidden=false;}});
|
||||
@@ -1185,11 +1270,11 @@
|
||||
const form=e.target?.closest?.('.instance-form');if(!form||e.target!==form)return;e.preventDefault();if(!(canSend || isTester)||out()||!instanceView)return;
|
||||
const op=form.dataset.op;if(instanceBusyOps.has(op))return;instanceReceiptOp=op;instanceDrafts[op]=instanceFields(form);let payload;
|
||||
try{payload=instancePayload(op,instanceDrafts[op]);}catch(err){instanceReceipt=err.message;instanceReceipts[op]=instanceReceipt;render(false);return;}
|
||||
instanceBusyOps.add(op);instanceReceipt='';instanceReceipts[op]='';render(false);
|
||||
try{const response=await fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){instanceReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();
|
||||
instanceBusyOps.add(op);instanceReceipt='';instanceReceipts[op]='';render(false);const epoch=orgEpoch;
|
||||
try{const response=await fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(epoch!==orgEpoch){if(response.type==='opaqueredirect')signOut();return;}if(response.type==='opaqueredirect'){instanceReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();if(epoch!==orgEpoch)return;
|
||||
if(response.ok&&instanceAnswer(result)){instanceDrafts[op]=null;instanceView=result;const prior=last.get('instances');if(prior)last.set('instances',{...prior,rows:[result]});instanceReceipt=`${op==='add'?'Added':'Changed'} ${payload.instance}. ${result.restart?restartText():NO_RESTART_TEXT}`;}
|
||||
else instanceReceipt=response.ok||result?.sent==='unknown'?unknownInstance:`${result?.message||'Refused.'} Draft kept.`;}}
|
||||
catch{instanceReceipt=unknownInstance;}finally{instanceBusyOps.delete(op);instanceReceiptOp=op;instanceReceipts[op]=instanceReceipt;if(!out())render(false);}
|
||||
catch{if(epoch!==orgEpoch)return;instanceReceipt=unknownInstance;}finally{if(epoch===orgEpoch){instanceBusyOps.delete(op);instanceReceiptOp=op;instanceReceipts[op]=instanceReceipt;if(!out())render(false);}}
|
||||
});
|
||||
// Row 65 (mos-0840): the routing table. One row per instance: harness, model and thinking, and
|
||||
// the fallback's three. Each cell offers the switched-on harnesses, the model table's keys and the
|
||||
@@ -1215,8 +1300,8 @@
|
||||
const routesApplyMarkup=()=>routesApply?.length?`<ul class="routes-apply" aria-label="How the change reaches each agent">${routesApply.map(a=>`<li><b>${txt(a.name)}</b>: ${txt(a.text)}${a.restart&&canSend&&!isTester?` ${window.MosaicAgents?.restartMarkup(a.name,a.thread)??''}`:''}</li>`).join('')}</ul>`:'';
|
||||
let harnessSettings=null, harnessDraft=null, harnessLoading=false, harnessReceipt='', harnessBusy=false;
|
||||
function readHarnesses(){
|
||||
if(harnessLoading || harnessSettings !== null || !canSend || isTester)return; harnessLoading=true;
|
||||
fetch('/api/proxy/plugins',{cache:'no-store',redirect:'manual'}).then(async r=>{const v=await r.json();if(!r.ok || !/^[a-f0-9]{64}$/.test(v.revision??'') || !Array.isArray(v.available?.harness) || !v.available.harness.every(n=>typeof n==='string') || !Array.isArray(v.plugins?.harness??[]))throw Error();harnessSettings=v;}).catch(()=>{harnessSettings=false;harnessReceipt='Harnesses could not be read.';}).finally(()=>{harnessLoading=false;if(routeOf().split('?')[0]==='/settings/routing')render(false);});
|
||||
if(harnessLoading || harnessSettings !== null || !canSend || isTester)return; harnessLoading=true;const epoch=orgEpoch;
|
||||
fetch('/api/proxy/plugins',{cache:'no-store',redirect:'manual'}).then(async r=>{const v=await r.json();if(epoch!==orgEpoch)return;if(!r.ok || !/^[a-f0-9]{64}$/.test(v.revision??'') || !Array.isArray(v.available?.harness) || !v.available.harness.every(n=>typeof n==='string') || !Array.isArray(v.plugins?.harness??[]))throw Error();harnessSettings=v;}).catch(()=>{if(epoch!==orgEpoch)return;harnessSettings=false;harnessReceipt='Harnesses could not be read.';}).finally(()=>{if(epoch!==orgEpoch)return;harnessLoading=false;if(routeOf().split('?')[0]==='/settings/routing')render(false);});
|
||||
}
|
||||
const ROUTE_CELLS=[['harness','Harness'],['model','Model'],['thinking','Thinking'],['fallback.harness','Fallback harness'],['fallback.model','Fallback model'],['fallback.thinking','Fallback thinking']];
|
||||
const routeCells=r=>({harness:r.harness??'',model:r.model??'',thinking:r.thinking??'','fallback.harness':r.fallback?.harness??'','fallback.model':r.fallback?.model??'','fallback.thinking':r.fallback?.thinking??''});
|
||||
@@ -1293,32 +1378,32 @@
|
||||
if(harnessSettings.revision!==routesView?.revision){harnessReceipt='The business changed. Reload this page before saving harnesses.';render(false);return;}
|
||||
const used=old.filter(n=>!selected.includes(n) && Object.values(routesView.instances).some(r=>r.harness===n || r.fallback?.harness===n));
|
||||
if(used.length){harnessReceipt=`A saved route uses ${used.join(', ')}. Change the route before switching it off.`;render(false);return;}
|
||||
harnessBusy=true;const button=e.target;button.disabled=true;harnessReceipt='';
|
||||
try{const response=await fetch('/api/proxy/plugins/set',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision:harnessSettings.revision,plugins:{harness:selected}}),cache:'no-store',redirect:'manual'});const result=await response.json();if(!response.ok){harnessReceipt=result.message||result.error||'Harness setting was refused.';return;}harnessSettings={...result,available:harnessSettings.available};harnessDraft=null;harnessReceipt='Harnesses saved. The next launch uses the new settings.';routesView=null;last.delete('routes');}
|
||||
catch{harnessReceipt='Outcome unknown. Read the harnesses again before trying.';harnessSettings=false;}
|
||||
finally{harnessBusy=false;if(!out())render(false);}
|
||||
harnessBusy=true;const button=e.target;button.disabled=true;harnessReceipt='';const epoch=orgEpoch;
|
||||
try{const response=await fetch('/api/proxy/plugins/set',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision:harnessSettings.revision,plugins:{harness:selected}}),cache:'no-store',redirect:'manual'});const result=await response.json();if(epoch!==orgEpoch)return;if(!response.ok){harnessReceipt=result.message||result.error||'Harness setting was refused.';return;}harnessSettings={...result,available:harnessSettings.available};harnessDraft=null;harnessReceipt='Harnesses saved. The next launch uses the new settings.';routesView=null;last.delete('routes');}
|
||||
catch{if(epoch!==orgEpoch)return;harnessReceipt='Outcome unknown. Read the harnesses again before trying.';harnessSettings=false;}
|
||||
finally{if(epoch===orgEpoch){harnessBusy=false;if(!out())render(false);}}
|
||||
});
|
||||
document.addEventListener('click',async e=>{if(e.target.id!=='provider-switch'||!canSend||isTester)return;
|
||||
const button=e.target,status=$('provider-switch-status');if(routesDraft){status.textContent='Save or discard the route draft first.';return;}button.disabled=true;
|
||||
try {const read=await fetch('/api/proxy/plugins',{cache:'no-store'});const settings=await read.json();if(!read.ok||!/^[a-f0-9]{64}$/.test(settings.revision??''))throw Error('Could not read provider settings.');
|
||||
const button=e.target,status=$('provider-switch-status');if(routesDraft){status.textContent='Save or discard the route draft first.';return;}button.disabled=true;const epoch=orgEpoch;
|
||||
try {const read=await fetch('/api/proxy/plugins',{cache:'no-store'});const settings=await read.json();if(epoch!==orgEpoch)return;if(!read.ok||!/^[a-f0-9]{64}$/.test(settings.revision??''))throw Error('Could not read provider settings.');
|
||||
const names=Array.isArray(settings.plugins?.['model-provider'])?settings.plugins['model-provider']:[];
|
||||
// The fresh read decides: a stale label never turns the proxy module on when it meant off.
|
||||
const on=names.includes('cliproxy');if(String(on)!==button.dataset.enabled){button.dataset.enabled=String(on);button.textContent=`${on?'Disable':'Enable'} model proxy catalogue`;button.disabled=false;status.textContent='The switch now shows the current setting. Press it again to change it.';return;}
|
||||
const next=on?names.filter(n=>n!=='cliproxy'):[...names,'cliproxy'];
|
||||
const response=await fetch('/api/proxy/plugins/set',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision:settings.revision,plugins:{'model-provider':next}}),cache:'no-store',redirect:'manual'});
|
||||
if(!response.ok)throw Error('Provider setting was refused.');harnessSettings=null;routesView=null;last.delete('routes');render(true);
|
||||
}catch(error){status.textContent=error.message;button.disabled=false;}
|
||||
if(epoch!==orgEpoch)return;if(!response.ok)throw Error('Provider setting was refused.');harnessSettings=null;routesView=null;last.delete('routes');render(true);
|
||||
}catch(error){if(epoch!==orgEpoch)return;status.textContent=error.message;button.disabled=false;}
|
||||
});
|
||||
document.addEventListener('click',e=>{if(e.target.matches?.('.fallback-toggle'))e.target.closest('tr')?.classList.add('fallback-open');});
|
||||
document.addEventListener('click',e=>{if(e.target.id!=='routes-reload')return;routesDraft=null;routesView=null;routesReceipt='';routesApply=null;last.delete('routes');render(true);});
|
||||
document.addEventListener('submit',async e=>{
|
||||
if(e.target.id!=='routes-form')return;e.preventDefault();if(!(canSend || isTester)||out()||routesBusy||!routesView)return;
|
||||
routesDraft=routesFields(e.target);let payload;try{payload=routesPayload(routesDraft);}catch(err){routesReceipt=err.message;render(false);return;}
|
||||
routesBusy=true;routesReceipt='';routesApply=null;render(false);
|
||||
try{const response=await fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){routesReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();
|
||||
if(response.ok&&routesAnswer(result)){routesDraft=null;routesView=result;harnessSettings=null;const prior=last.get('routes');if(prior)last.set('routes',{...prior,rows:[result]});routesReceipt=`Routes saved for ${Object.keys(payload.routes).join(', ')}. The next launch of each uses the new route.`;routesApply=await routesApplyRead(Object.keys(payload.routes));}
|
||||
routesBusy=true;routesReceipt='';routesApply=null;render(false);const epoch=orgEpoch;
|
||||
try{const response=await fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(epoch!==orgEpoch){if(response.type==='opaqueredirect')signOut();return;}if(response.type==='opaqueredirect'){routesReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();if(epoch!==orgEpoch)return;
|
||||
if(response.ok&&routesAnswer(result)){routesDraft=null;routesView=result;harnessSettings=null;const prior=last.get('routes');if(prior)last.set('routes',{...prior,rows:[result]});routesReceipt=`Routes saved for ${Object.keys(payload.routes).join(', ')}. The next launch of each uses the new route.`;const applied=await routesApplyRead(Object.keys(payload.routes));if(epoch!==orgEpoch)return;routesApply=applied;}
|
||||
else routesReceipt=response.ok||result?.sent==='unknown'?unknownRoutes:`${result?.message||'Refused.'}${catalogueStep(result?.error,payload.routes)} Draft kept.`;}}
|
||||
catch{routesReceipt=unknownRoutes;}finally{routesBusy=false;if(!out())render(false);}
|
||||
catch{if(epoch!==orgEpoch)return;routesReceipt=unknownRoutes;}finally{if(epoch===orgEpoch){routesBusy=false;if(!out())render(false);}}
|
||||
});
|
||||
// Row 194 (#1692, D7): the Agents section lives in agents.js (window.MosaicAgents) and draws from
|
||||
// the same instances read. A refresh drops that read so the next render asks the bus again.
|
||||
@@ -1529,6 +1614,7 @@
|
||||
if (!s || !saveable(s)) return;
|
||||
const sent = { project: s.project, path: s.path, base: s.commit, content: s.text }, unknown = 'Outcome unknown: the save may have gone through. Copy your text, then reload the file to check before saving again. Draft kept.';
|
||||
s.saving = true; s.status = '';
|
||||
const epoch = orgEpoch;
|
||||
let res = null, body = null;
|
||||
try {
|
||||
paintDoc();
|
||||
@@ -1540,13 +1626,16 @@
|
||||
} else if (body?.sent === false) s.status = `${typeof body.message === 'string' && body.message ? body.message : 'Not saved.'} Draft kept.`;
|
||||
else s.status = unknown;
|
||||
} finally { s.saving = false; } // a throw above must not leave Save off and Reload refused
|
||||
// #1873 SP1: a switch while this was in flight cleared the drafts; the old file's outcome is not
|
||||
// painted or announced on the new organization's page.
|
||||
if (epoch !== orgEpoch) { if (res?.type === 'opaqueredirect') signOut(); return; }
|
||||
paintDoc();
|
||||
announce(s.status);
|
||||
if (res?.type === 'opaqueredirect') signOut();
|
||||
});
|
||||
|
||||
// Not found and states
|
||||
function notFound(why) { return `${head('Not found')}<div class="empty nf"><b>${why || 'No page at this address.'}</b><p>Nothing is at <code>${txt(location.hash || '#/')}</code>. <a href="#/">Go to the ${window.MosaicOrg ? 'Dashboard' : 'Board'}</a> or press <kbd>Ctrl K</kbd> to search.</p></div>`; }
|
||||
function notFound(why) { return `${head('Not found')}<div class="empty nf"><b>${why || 'No page at this address.'}</b><p>Nothing is at <code>${txt(location.hash || '#/')}</code>. <a href="${window.MosaicOrg ? '#/dashboard' : '#/'}">Go to the ${window.MosaicOrg ? 'Dashboard' : 'Board'}</a> or press <kbd>Ctrl K</kbd> to search.</p></div>`; }
|
||||
const loadingView = (source = null) => `${head('Reading…')}<div class="skel-rows" aria-busy="true"><span class="sr-only">${source ? `Reading the ${esc(source.toLowerCase())}.` : 'Reading from the bus.'}</span><span class="skel"></span><span class="skel"></span><span class="skel"></span><span class="skel"></span></div>`;
|
||||
const retry = '<div class="actions"><button type="button" class="btn" data-retry>Read again</button></div>';
|
||||
// Why a read failed, in words, with the bus code as given. Never a path or a guess.
|
||||
@@ -1601,17 +1690,43 @@
|
||||
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.
|
||||
// SP11: with no organization it is still the Dashboard (it says so and offers New organization);
|
||||
// #/ is then the Console's own board.
|
||||
if ((n === 0 && window.MosaicOrg) || (a === 'dashboard' && n === 1 && window.MosaicWorkspace)) 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 }));
|
||||
if (a === 'tasks' && n === 1 && window.MosaicOrg) return window.MosaicTasksBoard.view();
|
||||
if (a === 'organization' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.dashboard(undefined, { q, configIn: orgConfig });
|
||||
// SP3: #/inbox is every organization's items; #/orgs/<id>/inbox is that list for one, with its hire form.
|
||||
if (a === 'inbox' && n === 1 && window.MosaicOrg) {
|
||||
const scope = /^#\/orgs\/([^/?]+)/.exec(location.hash)?.[1];
|
||||
let org = null; try { org = scope ? decodeURIComponent(scope) : null; } catch { return notFound(); }
|
||||
return configRead.then(() => window.MosaicWorkspace.inboxPage(q, { scope: org, hire: org ? hireStartHtml : null, hireIn: org ? null : decidesIn, answer: d => inboxAnswer(d, current), answered: inboxAnswered }));
|
||||
}
|
||||
// SP4: #/tasks is every organization's tasks on one board; #/orgs/<id>/tasks is that board for one.
|
||||
if (a === 'tasks' && n === 1 && window.MosaicOrg) {
|
||||
const scope = /^#\/orgs\/([^/?]+)/.exec(location.hash)?.[1];
|
||||
let org = null; try { org = scope ? decodeURIComponent(scope) : null; } catch { return notFound(); }
|
||||
return configRead.then(() => window.MosaicTasksBoard.view(q, { scope: org, writesIn: tasksIn }));
|
||||
}
|
||||
if (a === 'inbox' && n === 1) return inboxView(get);
|
||||
if (a === 'inbox' && n === 2) return SAFE.test(b) ? decisionView(get, b, current) : notFound(`${txt(b)} is not a decision id.`);
|
||||
if (a === 'messages' && n === 1) return messagesView(get, q);
|
||||
if (a === 'tasks' && n === 1) return tasksView(get);
|
||||
if (a === 'tasks' && n === 2) return TASK.test(b) ? taskView(get, b) : notFound(`${txt(b)} is not a task reference.`);
|
||||
// SP5: #/agents is every organization's agents; #/orgs/<id>/agents is that table for one, above its levels grid and held roles.
|
||||
if (a === 'agents' && n === 1 && window.MosaicOrg) {
|
||||
const scope = /^#\/orgs\/([^/?]+)/.exec(location.hash)?.[1];
|
||||
let org = null; try { org = scope ? decodeURIComponent(scope) : null; } catch { return notFound(); }
|
||||
if (!org) return configRead.then(() => window.MosaicWorkspace.agentsPage(q, { scope: null, fireIn: decidesIn, configIn: orgConfig }));
|
||||
return agentsView(get, q, org);
|
||||
}
|
||||
if (a === 'agents' && n === 1) return agentsView(get);
|
||||
// SP9 (#1873): #/orgs/<id>/workflows lists an organization's workflows; #/orgs/<id>/workflows/<name> draws one, read only.
|
||||
if (a === 'workflows' && (n === 1 || n === 2) && window.MosaicOrg && window.MosaicWorkflows?.page) {
|
||||
const scope = /^#\/orgs\/([^/?]+)/.exec(location.hash)?.[1];
|
||||
let org = null; try { org = scope ? decodeURIComponent(scope) : window.MosaicOrg.id; } catch { return notFound(); }
|
||||
return org ? window.MosaicWorkflows.page(org, n === 2 ? b : null, q) : notFound();
|
||||
}
|
||||
if (a === 'queue' && n === 1) return queueView(get, q);
|
||||
if (a === 'queue' && n === 2) return /^[1-9]\d{0,5}$/.test(b) ? queueRowView(get, b) : notFound(`${txt(b)} is not a queue row id.`);
|
||||
if (a === 'settings' && (n === 1 || (n === 2 && [...settingsPages.map(p=>p[0]),'names'].includes(b)))) return settingsView(get, b || 'appearance');
|
||||
@@ -1626,7 +1741,15 @@
|
||||
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');
|
||||
// SP12: the organization page repaints in place too when only its query changes (the agent drawer).
|
||||
const drawerPage = path => wsPage(path) || (path === '/organization' && !!window.MosaicOrg);
|
||||
// A hash change that only opens, closes or switches the agent drawer is a repaint, not a navigation.
|
||||
const agentOnly = (from, to) => {
|
||||
const split = u => { const h = String(u || '').split('#')[1] ?? ''; const [p, q = ''] = h.split('?'); const n = new URLSearchParams(q); const agent = n.get('agent'); n.delete('agent'); n.sort(); return { p, rest: n.toString(), agent }; };
|
||||
const a = split(from), b = split(to);
|
||||
return a.p === b.p && a.rest === b.rest && a.agent !== b.agent && /^\/(agents|(orgs\/[^/?]+\/)?organization)$/.test(b.p);
|
||||
};
|
||||
const wsPage = path => (path === '/dashboard' && !!window.MosaicWorkspace) || !!window.MosaicOrg && (path === '/' || path === '' || path === '/dashboard' || path === '/projects' || path === '/inbox' || path === '/agents' || path === '/tasks' || /^\/workflows(\/[^/]+)?$/.test(path));
|
||||
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,14 +1799,22 @@
|
||||
// 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).
|
||||
const samePage = wsPage(path) && typeof shown === 'string' && shown.split('?')[0] === path;
|
||||
const samePage = drawerPage(path) && typeof shown === 'string' && shown.split('?')[0] === path;
|
||||
if (path !== '/settings/model-proxy' && proxyCleanup) {proxyCleanup();proxyCleanup=null;}
|
||||
if ((navigated || path !== '/settings/organizations') && orgCleanup) {orgCleanup();orgCleanup=null;}
|
||||
// Leaving a decision discards its confirmation immediately, including a preview in flight.
|
||||
if (answer && (navigated || path !== `/inbox/${answer.id}`)) answer = null;
|
||||
if (answer && (navigated || path !== (answer.org ? '/inbox' : `/inbox/${answer.id}`))) answer = null;
|
||||
const g = ++gen, routeVersion = routesEditVersion;
|
||||
const current = () => g === gen && route === routeOf() && !out();
|
||||
// In organization mode #/ is the cross-organization dashboard; the processing controls stay on the board.
|
||||
@@ -1696,13 +1827,17 @@
|
||||
window.MosaicChat?.leave(chatPath);
|
||||
if (path === '/business') { location.replace('#/settings/business'); return; }
|
||||
if (path === '/settings/display') { location.replace('#/settings/appearance'); return; }
|
||||
// U5: Side by side draws its own two panes (panes.js); every other page stops their polling.
|
||||
if (path !== '/side-by-side') window.MosaicPanes?.unmount();
|
||||
else if (window.MosaicOrg && window.MosaicPanes) {
|
||||
// #1873 SP6: the Assistant page draws its own thread panes (assistant.js); every other page
|
||||
// stops their polling. It is a system page: an organization's address for it, and the old
|
||||
// Side by side address, land on `#/assistant`.
|
||||
if (window.MosaicOrg && window.MosaicAssistant && path === '/side-by-side') { location.replace('#/assistant'); return; }
|
||||
if (path !== '/assistant') window.MosaicAssistant?.unmount();
|
||||
else if (window.MosaicOrg && window.MosaicAssistant) {
|
||||
if (!window.MosaicOrg.system()) { location.replace(`#/assistant${query ? `?${query}` : ''}`); return; }
|
||||
const fresh = navigated || shown !== path;
|
||||
shown = path;
|
||||
if (fresh) { unmountEditor(); await window.MosaicPanes.mount($('bus-view'), query, { navigated }); }
|
||||
else window.MosaicPanes.refresh();
|
||||
if (fresh) { unmountEditor(); await window.MosaicAssistant.mount($('bus-view'), query, { navigated }); }
|
||||
else window.MosaicAssistant.refresh(query);
|
||||
return;
|
||||
}
|
||||
if (chatPath === '/chat' || chatPath.startsWith('/chat/') || /^\/projects\/[0-9a-f-]+\/(?:settings|conversations|tasks|requirements|files)$/i.test(chatPath)) {
|
||||
@@ -1737,19 +1872,25 @@
|
||||
// The board page keeps its own title; the Inbox count still follows the bus.
|
||||
if (shown !== null) { shown = null; unmountEditor(); $('bus-view').innerHTML = ''; document.title = 'Dashboard · Mosaic Console'; if (navigated) $('main').focus(); }
|
||||
// The held roles (row 95) are read with it. A refusal of either forgets both, as on the bus pages.
|
||||
const epoch = orgEpoch;
|
||||
const [inbox, agents, messages, tasks, queue, business, projects] = await Promise.allSettled([
|
||||
fetchRead('inbox'), fetchRead('agents'), fetchRead('messages'), fetchRead('tasks'), fetchApi('queue'), fetchApi('business'),
|
||||
fetch('/api/chat/projects', { cache: 'no-store', redirect: 'manual' }).then(async r => { if (r.type === 'opaqueredirect') { signOut(); throw signedOut(); } const body = await r.json(); if (signInRequired(r, body)) { signOutSso(); throw signedOut(); } if (!r.ok || !Array.isArray(body.rows)) throw new ReadError(body?.error, body?.message, r.status); return body; })
|
||||
]);
|
||||
const failed = [inbox, agents, messages, tasks].filter(s => s.status === 'rejected').map(s => s.reason);
|
||||
if (failed.some(err => err.code === 'signed-out')) return;
|
||||
// #1873 SP1: an answer read for an organization that is no longer open is not kept.
|
||||
if (epoch !== orgEpoch) return;
|
||||
if (inbox.status === 'fulfilled') last.set('inbox', inbox.value);
|
||||
if (agents.status === 'fulfilled') last.set('agents', agents.value);
|
||||
if (messages.status === 'fulfilled') last.set('messages', messages.value);
|
||||
const refused = failed.find(refusal);
|
||||
if (refused) forget();
|
||||
if (g !== gen) return;
|
||||
if (inbox.status === 'fulfilled' && !refused) countInbox(inbox.value.rows);
|
||||
// SP3 (#1873): with organizations the sidebar count is every organization's, from the same
|
||||
// workspace read as #/inbox, never this one organization's inbox.
|
||||
if (window.MosaicOrg) window.MosaicWorkspace?.inboxCount?.();
|
||||
else if (inbox.status === 'fulfilled' && !refused) countInbox(inbox.value.rows);
|
||||
if (messages.status === 'fulfilled' && !refused) messageCount = messages.value.unread;
|
||||
const good = result => !refused && result.status === 'fulfilled' ? result.value : null;
|
||||
const taskRows = good(tasks)?.rows, queueRows = good(queue)?.rows, projectRows = good(projects)?.rows;
|
||||
@@ -1810,6 +1951,7 @@
|
||||
else if (focusSend && $('send-body')) { $('send-body').focus(); announce(`Message to ${draft.to}`); }
|
||||
else if (navigated && h1) { const target=settingsFocusDisclosure&&path.startsWith('/settings/') ? $('settings-disclosure') : path.startsWith('/settings/') ? ($('settings-page-title')||h1) : h1; settingsFocusDisclosure=false; target.focus(); announce(target.textContent); }
|
||||
focusSend = false;
|
||||
window.MosaicWorkspace?.afterPaint?.();
|
||||
schedule();
|
||||
}
|
||||
|
||||
@@ -1861,7 +2003,7 @@
|
||||
// Ctrl+K opens it. It lists the views, and the decisions and tasks from the last inbox and
|
||||
// tasks reads (opening it reads both again, GET only). Enter goes; Esc closes and puts focus
|
||||
// back where it was. It only navigates.
|
||||
const VIEWS = [['#/', 'Dashboard'], ['#/projects', 'Projects'], ['#/inbox', 'Inbox'], ['#/messages', 'Assistant'], ['#/tasks', 'Tasks'], ['#/agents', 'Agents'], ['#/queue', 'Queue'], ['#/settings/business', 'Business'], ['#/docs', 'Docs'], ['#/settings', 'Settings'], ['#/settings/display', 'Sidebar display']];
|
||||
const VIEWS = [['#/', 'Dashboard'], ['#/projects', 'Projects'], ['#/inbox', 'Inbox'], ...(window.MosaicAssistant ? [['#/assistant', 'Assistant'], ['#/messages', 'Messages']] : [['#/messages', 'Assistant']]), ['#/tasks', 'Tasks'], ['#/agents', 'Agents'], ['#/queue', 'Queue'], ['#/settings/business', 'Business'], ['#/docs', 'Docs'], ['#/settings', 'Settings'], ['#/settings/display', 'Sidebar display']];
|
||||
let pkAll = [], pkShown = [], pkAt = 0, pkBack = null;
|
||||
function pkItems() {
|
||||
const decisions = (last.get('inbox')?.rows || []).map(d => ({ href: `#/inbox/${enc(d.id)}`, label: first(d.question), group: `decision ${short(d.id)}` }));
|
||||
@@ -1883,7 +2025,9 @@
|
||||
if (d.open) return;
|
||||
pkBack = document.activeElement; pkAt = 0; $('cmdk-input').value = '';
|
||||
pkItems(); pkFill(); d.showModal(); $('cmdk-input').focus();
|
||||
Promise.allSettled(['inbox', 'tasks'].map(async v => last.set(v, await fetchRead(v)))).then(rs => {
|
||||
const epoch = orgEpoch;
|
||||
Promise.allSettled(['inbox', 'tasks'].map(async v => { const r = await fetchRead(v); if (epoch === orgEpoch) last.set(v, r); })).then(rs => {
|
||||
if (epoch !== orgEpoch) return;
|
||||
if (rs.some(r => r.status === 'rejected' && refusal(r.reason))) { forget(); sections(routeOf().split('?')[0]); }
|
||||
if (d.open) { pkItems(); pkFill(); }
|
||||
});
|
||||
@@ -1923,11 +2067,13 @@
|
||||
if (out()) { receipt = { kind: 'refused', to: to || 'no role', code: 'sign-in', message: 'signed out; reload the page to sign in again' }; paintSend(); announce('Not sent: ' + receipt.message); return; }
|
||||
if (!to || !text.trim()) { receipt = { kind: 'refused', to: to || 'no role', code: 'invalid-request', message: to ? 'the message is empty' : 'choose a role first' }; paintSend(); announce('Not sent: ' + receipt.message); return; }
|
||||
sending = true; receipt = null; paintSend();
|
||||
const epoch = orgEpoch;
|
||||
let res = null, body = null;
|
||||
try {
|
||||
res = await fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });
|
||||
try { body = await res.json(); } catch { body = null; }
|
||||
} catch { res = null; }
|
||||
if (epoch !== orgEpoch) { if (res?.type === 'opaqueredirect') signOut(); return; }
|
||||
// Sent: an id came back. Not sent: a 4xx (refused before the bus wrote), the server's
|
||||
// sent:false, or a redirect, which only the sign-in proxy gives (an expired session), so the
|
||||
// request never reached the Console. Anything else, no answer included, may have been sent,
|
||||
@@ -1958,7 +2104,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,20 +2121,50 @@
|
||||
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);
|
||||
};
|
||||
configRead = fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(applyConfig).catch(() => {});
|
||||
configRead = fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(c => { applyConfig(c); return c; }).catch(() => null);
|
||||
// SP14 (#1873): who is signed in, from this same read, for what the page keeps per person
|
||||
// (assistant.js's kept pane messages). null when the read failed or named nobody.
|
||||
window.MosaicViewer = configRead.then(c => (typeof c?.viewer === 'string' && c.viewer ? c.viewer : null));
|
||||
// E1 (P1-5): after Restart bus the organization dashboard draws its bus line now, not at the next poll.
|
||||
// E2E6 P2-2: a write on the organization page that changes other sections (a pack apply) asks for a
|
||||
// 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); });
|
||||
// SP5: the system Agents page shows every organization, so a change in any of them redraws it.
|
||||
document.addEventListener('mosaic-org-changed', e => { if (!window.MosaicOrg) return; const path = routeOf().split('?')[0]; if ((path === '/agents' && window.MosaicOrg.system()) || (e.detail?.org === window.MosaicOrg.id && ['/organization', '/agents'].includes(path))) render(false); });
|
||||
// #1873 SP1: another organization opened in place (display.js, before this file's hashchange
|
||||
// render). Nothing read, drafted or confirmed for the old one carries over; the new one's config
|
||||
// is read before the next page draws. A write already sent finishes against the old organization,
|
||||
// and its answer is not drawn on the new one.
|
||||
document.addEventListener('mosaic-org-switch', () => {
|
||||
// Writes still in flight keep going to the old organization; their answers are dropped (orgEpoch)
|
||||
// and every busy flag and receipt starts clean here.
|
||||
orgEpoch++;
|
||||
sending = false; receipt = null; replies = [];
|
||||
hireBusy = false; fireBusy = false; settingsSaving = false; resetReceipt = '';
|
||||
assistantBusy = false; assistantFormsBusy.clear(); instanceBusyOps = new Set();
|
||||
routesBusy = false; routesApply = null; harnessBusy = false; harnessLoading = false;
|
||||
forget(); clearTimeout(timer);
|
||||
answer = null; heldErr = null; heldHtml = null; readAt = null; shown = null; projectContext = null;
|
||||
draft.to = ''; draft.body = '';
|
||||
hireDraft = { op: 'hire', level: 'org' }; hireTemplates = []; hireStatus = ''; hireCode = '';
|
||||
fireAsk = null; fireReceipt = null;
|
||||
settingsSnapshot = null; settingsInputs = null; settingsReceipt = ''; settingInvalidKey = null;
|
||||
assistantDrafts = Object.create(null); nameDrafts = Object.create(null); assistantReceipts = Object.create(null); assistantReceiptKinds = Object.create(null); assistantCardReceipts = Object.create(null);
|
||||
instanceView = null; instanceDrafts = { add: null, change: null }; instanceReceipt = ''; instanceReceiptOp = null; instanceReceipts = Object.create(null);
|
||||
routesView = null; routesDraft = null; routesReceipt = ''; routesEditVersion++;
|
||||
harnessSettings = null; harnessDraft = null; harnessReceipt = '';
|
||||
// A file draft is keyed by project and path only; the page reload that used to drop it is gone.
|
||||
docKey = null; docStates.clear(); pkAll = []; pkShown = []; pkAt = 0;
|
||||
configRead = switchRead = fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(c => applyConfig(c, { quiet: true })).catch(() => {});
|
||||
});
|
||||
document.addEventListener('mosaic-config', e => { if (e.detail && typeof e.detail === 'object') { applyConfig(e.detail); if (window.MosaicOrg && routeOf().split('?')[0] === '/organization') render(false); } });
|
||||
// The cross-organization pages ask for a repaint after a filter, sort or selection change,
|
||||
// and report the inbox count across organizations for the sidebar.
|
||||
document.addEventListener('mosaic-workspace-render', () => { if (wsPage(routeOf().split('?')[0])) render(false); });
|
||||
document.addEventListener('mosaic-workspace-render', () => { if (drawerPage(routeOf().split('?')[0])) render(false); });
|
||||
document.addEventListener('mosaic-workspace-inbox', e => { const d = e.detail; if (d && Number.isFinite(d.open)) { inboxCount = { open: d.open, blocking: Number.isFinite(d.blocking) ? d.blocking : 0 }; sections(routeOf().split('?')[0]); } });
|
||||
// This context comes from the conversations renderer after its authorized read.
|
||||
let projectContext = null;
|
||||
@@ -2065,7 +2241,7 @@
|
||||
});
|
||||
nav.addEventListener('click', e => { if (drawerOpen && e.target.closest('a[href^="#/"]')) drawer(false, false); });
|
||||
window.addEventListener('resize', () => { if (drawerOpen && innerWidth >= 760) drawer(false, false); if (drawerOpen) $('nav-overflow').hidden = nav.scrollHeight <= nav.clientHeight + 1; });
|
||||
window.addEventListener('hashchange', () => render(true));
|
||||
window.addEventListener('hashchange', e => render(!agentOnly(e.oldURL, e.newURL)));
|
||||
$('refresh').addEventListener('click', () => render(false));
|
||||
$('pause').addEventListener('click', () => setTimeout(schedule)); // after app.js flips aria-pressed
|
||||
render(false).then(() => { if (!$('bus-view').hidden) { const h1 = $('bus-view').querySelector('h1'); if (h1 && location.hash.startsWith('#/')) h1.focus(); } });
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
/* Row 129: business chat, isolated from the Board's seat conversations and role composer. */
|
||||
(() => {
|
||||
'use strict';
|
||||
// #1873 SP6: one chat view, more than one instance. The page's own instance (no env) is the chat
|
||||
// as it always was. An Assistant pane makes its own instance with env.org: its reads and writes
|
||||
// name that organization in the address, so a send always reaches the pane's own organization,
|
||||
// whichever organization the page last opened, and its listeners answer only inside its container.
|
||||
function chatInstance(env = {}) {
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
// crypto.randomUUID exists only in a secure context; a dev owner Console opened by LAN name
|
||||
// (http://NAME:PORT/) is not one, so build the same v4 form from getRandomValues there.
|
||||
@@ -18,6 +23,14 @@
|
||||
// E2E6 P2-15: a message's `inline code` spans show as code, not as raw backticks. The text is escaped
|
||||
// first; a pair of single backticks on one line then wraps its already-escaped contents in <code>.
|
||||
const prose = value => txt(value).replace(/`([^`\n]+)`/g, '<code>$1</code>');
|
||||
const pane = typeof env.org === 'string', life = new AbortController();
|
||||
const on = (type, fn, capture = false) => document.addEventListener(type, fn, { capture, signal: life.signal });
|
||||
// A pane prefixes its element ids so two panes never share one; handlers read them back unprefixed.
|
||||
const idOf = el => { const id = typeof el?.id === 'string' ? el.id : ''; return pane && id.startsWith(env.prefix) ? 'chat-' + id.slice(env.prefix.length) : id; };
|
||||
// A pane's ids carry its prefix; byId takes the page's own 'chat-…' name.
|
||||
const byId = id => document.getElementById(pane && id.startsWith('chat-') ? env.prefix + id.slice(5) : id);
|
||||
const pid = id => (pane && id.startsWith('chat-') ? env.prefix + id.slice(5) : id);
|
||||
const mine = target => pane ? !!current?.container.contains(target) : !target?.closest?.('[data-assist-pane]');
|
||||
const states = new Map(), forms = new Map(), knownRequests = new Map();
|
||||
const pinnedScrolls = new WeakMap(), settlingPins = new WeakMap();
|
||||
const pageFlow = log => log.closest('.project-chat') && matchMedia('(max-width: 319px), (max-height: 540px)').matches;
|
||||
@@ -27,7 +40,7 @@
|
||||
? (pageTailTarget(log)?.getBoundingClientRect().bottom ?? 0) - innerHeight < 40 || messageEnd && Math.abs((log.lastElementChild?.getBoundingClientRect().bottom ?? 0) - innerHeight) < 40
|
||||
: log.scrollHeight - log.scrollTop - log.clientHeight < 40;
|
||||
const pageTailTarget = log => {
|
||||
const form = log.closest('.project-chat')?.querySelector('#chat-send-form, #chat-room-send-form');
|
||||
const form = log.closest('.project-chat')?.querySelector(`#${pid('chat-send-form')}, #${pid('chat-room-send-form')}`);
|
||||
const field = form?.querySelector('textarea');
|
||||
const usable = current && (!field?.disabled && !current.channelDisabled || state(current.key ?? current.id).pending);
|
||||
return form && field && usable && form.getClientRects().length ? form : log.lastElementChild;
|
||||
@@ -69,8 +82,7 @@
|
||||
const channelMessage = 'The chat channel is disabled for this business. Ask the person who manages it to enable WebUI chat, then refresh.';
|
||||
function forget() {
|
||||
generation++; controller?.abort(); states.clear(); forms.clear(); knownRequests.clear(); libraries.clear(); reverts.clear();
|
||||
window.MosaicRequests?.forget();
|
||||
window.MosaicProjectArchive?.forget();
|
||||
if (!pane) { window.MosaicRequests?.forget(); window.MosaicProjectArchive?.forget(); }
|
||||
if (current) current.container.replaceChildren();
|
||||
current = null;
|
||||
}
|
||||
@@ -78,11 +90,21 @@
|
||||
signedOut = true; forget();
|
||||
document.dispatchEvent(new Event('mosaic-signed-out'));
|
||||
}
|
||||
// SP11: whether the thread's agent runs no process, from the bus instances read the Agents page uses.
|
||||
// true: paused or stopped; false: running; null: the read failed or does not list the agent.
|
||||
async function agentIdle(instance, signal) {
|
||||
try {
|
||||
const res = await fetch(pane ? `/api/orgs/${encodeURIComponent(env.org)}/bus/instances` : '/api/bus/instances', { cache: 'no-store', redirect: 'manual', signal });
|
||||
if (!res.ok || res.type === 'opaqueredirect') return null;
|
||||
const item = (await res.json())?.rows?.[0]?.instances?.[instance];
|
||||
return item && typeof item === 'object' && Array.isArray(item.running) ? item.running.length === 0 : null;
|
||||
} catch (err) { if (err.name === 'AbortError') throw err; return null; }
|
||||
}
|
||||
async function request(path, value, signal) {
|
||||
if (out()) throw new ChatError('signed-out');
|
||||
let res;
|
||||
try {
|
||||
res = await fetch('/api/chat/' + path, {
|
||||
res = await fetch((pane ? `/api/orgs/${encodeURIComponent(env.org)}/chat/` : '/api/chat/') + path, {
|
||||
cache: 'no-store', redirect: 'manual', signal,
|
||||
...(value === undefined ? {} : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value) }),
|
||||
});
|
||||
@@ -104,12 +126,14 @@
|
||||
}
|
||||
// In organization mode #/projects is the cross-organization page, so the organization's own
|
||||
// project list lives at #/chat. That list keeps the project surface and its #/projects links.
|
||||
const legacyRoute = () => { const r = window.MosaicOrg?.route() ?? location.hash.slice(1); return r.startsWith('/chat') && !(window.MosaicOrg && r.split('?')[0] === '/chat'); };
|
||||
const projectPath = id => legacyRoute() ? `#/chat/projects/${id}` : `#/projects/${id}`;
|
||||
const threadPath = id => legacyRoute() ? `#/chat/threads/${id}` : `#/projects/threads/${id}`;
|
||||
const roomPath = id => legacyRoute() ? `#/chat/rooms/${id}` : `#/projects/rooms/${id}`;
|
||||
const orgBase = pane ? '#/orgs/' + encodeURIComponent(env.org) : '#';
|
||||
const legacyRoute = () => { if (pane) return false; const r = window.MosaicOrg?.route() ?? location.hash.slice(1); return r.startsWith('/chat') && !(window.MosaicOrg && r.split('?')[0] === '/chat'); };
|
||||
const projectPath = id => legacyRoute() ? `#/chat/projects/${id}` : `${orgBase}/projects/${id}`;
|
||||
const threadPath = id => legacyRoute() ? `#/chat/threads/${id}` : `${orgBase}/projects/threads/${id}`;
|
||||
const roomPath = id => legacyRoute() ? `#/chat/rooms/${id}` : `${orgBase}/projects/rooms/${id}`;
|
||||
const link = (type, id, label) => UUID.test(id) ? `<a href="${type === 'projects' ? projectPath(id) : threadPath(id)}">${txt(label)}</a>` : txt(label);
|
||||
const heading = text => `<h1 tabindex="-1">${txt(text)}</h1>`;
|
||||
// A pane's title is its own header; the page keeps its one h1.
|
||||
const heading = text => pane ? `<p class="chat-pane-state"><strong>${txt(text)}</strong></p>` : `<h1 tabindex="-1">${txt(text)}</h1>`;
|
||||
const selectedInactive = () => { const s = current?.sessions?.find(r => r.id === current.session); return !!s?.state && s.state !== 'active'; };
|
||||
const disabledSession = pending => disabled(pending || selectedInactive());
|
||||
const disabled = pending => !current.canSend || current.orgArchived || out() || pending ? ' disabled' : '';
|
||||
@@ -199,7 +223,8 @@
|
||||
return `<div class="chat-library"><p role="status">${parts.join(' · ')}</p>${list}</div>`;
|
||||
}
|
||||
async function loadLibrary(project, g, signal) {
|
||||
if (project === null) return;
|
||||
// The library routes read the page's organization, not a pane's: a pane shows no library line.
|
||||
if (project === null || pane) return;
|
||||
let entry;
|
||||
try {
|
||||
const res = await fetch('/api/library/status?project=' + encodeURIComponent(project), { cache: 'no-store', redirect: 'manual', signal });
|
||||
@@ -241,6 +266,8 @@
|
||||
const REQUEST_ROWS = 3;
|
||||
// The request panel, Files and summary each read independently. Share only confirmed
|
||||
// request-not-found responses while a thread is open, without changing other fetch paths.
|
||||
// Panes mount no request panels, so only the page's instance wraps fetch.
|
||||
if (!pane) {
|
||||
const fetchPage = window.fetch.bind(window);
|
||||
window.fetch = async (url, init) => {
|
||||
const path = typeof url === 'string' && /^\/api\/requests\/([A-Za-z0-9][A-Za-z0-9._-]{0,159})(?:\/workspace)?$/.exec(url);
|
||||
@@ -260,11 +287,12 @@
|
||||
}
|
||||
return response;
|
||||
};
|
||||
}
|
||||
// A request read elsewhere on this page (any requests.js panel) proves the id now exists, so
|
||||
// every open thread forgets that miss and the next refresh reads it again. The id's own
|
||||
// generation also advances, so a 404 still in flight from before this observation cannot
|
||||
// record the miss after it. Only that id moves; other ids' slow misses still settle.
|
||||
document.addEventListener('mosaic-request-state', event => {
|
||||
on('mosaic-request-state', event => {
|
||||
const id = event.detail?.state?.request;
|
||||
if (typeof id !== 'string' || out()) return;
|
||||
for (const s of states.values()) {
|
||||
@@ -274,7 +302,7 @@
|
||||
});
|
||||
function requestRows(rows) {
|
||||
const ids = new Set();
|
||||
if (!window.MosaicRequests || !roomOwner()) return ids;
|
||||
if (pane || !window.MosaicRequests || !roomOwner()) return ids;
|
||||
for (let i = rows.length - 1; i >= 0 && ids.size < REQUEST_ROWS; i--) {
|
||||
const m = rows[i];
|
||||
if (m.from_role === 'human' && typeof m.request === 'string' && REQUEST_ID.test(m.request)) ids.add(m.id);
|
||||
@@ -286,7 +314,7 @@
|
||||
const withRequest = requestRows(s.rows);
|
||||
const receipt = s.receipt;
|
||||
const project = current.projects.find(p => p.id === t.project);
|
||||
const redesigned = project && roomOwner() && window.MosaicProjects && (!legacyRoute() || current.path?.startsWith('/projects'));
|
||||
const redesigned = !pane && project && roomOwner() && window.MosaicProjects && (!legacyRoute() || current.path?.startsWith('/projects'));
|
||||
// Keep each request tied to its originating message while placing its card after the first reply.
|
||||
const mountsAfter = new Map();
|
||||
if (redesigned) s.rows.forEach((m, i) => {
|
||||
@@ -297,9 +325,13 @@
|
||||
});
|
||||
const mount = m => `<div class="request-mount" data-request="${esc(m.request)}" data-origin-message="${esc(m.id)}"></div>`;
|
||||
const messages = s.rows.map((m, i) => `<li class="${m.from_role === 'human' ? 'chat-message-human' : 'chat-message-agent'}" data-message="${esc(m.id)}"><div class="chat-message-meta"><span class="chat-avatar" aria-hidden="true">${txt(String(m.from_role || '?').slice(0, 1).toUpperCase())}</span><strong>${txt(m.from_role === 'human' ? 'You' : m.from_role)}</strong> ${stamp(m.at)}</div>${messageBody(m)}${!redesigned && withRequest.has(m.id) ? mount(m) : ''}</li>${redesigned ? (mountsAfter.get(i) || []).map(origin => `<li class="project-request-item">${mount(origin)}</li>`).join('') : ''}`).join('');
|
||||
const frame = redesigned ? window.MosaicProjects.header(project, 'conversations', current.threads, t.name, true) : heading(t.name);
|
||||
const details = redesigned ? `<dialog class="chat-details project-dialog" id="project-conversation-dialog" aria-labelledby="project-conversation-title"><h2 id="project-conversation-title" tabindex="-1">Conversation details</h2><button class="btn" type="button" data-dialog-close>Close</button>${sessionControls(t)}${sharing(t)}<p>Reading never wakes the assistant. An accepted message is not an answer.</p>${threadWrites() ? `<button class="btn" type="button" id="chat-resume"${disabledSession(s.pending || current.channelDisabled)}>Resume assistant</button>` : ''}</dialog>` : `<details class="chat-details"><summary id="chat-details-toggle">Conversation details</summary>${sessionControls(t)}${sharing(t)}</details><p class="muted">Reading never wakes the assistant. An accepted message is not an answer.</p>${threadWrites() ? `<button class="btn" type="button" id="chat-resume"${disabledSession(s.pending || current.channelDisabled)}>Resume assistant</button>` : ''}`;
|
||||
return `<section class="chat-conversation${redesigned ? ' project-chat' : ''}" data-project="${esc(t.project)}" data-thread="${esc(t.id)}" data-session="${esc(current.session)}"><p>${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}</p>${frame}${libraryLine(libraryOf(current, t))}${details}${redesigned ? '<p class="small muted" data-conversation-request-status></p>' : ''}<ol class="chat-log" tabindex="0" aria-label="Thread conversation">${messages || (current.readFailed ? '<li class="muted">Messages unavailable.</li>' : '<li class="muted">No messages yet.</li>')}</ol><div class="chat-paging">${current.readFailed ? '<button type="button" class="btn" data-chat-retry>Read again</button>' : s.more ? '<p>Showing earlier history. Read forward to reach the current conversation.</p><button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div>${threadWrites() ? `<form id="chat-send-form"><label for="chat-body">Message ${txt(t.instance)}</label><textarea id="chat-body" rows="4" placeholder="${redesigned ? `Message ${esc(t.instance)}` : 'Write a message…'}"${disabledSession(s.pending)}>${esc(s.draft)}</textarea><p class="chat-audience">To: ${redesigned ? `<span class="room-chip" aria-label="Recipient ${esc(t.instance)}">${txt(t.instance)}</span>` : `${txt(t.instance)} · Private conversation`}</p>${redesigned ? '<p class="project-composer-hint">Enter adds a new line.</p>' : ''}<button class="btn primary" type="submit"${disabledSession(s.pending || current.channelDisabled)}>Send</button></form>` : ''}<p id="chat-receipt" role="status">${txt(receipt)}</p>${redesigned ? '' : '<section class="chat-files"><h2>Files</h2><div data-chat-files>Files unavailable.</div></section><div data-chat-summary></div>'}</section>`;
|
||||
const frame = pane ? '' : redesigned ? window.MosaicProjects.header(project, 'conversations', current.threads, t.name, true) : heading(t.name);
|
||||
// A pane leaves out the details: sessions and sharing are unavailable on an organization Console.
|
||||
// SP11: Resume shows unless the instances read says the agent is running.
|
||||
const resumable = threadWrites() && current.agentIdle !== false;
|
||||
const resume = resumable ? `<button class="btn${pane ? ' pane-resume' : ''}" type="button" id="chat-resume"${disabledSession(s.pending || current.channelDisabled)}>Resume assistant</button>` : '';
|
||||
const details = pane ? `<p class="muted small">Reading never wakes the assistant. An accepted message is not an answer.</p>` : redesigned ? `<dialog class="chat-details project-dialog" id="project-conversation-dialog" aria-labelledby="project-conversation-title"><h2 id="project-conversation-title" tabindex="-1">Conversation details</h2><button class="btn" type="button" data-dialog-close>Close</button>${sessionControls(t)}${sharing(t)}<p>Reading never wakes the assistant. An accepted message is not an answer.</p>${resumable ? `<button class="btn" type="button" id="chat-resume"${disabledSession(s.pending || current.channelDisabled)}>Resume assistant</button>` : ''}</dialog>` : `<details class="chat-details"><summary id="chat-details-toggle">Conversation details</summary>${sessionControls(t)}${sharing(t)}</details><p class="muted">Reading never wakes the assistant. An accepted message is not an answer.</p>${resumable ? `<button class="btn" type="button" id="chat-resume"${disabledSession(s.pending || current.channelDisabled)}>Resume assistant</button>` : ''}`;
|
||||
return `<section class="chat-conversation${redesigned ? ' project-chat' : ''}" data-project="${esc(t.project)}" data-thread="${esc(t.id)}" data-session="${esc(current.session)}">${pane ? '' : `<p>${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}</p>`}${frame}${libraryLine(libraryOf(current, t))}${details}${redesigned ? '<p class="small muted" data-conversation-request-status></p>' : ''}<ol class="chat-log" tabindex="0" aria-label="Thread conversation">${messages || (current.readFailed ? '<li class="muted">Messages unavailable.</li>' : '<li class="muted">No messages yet.</li>')}</ol><div class="chat-paging">${current.readFailed ? '<button type="button" class="btn" data-chat-retry>Read again</button>' : s.more ? '<p>Showing earlier history. Read forward to reach the current conversation.</p><button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div>${threadWrites() ? `<form id="chat-send-form"><label for="chat-body">Message ${txt(t.instance)}</label><textarea id="chat-body" rows="4" placeholder="${redesigned ? `Message ${esc(t.instance)}` : 'Write a message…'}"${disabledSession(s.pending)}>${esc(s.draft)}</textarea><p class="chat-audience">To: ${redesigned ? `<span class="room-chip" aria-label="Recipient ${esc(t.instance)}">${txt(t.instance)}</span>` : `${txt(t.instance)} · Private conversation`}</p>${redesigned ? '<p class="project-composer-hint">Enter adds a new line.</p>' : ''}<button class="btn primary" type="submit"${disabledSession(s.pending || current.channelDisabled)}>Send</button></form>` : ''}<p id="chat-receipt" role="status">${txt(receipt)}</p>${pane ? resume : ''}${redesigned || pane ? '' : '<section class="chat-files"><h2>Files</h2><div data-chat-files>Files unavailable.</div></section><div data-chat-summary></div>'}</section>`;
|
||||
}
|
||||
// C2-10c: rooms reuse the same state, renderer and generation fences as threads.
|
||||
const ROOM_LINES = Object.freeze({
|
||||
@@ -475,19 +507,20 @@
|
||||
if (err.code === 'channel-disabled' && current?.key === c.key) current.channelDisabled = true;
|
||||
} finally { s.pending = false; if (current?.key === c.key && !out()) paint(); }
|
||||
}
|
||||
document.addEventListener('keydown', event => {
|
||||
if (current?.kind !== 'room' || event.target.id !== 'chat-room-body' || !current.completion?.items.length) return;
|
||||
on('keydown', event => {
|
||||
if (!mine(event.target)) return;
|
||||
if (current?.kind !== 'room' || idOf(event.target) !== 'chat-room-body' || !current.completion?.items.length) return;
|
||||
const c = current.completion;
|
||||
if (['ArrowDown', 'ArrowUp'].includes(event.key)) { event.preventDefault(); c.index = (c.index + (event.key === 'ArrowDown' ? 1 : c.items.length - 1)) % c.items.length; drawRoomCompletion(); }
|
||||
if (['Enter', 'Tab'].includes(event.key)) { event.preventDefault(); completeRoom(c.items[c.index].instance); }
|
||||
if (event.key === 'Escape') { event.preventDefault(); current.completion = null; drawRoomCompletion(); }
|
||||
});
|
||||
document.addEventListener('mousedown', event => { if (event.target.closest?.('[data-room-complete]')) event.preventDefault(); });
|
||||
on('mousedown', event => { if (event.target.closest?.('[data-room-complete]')) event.preventDefault(); });
|
||||
|
||||
function capture() {
|
||||
if (!current) return null;
|
||||
const active = document.activeElement, log = current.container.querySelector('.chat-log');
|
||||
const field = active?.id?.startsWith('chat-') ? { id: active.id, start: active.selectionStart, end: active.selectionEnd } : null;
|
||||
const field = idOf(active).startsWith('chat-') && current.container.contains(active) ? { id: active.id, start: active.selectionStart, end: active.selectionEnd } : null;
|
||||
const dialog = current.container.querySelector('dialog.project-dialog[open]');
|
||||
const dialogValues = dialog ? [...dialog.querySelectorAll('input[id],select[id],textarea[id]')].map(e=>({id:e.id,value:e.value,checked:e.checked})) : [];
|
||||
const dialogFocus = dialog?.contains(active) ? { id:active.id, index:[...dialog.querySelectorAll('button,a,input,select,textarea,[tabindex]')].indexOf(active), value:'value' in active ? active.value : null, start:active.selectionStart, end:active.selectionEnd } : null;
|
||||
@@ -512,7 +545,7 @@
|
||||
// Refreshes often read exactly the same view. Remember the generated presentation,
|
||||
// not the mutable DOM (which also contains independently mounted request controls).
|
||||
function refreshProjectData(c, redesigned, restore) {
|
||||
if (c.user || c.tester || !window.MosaicProjects) return;
|
||||
if (pane || c.user || c.tester || !window.MosaicProjects) return;
|
||||
const project = c.projects.find(p => p.id === c.id);
|
||||
const projectContent = c.container.querySelector('[data-project-content]');
|
||||
if (redesigned && (c.kind === 'project' || c.kind === 'thread' || c.kind === 'room')) {
|
||||
@@ -526,6 +559,7 @@
|
||||
// visible view is unchanged: a request's phase moves without a new message. A quiet reread
|
||||
// keeps the present content until a changed result arrives.
|
||||
function refreshRequestData(c, quiet) {
|
||||
if (pane) return;
|
||||
window.MosaicRequests?.mount?.(c.container, { archiveUnknown: !!c.archiveUnknown, projectArchived: !!c.projectArchived });
|
||||
const t = c.kind === 'thread' ? c.thread : null;
|
||||
const repo = c.kind === 'project' ? c.projects.find(p => p.id === c.id)?.repo : libraryOf(c, t);
|
||||
@@ -562,7 +596,7 @@
|
||||
});
|
||||
for (let i = 0; i < desired.length; i++) if (liveLog.children[i] !== desired[i]) liveLog.insertBefore(desired[i], liveLog.children[i] || null);
|
||||
while (liveLog.children.length > desired.length) liveLog.lastElementChild.remove();
|
||||
window.MosaicRequests?.mount?.(c.container, { archiveUnknown: !!c.archiveUnknown, projectArchived: !!c.projectArchived });
|
||||
if (!pane) window.MosaicRequests?.mount?.(c.container, { archiveUnknown: !!c.archiveUnknown, projectArchived: !!c.projectArchived });
|
||||
const s = state(c.key ?? c.id);
|
||||
if (s.tailRequested || s.follow && ((keep?.tail ?? true) || keep?.settlingPin)) pinTranscript(liveLog);
|
||||
else if (keep?.top !== undefined) { if (pageFlow(liveLog)) window.scrollTo(0, keep.top); else liveLog.scrollTop = keep.top; }
|
||||
@@ -604,8 +638,8 @@
|
||||
function paint(navigated = false, read = false) {
|
||||
if (!current || out()) return;
|
||||
const keep = capture(), c = current;
|
||||
const redesigned = (!legacyRoute() || c.path?.startsWith('/projects')) && !!window.MosaicProjects && !c.user && !c.tester;
|
||||
let html = '<div class="chat-area"><p><a href="#/projects">Projects</a></p>';
|
||||
const redesigned = !pane && (!legacyRoute() || c.path?.startsWith('/projects')) && !!window.MosaicProjects && !c.user && !c.tester;
|
||||
let html = pane ? '<div class="chat-area">' : '<div class="chat-area"><p><a href="#/projects">Projects</a></p>';
|
||||
if (c.orgArchived) html += `<p role="status">${c.orgArchived === 'room-not-open' ? 'This room is not open.' : 'This organization is archived.'} Sending is unavailable; your draft is kept.</p>`;
|
||||
if (c.error && !(c.kind === 'room' && c.error === ROOM_LINES['room-not-open'] && redesigned)) html += `<p class="chat-error banner err" role="alert">${txt(c.error)}</p>`;
|
||||
if (c.archiveUnknown && !c.loading) html += '<p role="status" data-archive-notice>Archive status unavailable. Some changes are unavailable; sending may be refused; refresh to check.</p>';
|
||||
@@ -635,8 +669,10 @@
|
||||
} else {
|
||||
html += heading('Projects') + '<p>Projects and their conversations.</p><div class="chat-projects">' + c.projects.map(p => `<section class="panel"><h2>${link('projects', p.id, p.name)}</h2>${p.repo ? `<p class="muted">Repository: ${txt(p.repo)}</p>` : ''}${threads(c.threads.filter(t => t.project === p.id && !t.shared))}</section>`).join('') + (c.projects.length ? '' : c.readFailed ? '<p>Projects unavailable.</p>' : '<p>No projects yet.</p>') + '</div><h2>Shared with me</h2>' + threads(c.threads.filter(t => t.shared)) + creation('project', 'projects');
|
||||
}
|
||||
if (!c.canSend) html += window.MosaicOrgBusStopped ? '<p class="muted">The bus is stopped. Use Start the bus on Settings → Organizations to send messages.</p>' : '<p class="muted">Creating and sending require signed-in send mode. This Console only reads.</p>';
|
||||
if (!c.canSend && pane) html += '<p class="muted">Sending is unavailable in this organization. This thread only reads.</p>';
|
||||
else if (!c.canSend) html += window.MosaicOrgBusStopped ? '<p class="muted">The bus is stopped. Use Start the bus on Settings → Organizations to send messages.</p>' : '<p class="muted">Creating and sending require signed-in send mode. This Console only reads.</p>';
|
||||
html += '</div>';
|
||||
if (pane) html = html.replace(/\b(id|for|aria-controls|aria-describedby|aria-labelledby)="chat-/g, `$1="${env.prefix}`);
|
||||
// Drafts already live in the focused textarea; their changing text is not a
|
||||
// reason for a background read to replace the control.
|
||||
const fingerprint = visualKey(html);
|
||||
@@ -690,13 +726,13 @@
|
||||
// which names the project as the organization file does: the chat project's repo key.
|
||||
if (remote && roomOwner() && window.MosaicOrg && typeof window.MosaicProjectRepo?.mount === 'function') window.MosaicProjectRepo.mount({ container: remote, project: project?.repo ?? null, archived: !!c.projectArchived });
|
||||
else if (remote && roomOwner() && typeof window.MosaicProjectRemote?.mount === 'function') { remote.replaceChildren(); window.MosaicProjectRemote.mount({ container: remote, projectId: c.id, repositoryKey: project?.repo ?? null, projectArchived: !!c.projectArchived, preserve: true }); }
|
||||
if (!window.MosaicOrg && (!redesigned || c.kind !== 'project' || c.settings || c.projectArchived)) window.MosaicProjectArchive?.mount(c.container, c);
|
||||
if (!pane && !window.MosaicOrg && (!redesigned || c.kind !== 'project' || c.settings || c.projectArchived)) window.MosaicProjectArchive?.mount(c.container, c);
|
||||
if (redesigned && c.kind === 'project' && c.projectArchived && !c.settings) {
|
||||
const controls = c.container.querySelector('.project-archive-control');
|
||||
if (controls) c.container.querySelector('.project-tabs')?.insertAdjacentElement('afterend', controls);
|
||||
}
|
||||
refreshProjectData(c, redesigned, !navigated ? keep : null);
|
||||
document.title = `${c.container.querySelector('h1')?.textContent ?? 'Chat'} · Mosaic Console`;
|
||||
if (!pane) document.title = `${c.container.querySelector('h1')?.textContent ?? 'Chat'} · Mosaic Console`;
|
||||
if (!navigated && keep?.toggles) [...c.container.querySelectorAll('.project-new-conversation,.project-create')].forEach((e,i)=>{e.open=!!keep.toggles[i];});
|
||||
const details = c.container.querySelector('.chat-details');
|
||||
if (details && keep?.detailsOpen && details.tagName === 'DETAILS') details.open = true;
|
||||
@@ -736,7 +772,7 @@
|
||||
f.focus({ preventScroll: true });
|
||||
if (keep.field.start != null && typeof f.setSelectionRange === 'function') f.setSelectionRange(keep.field.start, keep.field.end);
|
||||
}
|
||||
} else if (navigated) c.container.querySelector('h1')?.focus({ preventScroll: true });
|
||||
} else if (navigated && !pane) c.container.querySelector('h1')?.focus({ preventScroll: true });
|
||||
updateRoomCompletion();
|
||||
}
|
||||
function readError(err) {
|
||||
@@ -765,7 +801,7 @@
|
||||
paint(); return;
|
||||
}
|
||||
const old = current;
|
||||
current = { container, kind, id, orgArchived: old?.orgArchived ?? false, loading: true, archiveUnknown: kind === 'thread' && !user && !tester, settings: !!settings, section, path: originalPath, canSend, user, tester, chatGrants: [], thread: old && old.id === id ? old.thread : null, shared: old && old.id === id ? old.shared : false, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', readFailed: false, channelDisabled: old?.channelDisabled ?? false, refused: false, legacyDefault: old && old.id === id ? old.legacyDefault : false, sessions: old && old.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old && old.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old && old.id === id ? old.key : id };
|
||||
current = { container, kind, id, orgArchived: old?.orgArchived ?? false, loading: true, archiveUnknown: kind === 'thread' && !user && !tester, settings: !!settings, section, path: originalPath, canSend, user, tester, chatGrants: [], thread: old && old.id === id ? old.thread : null, shared: old && old.id === id ? old.shared : false, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', readFailed: false, channelDisabled: old?.channelDisabled ?? false, refused: false, legacyDefault: old && old.id === id ? old.legacyDefault : false, sessions: old && old.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old && old.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old && old.id === id ? old.key : id, agentIdle: old && old.id === id ? old.agentIdle : null };
|
||||
current.projectArchived = current.projects.some(p => p.id === (kind === 'project' ? id : current.thread?.project) && p.archived);
|
||||
if (kind === 'thread' && old?.kind === 'thread' && old.id === id && !navigated && old.archiveUnknown === false && old.projects.some(p => p.id === old.thread?.project)) current.archiveUnknown = false;
|
||||
if (current.projectArchived) current.archiveUnknown = false;
|
||||
@@ -773,8 +809,8 @@
|
||||
// server paths lack archive guards, and the request panel's writes.
|
||||
const withdraw = () => {
|
||||
lastPaint = null;
|
||||
for (const el of container.querySelectorAll('#chat-session-form input, #chat-session-form button, #chat-share-form input, #chat-share-form button, [data-unshare], #chat-cancel, [data-revert]')) el.disabled = true;
|
||||
window.MosaicRequests?.mount?.(container, { archiveUnknown: !!current.archiveUnknown, projectArchived: !!current.projectArchived });
|
||||
for (const el of container.querySelectorAll(['chat-session-form input', 'chat-session-form button', 'chat-share-form input', 'chat-share-form button', 'chat-cancel'].map(x => '#' + pid(x)).concat('[data-unshare]', '[data-revert]').join(', '))) el.disabled = true;
|
||||
if (!pane) window.MosaicRequests?.mount?.(container, { archiveUnknown: !!current.archiveUnknown, projectArchived: !!current.projectArchived });
|
||||
if (!current.archiveUnknown) return;
|
||||
if (container.querySelector('[data-archive-notice]')) return;
|
||||
const notice = document.createElement('p');
|
||||
@@ -815,7 +851,8 @@
|
||||
current.thread = current.threads.find(t => t.id === id);
|
||||
current.shared = current.thread?.shared === true;
|
||||
const projectId = kind === 'project' ? id : current.thread?.project;
|
||||
if (projectId && !current.projects.some(p => p.id === projectId) && !user && !tester) {
|
||||
// A pane leaves an unlisted project's archive state unknown: the archive lookup reads the page's organization.
|
||||
if (projectId && !current.projects.some(p => p.id === projectId) && !user && !tester && !pane) {
|
||||
try {
|
||||
if (typeof window.MosaicProjectArchive?.resolve !== 'function') throw Error('archive-unavailable');
|
||||
const resolved = await window.MosaicProjectArchive.resolve(projectId, signal);
|
||||
@@ -830,7 +867,7 @@
|
||||
current.archiveUnknown = true;
|
||||
}
|
||||
}
|
||||
else if (projectId) current.archiveUnknown = false;
|
||||
else if (projectId && (!pane || current.projects.some(p => p.id === projectId))) current.archiveUnknown = false;
|
||||
current.projectArchived = current.projects.some(p => p.id === projectId && p.archived);
|
||||
// A same-thread refresh kept the controls of the last resolved (live) state. If this
|
||||
// lookup now finds the project archived or unknown, withdraw them before the slower
|
||||
@@ -848,6 +885,10 @@
|
||||
if (g !== generation || out()) return;
|
||||
current.shares = shares.rows;
|
||||
}
|
||||
if (kind === 'thread' && current.thread && threadWrites()) {
|
||||
current.agentIdle = await agentIdle(current.thread.instance, signal);
|
||||
if (g !== generation || out()) return;
|
||||
}
|
||||
let s = null, page = null, after = null;
|
||||
if (kind === 'thread' && catalog === null) {
|
||||
current.legacyDefault = true; current.sessions = []; current.session = null;
|
||||
@@ -958,7 +999,7 @@
|
||||
for (const row of state(current.key).rows) if (row.from_role === 'human' && typeof row.request === 'string' && REQUEST_ID.test(row.request)) ids.add(row.request);
|
||||
knownRequests.set(projectId, { repo: project.repo, ids });
|
||||
}
|
||||
if (project) document.dispatchEvent(new CustomEvent('mosaic-project-context', { detail: { projectId, repositoryKey: project.repo ?? null, thread: kind === 'thread' ? id : null, session: kind === 'thread' ? current.session : null } }));
|
||||
if (project && !pane) document.dispatchEvent(new CustomEvent('mosaic-project-context', { detail: { projectId, repositoryKey: project.repo ?? null, thread: kind === 'thread' ? id : null, session: kind === 'thread' ? current.session : null } }));
|
||||
}
|
||||
paint(navigated, true);
|
||||
const t = kind === 'thread' ? current.threads.find(t => t.id === id) : null;
|
||||
@@ -988,17 +1029,17 @@
|
||||
}
|
||||
}
|
||||
function refresh(next = false) {
|
||||
if (current) return render({ ...current, path: (window.MosaicOrg?.route() ?? location.hash.slice(1)).split('?')[0], next });
|
||||
if (current) return render({ ...current, path: pane ? current.path : (window.MosaicOrg?.route() ?? location.hash.slice(1)).split('?')[0], next });
|
||||
}
|
||||
function leave(path) {
|
||||
if (!path.startsWith('/chat') && !path.startsWith('/projects')) { generation++; controller?.abort(); current = null; }
|
||||
}
|
||||
document.addEventListener('input', event => {
|
||||
if (!current || out()) return;
|
||||
if (event.target.id === 'chat-room-body') { state(current.key).draft = event.target.value; updateRoomCompletion(); return; }
|
||||
if (event.target.id === 'chat-operation') { const f = form('clear:' + current.key); if (f.pending) return; f.op = event.target.value; f.record = null; const button = document.getElementById('chat-cancel'); if (button) button.disabled = !current.canSend || current.archiveUnknown || current.projectArchived || f.pending || !f.op; }
|
||||
if (event.target.id === 'chat-share-target') form('share:' + current.id).to = event.target.value;
|
||||
if (event.target.id === 'chat-body') state(current.key ?? current.id).draft = event.target.value;
|
||||
on('input', event => {
|
||||
if (!current || out() || !mine(event.target)) return;
|
||||
if (idOf(event.target) === 'chat-room-body') { state(current.key).draft = event.target.value; updateRoomCompletion(); return; }
|
||||
if (idOf(event.target) === 'chat-operation') { const f = form('clear:' + current.key); if (f.pending) return; f.op = event.target.value; f.record = null; const button = byId('chat-cancel'); if (button) button.disabled = !current.canSend || current.archiveUnknown || current.projectArchived || f.pending || !f.op; }
|
||||
if (idOf(event.target) === 'chat-share-target') form('share:' + current.id).to = event.target.value;
|
||||
if (idOf(event.target) === 'chat-body') state(current.key ?? current.id).draft = event.target.value;
|
||||
const f = event.target.closest('.chat-create');
|
||||
if (f && event.target.dataset.chatField) {
|
||||
const draft = form(f.dataset.key), field = event.target.dataset.chatField;
|
||||
@@ -1010,7 +1051,7 @@
|
||||
// User input cancels a pending page pin, including scrollbar dragging.
|
||||
// Tab counts from any target: it moves focus and can scroll the document.
|
||||
const readerInput = event => {
|
||||
if (!['thread', 'room'].includes(current?.kind)) return;
|
||||
if (!['thread', 'room'].includes(current?.kind) || !mine(event.target)) return;
|
||||
if (event.type === 'keydown' && event.key !== 'Tab' && (!['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' '].includes(event.key) || event.target.closest?.('input, textarea, select, button, a, [contenteditable=true]'))) return;
|
||||
if (event.type === 'pointerdown' && event.target !== document.documentElement && event.target !== document.body && !event.target.matches?.('.chat-log')) return;
|
||||
const log = current.container.querySelector('.chat-log');
|
||||
@@ -1024,8 +1065,8 @@
|
||||
}
|
||||
else if (event.key === 'Tab' || event.target === log || log.contains(event.target)) { pinnedScrolls.delete(log); settlingPins.delete(log); }
|
||||
};
|
||||
for (const type of ['wheel', 'touchstart', 'pointerdown', 'keydown']) document.addEventListener(type, readerInput, true);
|
||||
document.addEventListener('scroll', event => {
|
||||
for (const type of ['wheel', 'touchstart', 'pointerdown', 'keydown']) on(type, readerInput, true);
|
||||
on('scroll', event => {
|
||||
if (!['thread', 'room'].includes(current?.kind)) return;
|
||||
const log = current.container.querySelector('.chat-log');
|
||||
if (!log) return;
|
||||
@@ -1110,25 +1151,25 @@
|
||||
if (!out() && current && (isThread ? (current.key ?? current.id) === key : (action === 'createSession' ? current.id === targetThread : key === 'projects' ? current.kind === 'list' : current.id === key))) paint();
|
||||
}
|
||||
}
|
||||
document.addEventListener('submit', event => {
|
||||
if (!current || !event.target.id.startsWith('chat-')) return;
|
||||
on('submit', event => {
|
||||
if (!current || !mine(event.target) || !idOf(event.target).startsWith('chat-')) return;
|
||||
event.preventDefault();
|
||||
if (event.target.id === 'chat-room-send-form') {
|
||||
if (idOf(event.target) === 'chat-room-send-form') {
|
||||
void sendRoom();
|
||||
} else if (event.target.id === 'chat-share-form') {
|
||||
} else if (idOf(event.target) === 'chat-share-form') {
|
||||
void changeShare('share', form('share:' + current.id).to ?? '');
|
||||
} else if (event.target.id === 'chat-send-form') {
|
||||
} else if (idOf(event.target) === 'chat-send-form') {
|
||||
const id = current.id, session = current.session, key = current.key;
|
||||
void write('send', key, { body: state(key).draft, ...(session ? { session } : {}) }, `threads/${id}/messages`);
|
||||
} else if (event.target.id === 'chat-session-form') {
|
||||
} else if (idOf(event.target) === 'chat-session-form') {
|
||||
const key = 'session:' + current.id, f = form(key);
|
||||
// Keep an ambiguous create's key and payload; editing requires a deliberate new attempt.
|
||||
f.op ??= opId();
|
||||
f.payload ??= { name: f.name, op: f.op };
|
||||
void write('createSession', key, f.payload, `threads/${current.id}/sessions`);
|
||||
} else if (event.target.id === 'chat-operation-form') {
|
||||
} else if (idOf(event.target) === 'chat-operation-form') {
|
||||
const clear = form('clear:' + current.key);
|
||||
clear.op = document.getElementById('chat-operation').value;
|
||||
clear.op = byId('chat-operation').value;
|
||||
void checkClear(current.id, current.key, clear);
|
||||
} else if (event.target.classList.contains('chat-create')) {
|
||||
const key = event.target.dataset.key, f = form(key), project = key === 'projects';
|
||||
@@ -1137,9 +1178,9 @@
|
||||
void write(project ? 'createProject' : 'createThread', key, value, project ? 'projects' : 'threads');
|
||||
}
|
||||
});
|
||||
document.addEventListener('click', event => {
|
||||
if (!current || out()) return;
|
||||
if (event.target.id === 'chat-room-open' || event.target.closest?.('[data-room-open-inline]')) void openRoom();
|
||||
on('click', event => {
|
||||
if (!current || out() || !mine(event.target)) return;
|
||||
if (idOf(event.target) === 'chat-room-open' || event.target.closest?.('[data-room-open-inline]')) void openRoom();
|
||||
const projectRoomOpen=event.target.closest?.('[data-project-room-open]'); if(projectRoomOpen && UUID.test(projectRoomOpen.dataset.projectRoomOpen)) void openRoom(projectRoomOpen.dataset.projectRoomOpen);
|
||||
const chip = event.target.closest?.('[data-room-chip]');
|
||||
if (chip) { const name = chip.dataset.roomChip; document.querySelector(`[data-room-member="${CSS.escape(name)}"]`)?.click(); document.querySelector(`[data-room-chip="${CSS.escape(name)}"]`)?.focus(); }
|
||||
@@ -1154,16 +1195,17 @@
|
||||
const option = event.target.closest?.('[data-room-complete]');
|
||||
if (option) completeRoom(option.dataset.roomComplete);
|
||||
if (event.target.dataset.unshare) void changeShare('unshare', event.target.dataset.unshare);
|
||||
if (event.target.id === 'chat-resume') void write('open', current.key, current.session ? { session: current.session } : {}, `threads/${current.id}/open`);
|
||||
if (event.target.id === 'chat-cancel') void cancelClear();
|
||||
if (event.target.id === 'chat-clear') void clearContext();
|
||||
if (event.target.id === 'chat-next') void refresh(true);
|
||||
if (idOf(event.target) === 'chat-resume') void write('open', current.key, current.session ? { session: current.session } : {}, `threads/${current.id}/open`);
|
||||
if (idOf(event.target) === 'chat-cancel') void cancelClear();
|
||||
if (idOf(event.target) === 'chat-clear') void clearContext();
|
||||
if (idOf(event.target) === 'chat-next') void refresh(true);
|
||||
if (event.target.closest?.('[data-chat-retry]')) void refresh();
|
||||
const revertButton = event.target.closest?.('[data-revert]');
|
||||
if (revertButton) void revert(revertButton.dataset.revert);
|
||||
if (event.target.id === 'chat-new') { state(current.key ?? current.id).tailRequested = true; state(current.key ?? current.id).follow = true; paint(); }
|
||||
if (idOf(event.target) === 'chat-new') { state(current.key ?? current.id).tailRequested = true; state(current.key ?? current.id).follow = true; paint(); }
|
||||
});
|
||||
document.addEventListener('change', event => {
|
||||
on('change', event => {
|
||||
if (!mine(event.target)) return;
|
||||
if (event.target.matches?.('[data-room-member]') && current?.kind === 'room') {
|
||||
const field = document.getElementById('chat-room-body'), s = state(current.key);
|
||||
const parsed = roomAddress(field.value);
|
||||
@@ -1175,7 +1217,7 @@
|
||||
s.draft = chosen.map(name => '@' + name).join(' ') + (parsed.body ? ' ' + parsed.body : ' ');
|
||||
s.receipt = ''; paint(); return;
|
||||
}
|
||||
if (event.target.id === 'chat-session' && current && UUID.test(event.target.value)) {
|
||||
if (idOf(event.target) === 'chat-session' && current && UUID.test(event.target.value)) {
|
||||
location.hash = sessionPath(current.id, event.target.value);
|
||||
}
|
||||
});
|
||||
@@ -1246,7 +1288,27 @@
|
||||
if (!out() && current?.key === key) { await refresh(); paint(); }
|
||||
}
|
||||
}
|
||||
document.addEventListener('mosaic-signed-out', () => { signedOut = true; forget(); });
|
||||
on('mosaic-signed-out', () => { signedOut = true; forget(); });
|
||||
const hasDraft = () => [...states.values()].some(s => Boolean(s.draft?.trim())) || [...forms.values()].some(f => Boolean(f.name?.trim() || f.repo || f.instanceChosen));
|
||||
window.MosaicChat = Object.freeze({ render, leave, forget, projectPath, hasDraft });
|
||||
// SP11: the unsent text of a pane's thread, and whether a write is in flight or a sent message
|
||||
// still waits for the conversation to show it.
|
||||
const draft = () => [...states.values()].find(s => s.draft?.trim())?.draft || '';
|
||||
const busy = () => [...states.values()].some(s => s.pending || !!s.receiptId);
|
||||
// Ends a pane instance: its listeners, reads and observers stop and its container is left as is.
|
||||
function dispose() {
|
||||
life.abort(); generation++; controller?.abort(); transcriptObserver?.disconnect(); transcriptResize?.disconnect();
|
||||
current = null; lastPaint = null;
|
||||
}
|
||||
return { render, leave, forget, refresh, projectPath, hasDraft, draft, busy, dispose };
|
||||
}
|
||||
const main = chatInstance();
|
||||
const ORG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
let panes = 0;
|
||||
// An Assistant pane: one thread of one organization, read and sent through that organization's own routes.
|
||||
function pane(org) {
|
||||
if (typeof org !== 'string' || !ORG.test(org)) throw new TypeError('pane needs an organization id');
|
||||
const chat = chatInstance({ org, prefix: `chat-p${++panes}-` });
|
||||
return Object.freeze({ render: chat.render, refresh: chat.refresh, dispose: chat.dispose, hasDraft: chat.hasDraft, draft: chat.draft, busy: chat.busy });
|
||||
}
|
||||
window.MosaicChat = Object.freeze({ render: main.render, leave: main.leave, forget: main.forget, projectPath: main.projectPath, hasDraft: main.hasDraft, pane });
|
||||
})();
|
||||
@@ -21,7 +21,7 @@
|
||||
.then(r => (r.type === 'opaqueredirect' ? null : r.ok ? r.json() : null));
|
||||
const ready = readConfig().then(c => { config = c; boot = typeof c?.boot === 'string' ? c.boot : null; return c; }).catch(() => null);
|
||||
|
||||
const dirty = () => !!(window.MosaicChat?.hasDraft?.() || window.MosaicOrgCreate?.hasDraft?.()
|
||||
const dirty = () => !!(window.MosaicChat?.hasDraft?.() || window.MosaicAssistant?.hasDraft?.() || window.MosaicOrgCreate?.hasDraft?.() || window.MosaicWorkflows?.hasDraft?.()
|
||||
|| document.querySelector('.settings-dot')
|
||||
|| [...document.querySelectorAll('#reply, #conv-reply, #doc-source, #send-body')].some(el => !!el.value?.trim()));
|
||||
|
||||
|
||||
@@ -1,60 +1,73 @@
|
||||
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\/[^/]+/, '') || '/';
|
||||
// SP13: the organization segment stops at `/` or `?`, so `#/orgs/<id>?agent=…` names <id>.
|
||||
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] ? '/' : m[1][0] === '?' ? `/${m[1]}` : 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. #1873 SP2: an
|
||||
// organization's own bare address (#/orgs/<id>, no trailing slash) is that organization's page;
|
||||
// #/orgs/<id>/ stays the old dashboard address. SP3: `#/inbox` is a system route too (every
|
||||
// organization's items); `#/orgs/<id>/inbox` is that list, filtered. SP5: `#/agents` is every
|
||||
// organization's agents; `#/orgs/<id>/agents` is that table for one organization. SP4: `#/tasks` is
|
||||
// one task board for every organization; `#/orgs/<id>/tasks` is that board, scoped to one.
|
||||
// SP6: `#/assistant` is the Assistant page, threads of any organization side by side.
|
||||
const system = hash => { const bare = tail(hash).split('?')[0]; return bare === '/dashboard' || (!orgOf(hash) && (bare === '/inbox' || bare === '/agents' || bare === '/tasks' || bare === '/assistant')); };
|
||||
const normalize = (hash, seg = current) => {
|
||||
const path = tail(hash), bare = path.split('?')[0], named = /^#\/orgs\/([^/?]+)$/.exec(hash.split('?')[0])?.[1];
|
||||
if (named) return `#/orgs/${named}/organization${hash.slice(hash.split('?')[0].length)}`;
|
||||
if (bare === '/' || bare === '/dashboard') return `#/dashboard${path.slice(bare.length)}`;
|
||||
return orgOf(hash) || !seg || system(hash) ? 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 and the Inbox name every bus themselves.
|
||||
// #1873 SP2: the organization's own page draws its down banner with the restart action, so this one hides there.
|
||||
const paintBus = () => {
|
||||
const banner = document.getElementById('bus-down');
|
||||
if (!banner) return;
|
||||
const org = rowsSeen.find(row => current && row.id === decodeURIComponent(current) && !row.invalid), path = route().split('?')[0];
|
||||
if (system(location.hash) || (path === '/organization' && org?.busHealthy === false) || (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 +75,119 @@ 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;
|
||||
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();
|
||||
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;
|
||||
// SP11: the Assistant keeps its panes' unsent text per thread, so it is not asked about here.
|
||||
const dirty = () => window.MosaicChat?.hasDraft?.() || window.MosaicOrgCreate?.hasDraft?.() || window.MosaicWorkflows?.hasDraft?.() || [...document.querySelectorAll('#reply, #conv-reply, #doc-source')].some(el => !!el.value?.trim());
|
||||
// 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?.();
|
||||
window.MosaicWorkflows?.forget?.();
|
||||
for (const el of document.querySelectorAll('#reply, #conv-reply, #doc-source')) el.value = '';
|
||||
}
|
||||
function confirmSwitch(next) {
|
||||
const dialog = document.createElement('dialog');
|
||||
dialog.setAttribute('aria-label', 'Unsaved draft');
|
||||
dialog.innerHTML = '<p>You have an unsaved draft. Stay here or discard it and continue.</p><button type="button" data-stay>Stay</button> <button type="button" data-discard>Discard</button>';
|
||||
document.body.append(dialog);
|
||||
const stay = () => { const picker = document.getElementById('org-picker'); if (picker) picker.value = decodeURIComponent(id); dialog.close(); dialog.remove(); };
|
||||
const close = () => { dialog.close(); dialog.remove(); };
|
||||
const stay = () => { const picker = document.getElementById('org-picker'); if (picker && current) picker.value = decodeURIComponent(current); close(); };
|
||||
dialog.querySelector('[data-stay]').onclick = stay;
|
||||
dialog.querySelector('[data-discard]').onclick = () => change(next);
|
||||
dialog.querySelector('[data-discard]').onclick = () => { close(); discard(); skipGuard = true; location.hash = next; };
|
||||
dialog.addEventListener('cancel', e => { e.preventDefault(); stay(); });
|
||||
dialog.showModal(); dialog.querySelector('[data-stay]').focus();
|
||||
}
|
||||
// Capture phase: this runs before bus.js renders, so a switch is scoped before the page reads.
|
||||
window.addEventListener('hashchange', event => {
|
||||
const oldHash = event.oldURL ? new URL(event.oldURL).hash : previous;
|
||||
let next = location.hash;
|
||||
if (!next.startsWith('#/orgs/')) { next = `#/orgs/${id}${next.startsWith('#/') ? next.slice(1) : '/'}`; history.replaceState(null, '', next); }
|
||||
const nextId = /^#\/orgs\/([^/]+)/.exec(next)?.[1];
|
||||
if ((nextId && nextId !== id) || (next !== oldHash && window.MosaicOrgCreate?.hasDraft?.())) {
|
||||
const raw = location.hash;
|
||||
// First run (no organization yet) keeps its own addresses: #/ is its board, not #/dashboard.
|
||||
// Before the first list read settles, the settle step normalizes whatever address it finds.
|
||||
if (!settled || !current) { previous = raw; return; }
|
||||
const seg = orgOf(raw) ?? current, next = normalize(raw, seg);
|
||||
if (next !== raw) history.replaceState(null, '', next);
|
||||
const switching = seg !== current;
|
||||
if (!skipGuard && next !== oldHash && (switching || window.MosaicOrgCreate?.hasDraft?.()) && dirty()) {
|
||||
event.stopImmediatePropagation();
|
||||
if (dirty()) { history.replaceState(null, '', oldHash); confirmSwitch(next); } else change(next);
|
||||
history.replaceState(null, '', oldHash); confirmSwitch(next);
|
||||
return;
|
||||
}
|
||||
previous = next;
|
||||
skipGuard = false; previous = next;
|
||||
if (switching) rescope(seg);
|
||||
paintBus();
|
||||
}, true);
|
||||
window.MosaicOrg = Object.freeze({ id: decodeURIComponent(id), route, get list() { return list; }, readList, refreshList, switchTo: next => { location.hash = `#/orgs/${encodeURIComponent(next)}${route()}`; } });
|
||||
const api = Object.freeze({
|
||||
get id() { return current ? decodeURIComponent(current) : null; },
|
||||
// The open organization's name as the last list read gave it, for the breadcrumb.
|
||||
get label() { const id = current ? decodeURIComponent(current) : null, row = id && rowsSeen.find(r => r.id === id); return id ? (typeof row?.label === 'string' && row.label ? row.label : id) : null; },
|
||||
ready, route, get list() { return list; }, readList, refreshList,
|
||||
// SP3: whether this address is a system page (no organization's), for the shell's theme scope.
|
||||
system: () => system(location.hash),
|
||||
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.
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
<link rel="stylesheet" href="/project-archive.css"><link rel="stylesheet" href="/projects.css">
|
||||
<link rel="stylesheet" href="/processing.css"><script src="/processing.js" defer></script>
|
||||
<link rel="stylesheet" href="/org-create.css"><script src="/org-create.js" defer></script>
|
||||
<link rel="stylesheet" href="/onboarding.css"><script src="/onboarding.js" defer></script><link rel="stylesheet" href="/panes.css">
|
||||
<script src="/workspace-data.js" defer></script><script src="/workspace.js" defer></script><script src="/tasks-board.js" defer></script><script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><link rel="stylesheet" href="/autonomy.css"><script src="/autonomy.js" defer></script><link rel="stylesheet" href="/packs.css"><script src="/packs.js" defer></script><script src="/workflows.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/panes.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
|
||||
<link rel="stylesheet" href="/onboarding.css"><script src="/onboarding.js" defer></script><link rel="stylesheet" href="/assistant.css">
|
||||
<script src="/workspace-data.js" defer></script><script src="/workspace.js" defer></script><script src="/tasks-board.js" defer></script><script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><link rel="stylesheet" href="/autonomy.css"><script src="/autonomy.js" defer></script><link rel="stylesheet" href="/packs.css"><script src="/packs.js" defer></script><link rel="stylesheet" href="/workflows.css"><script src="/workflows.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/assistant.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
|
||||
<body>
|
||||
<div hidden aria-hidden="true"><select id="palette"></select><select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></div>
|
||||
<a class="skip" href="#main">Skip to content</a>
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
/* Side by side (U5, sample 07 "EXT 8"): a bar, then two panes with a divider between them. Each pane
|
||||
has its own header with the organization's mark and edge, and its own scroll. One pane and a
|
||||
shown-pane switch on a phone. Loaded after shell.css; every rule is scoped to the side-by-side page. */
|
||||
html[data-organizations] .content:has(> #bus-view > .sbs){padding:0}
|
||||
html[data-organizations] .sbs{margin:0;display:flex;flex-direction:column;min-height:0;height:calc(100vh - var(--bar-h,53px) - var(--foot-h,33px))}
|
||||
html[data-organizations] .sbs .panebar{display:flex;align-items:center;gap:10px;padding:8px 14px;border-bottom:1px solid var(--line);background:var(--canvas);flex-wrap:wrap}
|
||||
html[data-organizations] .sbs .panebar h1{font-size:16px;font-weight:700;margin:0;line-height:1.4}
|
||||
html[data-organizations] .sbs .panebar h1:focus{outline:none}
|
||||
html[data-organizations] .sbs .panebar .note{font-size:13px;color:var(--muted)}
|
||||
html[data-organizations] .sbs .panebar [data-pane-add]{margin-left:auto}
|
||||
html[data-organizations] .sbs .ws-seg.pane-tabs{display:none;margin-left:0}
|
||||
html[data-organizations] .sbs .pane-tabs>button{gap:5px;max-width:44vw}
|
||||
html[data-organizations] .sbs .pane-tabs>button>span:last-child{overflow:hidden;text-overflow:ellipsis}
|
||||
|
||||
html[data-organizations] .panes{--sbs-a:1fr;--sbs-b:1fr;display:grid;grid-template-columns:minmax(0,1fr);flex:1;min-height:0}
|
||||
html[data-organizations] .panes.two{grid-template-columns:minmax(0,var(--sbs-a)) 1px minmax(0,var(--sbs-b))}
|
||||
html[data-organizations] .pane{display:flex;flex-direction:column;min-width:0;min-height:0}
|
||||
html[data-organizations] .pane[hidden]{display:none}
|
||||
html[data-organizations] .pane-h{display:flex;align-items:center;gap:8px;padding:8px 14px;border-bottom:1px solid var(--line);background:var(--surface);box-shadow:inset 0 3px 0 var(--org,var(--line));min-width:0}
|
||||
html[data-organizations] .pane-h .ttl{font-weight:700;color:var(--strong,var(--text));overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:0 1 auto}
|
||||
html[data-organizations] .pane-h .ttl small{font-weight:500;color:var(--muted);margin-left:6px;font-size:inherit}
|
||||
html[data-organizations] .pane-h [data-pane-state]:empty{display:none}
|
||||
html[data-organizations] .pane-pick{flex:0 1 180px;min-width:0;max-width:220px;margin-left:auto;font:500 12px/1.4 var(--font);color:var(--text);background:var(--canvas);border:1px solid var(--line);border-radius:var(--r);padding:3px 6px;min-height:0}
|
||||
html[data-organizations] .pane-h .tools{display:flex;gap:2px;flex:none}
|
||||
html[data-organizations] .pane-tool{display:inline-grid;place-items:center;width:26px;height:26px;border-radius:var(--r);color:var(--muted);border:0;background:none;padding:0;min-height:0;cursor:pointer}
|
||||
html[data-organizations] .pane-tool:hover{background:var(--raised);color:var(--text)}
|
||||
html[data-organizations] .pane-tool[hidden]{display:none}
|
||||
html[data-organizations] .pane-tool svg.i{width:15px;height:15px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
|
||||
html[data-organizations] .pane-line{margin:0;padding:6px 14px;border-bottom:1px solid var(--line);font-size:13px;color:var(--text);background:color-mix(in srgb,var(--warn,#b7791f) 12%,var(--surface))}
|
||||
html[data-organizations] .pane-line[hidden]{display:none}
|
||||
html[data-organizations] .pane-body{overflow:auto;padding:14px;min-height:0;flex:1}
|
||||
html[data-organizations] .pane-body:focus{outline:none}
|
||||
html[data-organizations] .pane-body:focus-visible{outline:2px solid var(--focus,var(--action));outline-offset:-2px}
|
||||
/* A page inside a pane is a section of the side-by-side page: smaller headings, no page chrome. */
|
||||
html[data-organizations] .pane-body .ws h1{font-size:20px}
|
||||
html[data-organizations] .pane-body .chat-area.pane-project{display:block;height:auto;min-height:0;overflow:visible;padding:0}
|
||||
/* A project in a pane is compact (sample 07): the pane header already names the project and its
|
||||
organization, so the page heading stays only for assistive technology and focus, the full-page
|
||||
actions give way (North star and PRD are on the Requirements tab, Settings in the full view), and
|
||||
the tabs, measures and current work come first. Project pages size some parts from the window
|
||||
(projects.css); in a pane they size from the pane. */
|
||||
html[data-organizations] .pane-body{container-type:inline-size}
|
||||
html[data-organizations] .pane-body .project-header{display:block;width:auto;max-width:100%;margin:0 0 6px;padding:0}
|
||||
html[data-organizations] .pane-body .project-header .project-heading,html[data-organizations] .pane-body .project-header .project-heading>div{display:block}
|
||||
html[data-organizations] .pane-body .project-header-icon,html[data-organizations] .pane-body .project-actions{display:none}
|
||||
html[data-organizations] .pane-body .project-header h1{position:absolute;width:1px;height:1px;margin:-1px;padding:0;border:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
|
||||
html[data-organizations] .pane-body:has(.project-header h1:focus-visible){outline:2px solid var(--focus,var(--action));outline-offset:-2px}
|
||||
html[data-organizations] .pane-body .project-header .project-heading p{margin:0;font-size:13px;color:var(--muted)}
|
||||
html[data-organizations] .pane-body .project-tabs a{min-height:40px;padding:0 12px;font-size:14px}
|
||||
html[data-organizations] .pane-body .project-metrics{grid-template-columns:repeat(2,minmax(0,1fr));margin-top:10px;padding:6px 0}
|
||||
html[data-organizations] .pane-body .project-metrics a{padding:4px 12px;border-right:0;border-bottom:0;line-height:1.3}
|
||||
html[data-organizations] .pane-body .project-metrics a span,html[data-organizations] .pane-body .project-metrics small{font-size:12px;overflow-wrap:anywhere}
|
||||
html[data-organizations] .pane-body .project-metrics strong{font-size:20px}
|
||||
html[data-organizations] .pane-body .project-metrics p{margin:6px 12px 0;padding-top:6px}
|
||||
html[data-organizations] .pane-body .project-columns{grid-template-columns:minmax(0,1fr);gap:14px;margin-top:12px}
|
||||
html[data-organizations] .pane-body .project-columns h2{font-size:16px;margin:12px 0 8px}
|
||||
html[data-organizations] .pane-body .project-columns h3{font-size:14px}
|
||||
html[data-organizations] .pane-body .project-columns .project-section-title h2,html[data-organizations] .pane-body .project-columns aside>h2:first-child{margin-top:0}
|
||||
@container (min-width:560px){
|
||||
html[data-organizations] .pane-body .project-metrics{grid-template-columns:repeat(4,minmax(0,1fr))}
|
||||
html[data-organizations] .pane-body .project-metrics a:not(:nth-child(4)){border-right:1px solid var(--line)}
|
||||
}
|
||||
/* E2E6 P2-9: a dashboard in a pane sizes from the pane, not the window. Half a 1440 window left the
|
||||
Settings column too narrow for its values (clipped to 1-2 characters): the split stacks below 900px
|
||||
of pane, and a narrow Settings list puts each value on its own line, as at phone width. */
|
||||
@container (max-width:899px){
|
||||
html[data-organizations] .pane-body .ws-split{grid-template-columns:minmax(0,1fr)}
|
||||
}
|
||||
@container (max-width:559px){
|
||||
html[data-organizations] .pane-body .ws-cfg li{grid-template-columns:18px minmax(0,1fr) auto auto}
|
||||
html[data-organizations] .pane-body .ws-cfg .v{grid-column:2/-1;grid-row:2}
|
||||
}
|
||||
/* The side-by-side page has its own bar; the page breadcrumb would name only one pane. */
|
||||
body:has(#bus-view > .sbs) #breadcrumbs{display:none}
|
||||
html[data-organizations] .content:has(> #bus-view > .sbs) > .org-cue{margin:8px 14px}
|
||||
|
||||
html[data-organizations] .pane-div{position:relative;background:var(--line);cursor:col-resize;touch-action:none;outline:none}
|
||||
html[data-organizations] .pane-div::before{content:"";position:absolute;inset:0 -4px;z-index:1}
|
||||
html[data-organizations] .pane-div:hover,html[data-organizations] .pane-div.dragging{background:var(--action)}
|
||||
html[data-organizations] .pane-div:focus-visible{background:var(--action);box-shadow:0 0 0 2px var(--action)}
|
||||
html[data-organizations] .pane-div[hidden]{display:none}
|
||||
|
||||
@media (max-width:760px){
|
||||
html[data-organizations] .content:has(> #bus-view > .sbs){padding:0}
|
||||
html[data-organizations] .sbs{height:auto}
|
||||
html[data-organizations] .sbs .ws-seg.pane-tabs{display:inline-flex}
|
||||
html[data-organizations] .sbs .hide-m{display:none}
|
||||
html[data-organizations] .panes,html[data-organizations] .panes.two{grid-template-columns:minmax(0,1fr)}
|
||||
html[data-organizations] .pane.alt,html[data-organizations] .pane-div{display:none}
|
||||
html[data-organizations] .pane-body{overflow:visible}
|
||||
html[data-organizations] .pane-h{flex-wrap:wrap}
|
||||
html[data-organizations] .pane-pick{flex:1 1 100%;max-width:none;order:5;margin-left:0}
|
||||
}
|
||||
@@ -1,484 +0,0 @@
|
||||
// Side by side (U5, sample 07): two panes, each showing one page of any organization. A pane holds
|
||||
// the organization dashboard, the Agents levels or a project page (overview, conversations, tasks,
|
||||
// requirements, files), drawn by the same modules as the full view (workspace.js, projects.js) into
|
||||
// the pane instead of #bus-view. Every read names the pane's organization, so the panes never share
|
||||
// state. Pages that keep one global form, dialog or editor (a conversation, the room, settings, the
|
||||
// queue, docs, the inbox) are not offered in a pane; their links open the full view.
|
||||
//
|
||||
// The panes' routes, the divider and the phone's shown pane live in the URL
|
||||
// (#/orgs/<page>/side-by-side?a=<org>/<route>&b=<org>/<route>&w=<percent>&show=a|b), updated with
|
||||
// replaceState so nothing reloads, and the last layout is remembered in this browser.
|
||||
(() => {
|
||||
'use strict';
|
||||
if (!document.documentElement.hasAttribute('data-organizations')) return;
|
||||
const data = window.MosaicWorkspaceData, ws = () => window.MosaicWorkspace;
|
||||
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const enc = encodeURIComponent;
|
||||
const STORE = 'mosaic-side-by-side-v1', POLL = 10000, LIST_TTL = 3000, PROJECTS_TTL = 30000;
|
||||
const MIN = 25, MAX = 75, STEP = 5;
|
||||
const ORG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
const PROJECT = /^\/projects\/([A-Za-z0-9][A-Za-z0-9._-]{0,159})(?:\/(conversations|tasks|requirements|files))?$/;
|
||||
const SECTIONS = { overview: 'Overview', conversations: 'Conversations', tasks: 'Tasks', requirements: 'Requirements', files: 'Files' };
|
||||
const FOCUSABLE = 'a[href],button:not([disabled]),select,textarea,input,summary,[tabindex]:not([tabindex="-1"])';
|
||||
const NAMES = { a: 'left', b: 'right' };
|
||||
const load = () => { try { return localStorage.getItem(STORE); } catch { return null; } };
|
||||
const save = value => { try { localStorage.setItem(STORE, value); } catch {} };
|
||||
const paused = () => document.getElementById('pause')?.getAttribute('aria-pressed') === 'true';
|
||||
const svg = d => `<svg class="i sm" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${d}</svg>`;
|
||||
const FULL = svg('<rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M12 4.5v15"/>');
|
||||
const CLOSE = svg('<path d="M6 6l12 12M18 6 6 18"/>');
|
||||
const PLUS = svg('<path d="M12 5v14M5 12h14"/>');
|
||||
|
||||
// A pane's page: the routes a pane can draw. Anything else is not pane-safe.
|
||||
function page(route) {
|
||||
if (route === '/organization') return { kind: 'dashboard' };
|
||||
if (route === '/agents') return { kind: 'agents' };
|
||||
const m = PROJECT.exec(route);
|
||||
if (m && m[1] !== 'threads' && m[1] !== 'rooms') return { kind: 'project', id: m[1], section: m[2] || 'overview' };
|
||||
return null;
|
||||
}
|
||||
// "<org>/<route>" from the URL; null when either part is not one a pane draws.
|
||||
function parsePane(value) {
|
||||
if (typeof value !== 'string') return null;
|
||||
const cut = value.indexOf('/');
|
||||
if (cut < 1) return null;
|
||||
const org = value.slice(0, cut), route = value.slice(cut);
|
||||
return ORG.test(org) && page(route) ? { org, route } : null;
|
||||
}
|
||||
const paneValue = p => `${p.org}${p.route}`;
|
||||
const clampW = n => Math.min(MAX, Math.max(MIN, Math.round(n)));
|
||||
|
||||
// ---------- shared reads (one organization list and project lists for both panes) ----------
|
||||
let listAt = 0, listPromise = null, projectReads = new Map();
|
||||
function orgList(fresh = false) {
|
||||
if (!listPromise || fresh || Date.now() - listAt > LIST_TTL) {
|
||||
listAt = Date.now();
|
||||
listPromise = Promise.all([data.organizations(), ws()?.themes?.()]).then(([rows]) => (Array.isArray(rows) ? rows : null)).catch(() => null);
|
||||
}
|
||||
return listPromise;
|
||||
}
|
||||
function projectsOf(org, fresh = false) {
|
||||
const hit = projectReads.get(org);
|
||||
if (hit && !fresh && Date.now() - hit.at < PROJECTS_TTL) return hit.promise;
|
||||
const promise = data.projects(org).then(b => (Array.isArray(b?.rows) ? b.rows.filter(p => p && typeof p.id === 'string') : null)).catch(() => null);
|
||||
projectReads.set(org, { at: Date.now(), promise });
|
||||
return promise;
|
||||
}
|
||||
|
||||
// ---------- state ----------
|
||||
let mounted = null; // { root, panes: [pane], w, show, gen }
|
||||
let mountGen = 0;
|
||||
let listCache = null; // the last organization list read, for the shown-pane tabs
|
||||
|
||||
function readQuery(query) {
|
||||
const q = new URLSearchParams(query || '');
|
||||
return { a: parsePane(q.get('a')), b: parsePane(q.get('b')), w: Number(q.get('w')), show: q.get('show'), named: q.has('a') || q.has('b') };
|
||||
}
|
||||
function queryOf(state) {
|
||||
const q = new URLSearchParams();
|
||||
state.panes.forEach((p, i) => q.set(i ? 'b' : 'a', paneValue(p)));
|
||||
q.set('w', String(state.w));
|
||||
q.set('show', state.show);
|
||||
return q.toString().replace(/%2F/g, '/');
|
||||
}
|
||||
function writeUrl() {
|
||||
if (!mounted) return;
|
||||
const qs = queryOf(mounted);
|
||||
save(qs);
|
||||
const next = `#/orgs/${enc(window.MosaicOrg.id)}/side-by-side?${qs}`;
|
||||
if (location.hash !== next) history.replaceState(null, '', next);
|
||||
}
|
||||
// The first pane is this page's organization; the second is the next organization on the list,
|
||||
// or this one's Agents when there is no other.
|
||||
async function defaults() {
|
||||
const here = window.MosaicOrg.id, list = await orgList();
|
||||
const other = (list || []).find(o => o && !o.invalid && o.id !== here && ORG.test(o.id));
|
||||
return [{ org: here, route: '/organization' }, other ? { org: other.id, route: '/organization' } : { org: here, route: '/agents' }];
|
||||
}
|
||||
|
||||
// ---------- markup ----------
|
||||
function paneShell(key) {
|
||||
const name = NAMES[key];
|
||||
return `<section class="pane" id="pane-${key}" data-pane="${key}" aria-label="${name[0].toUpperCase()}${name.slice(1)} pane">
|
||||
<div class="pane-h ws-o" data-pane-head>
|
||||
<span data-pane-av></span><span class="ttl" data-pane-ttl></span><span data-pane-state></span>
|
||||
<label class="ws-sr" for="pane-${key}-pick">Page in the ${name} pane</label>
|
||||
<select id="pane-${key}-pick" class="pane-pick" data-pane-pick></select>
|
||||
<span class="tools"><a class="pane-tool" data-pane-full href="#" title="Open in full view"><span class="ws-sr" data-pane-full-label>Open in full view</span>${FULL}</a><button type="button" class="pane-tool" data-pane-close title="Close pane" aria-label="Close the ${name} pane">${CLOSE}</button></span>
|
||||
</div>
|
||||
<p class="pane-line" data-pane-line role="status"></p>
|
||||
<div class="pane-body" data-pane-body tabindex="0" aria-label="${name[0].toUpperCase()}${name.slice(1)} pane content"></div>
|
||||
</section>`;
|
||||
}
|
||||
function frame() {
|
||||
return `<section class="sbs" data-side-by-side aria-labelledby="sbs-title">
|
||||
<div class="panebar"><h1 id="sbs-title" tabindex="-1">Side by side</h1><span class="note" data-sbs-note></span>
|
||||
<span class="ws-seg pane-tabs" role="group" aria-label="Shown pane" data-sbs-tabs></span>
|
||||
<button type="button" class="ws-btn hide-m" data-pane-add>${PLUS}Add pane</button></div>
|
||||
<div class="panes" data-sbs-panes>${paneShell('a')}<div class="pane-div" role="separator" tabindex="0" aria-orientation="vertical" aria-label="Resize panes" aria-controls="pane-a pane-b" aria-valuemin="${MIN}" aria-valuemax="${MAX}" data-sbs-divider></div>${paneShell('b')}</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
// Orgs in the picker: every organization on the list. A pane draws a valid one's dashboard, Agents or
|
||||
// any project the organization's project read lists; an invalid one is listed and cannot be chosen.
|
||||
async function pickerHtml(p, list) {
|
||||
const groups = await Promise.all((list || []).filter(o => o && ORG.test(o.id)).map(async org => {
|
||||
const name = ws().label(org);
|
||||
if (org.invalid) return `<optgroup label="${esc(name)} · invalid"><option disabled value="">Organization invalid</option></optgroup>`;
|
||||
const rows = await projectsOf(org.id);
|
||||
const opt = (route, text) => `<option value="${esc(`${org.id}${route}`)}">${esc(text)}</option>`;
|
||||
const projects = rows === null ? '<option disabled value="">Projects unavailable</option>' : rows.filter(r => !r.archived && PROJECT.test(`/projects/${r.id}`)).map(r => opt(`/projects/${r.id}`, `Project: ${r.name || r.id}`)).join('');
|
||||
return `<optgroup label="${esc(name)}${org.busHealthy ? '' : ' · bus unavailable'}">${opt('/organization', 'Dashboard')}${opt('/agents', 'Agents')}${projects}</optgroup>`;
|
||||
}));
|
||||
let html = groups.join('');
|
||||
const value = paneValue(p);
|
||||
// A project section, or a page the list no longer names, is still the pane's page: keep it selectable.
|
||||
if (!html.includes(`value="${esc(value)}"`)) html = `<option value="${esc(value)}">${esc(p.title || value)}</option>${html}`;
|
||||
return html;
|
||||
}
|
||||
|
||||
// ---------- drawing one pane ----------
|
||||
const els = p => ({
|
||||
head: p.el.querySelector('[data-pane-head]'), av: p.el.querySelector('[data-pane-av]'), ttl: p.el.querySelector('[data-pane-ttl]'),
|
||||
state: p.el.querySelector('[data-pane-state]'), pick: p.el.querySelector('[data-pane-pick]'), full: p.el.querySelector('[data-pane-full]'),
|
||||
fullLabel: p.el.querySelector('[data-pane-full-label]'), close: p.el.querySelector('[data-pane-close]'), line: p.el.querySelector('[data-pane-line]'), body: p.el.querySelector('[data-pane-body]'),
|
||||
});
|
||||
const setHtml = (el, html) => { if (el && el.innerHTML !== html) el.innerHTML = html; };
|
||||
const setText = (el, text) => { if (el && el.textContent !== text) el.textContent = text; };
|
||||
|
||||
// Links a page draws are relative to the page's organization. In a pane they name the pane's
|
||||
// organization; reads the page links to (/api/...) name it too.
|
||||
function rewrite(p, root) {
|
||||
const prefix = `#/orgs/${enc(p.org)}`;
|
||||
for (const a of root.querySelectorAll('a[href^="#/"]')) if (!a.getAttribute('href').startsWith('#/orgs/')) a.setAttribute('href', prefix + a.getAttribute('href').slice(1));
|
||||
for (const a of root.querySelectorAll('a[href^="/api/"]')) if (!a.getAttribute('href').startsWith('/api/orgs/')) a.setAttribute('href', `/api/orgs/${enc(p.org)}/${a.getAttribute('href').slice(5)}`);
|
||||
// The pane's title is the page's heading; the side-by-side page keeps the one level-1 heading.
|
||||
for (const h of root.querySelectorAll('h1:not([aria-level])')) h.setAttribute('aria-level', '2');
|
||||
}
|
||||
// Focus and scroll survive a repaint of this pane only. Focus in the other pane is never taken.
|
||||
function capture(p, root) {
|
||||
const a = document.activeElement, inside = !!a && a !== root && root.contains(a);
|
||||
return {
|
||||
top: root.scrollTop,
|
||||
focus: inside ? { key: a.dataset?.focusKey || null, href: a.getAttribute('href'), index: [...root.querySelectorAll(FOCUSABLE)].indexOf(a), h1: a.tagName === 'H1' } : null,
|
||||
};
|
||||
}
|
||||
function restore(p, root, kept) {
|
||||
root.scrollTop = kept.top;
|
||||
const f = kept.focus, a = document.activeElement;
|
||||
if (!f || (a && a !== document.body && !root.contains(a))) return;
|
||||
const target = (f.key && root.querySelector(`[data-focus-key="${CSS.escape(f.key)}"]`))
|
||||
|| (f.href && [...root.querySelectorAll('a[href]')].find(x => x.getAttribute('href') === f.href))
|
||||
|| (f.h1 && root.querySelector('h1'))
|
||||
|| root.querySelectorAll(FOCUSABLE)[f.index];
|
||||
(target || root).focus({ preventScroll: true });
|
||||
}
|
||||
function paintBody(p, html) {
|
||||
const { body } = els(p);
|
||||
if (p.html === html && body.childElementCount) return;
|
||||
const kept = capture(p, body), live = window.MosaicWorkspace?.holdLive?.(body);
|
||||
body.innerHTML = html; p.html = html; p.project = null;
|
||||
rewrite(p, body);
|
||||
// E2E6 P1-1: an editor in use keeps its node, draft and focus; its ids were rewritten when first painted.
|
||||
if (live && window.MosaicWorkspace.graftLive(body, live)) kept.focus = null;
|
||||
restore(p, body, kept);
|
||||
}
|
||||
|
||||
// A focus move that finishes after an asynchronous read happens only if the viewer has not
|
||||
// moved on: focus is still where it was when the action started, nowhere, or inside this pane.
|
||||
function mayFocus(p, before) {
|
||||
const a = document.activeElement;
|
||||
return !a || a === document.body || a === before || p.el.contains(a);
|
||||
}
|
||||
async function drawPane(p, { fresh = false, focusHead = false } = {}) {
|
||||
if (!mounted || !p.el.isConnected) return;
|
||||
const before = document.activeElement;
|
||||
const turn = ++p.turn, gen = mountGen, live = () => mounted && gen === mountGen && turn === p.turn && p.el.isConnected;
|
||||
clearTimeout(p.timer);
|
||||
const e = els(p), what = page(p.route);
|
||||
const list = await orgList(fresh);
|
||||
if (!live()) return;
|
||||
if (list) listCache = list;
|
||||
const org = list?.find(o => o?.id === p.org) || null;
|
||||
const name = org ? ws().label(org) : p.org;
|
||||
// Header: the organization's mark, the page's title, its state, the picker and the tools.
|
||||
if (org) e.head.setAttribute('style', ws().colours(org)); else e.head.removeAttribute('style');
|
||||
setHtml(e.av, org ? ws().av(org, 'md') : '<span class="ws-av md" aria-hidden="true">?</span>');
|
||||
const [scls, sglyph, sword] = org ? ws().state(org) : ['s-unknown', '?', 'not on your list'];
|
||||
setHtml(e.state, org?.busHealthy && !org.invalid ? '' : ws().dot(scls, sglyph, sword, true));
|
||||
const fullHref = `#/orgs/${enc(p.org)}${p.route}`;
|
||||
if (e.full.getAttribute('href') !== fullHref) e.full.setAttribute('href', fullHref);
|
||||
|
||||
let line = '', html = null;
|
||||
if (!list) line = 'The organization list could not be read. This pane shows what its own reads return.';
|
||||
else if (!org) { line = `${p.org} is not on your organization list.`; html = '<p class="ws-line">Nothing is read for an organization that is not on your list.</p>'; }
|
||||
else if (org.invalid) { line = `${name}: organization invalid${org.reason ? ` (${org.reason})` : ''}.`; html = '<p class="ws-line">Nothing is read from an invalid organization. Fix its file under Settings, Organizations.</p>'; }
|
||||
else if (!org.busHealthy) line = `${name}: bus unavailable. Reads from this organization may fail; the other pane is not affected.`;
|
||||
setText(e.line, line); e.line.hidden = !line;
|
||||
|
||||
let title = what?.kind === 'agents' ? 'Agents' : 'Dashboard';
|
||||
if (html === null && what?.kind === 'dashboard') html = await ws().dashboard(p.org, { pane: true, idPrefix: `pane-${p.key}-` }).catch(() => '<p class="ws-line">Dashboard unavailable.</p>');
|
||||
else if (html === null && what?.kind === 'agents') html = await ws().agentGrid('', p.org).catch(() => '<p class="ws-line">Agents unavailable.</p>');
|
||||
if (!live()) return;
|
||||
if (html === null && what?.kind === 'project') {
|
||||
title = await drawProject(p, e, what, fresh, live);
|
||||
if (title === null || !live()) return;
|
||||
} else if (html !== null) paintBody(p, html);
|
||||
p.title = title;
|
||||
setHtml(e.ttl, `${esc(title)}<small>${esc(name)}</small>`);
|
||||
p.el.setAttribute('aria-label', `${NAMES[p.key][0].toUpperCase()}${NAMES[p.key].slice(1)} pane: ${title}, ${name}`);
|
||||
setText(e.fullLabel, `Open ${title}, ${name}, in full view`);
|
||||
// The picker is rebuilt only while it is closed and unfocused, so a choice in progress stays.
|
||||
if (document.activeElement !== e.pick) {
|
||||
const options = await pickerHtml(p, list);
|
||||
if (!live()) return;
|
||||
if (document.activeElement !== e.pick) { setHtml(e.pick, options); e.pick.value = paneValue(p); }
|
||||
}
|
||||
paintBar(); fit();
|
||||
if (focusHead && mayFocus(p, before)) (e.body.querySelector('h1') || e.body).focus({ preventScroll: true });
|
||||
p.timer = setTimeout(() => tick(p), POLL);
|
||||
}
|
||||
function tick(p) {
|
||||
if (!mounted || !p.el.isConnected) return;
|
||||
if (paused()) { p.timer = setTimeout(() => tick(p), POLL); return; }
|
||||
void drawPane(p);
|
||||
}
|
||||
|
||||
// A project page: projects.js's header and content, mounted into the pane with the pane's
|
||||
// organization, read-only (a pane never sends).
|
||||
// Every write below happens only while this draw is still the pane's current one (live()), so a
|
||||
// slow read never paints an old page or organization over the one the viewer has since chosen.
|
||||
async function drawProject(p, e, what, fresh, live) {
|
||||
const org = p.org;
|
||||
const [projects, threadsRead] = await Promise.all([projectsOf(org, fresh), data.threads(org).catch(() => null)]);
|
||||
if (!live() || p.org !== org) return null;
|
||||
const project = projects?.find(r => r.id === what.id);
|
||||
const sectionName = SECTIONS[what.section];
|
||||
if (!project) {
|
||||
paintBody(p, projects === null ? '<p class="ws-line">Projects unavailable. The organization did not answer the project read.</p>' : '<p class="ws-line">This project is not in the organization\'s project list.</p>');
|
||||
return what.id;
|
||||
}
|
||||
const threads = Array.isArray(threadsRead?.rows) ? threadsRead.rows : [];
|
||||
const head = window.MosaicProjects.header(project, what.section, threads);
|
||||
const conv = what.section === 'conversations' ? conversations(project, threads, threadsRead) : null;
|
||||
const key = JSON.stringify([org, project.id, what.section, head, conv]);
|
||||
const { body } = e;
|
||||
if (p.project?.key !== key) {
|
||||
const kept = capture(p, body);
|
||||
body.innerHTML = `<div class="chat-area pane-project">${head}${conv ?? '<div data-project-content>Reading…</div>'}</div>`;
|
||||
p.html = null; p.project = { key };
|
||||
rewrite(p, body);
|
||||
restore(p, body, kept);
|
||||
}
|
||||
const content = body.querySelector('[data-project-content]');
|
||||
if (content) await window.MosaicProjects.mountContent({ container: content, project, threads, section: what.section, canSend: false, org, pane: true, views: viewsOf(p, org), idPrefix: `pane-${p.key}-` });
|
||||
return what.section === 'overview' ? project.name || project.id : `${project.name || project.id} · ${sectionName}`;
|
||||
}
|
||||
// A pane's request choice and PRD disclosure, per organization, kept on the pane so a repaint
|
||||
// that replaces the project content keeps them.
|
||||
function viewsOf(p, org) {
|
||||
if (!p.views.has(org)) p.views.set(org, { selected: new Map(), expanded: new Set() });
|
||||
return p.views.get(org);
|
||||
}
|
||||
function conversations(project, threads, read) {
|
||||
if (!read) return '<p class="ws-line">Conversations unavailable.</p>';
|
||||
const own = threads.filter(t => t && !t.shared && t.project === project.id && typeof t.id === 'string');
|
||||
const rows = own.map(t => `<a class="project-conversation-row" href="#/projects/threads/${enc(t.id)}" data-focus-key="${esc(`conv:${t.id}`)}"><span class="project-conversation-icon" aria-hidden="true">◌</span><span><strong>${esc(t.name || t.id)}</strong><small>With ${esc(t.instance || 'no agent recorded')}</small></span><span aria-hidden="true">›</span></a>`).join('');
|
||||
return `<div class="project-section-title"><h2>Conversations</h2></div><div data-project-conversations>${rows || '<p>No conversations yet.</p>'}</div><p class="ws-line">A conversation opens in its full view.</p>`;
|
||||
}
|
||||
|
||||
// ---------- the bar, tabs, divider and layout ----------
|
||||
function paintBar() {
|
||||
if (!mounted) return;
|
||||
const { root, panes, show } = mounted, two = panes.length === 2;
|
||||
setText(root.querySelector('[data-sbs-note]'), `${panes.length} ${panes.length === 1 ? 'pane' : 'panes'} · each pane reads its own organization's bus and keeps its own state`);
|
||||
const tabs = root.querySelector('[data-sbs-tabs]'), focusedTab = tabs.contains(document.activeElement) ? document.activeElement.dataset.paneShow : null;
|
||||
const list = listCache;
|
||||
const html = panes.map(p => {
|
||||
const org = list?.find(o => o?.id === p.org);
|
||||
return `<button type="button" data-pane-show="${p.key}" aria-pressed="${show === p.key}" aria-controls="pane-${p.key}">${org ? ws().av(org, 'xs') : ''}<span>${esc(p.title || 'Reading…')}</span></button>`;
|
||||
}).join('');
|
||||
if (tabs.innerHTML !== html) { tabs.innerHTML = html; if (focusedTab) tabs.querySelector(`[data-pane-show="${focusedTab}"]`)?.focus({ preventScroll: true }); }
|
||||
const add = root.querySelector('[data-pane-add]');
|
||||
add.disabled = two; add.title = two ? 'Side by side holds two panes. Close one to choose another.' : 'Open a second pane';
|
||||
for (const p of panes) { const c = p.el.querySelector('[data-pane-close]'); c.hidden = !two; }
|
||||
}
|
||||
function layout() {
|
||||
if (!mounted) return;
|
||||
const { root, panes, w, show } = mounted, two = panes.length === 2;
|
||||
const box = root.querySelector('[data-sbs-panes]'), divider = root.querySelector('[data-sbs-divider]');
|
||||
box.classList.toggle('two', two);
|
||||
box.style.setProperty('--sbs-a', `${w}fr`); box.style.setProperty('--sbs-b', `${100 - w}fr`);
|
||||
divider.hidden = !two;
|
||||
divider.setAttribute('aria-valuenow', String(w));
|
||||
divider.setAttribute('aria-valuetext', `Left pane ${w} percent, right pane ${100 - w} percent`);
|
||||
root.querySelector('#pane-b').hidden = !two;
|
||||
for (const p of panes) p.el.classList.toggle('alt', show !== p.key);
|
||||
fit();
|
||||
}
|
||||
// Wide screens: the panes fill the window below the bar and each scrolls on its own.
|
||||
const wide = window.matchMedia('(min-width: 761px)');
|
||||
function fit() {
|
||||
if (!mounted) return;
|
||||
const { root } = mounted;
|
||||
if (!wide.matches) { root.style.height = ''; return; }
|
||||
const foot = document.querySelector('body > .foot, .foot')?.getBoundingClientRect().height || 0;
|
||||
const top = root.getBoundingClientRect().top;
|
||||
root.style.height = `${Math.max(360, Math.floor(window.innerHeight - Math.max(top, 0) - foot))}px`;
|
||||
}
|
||||
function setWidth(w, persist = true) {
|
||||
if (!mounted) return;
|
||||
const next = clampW(w);
|
||||
if (next === mounted.w) return;
|
||||
mounted.w = next; layout(); if (persist) writeUrl();
|
||||
}
|
||||
|
||||
// ---------- events (bound once to the mount root) ----------
|
||||
function bind(root) {
|
||||
const divider = root.querySelector('[data-sbs-divider]');
|
||||
divider.addEventListener('keydown', e => {
|
||||
const w = mounted?.w; if (w === undefined) return;
|
||||
const next = { ArrowLeft: w - STEP, ArrowDown: w - STEP, ArrowRight: w + STEP, ArrowUp: w + STEP, PageDown: w - 10, PageUp: w + 10, Home: MIN, End: MAX }[e.key];
|
||||
if (next === undefined) return;
|
||||
e.preventDefault(); setWidth(next);
|
||||
});
|
||||
divider.addEventListener('pointerdown', e => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault(); divider.focus({ preventScroll: true }); divider.setPointerCapture(e.pointerId); divider.classList.add('dragging');
|
||||
const box = root.querySelector('[data-sbs-panes]').getBoundingClientRect();
|
||||
const move = ev => setWidth(((ev.clientX - box.left) / box.width) * 100, false);
|
||||
const up = () => { divider.removeEventListener('pointermove', move); divider.removeEventListener('pointerup', up); divider.removeEventListener('pointercancel', up); divider.classList.remove('dragging'); writeUrl(); };
|
||||
divider.addEventListener('pointermove', move); divider.addEventListener('pointerup', up); divider.addEventListener('pointercancel', up);
|
||||
});
|
||||
root.addEventListener('change', e => {
|
||||
const pick = e.target.closest?.('[data-pane-pick]'); if (!pick || !mounted) return;
|
||||
const p = mounted.panes.find(x => x.el.contains(pick)), next = parsePane(pick.value);
|
||||
if (!p || !next) return;
|
||||
go(p, next, false);
|
||||
});
|
||||
root.addEventListener('click', e => {
|
||||
if (!mounted) return;
|
||||
const show = e.target.closest?.('[data-pane-show]');
|
||||
if (show) { mounted.show = show.dataset.paneShow === 'b' && mounted.panes.length === 2 ? 'b' : 'a'; layout(); paintBar(); writeUrl(); return; }
|
||||
if (e.target.closest?.('[data-pane-add]')) { void addPane(); return; }
|
||||
const close = e.target.closest?.('[data-pane-close]');
|
||||
if (close) { closePane(mounted.panes.find(x => x.el.contains(close))); return; }
|
||||
// A plain click on a link to a page a pane draws, in the pane's own organization, opens it in
|
||||
// the pane. Anything else (another organization, a page a pane does not draw, a new tab) is
|
||||
// an ordinary link to the full view.
|
||||
const a = e.target.closest?.('a[href^="#/orgs/"]');
|
||||
if (!a || a.hasAttribute('data-pane-full') || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
const p = mounted.panes.find(x => x.el.contains(a)); if (!p) return;
|
||||
const m = /^#\/orgs\/([^/?#]+)(\/[^?#]*)$/.exec(a.getAttribute('href'));
|
||||
let org, route;
|
||||
try { org = decodeURIComponent(m?.[1] ?? ''); route = decodeURIComponent(m?.[2] ?? ''); } catch { return; }
|
||||
if (org !== p.org || !page(route)) return;
|
||||
e.preventDefault();
|
||||
go(p, { org, route }, true);
|
||||
});
|
||||
}
|
||||
function go(p, next, focusHead) {
|
||||
p.org = next.org; p.route = next.route; p.html = null; p.project = null; p.title = null;
|
||||
els(p).body.scrollTop = 0;
|
||||
writeUrl();
|
||||
void drawPane(p, { focusHead });
|
||||
}
|
||||
async function addPane() {
|
||||
if (!mounted || mounted.panes.length !== 1) return;
|
||||
const before = document.activeElement;
|
||||
const [a] = mounted.panes, list = await orgList();
|
||||
if (!mounted || mounted.panes.length !== 1) return;
|
||||
const other = (list || []).find(o => o && !o.invalid && o.id !== a.org && ORG.test(o.id));
|
||||
const b = makePane('b', other ? { org: other.id, route: '/organization' } : { org: a.org, route: a.route === '/agents' ? '/organization' : '/agents' });
|
||||
mounted.panes.push(b); mounted.show = 'b';
|
||||
layout(); paintBar(); writeUrl();
|
||||
await drawPane(b);
|
||||
if (mounted?.panes.includes(b) && mayFocus(b, before)) els(b).pick.focus({ preventScroll: true });
|
||||
}
|
||||
function closePane(p) {
|
||||
if (!mounted || !p || mounted.panes.length !== 2) return;
|
||||
clearTimeout(p.timer); p.turn++;
|
||||
const keep = mounted.panes.find(x => x !== p);
|
||||
// The remaining pane becomes the first: its page moves into pane a's element.
|
||||
const a = mounted.panes[0];
|
||||
if (keep !== a) {
|
||||
const b = mounted.panes[1];
|
||||
clearTimeout(b.timer); b.turn++;
|
||||
a.org = keep.org; a.route = keep.route; a.html = null; a.project = null; a.title = keep.title; a.views = keep.views;
|
||||
}
|
||||
setHtml(els(mounted.panes[1]).body, ''); mounted.panes[1].html = null; mounted.panes[1].project = null;
|
||||
mounted.panes = [a]; mounted.show = 'a';
|
||||
layout(); paintBar(); writeUrl();
|
||||
// The close button just left the page; focus goes to the remaining pane's picker now, and again
|
||||
// after its draw only if the viewer has not moved elsewhere meanwhile.
|
||||
els(a).pick.focus({ preventScroll: true });
|
||||
const before = document.activeElement;
|
||||
void drawPane(a).then(() => { if (mounted?.panes[0] === a && mayFocus(a, before)) els(a).pick.focus({ preventScroll: true }); });
|
||||
}
|
||||
function makePane(key, at) {
|
||||
const el = mounted.root.querySelector(`#pane-${key}`);
|
||||
return { key, el, org: at.org, route: at.route, turn: 0, timer: null, html: null, project: null, title: null, views: new Map() };
|
||||
}
|
||||
|
||||
// ---------- public ----------
|
||||
async function mount(container, query, { navigated = false } = {}) {
|
||||
unmount();
|
||||
const gen = ++mountGen;
|
||||
container.innerHTML = frame();
|
||||
const root = container.querySelector('[data-side-by-side]');
|
||||
mounted = { root, panes: [], w: 50, show: 'a', gen, observers: [] };
|
||||
// projects.js paints a project's content after its own reads; its links are rewritten as they
|
||||
// land. One observer per pane element for the life of the mount, applying whichever pane object
|
||||
// holds that element now, so a closed and re-added pane never runs an old organization's rewrite.
|
||||
const m = mounted;
|
||||
for (const key of ['a', 'b']) {
|
||||
const body = root.querySelector(`#pane-${key} [data-pane-body]`);
|
||||
const observer = new MutationObserver(() => {
|
||||
if (mounted !== m) return;
|
||||
const p = m.panes.find(x => x.key === key);
|
||||
if (p) rewrite(p, body);
|
||||
});
|
||||
observer.observe(body, { childList: true, subtree: true });
|
||||
m.observers.push(observer);
|
||||
}
|
||||
bind(root);
|
||||
window.addEventListener('resize', fit);
|
||||
document.title = 'Side by side · Mosaic Console';
|
||||
if (navigated) root.querySelector('h1').focus({ preventScroll: true });
|
||||
let wanted = readQuery(query);
|
||||
if (!wanted.named) { const kept = readQuery(load() || ''); if (kept.a) wanted = { ...kept, named: true }; }
|
||||
let at = [wanted.a, wanted.b].filter(Boolean);
|
||||
if (!at.length) at = await defaults();
|
||||
if (!mounted || gen !== mountGen) return;
|
||||
listCache = await orgList();
|
||||
if (!mounted || gen !== mountGen) return;
|
||||
mounted.panes = at.slice(0, 2).map((x, i) => makePane(i ? 'b' : 'a', x));
|
||||
mounted.w = Number.isFinite(wanted.w) && wanted.w ? clampW(wanted.w) : 50;
|
||||
mounted.show = wanted.show === 'b' && mounted.panes.length === 2 ? 'b' : 'a';
|
||||
layout(); paintBar(); writeUrl();
|
||||
await Promise.all(mounted.panes.map(p => drawPane(p)));
|
||||
}
|
||||
function unmount() {
|
||||
if (!mounted) return;
|
||||
for (const p of mounted.panes) { clearTimeout(p.timer); p.turn++; }
|
||||
for (const o of mounted.observers) o.disconnect();
|
||||
window.removeEventListener('resize', fit);
|
||||
mounted = null; mountGen++;
|
||||
}
|
||||
// The Console's Refresh: both panes read again now, each keeping its own focus and scroll.
|
||||
function refresh() {
|
||||
if (!mounted) return;
|
||||
projectReads = new Map();
|
||||
void orgList(true).then(rows => { if (rows) listCache = rows; });
|
||||
for (const p of mounted.panes) void drawPane(p, { fresh: true });
|
||||
}
|
||||
// E2E6 P2-2: a pane showing the organization a write changed reads it again.
|
||||
document.addEventListener('mosaic-org-changed', e => {
|
||||
if (!mounted) return;
|
||||
for (const p of mounted.panes) if (p.org === e.detail?.org) void drawPane(p, { fresh: true });
|
||||
});
|
||||
// The list the tabs draw from follows each fresh read of the organization list.
|
||||
document.addEventListener('mosaic-org-list', e => { if (Array.isArray(e.detail?.rows)) listCache = e.detail.rows; });
|
||||
|
||||
window.MosaicPanes = Object.freeze({ mount, unmount, refresh, page });
|
||||
})();
|
||||
@@ -30,7 +30,7 @@
|
||||
.avatar-wrap{position:relative}.avatar-wrap>button{display:flex;align-items:center;gap:8px;min-height:44px;border:0;background:transparent;color:var(--strong);font:inherit;cursor:pointer}.avatar-initial{display:grid;place-items:center;width:34px;height:34px;border-radius:50%;background:var(--action);color:var(--onAction);font-weight:700}.avatar-menu{position:absolute;right:0;top:calc(100% + 8px);z-index:50;width:240px;background:var(--surface);border:1px solid var(--line);border-radius:var(--r);padding:8px;box-shadow:var(--shadow)}.avatar-menu[hidden]{display:none}.avatar-menu a,.avatar-menu button,.avatar-menu label,.avatar-menu #local-sign-out{display:block;width:100%;padding:9px;color:var(--text);font:inherit;text-align:left}.avatar-menu a{text-decoration:none}.avatar-menu button{background:none;border:0;cursor:pointer}.avatar-menu a:hover,.avatar-menu button:hover{background:var(--raised)}.avatar-menu label{font-size:12px;color:var(--muted)}.avatar-menu select{display:block;width:100%;min-height:44px;margin-top:4px}.avatar-menu small{display:block;color:var(--muted)}.avatar-menu #local-sign-out{opacity:.7}
|
||||
#project-context,#project-tree,#board-tree{display:none!important}
|
||||
.s1-section{font-size:14px;gap:12px}.s1-section[aria-current=page]{background:color-mix(in srgb,var(--action) 30%,var(--surface));color:var(--strong)}
|
||||
.breadcrumbs{display:flex;gap:12px;align-items:center;margin:0 0 18px;color:var(--muted);font-size:13px}.breadcrumbs a{color:var(--action);text-decoration:none}.breadcrumbs [aria-current]{color:var(--strong)}
|
||||
.breadcrumbs[hidden]{display:none}.breadcrumbs{display:flex;gap:12px;align-items:center;margin:0 0 18px;color:var(--muted);font-size:13px}.breadcrumbs a{color:var(--action);text-decoration:none}.breadcrumbs [aria-current]{color:var(--strong)}
|
||||
#board-head h1{margin-bottom:0}#board-head p{margin:2px 0}.machine-off #status{display:none}
|
||||
#dashboard-summary,.dashboard-card{border:1px solid var(--line);border-radius:var(--r-lg);background:var(--surface)}#dashboard-summary{margin:20px 0}.kpi-grid{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));padding:18px 0}.kpi{padding:0 18px;border-right:1px solid var(--line);min-width:0}.kpi:last-child{border-right:0}.kpi div{font-size:12px;color:var(--strong);font-weight:700;min-height:36px}.kpi strong{display:block;font-size:28px;line-height:1.2;color:var(--strong)}.kpi span{display:block;color:var(--muted);font-size:12px;margin-top:4px}.kpi-footer{border-top:1px solid var(--line);padding:8px 18px;margin:0;color:var(--muted);font-size:12px}
|
||||
#dashboard-cards{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;margin:20px 0}.dashboard-card{padding:16px}.dashboard-card h2{margin:0 0 10px}.dashboard-card .processing-controls{padding:0;border:0}.dashboard-card .processing-controls strong{display:none}.dashboard-card .processing-controls .btn{min-height:44px}.dashboard-card ul{padding-left:20px}.dashboard-card a{color:var(--action)}
|
||||
|
||||
@@ -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;
|
||||
@@ -198,30 +202,22 @@
|
||||
return `<h2>Reviewed deliveries <span class="project-count">${f.reviewed.length}</span></h2>${f.reviewed.length?f.reviewed.map(({r,file})=>`<div class="project-panel"><span class="project-delivery-icon" aria-hidden="true">▤</span><strong>${clean(file.name)}</strong> <span class="project-chip success">Reviewed</span><p>Request ${clean(r.id)} · from ${clean(r.thread.name)} · Reviewed delivery. Not landed or deployed.</p><div class="project-delivery-actions"><a class="btn" target="_blank" rel="noopener noreferrer" href="/api/requests/${encodeURIComponent(r.id)}/artifact/${encodeURIComponent(file.id)}/text">Open as text</a> <a class="btn" download href="/api/requests/${encodeURIComponent(r.id)}/artifact/${encodeURIComponent(file.id)}">Download</a>${deliveryPr(r)?` <a class="btn" target="_blank" rel="noopener noreferrer" href="${esc(deliveryPr(r))}">Open pull request</a>`:''}</div><p>Open shows plain text in a new tab. Downloaded HTML can be opened locally after saving.</p></div>`).join(''):`<p>${data.disabled?'Files are unavailable.':data.failed?'Reviewed files are unavailable.':data.requests.length?'No reviewed file is available.':'No request files yet. Files appear when a request in this project produces them.'}</p>`}<h2>Draft workspace <span class="project-count">${f.drafts.length}</span></h2><p>Names only. Draft files may change and are not reviewed deliveries.</p><div class="project-panel project-file-list">${f.drafts.length?f.drafts.map(({r,name})=>`<div><span><span aria-hidden="true">▤</span> <code>${clean(name)}</code></span><span>Request ${clean(r.id)} · <span class="project-chip">Draft</span></span></div>`).join(''):data.failed || data.requests.some(r=>r.workspace?.available!==true || !Array.isArray(r.workspace.entries))?'<p>Could not read draft workspace. Try again later.</p>':'<p>No draft files found.</p>'}${data.requests.some(r=>r.workspace?.truncated)?'<p>The draft list was truncated.</p>':''}</div><h2>Project documents ${docs?`<span class="project-count">${docs.length}</span>`:''}</h2><div class="project-panel project-file-list">${!project.repo?'No linked repository.':data.recordsMissing?window.MosaicRecords.markup(project.repo):!docs?'Project documents unavailable. Docs could not read this project’s repository.':docs.length?docs.map(name=>`<div><span><span aria-hidden="true">▤</span> <code>${clean(name)}</code></span><a href="#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent(name)}">Open in Docs</a></div>`).join(''):'No project documents found.'}</div>`;
|
||||
}
|
||||
const expandedPrds = new Set();
|
||||
// U5: a project in a Side by side pane keeps its own request choice and PRD disclosure, so two
|
||||
// panes showing the same project (or two organizations' projects with one id) never share them.
|
||||
// The pane passes `views`, kept on the pane per organization, so the state outlives a repaint
|
||||
// that replaces the content element (a new conversation changes the header). Without it, the
|
||||
// state lives as long as the container.
|
||||
const paneViews = new WeakMap();
|
||||
const viewState=(container,pane,views)=>{if(!pane) return {selected:selectedRequests,expanded:expandedPrds}; if(views?.selected instanceof Map && views.expanded instanceof Set) return views; if(!paneViews.has(container)) paneViews.set(container,{selected:new Map(),expanded:new Set()}); return paneViews.get(container);};
|
||||
function requirementView(data,project,selected,expandedSet=expandedPrds,idPrefix='') {
|
||||
const pid=esc(idPrefix);
|
||||
function requirementView(data,project,selected,expandedSet=expandedPrds) {
|
||||
const options=data.requests.filter(r=>r.draft), r=options.find(x=>x.id===selected)||options[0], d=r?.draft;
|
||||
const north=typeof data.north?.content==='string'?data.north.content:null;
|
||||
const select=options.length>1?`<label>Request <select id="${pid}project-request-select">${options.map(x=>`<option value="${clean(x.id)}"${x===r?' selected':''}>${clean(x.id)} · ${clean(x.thread.name)}</option>`).join('')}</select></label>`:'';
|
||||
const select=options.length>1?`<label>Request <select id="project-request-select">${options.map(x=>`<option value="${clean(x.id)}"${x===r?' selected':''}>${clean(x.id)} · ${clean(x.thread.name)}</option>`).join('')}</select></label>`:'';
|
||||
const candidate=d?.candidate, expanded=r && expandedSet.has(project.id+':'+r.id);
|
||||
const docPath=candidate?.prd_path, docLink=project.repo && typeof docPath==='string' && /^docs\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.md$/.test(docPath) ? `#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent(docPath)}` : null;
|
||||
const rows=candidate ? [['PRD version',candidate.prd_version],['Approved snapshot',d.snapshot.id || 'None'],['PRD file',docPath],['PRD blob',candidate.prd_blob],['Records commit',candidate.records_commit],['Confirmed intent commit',candidate.spec_commit],['Mode revision',candidate.mode_revision]].map(([label,value])=>`<div class="project-provenance-row"><span>${label}</span>${label==='PRD file' && docLink?`<a href="${docLink}"><code>${clean(value)}</code></a>`:`<code>${clean(value)}</code>`}</div>`).join('') : '<p>Unavailable until a PRD is read.</p>';
|
||||
return `${r?`<div class="project-panel project-source">${select || `Request <code>${clean(r.id)}</code>`} · ${chip(r.state)} · from ${clean(r.thread.name)} <a href="${conversationLink(r.thread)}">Open conversation</a></div>`:select}<div class="project-columns"><section><h2>Project direction</h2><h3 id="${pid}project-north-star">North star</h3>${north!==null?`<div data-north-star></div><a class="project-text-link" href="#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent('spec/north-star.md')}">Open north star in Docs</a>`:data.northRecordsMissing?`<p>${window.MosaicRecords.markup(project.repo)}</p>`:data.northMissing?'<p>No north star yet.</p>':project.repo?'<p>North star unavailable. Docs could not read this project’s repository.</p>':`<p>This project has no linked repository. The north star and project documents live in a repository. Linking one to an existing project isn't available yet.</p>`}<h3 id="${pid}project-prd">${candidate?`PRD ${clean(candidate.prd_version)}`:'PRD'} ${candidate?`<span class="project-chip ${d.snapshot.id?'success':''}">${d.snapshot.id?'Approved':'Draft'}</span>`:''}</h3>${d?`<p>${d.snapshot.id?'Approved snapshot '+clean(d.snapshot.id):'This candidate has no active approved snapshot.'}</p><div class="project-panel project-prd-panel"><div class="project-prd-preview${expanded?' expanded':''}" data-prd></div><button type="button" class="btn project-prd-toggle" aria-expanded="${!!expanded}">${expanded?'Show less':'Show full PRD'}</button></div><h3>Requirements <span class="project-count">${d.requirements.length}</span></h3><div class="project-panel">${d.requirements.map((req,i)=>`<details data-restore-key="req:${clean(r.id)}:${clean(req.id)}"><summary>${clean(req.id)} · ${clean(req.title)}</summary><p>${clean(req.text)}</p><p>Requirement identity: <code>${clean(candidate.requirements[i]?.path)}</code> · <code>${clean(candidate.requirements[i]?.blob)}</code></p></details>`).join('')}</div>`:`<p>${(data.failed || data.requests.some(x=>x.materialFailed))?'Request or PRD unavailable. Refresh to try again.':'No current PRD candidate or approved snapshot. Waiting for a PRD submission.'}</p>`}${d && data.requests.some(x=>x.materialFailed)?'<p role="status">Some request PRDs could not be read. Refresh to try again.</p>':''}</section><aside class="project-panel project-provenance"><details data-restore-key="provenance"${matchMedia('(min-width:700px)').matches?' open':''}><summary>Version and provenance</summary>${rows}${candidate?`<p>Approval and revocation live in the conversation's Request details.</p><a href="${conversationLink(r.thread)}">Open conversation</a>`:''}</details></aside></div>`;
|
||||
return `${r?`<div class="project-panel project-source">${select || `Request <code>${clean(r.id)}</code>`} · ${chip(r.state)} · from ${clean(r.thread.name)} <a data-full-view href="${conversationLink(r.thread)}">Open conversation</a></div>`:select}<div class="project-columns"><section><h2>Project direction</h2><h3 id="project-north-star">North star</h3>${north!==null?`<div data-north-star></div><a class="project-text-link" href="#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent('spec/north-star.md')}">Open north star in Docs</a>`:data.northRecordsMissing?`<p>${window.MosaicRecords.markup(project.repo)}</p>`:data.northMissing?'<p>No north star yet.</p>':project.repo?'<p>North star unavailable. Docs could not read this project’s repository.</p>':`<p>This project has no linked repository. The north star and project documents live in a repository. Linking one to an existing project isn't available yet.</p>`}<h3 id="project-prd">${candidate?`PRD ${clean(candidate.prd_version)}`:'PRD'} ${candidate?`<span class="project-chip ${d.snapshot.id?'success':''}">${d.snapshot.id?'Approved':'Draft'}</span>`:''}</h3>${d?`<p>${d.snapshot.id?'Approved snapshot '+clean(d.snapshot.id):'This candidate has no active approved snapshot.'}</p><div class="project-panel project-prd-panel"><div class="project-prd-preview${expanded?' expanded':''}" data-prd></div><button type="button" class="btn project-prd-toggle" aria-expanded="${!!expanded}">${expanded?'Show less':'Show full PRD'}</button></div><h3>Requirements <span class="project-count">${d.requirements.length}</span></h3><div class="project-panel">${d.requirements.map((req,i)=>`<details data-restore-key="req:${clean(r.id)}:${clean(req.id)}"><summary>${clean(req.id)} · ${clean(req.title)}</summary><p>${clean(req.text)}</p><p>Requirement identity: <code>${clean(candidate.requirements[i]?.path)}</code> · <code>${clean(candidate.requirements[i]?.blob)}</code></p></details>`).join('')}</div>`:`<p>${(data.failed || data.requests.some(x=>x.materialFailed))?'Request or PRD unavailable. Refresh to try again.':'No current PRD candidate or approved snapshot. Waiting for a PRD submission.'}</p>`}${d && data.requests.some(x=>x.materialFailed)?'<p role="status">Some request PRDs could not be read. Refresh to try again.</p>':''}</section><aside class="project-panel project-provenance"><details data-restore-key="provenance"${matchMedia('(min-width:700px)').matches?' open':''}><summary>Version and provenance</summary>${rows}${candidate?`<p>Approval and revocation live in the conversation's Request details.</p><a data-full-view href="${conversationLink(r.thread)}">Open conversation</a>`:''}</details></aside></div>`;
|
||||
}
|
||||
function overview(data,project,canSend,receipt='') {
|
||||
const active=data.requests.filter(r=>r.state && !ended(r.state)), waiting=active.filter(r=>needs(r.state)), ordered=[...data.requests].sort(requestOrder), first=ordered[0], threads=[...data.threads];
|
||||
if(first) threads.sort((a,b)=>Number(b.id===first.thread.id)-Number(a.id===first.thread.id));
|
||||
const f=filesData(data), filesKnown=!data.failed && !!f.docs && data.requests.every(r=>r.workspace?.available===true && Array.isArray(r.workspace.entries) && r.state), count=filesKnown?f.reviewed.length+f.drafts.length+f.docs.length:'–';
|
||||
const n=typeof data.north?.content==='string'?data.north.content:null, candidate=ordered.find(r=>r.draft)?.draft;
|
||||
const metrics=[['Requests in progress',data.failed?'–':active.length,data.failed?'Not available yet':active[0]?label(active[0].state):'No active requests',first?conversationLink(first.thread):path(project.id,'conversations')],['Needs your input',data.failed?'–':waiting.length,data.failed?'Not available yet':waiting.length?label(waiting[0].state):'Nothing waiting',waiting[0]?conversationLink(waiting[0].thread):path(project.id,'conversations')],['Conversations',threads.length,'Project conversations',path(project.id,'conversations')],['Files',count,filesKnown?`${f.reviewed.length} reviewed · ${f.drafts.length} draft · ${f.docs.length} docs`:data.recordsMissing?'No project records yet':'Not available yet',path(project.id,'files')]];
|
||||
return `${waiting.length?`<div class="project-panel project-decision" role="status">${waiting.length} ${waiting.length===1?'decision needs':'decisions need'} your input. <a class="btn primary" href="${conversationLink(waiting[0].thread)}">Review decision</a></div>`:''}<div class="project-metrics">${metrics.map(([title,value,caption,href])=>`<a href="${href}"><span>${title}</span><strong>${value}</strong><small>${clean(caption)}</small></a>`).join('')}<p>Updated just now · Showing requests from recent messages.</p></div><div class="project-columns"><section><div class="project-section-title"><h2>Current work</h2>${canSend&&!project.archived?`<a class="btn" href="${path(project.id,'conversations')}?new=1">${plusIcon}New conversation</a>`:''}</div>${threads.length?threads.map((t,i)=>i===0?card(t,workFor(data,t),data.failed):`<a class="project-row" href="${conversationLink(t)}"><strong>${clean(t.name)}</strong><small>With ${clean(t.instance)} · ${workFor(data,t) ? label(workFor(data,t).state) : data.failed ? 'Status unavailable' : 'No active request'}</small>${workFor(data,t)?chip(workFor(data,t).state):''}</a>`).join(''):'<p>No conversations yet. Start one to begin work.</p>'}<h2>Tasks <span class="project-count">${taskRefs(data).length}</span></h2><div class="project-panel">${taskRefs(data).length?taskRefs(data).slice(0,2).map(ref=>`<div>${taskLink(ref,data)}</div>`).join(''):data.failed?'<p>Request tasks unavailable. Refresh to try again.</p>':'<p>No request tasks available.</p>'}<a href="${path(project.id,'tasks')}">View all project tasks</a></div></section><aside class="project-panel"><h2>Project direction</h2><h3>North star</h3>${n!==null?`<div data-north-star></div><a class="project-text-link" href="#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent('spec/north-star.md')}">Open north star in Docs</a>`:`<p>${data.northRecordsMissing?window.MosaicRecords.markup(project.repo):data.northMissing?'No north star yet.':project.repo?'North star unavailable. Docs could not read this project’s repository.':"This project has no linked repository. The north star and project documents live in a repository. Linking one to an existing project isn't available yet."}</p>`}<h3>${candidate?'PRD '+clean(candidate.candidate.prd_version):'PRD'} ${candidate?.snapshot.id?'<span class="project-chip success">Approved</span>':''}</h3><p>${candidate?(candidate.snapshot.id?'Approved snapshot '+clean(candidate.snapshot.id):'Draft'):(data.failed || data.requests.some(x=>x.materialFailed)?'PRD unavailable.':'No current PRD available.')}</p>${candidate && data.requests.some(x=>x.materialFailed)?'<p role="status">Some request PRDs could not be read. Refresh to try again.</p>':''}<a href="${path(project.id,'requirements')}">Open PRD and requirements</a><h3>Files ${filesKnown?`<span class="project-count">${count}</span>`:''}</h3>${[...f.reviewed.map(x=>({name:x.file.name,reviewed:true})),...f.drafts.map(x=>({name:x.name,reviewed:false}))].slice(0,3).map(file=>file.reviewed?`<p><a href="${path(project.id,'files')}"><code>${clean(file.name)}</code> · Reviewed</a></p>`:`<p><code>${clean(file.name)}</code> · Draft</p>`).join('')||(data.failed?'<p>Request files unavailable.</p>':'<p>No request files available.</p>')}<a href="${path(project.id,'files')}">All files</a><h3>Project facts</h3><div class="project-provenance-row"><span>Repository</span><span>${project.repo?clean(project.repo):'Not linked'}</span></div>${roomControls(project,data.room,canSend,receipt)}<div class="project-provenance-row"><span>Mode</span><span>${first?.state?.mode?clean(modeLabel(first.state.mode)):data.failed?'Unavailable':'No active request'}</span></div></aside></div>`;
|
||||
const metrics=[['Requests in progress',data.failed?'–':active.length,data.failed?'Not available yet':active[0]?label(active[0].state):'No active requests',first?conversationLink(first.thread):path(project.id,'conversations'),!!first],['Needs your input',data.failed?'–':waiting.length,data.failed?'Not available yet':waiting.length?label(waiting[0].state):'Nothing waiting',waiting[0]?conversationLink(waiting[0].thread):path(project.id,'conversations'),!!waiting[0]],['Conversations',threads.length,'Project conversations',path(project.id,'conversations')],['Files',count,filesKnown?`${f.reviewed.length} reviewed · ${f.drafts.length} draft · ${f.docs.length} docs`:data.recordsMissing?'No project records yet':'Not available yet',path(project.id,'files')]];
|
||||
return `${waiting.length?`<div class="project-panel project-decision" role="status">${waiting.length} ${waiting.length===1?'decision needs':'decisions need'} your input. <a class="btn primary" data-full-view href="${conversationLink(waiting[0].thread)}">Review decision</a></div>`:''}<div class="project-metrics">${metrics.map(([title,value,caption,href,full])=>`<a${full?' data-full-view':''} href="${href}"><span>${title}</span><strong>${value}</strong><small>${clean(caption)}</small></a>`).join('')}<p>Updated just now · Showing requests from recent messages.</p></div><div class="project-columns"><section><div class="project-section-title"><h2>Current work</h2>${canSend&&!project.archived?`<a class="btn" href="${path(project.id,'conversations')}?new=1">${plusIcon}New conversation</a>`:''}</div>${threads.length?threads.map((t,i)=>i===0?card(t,workFor(data,t),data.failed):`<a class="project-row" href="${conversationLink(t)}"><strong>${clean(t.name)}</strong><small>With ${clean(t.instance)} · ${workFor(data,t) ? label(workFor(data,t).state) : data.failed ? 'Status unavailable' : 'No active request'}</small>${workFor(data,t)?chip(workFor(data,t).state):''}</a>`).join(''):'<p>No conversations yet. Start one to begin work.</p>'}<h2>Tasks <span class="project-count">${taskRefs(data).length}</span></h2><div class="project-panel">${taskRefs(data).length?taskRefs(data).slice(0,2).map(ref=>`<div>${taskLink(ref,data)}</div>`).join(''):data.failed?'<p>Request tasks unavailable. Refresh to try again.</p>':'<p>No request tasks available.</p>'}<a href="${path(project.id,'tasks')}">View all project tasks</a></div></section><aside class="project-panel"><h2>Project direction</h2><h3>North star</h3>${n!==null?`<div data-north-star></div><a class="project-text-link" href="#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent('spec/north-star.md')}">Open north star in Docs</a>`:`<p>${data.northRecordsMissing?window.MosaicRecords.markup(project.repo):data.northMissing?'No north star yet.':project.repo?'North star unavailable. Docs could not read this project’s repository.':"This project has no linked repository. The north star and project documents live in a repository. Linking one to an existing project isn't available yet."}</p>`}<h3>${candidate?'PRD '+clean(candidate.candidate.prd_version):'PRD'} ${candidate?.snapshot.id?'<span class="project-chip success">Approved</span>':''}</h3><p>${candidate?(candidate.snapshot.id?'Approved snapshot '+clean(candidate.snapshot.id):'Draft'):(data.failed || data.requests.some(x=>x.materialFailed)?'PRD unavailable.':'No current PRD available.')}</p>${candidate && data.requests.some(x=>x.materialFailed)?'<p role="status">Some request PRDs could not be read. Refresh to try again.</p>':''}<a href="${path(project.id,'requirements')}">Open PRD and requirements</a><h3>Files ${filesKnown?`<span class="project-count">${count}</span>`:''}</h3>${[...f.reviewed.map(x=>({name:x.file.name,reviewed:true})),...f.drafts.map(x=>({name:x.name,reviewed:false}))].slice(0,3).map(file=>file.reviewed?`<p><a href="${path(project.id,'files')}"><code>${clean(file.name)}</code> · Reviewed</a></p>`:`<p><code>${clean(file.name)}</code> · Draft</p>`).join('')||(data.failed?'<p>Request files unavailable.</p>':'<p>No request files available.</p>')}<a href="${path(project.id,'files')}">All files</a><h3>Project facts</h3><div class="project-provenance-row"><span>Repository</span><span>${project.repo?clean(project.repo):'Not linked'}</span></div>${roomControls(project,data.room,canSend,receipt)}<div class="project-provenance-row"><span>Mode</span><span>${first?.state?.mode?clean(modeLabel(first.state.mode)):data.failed?'Unavailable':'No active request'}</span></div></aside></div>`;
|
||||
}
|
||||
function tasksView(data) {
|
||||
const refs=taskRefs(data);
|
||||
@@ -256,9 +252,7 @@
|
||||
const focus=f.id ? root.querySelector('#'+CSS.escape(f.id)) : f.details ? [...root.querySelectorAll('details[data-restore-key]')].find(el=>el.dataset.restoreKey===f.details)?.querySelector(':scope > summary') : root.querySelectorAll(FOCUSABLE)[f.index];
|
||||
focus?.focus({preventScroll:true});
|
||||
}
|
||||
// A Side by side pane passes `idPrefix` (pane-a-, pane-b-), so the same project in both panes
|
||||
// draws distinct ids; the full view passes none and keeps its ids.
|
||||
async function mountContent({container,project,threads,section,canSend,restore,roomReceipt,org=null,pane=false,views:paneState=null,idPrefix=''}) {
|
||||
async function mountContent({container,project,threads,section,canSend,restore,roomReceipt,org=null}) {
|
||||
if(!container || !project) return;
|
||||
const generation=epoch, run=(contentRuns.get(container) || 0)+1;
|
||||
contentRuns.set(container,run);
|
||||
@@ -266,11 +260,10 @@
|
||||
const data=await gather(project,threads,org).catch(()=>null);
|
||||
if(!container.isConnected || generation!==epoch || contentRuns.get(container)!==run || document.body.classList.contains('signed-out')) return;
|
||||
if(!data){contentPaints.delete(container);container.textContent='Project data unavailable. Refresh to try again.';return;}
|
||||
const views=viewState(container,pane,paneState), selectedRequests=views.selected, expandedPrds=views.expanded;
|
||||
const selected=selectedRequests.get(project.id);
|
||||
if(selected && !data.requests.some(r=>r.id===selected && r.draft)) selectedRequests.delete(project.id);
|
||||
const selection=selectedRequests.get(project.id);
|
||||
const view = section==='requirements'?requirementView(data,project,selection,expandedPrds,idPrefix):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
|
||||
const view = section==='requirements'?requirementView(data,project,selection,expandedPrds):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
|
||||
const selectedRequest=data.requests.find(r=>r.id===(selection||data.requests.find(x=>x.draft)?.id));
|
||||
// The generated presentation and separately mounted Markdown determine visible content.
|
||||
const signature=JSON.stringify([view, section==='requirements' ? selectedRequest?.draft?.prd : null, ['requirements','overview'].includes(section) ? data.north?.content : null, data.failed && ['files','tasks'].includes(section)]);
|
||||
@@ -280,7 +273,7 @@
|
||||
// Retained controls (the request selector) draw from the newest accepted read, even when
|
||||
// this read changes nothing visible and the DOM is kept.
|
||||
contentModels.set(container,{data,project,section,canSend,roomReceipt});
|
||||
if(!pane) window.MosaicOnboarding?.offer(container,{project,section,north:data.north,docs:data.docs,threads:data.threads,canSend}); // F2: its panel sits before the container, outside draw().
|
||||
window.MosaicOnboarding?.offer(container,{project,section,north:data.north,docs:data.docs,threads:data.threads,canSend}); // F2: its panel sits before the container, outside draw().
|
||||
if (contentPaints.get(container) === signature) return;
|
||||
contentPaints.set(container, signature);
|
||||
// Capture at commit time: the user may have interacted while the read was pending.
|
||||
@@ -288,12 +281,12 @@
|
||||
const liveRestore=contentPaints.has(container) && container.children.length ? captureView(container) : null;
|
||||
const draw=selected=>{
|
||||
const {data,project,section,canSend,roomReceipt}=contentModels.get(container);
|
||||
container.innerHTML=section==='requirements'?requirementView(data,project,selected,expandedPrds,idPrefix):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
|
||||
container.innerHTML=section==='requirements'?requirementView(data,project,selected,expandedPrds):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
|
||||
if(data.failed && ['files','tasks'].includes(section)) container.insertAdjacentHTML('afterbegin','<p role="status">Some request data could not be read; this list may be incomplete.</p>');
|
||||
const selectedRequest=data.requests.find(r=>r.id===(selected||data.requests.find(x=>x.draft)?.id));
|
||||
const md=container.querySelector('[data-prd]'); if(md && selectedRequest?.draft) { md.replaceChildren(window.MosaicMarkdown?.render(selectedRequest.draft.prd) || document.createTextNode(selectedRequest.draft.prd)); const toggle=container.querySelector('.project-prd-toggle'); toggle.hidden=!md.classList.contains('expanded') && md.scrollHeight<=md.clientHeight; toggle.addEventListener('click',()=>{const key=project.id+':'+selectedRequest.id; if(expandedPrds.has(key)) expandedPrds.delete(key); else expandedPrds.add(key); const open=expandedPrds.has(key); md.classList.toggle('expanded',open); toggle.setAttribute('aria-expanded',String(open));toggle.textContent=open?'Show less':'Show full PRD';}); }
|
||||
const north=container.querySelector('[data-north-star]'); if(north && typeof data.north?.content==='string') { const text=section==='overview' && data.north.content.length>500 ? previewCut(data.north.content,500) : data.north.content; north.replaceChildren(window.MosaicMarkdown?.render(text) || document.createTextNode(text)); }
|
||||
const requestSelect='#'+CSS.escape(idPrefix+'project-request-select');
|
||||
const requestSelect='#project-request-select';
|
||||
container.querySelector(requestSelect)?.addEventListener('change',e=>{selectedRequests.set(project.id,e.target.value);draw(e.target.value);container.querySelector(requestSelect)?.focus({preventScroll:true});});
|
||||
};
|
||||
draw(selection);
|
||||
@@ -301,8 +294,8 @@
|
||||
if (restore) {
|
||||
restoreView(container,restore);
|
||||
}
|
||||
const sectionTarget=pane ? null : new URLSearchParams(location.hash.split('?')[1] || '').get('section');
|
||||
if(section==='requirements' && ['prd','north-star'].includes(sectionTarget)) container.querySelector('#'+CSS.escape(idPrefix+'project-'+sectionTarget))?.scrollIntoView({block:'start'});
|
||||
const sectionTarget=new URLSearchParams(location.hash.split('?')[1] || '').get('section');
|
||||
if(section==='requirements' && ['prd','north-star'].includes(sectionTarget)) container.querySelector('#project-'+sectionTarget)?.scrollIntoView({block:'start'});
|
||||
}
|
||||
// Cut at a word boundary, unless that would leave only a markdown marker (a long heading).
|
||||
function previewCut(content,max) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -462,7 +462,7 @@ html[data-organizations] .ws-chips{display:flex;flex-wrap:wrap;align-items:cente
|
||||
|
||||
/* Page frame (sample main column). */
|
||||
html[data-organizations] .content:has(.ws){padding-top:18px;padding-inline:24px}
|
||||
body:has([data-org-dashboard]) #breadcrumbs,body:has([data-org-agents]) #breadcrumbs{display:none}
|
||||
body:has([data-org-dashboard]) #breadcrumbs{display:none}
|
||||
html[data-organizations] .ws{font-size:14px;line-height:1.5;color:var(--text);min-width:0}
|
||||
html[data-organizations] .ws h1{margin:0;font-size:28px;font-weight:800;letter-spacing:-.02em;line-height:1.15;color:var(--strong)}
|
||||
html[data-organizations] .ws h1:focus,html[data-organizations] .ws h1:focus-visible{outline:none}
|
||||
@@ -485,6 +485,13 @@ html[data-organizations] .ws-panel.tight{padding:12px 14px}
|
||||
html[data-organizations] .ws-split{display:grid;grid-template-columns:minmax(0,1.45fr) minmax(0,1fr);gap:16px;align-items:start}
|
||||
html[data-organizations] .ws-split{margin-top:22px}
|
||||
html[data-organizations] .ws-split>section>.ws-sec-h:first-child{margin-top:0}
|
||||
/* SP13: a column whose first child wraps its heading (the Workflows card) starts level with the other. */
|
||||
html[data-organizations] .ws-split>section>:first-child>.ws-sec-h:first-child{margin-top:0}
|
||||
/* SP13 (#1873): the organization page's two columns stack once its content is narrower than both need,
|
||||
with or without the agent drawer beside it: a wrap, not a viewport breakpoint. */
|
||||
html[data-organizations] .ws-dash>.ws-split{display:flex;flex-wrap:wrap;align-items:flex-start}
|
||||
html[data-organizations] .ws-dash>.ws-split>section{flex:1.45 1 480px;min-width:0}
|
||||
html[data-organizations] .ws-dash>.ws-split>section+section{flex:1 1 360px}
|
||||
/* E2E6 P1-3: the long editors (Autonomy, Packs, Workflows) span the page below the split, so neither
|
||||
column runs screens past the other at 1440. */
|
||||
html[data-organizations] .ws-wide{display:grid;grid-template-columns:minmax(0,1fr);gap:16px;margin-top:22px}
|
||||
@@ -506,9 +513,13 @@ html[data-organizations] .ws-doc:hover{background:var(--raised)}
|
||||
html[data-organizations] .ws-doc svg.i{width:14px;height:14px;color:var(--muted)}
|
||||
html[data-organizations] .ws-doc small{font-weight:500;color:var(--muted);font-size:11px}
|
||||
html[data-organizations] .ws-more{font-size:13px;font-weight:600;margin-left:4px;color:var(--action);text-decoration:none}
|
||||
html[data-organizations] .ws-tiles{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px}
|
||||
html[data-organizations] .ws-tile{background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:12px 14px;display:grid;gap:6px;align-content:start}
|
||||
html[data-organizations] .ws-tile.is-empty{grid-column:1/-1;border-style:dashed;background:transparent}
|
||||
/* SP8 (#1873): one row of figure tiles under the page heading, each a link to the page that lists what it counts. */
|
||||
html[data-organizations] .ws-tiles{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin:0 0 16px}
|
||||
html[data-organizations] .ws-tiles.six{grid-template-columns:repeat(6,minmax(0,1fr))}
|
||||
html[data-organizations] .ws-tile{background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:12px 14px;display:grid;gap:6px;align-content:start;min-width:0;color:inherit;text-decoration:none}
|
||||
html[data-organizations] a.ws-tile:hover{border-color:color-mix(in srgb,var(--action) 45%,var(--line))}
|
||||
html[data-organizations] a.ws-tile:focus-visible{outline:2px solid var(--action);outline-offset:2px}
|
||||
html[data-organizations] .ws-tile>.ws-label{font-size:12.5px;font-weight:600;letter-spacing:0;text-transform:none}
|
||||
html[data-organizations] .ws-empty-t{font-size:13.5px;font-weight:600;color:var(--strong)}
|
||||
html[data-organizations] .ws-tbl{overflow-x:auto;border:1px solid var(--line);border-radius:8px;background:var(--surface)}
|
||||
html[data-organizations] .ws-tbl table{width:100%;border-collapse:collapse;font-size:13px;margin:0}
|
||||
@@ -518,15 +529,20 @@ html[data-organizations] .ws-tbl tr:last-child td{border-bottom:0}
|
||||
html[data-organizations] .ws-tbl .t1{color:var(--strong)}
|
||||
html[data-organizations] .ws-tbl .t2{display:block;font-size:12px;color:var(--muted)}
|
||||
html[data-organizations] .ws-tbl td.mono{font-family:var(--mono);font-size:12.5px;white-space:nowrap}
|
||||
html[data-organizations] .ws-cfg{display:grid;list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--surface)}
|
||||
html[data-organizations] .ws-cfg{container-type:inline-size;display:grid;list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--surface)}
|
||||
html[data-organizations] .ws-cfg li{display:grid;align-items:center;grid-template-columns:18px 132px minmax(0,1fr) auto auto;gap:0 10px;padding:7px 12px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent)}
|
||||
html[data-organizations] .ws-cfg li:last-child{border-bottom:0}
|
||||
html[data-organizations] .ws-cfg svg.i{width:16px;height:16px;color:var(--muted)}
|
||||
html[data-organizations] .ws-cfg .k{font-size:13px;font-weight:600;color:var(--strong)}
|
||||
html[data-organizations] .ws-cfg .v{font-size:12px;color:var(--muted);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
html[data-organizations] .ws-cfg .v{font-size:12px;color:var(--muted);min-width:0;overflow-wrap:anywhere}
|
||||
html[data-organizations] .ws-cfg .src-tag{font:500 11px var(--mono);color:var(--accent);white-space:nowrap}
|
||||
html[data-organizations] .ws-cfg .go{font-size:12.5px;font-weight:600;color:var(--action);text-decoration:none;min-width:32px;text-align:right}
|
||||
html[data-organizations] .ws-cfg a.go:hover{text-decoration:underline}
|
||||
/* SP13: a Settings card narrower than its five columns need puts each value on its own line. */
|
||||
@container (max-width:560px){
|
||||
html[data-organizations] .ws-cfg li{grid-template-columns:18px minmax(0,1fr) auto auto}
|
||||
html[data-organizations] .ws-cfg .v{grid-column:2/-1;grid-row:2}
|
||||
}
|
||||
html[data-organizations] .ws-cfg li.danger .k,html[data-organizations] .ws-cfg li.danger svg.i,html[data-organizations] .ws-cfg li.danger .go,html[data-organizations] .ws-cfg li.danger .src-tag{color:var(--danger)}
|
||||
|
||||
/* Agents (sample 08). */
|
||||
@@ -567,7 +583,7 @@ html[data-organizations] .ws-rules .ws-lvl{margin-right:0}
|
||||
html[data-organizations] .ws-scope{margin:-8px 0 14px}
|
||||
|
||||
/* Across organizations (U3): the main dashboard, projects and inbox, samples 02, 04 and 05. */
|
||||
body:has([data-ws-home]) #breadcrumbs,body:has([data-ws-projects]) #breadcrumbs,body:has([data-ws-inbox]) #breadcrumbs{display:none}
|
||||
body:has([data-ws-home]) #breadcrumbs{display:none}
|
||||
html[data-organizations] .ws-edge{box-shadow:inset 3px 0 0 var(--org)}
|
||||
html[data-organizations] .ws-orgref{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;color:var(--strong);font-weight:600}
|
||||
html[data-organizations] .ws-chip{display:inline-flex;align-items:center;gap:5px;font-size:12px;font-weight:600;padding:1px 7px;border-radius:4px;background:var(--raised);color:var(--strong);white-space:nowrap;line-height:1.5}
|
||||
@@ -580,9 +596,8 @@ html[data-organizations] .ws-tbl .ws-dot.s-working b,html[data-organizations] .w
|
||||
html[data-organizations] .ws-tbl .ws-dot.s-waiting b{color:var(--warning)}
|
||||
html[data-organizations] .ws-tbl .ws-dot.s-error b{color:var(--danger)}
|
||||
html[data-organizations] .ws-tbl .ws-dot.s-idle b,html[data-organizations] .ws-tbl .ws-dot.s-offline b{color:var(--muted)}
|
||||
html[data-organizations] .ws-g5{display:grid;gap:10px;grid-template-columns:repeat(5,minmax(0,1fr))}
|
||||
html[data-organizations] .ws-tile .ws-big{font-size:24px;font-weight:800;color:var(--strong);letter-spacing:-.02em;font-variant-numeric:tabular-nums;line-height:1.1}
|
||||
html[data-organizations] .ws-tile .ws-big small{font-size:13px;font-weight:600;color:var(--muted);letter-spacing:0;margin-left:4px}
|
||||
html[data-organizations] .ws-tile .ws-big small{font-size:13px;font-weight:600;color:var(--muted);letter-spacing:0}
|
||||
html[data-organizations] .ws-tile .ws-delta{font-size:12px;color:var(--muted)}
|
||||
html[data-organizations] .ws-tile .ws-delta b{color:var(--success);font-weight:600}
|
||||
html[data-organizations] .ws-tile.quiet{border-style:dashed;background:transparent}
|
||||
@@ -591,6 +606,18 @@ 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)}
|
||||
/* #1873 SP2: a figure keeps its words on one line ("4 agents (0 working)", "none waiting"); the organization cell takes the wrap. */
|
||||
html[data-organizations] .ws-orgtbl td:not(.who){white-space:nowrap}
|
||||
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)}
|
||||
@@ -628,7 +655,7 @@ html[data-organizations] .ws-needs .ws-chip{grid-row:1/3;grid-column:3}
|
||||
html[data-organizations] .ws-more-line{margin:6px 0 0}
|
||||
html[data-organizations] .ws-sess td.who{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:0;width:100%}
|
||||
html[data-organizations] .ws-sess td.who .ws-orgref{vertical-align:middle}
|
||||
html[data-organizations] .ws-sess td.st{white-space:nowrap}
|
||||
html[data-organizations] .ws-sess td.ws-st{white-space:nowrap}
|
||||
/* Filters and tables on Projects and Inbox. */
|
||||
html[data-organizations] .ws-toolbar{display:flex;flex-wrap:wrap;gap:8px 10px;align-items:center;margin-bottom:12px}
|
||||
html[data-organizations] .ws-toolbar .ws-right{align-items:center}
|
||||
@@ -674,6 +701,24 @@ html[data-organizations] .ws-ibx .q:hover{text-decoration:underline}
|
||||
html[data-organizations] .ws-ibx .via{display:block;font-size:12px;color:var(--muted)}
|
||||
html[data-organizations] .ws-ibx tr.on{background:color-mix(in srgb,var(--action) 10%,var(--surface))}
|
||||
html[data-organizations] .ws-ibx tr.on td:first-child{box-shadow:inset 3px 0 0 var(--action)}
|
||||
/* SP3: the system Inbox's filter rows and the organization and project chips on each item. */
|
||||
html[data-organizations] .ws-ibx-filters{flex-direction:column;align-items:flex-start;gap:8px}
|
||||
html[data-organizations] .ws-fgroup{display:flex;gap:6px 8px;align-items:center;flex-wrap:wrap}
|
||||
html[data-organizations] .ws-ibx-filters .ws-fchips>.ws-label,html[data-organizations] .ws-fgroup>.ws-label{min-width:36px}
|
||||
html[data-organizations] .ws-ichips{display:flex;flex-wrap:wrap;align-items:center;gap:4px 6px;margin-top:3px}
|
||||
html[data-organizations] .ws-ichips .via{display:inline}
|
||||
/* #1873 SP5: the system Agents table. Open and Fire sit side by side at one size; a confirm wraps in its cell. */
|
||||
html[data-organizations] .ws-agt .ws-arole{font-size:12px;color:var(--muted)}
|
||||
html[data-organizations] .ws-agt td.ws-act{white-space:nowrap;text-align:right;width:1%}
|
||||
html[data-organizations] .ws-agt td.ws-act>*+*{margin-left:6px}
|
||||
html[data-organizations] .ws-agt td.ws-act .btn.small{min-height:0;padding:4px 10px;font:600 13px/1.4 var(--font);border-radius:var(--r)}
|
||||
html[data-organizations] .ws-agt .ws-fire-confirm{display:inline-flex;flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:6px;white-space:normal}
|
||||
html[data-organizations] .ws-agt .t2.mono{overflow-wrap:anywhere}
|
||||
html[data-organizations] .ws-agt tr.on{background:color-mix(in srgb,var(--action) 10%,var(--surface))}
|
||||
html[data-organizations] .ws-agt tr.on td:first-child{box-shadow:inset 3px 0 0 var(--action)}
|
||||
@media (forced-colors:active){html[data-organizations] .ws-agt tr.on td:first-child{border-left:3px solid Highlight}}
|
||||
html[data-organizations] .ws-ochip{background:color-mix(in srgb,var(--org,var(--muted)) 14%,var(--surface))}
|
||||
html[data-organizations] .ws-ochip i{width:8px;height:8px;border-radius:999px;background:var(--org,var(--muted));flex:none}
|
||||
html[data-organizations] .ws-msrc{align-items:center;gap:5px;font-size:12px;color:var(--muted);margin-bottom:2px}
|
||||
html[data-organizations] .ws-insp{border:1px solid var(--line);border-radius:10px;background:var(--surface);display:flex;flex-direction:column;min-width:0;position:sticky;top:12px;max-height:calc(100vh - 80px);overflow:auto}
|
||||
html[data-organizations] .ws-insp header{display:flex;align-items:center;gap:8px;padding:10px 14px;border-bottom:1px solid var(--line);background:var(--surface);position:sticky;top:0;z-index:1;box-shadow:inset 3px 0 0 var(--org)}
|
||||
@@ -693,8 +738,41 @@ html[data-organizations] .ws-cmd{display:flex;gap:6px;align-items:center;min-wid
|
||||
html[data-organizations] .ws-cmd code{flex:1;min-width:0;overflow-x:auto;white-space:nowrap;background:var(--surface);border:1px solid var(--line);border-radius:4px;padding:3px 6px;font-size:12px;color:var(--strong)}
|
||||
html[data-organizations] .ws-cmd .ws-btn{padding:4px 6px}
|
||||
html[data-organizations] #copy-status:empty{display:none}
|
||||
/* SP12 (#1873): the agent drawer, a non-modal region at the right of the page; the page stays under it. */
|
||||
html[data-organizations] .ws-drawer-open{color:inherit;font-weight:inherit;text-decoration:underline;text-decoration-color:color-mix(in srgb,currentColor 35%,transparent);text-underline-offset:3px}
|
||||
html[data-organizations] .ws-drawer-open:hover{text-decoration-color:currentColor}
|
||||
html[data-organizations] .ws-drawer{position:fixed;top:var(--bar-h,52px);right:0;bottom:0;width:min(420px,100vw);z-index:40;display:flex;flex-direction:column;background:var(--surface);border-left:1px solid var(--line);box-shadow:-8px 0 24px color-mix(in srgb,#000 14%,transparent);overflow:auto;overscroll-behavior:contain}
|
||||
html[data-organizations] .ws-drawer .hd{display:flex;align-items:center;gap:8px;padding:12px 14px 12px 17px;border-bottom:1px solid var(--line);background:var(--surface);position:sticky;top:0;z-index:1;box-shadow:inset 4px 0 0 var(--org,var(--action))}
|
||||
html[data-organizations] .ws-drawer h2{margin:0;font-size:18px;font-weight:800;color:var(--strong);overflow-wrap:anywhere;min-width:0}
|
||||
html[data-organizations] .ws-drawer h2:focus{outline:none}
|
||||
html[data-organizations] .ws-drawer h2:focus-visible{outline:2px solid var(--action);outline-offset:2px}
|
||||
html[data-organizations] .ws-drawer .x{margin-left:auto;flex:none;width:32px;height:32px;border:1px solid var(--line);border-radius:6px;background:var(--canvas);color:var(--strong);font-size:20px;line-height:1;cursor:pointer}
|
||||
html[data-organizations] .ws-drawer .body{padding:14px;display:grid;gap:16px;align-content:start}
|
||||
html[data-organizations] .ws-drawer-fields{margin:0;display:grid;gap:8px}
|
||||
html[data-organizations] .ws-drawer-f{display:grid;grid-template-columns:96px minmax(0,1fr);gap:8px;align-items:baseline;font-size:13px}
|
||||
html[data-organizations] .ws-drawer-f dt{color:var(--muted);font-weight:600}
|
||||
html[data-organizations] .ws-drawer-f dd{margin:0;color:var(--strong);overflow-wrap:anywhere;min-width:0}
|
||||
html[data-organizations] .ws-drawer-f code,html[data-organizations] .ws-drawer .mono{font:12px var(--mono)}
|
||||
html[data-organizations] .ws-drawer-sec{display:grid;gap:6px;margin:0}
|
||||
html[data-organizations] .ws-drawer-sec h3{margin:0;font:700 11px/1.3 var(--font);letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}
|
||||
html[data-organizations] .ws-drawer-list{list-style:none;margin:0;padding:0;display:grid;gap:4px;font-size:13px}
|
||||
html[data-organizations] .ws-drawer-list li{border:1px solid var(--line);border-radius:6px;padding:6px 9px;background:var(--canvas);overflow-wrap:anywhere}
|
||||
html[data-organizations] .ws-drawer-list .t2{margin-left:4px;font-size:12px;color:var(--muted)}
|
||||
html[data-organizations] .ws-drawer .ws-more{font-size:13px}
|
||||
html[data-organizations] .ws-drawer .note{margin:0}
|
||||
html[data-organizations] .ws-drawer-acts{display:flex;flex-wrap:wrap;gap:8px;align-items:center;border-top:1px solid var(--line);padding-top:12px}
|
||||
html[data-organizations] .ws-drawer-confirm{display:flex;flex-wrap:wrap;gap:8px;align-items:center;font-size:13px;color:var(--strong);flex-basis:100%}
|
||||
html[data-organizations] .ws-drawer .ws-btn.danger{color:var(--danger)}
|
||||
html[data-organizations] .ws-drawer-status{margin:0;flex-basis:100%;font-size:13px;color:var(--strong)}
|
||||
html[data-organizations] .ws-drawer-status:empty{display:none}
|
||||
html[data-organizations] .ws-drawer-acts .note{flex-basis:100%}
|
||||
/* Wide screens: the page keeps clear of the drawer, so nothing sits under it. */
|
||||
@media (min-width:1200px){html[data-organizations] body:has(.ws-drawer) .content{padding-right:calc(420px + 22px)}}
|
||||
@media (max-width:700px){
|
||||
html[data-organizations] .ws-drawer{top:0;left:0;right:0;width:auto;border-left:0;box-shadow:none}
|
||||
}
|
||||
@media (max-width:1100px){
|
||||
html[data-organizations] .ws-g5{grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
html[data-organizations] .ws-tiles.six{grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
html[data-organizations] .ws-ibx-grid.with-insp{grid-template-columns:minmax(0,1fr)}
|
||||
html[data-organizations] .ws-insp{position:static;max-height:none}
|
||||
}
|
||||
@@ -704,11 +782,9 @@ html[data-organizations] #copy-status:empty{display:none}
|
||||
html[data-organizations] .content:has(.ws){padding:14px 16px 24px}
|
||||
html[data-organizations] .tree{padding:10px 12px 20px}
|
||||
html[data-organizations] .ws h1{font-size:24px}
|
||||
html[data-organizations] .ws-split,html[data-organizations] .ws-tiles{grid-template-columns:minmax(0,1fr)}
|
||||
html[data-organizations] .ws-tile.is-empty{grid-column:auto}
|
||||
html[data-organizations] .ws-split{grid-template-columns:minmax(0,1fr)}
|
||||
html[data-organizations] .ws-tiles,html[data-organizations] .ws-tiles.six{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
html[data-organizations] .ws-sec-h .note{flex-basis:100%;order:3}
|
||||
html[data-organizations] .ws-cfg li{grid-template-columns:18px minmax(0,1fr) auto auto}
|
||||
html[data-organizations] .ws-cfg .v{grid-column:2/-1;grid-row:2}
|
||||
html[data-organizations] .ws-meta{padding-left:0;margin-top:0}
|
||||
html[data-organizations] .ws-orghead .ws-right{margin-left:0}
|
||||
html[data-organizations] .ws-busctl,html[data-organizations] .ws-busctl .bus-restart{align-items:flex-start}
|
||||
@@ -721,9 +797,6 @@ html[data-organizations] #copy-status:empty{display:none}
|
||||
html[data-organizations] .ws-levels>.span{grid-column:auto}
|
||||
html[data-organizations] .ws-levels>.last.lh{border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent)}
|
||||
html[data-organizations] .ws-levels .mlabel{display:flex}
|
||||
html[data-organizations] .ws-g5{grid-template-columns:minmax(0,1fr)}
|
||||
html[data-organizations] .ws-g5.keep2{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
html[data-organizations] .ws-g5.keep2>:last-child:nth-child(odd){grid-column:1/-1}
|
||||
html[data-organizations] .ws-orgcards{grid-template-columns:minmax(0,1fr)}
|
||||
html[data-organizations] .ws-srow{grid-template-columns:92px 20px minmax(0,1fr)}
|
||||
html[data-organizations] .ws-srow .ws-by{grid-column:1/-1}
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
// shown as the bus answered it. Vikunja tasks are read only here: the bus refuses them
|
||||
// (tracker-not-store), so the board offers no control for them.
|
||||
// bus.js calls view() for #/tasks in organization mode and repaints it on its own 10 s poll.
|
||||
// SP4 (#1873): #/tasks is a system page, every organization on one board; #/orgs/<id>/tasks is the
|
||||
// same board scoped to that organization (bus.js passes the scope). The org and project filters live
|
||||
// in the address query (?org=, ?project=), so a reload keeps them. A move or edit goes to the row's own
|
||||
// organization and is offered only where that organization's own config takes task changes (bus.js
|
||||
// passes writesIn), never judged by the organization opened last. A card opened on the system board
|
||||
// links to the task in its organization (#/orgs/<id>/tasks?task=<ref>), which opens it there.
|
||||
(() => {
|
||||
'use strict';
|
||||
if (!document.documentElement.hasAttribute('data-organizations')) return;
|
||||
@@ -35,7 +41,13 @@
|
||||
// The layout is remembered per viewer and per width: a phone opens on List until its viewer picks
|
||||
// Kanban, a wide screen on Kanban until its viewer picks List.
|
||||
const layouts = (() => { try { const v = JSON.parse(load(LAYOUT) || '{}'); return v && typeof v === 'object' ? v : {}; } catch { return {}; } })();
|
||||
const filter = { org: '', project: '', assignee: '', state: '', label: '', q: '' };
|
||||
// done: 'week' shows the tasks the Dashboard's Done this week tile counts. It stays in the browser:
|
||||
// the rows already carry done and their last change, the same test the tile's count uses.
|
||||
const filter = { org: '', project: '', assignee: '', state: '', label: '', q: '', done: '' };
|
||||
let addrAssignee = '';
|
||||
const WEEK_MS = 7 * 24 * 3600 * 1000;
|
||||
const doneThisWeek = t => t.done && t.changedAt && Date.now() - Date.parse(t.changedAt) <= WEEK_MS;
|
||||
const DONE_NOTE = 'Done this week counts done tasks by their last change; the bus records no time a task was done.';
|
||||
// The board style in force: the filtered project's when it has one, else the one organization's the
|
||||
// view is narrowed to (by the org chip or by a project of that org), else none.
|
||||
function activeBoard() {
|
||||
@@ -56,6 +68,11 @@
|
||||
// `opened` is the task as the edit dialog showed it: Save diffs against it and sends its digest, so a
|
||||
// poll that reads a newer task while the dialog is open turns a stale save into a task-conflict.
|
||||
let snap = null, canWrite = false, notice = null, dragging = null, dragEnded = null, opened = null;
|
||||
// `scope` is the one organization of #/orgs/<id>/tasks, or null on the system board. `allowed` holds
|
||||
// the organizations whose own config takes task changes from this Console, asked on every read.
|
||||
let scope = null, writesIn = null, allowed = new Set(), autoOpened = null;
|
||||
const ORG_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/, TASK_REF = /^(?:task:\d{1,15}|vikunja:\d{1,15}\/\d{1,15})$/;
|
||||
const ws = () => window.MosaicWorkspace;
|
||||
|
||||
const mark = () => window.MosaicWorkspace?.orgMark;
|
||||
const orgOf = id => snap?.orgs.find(o => o.id === id) || { id, label: id, theme: null };
|
||||
@@ -69,7 +86,7 @@
|
||||
const stateOf = t => (STATE_LABEL[t.state] ? t.state : UNREAD);
|
||||
// An org can answer while its tasks read fails (errors.tasks): its tasks are unavailable, not zero.
|
||||
const tasksRead = o => o?.state === 'ok' && !o.errors?.tasks;
|
||||
const orgWritable = o => canWrite && tasksRead(o) && o.tracker === 'built-in';
|
||||
const orgWritable = o => canWrite && tasksRead(o) && o.tracker === 'built-in' && allowed.has(o.id);
|
||||
const taskWritable = t => t.writable === true && typeof t.digest === 'string' && orgWritable(orgOf(t.org));
|
||||
const projectName = t => t.project?.name || t.project?.key || null;
|
||||
const dueText = iso => {
|
||||
@@ -84,6 +101,7 @@
|
||||
const PROJECT_SELECTOR = /^[a-z0-9][a-z0-9._-]{0,63}:[A-Za-z0-9._-]{1,128}$/, ROLE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
function readQuery() {
|
||||
const q = new URLSearchParams({ sections: 'tasks,projects' });
|
||||
if (scope) q.set('org', scope);
|
||||
if (PROJECT_SELECTOR.test(filter.project)) q.set('project', filter.project);
|
||||
if (filter.state) q.set('task-state', filter.state === UNREAD ? 'open' : filter.state);
|
||||
if (ROLE.test(filter.assignee)) q.set('assignee', filter.assignee);
|
||||
@@ -97,7 +115,6 @@
|
||||
mark()?.ready().catch(() => null),
|
||||
window.MosaicConsole?.ready,
|
||||
]);
|
||||
canWrite = window.MosaicConsole?.config?.orgSetup === true;
|
||||
if (response.type === 'opaqueredirect' || !response.ok) {
|
||||
const body = await response.json().catch(() => null);
|
||||
throw Object.assign(new Error('read failed'), { status: response.status, message: typeof body?.message === 'string' ? body.message : null });
|
||||
@@ -106,16 +123,46 @@
|
||||
if (!Array.isArray(body?.orgs) || !Array.isArray(body.tasks) || !Array.isArray(body.projects)) throw new Error('read failed');
|
||||
const named = new Map((list || []).map(o => [o.id, o]));
|
||||
const orgs = body.orgs.map(o => ({ ...o, id: o.org, label: named.get(o.org)?.label || o.org, theme: named.get(o.org)?.theme ?? o.theme ?? null, roles: Array.isArray(o.roles) ? o.roles : [] }));
|
||||
return { orgs, tasks: body.tasks, projects: body.projects, summary: body.summary, readAt: body.readAt };
|
||||
// Each organization that could take a change is asked through its own config. The server sets
|
||||
// orgSetup for the whole Console today, so the answer only differs where an organization's config
|
||||
// read fails or 404s. With no way to ask, none is asked and the board is read only.
|
||||
const asked = orgs.filter(o => tasksRead(o) && o.tracker === 'built-in');
|
||||
const ask = typeof writesIn === 'function' ? writesIn : null;
|
||||
const yes = ask ? await Promise.all(asked.map(o => Promise.resolve(ask(o.id)).then(v => v === true, () => false))) : [];
|
||||
const takes = new Set(asked.filter((o, i) => yes[i]).map(o => o.id));
|
||||
// The Console-wide word, for the head's reason: no organization that could take a change takes one.
|
||||
// With none to ask, the open organization's config still says whether this Console writes at all.
|
||||
const writes = asked.length ? takes.size > 0 : window.MosaicConsole?.config?.orgSetup === true;
|
||||
return { orgs, tasks: body.tasks, projects: body.projects, summary: body.summary, readAt: body.readAt, takes, asked: asked.map(o => o.id), writes, view: ws()?.orgsOf?.(body) ?? null };
|
||||
}
|
||||
// Only the latest read lands: a filter change and the poll can both be reading.
|
||||
let readSeq = 0;
|
||||
const reload = async () => { const n = ++readSeq, next = await read(); if (n !== readSeq) return false; snap = next; prune(); return true; };
|
||||
// A read that a newer one replaced keeps nothing; `newest` lets a view wait for the read that does.
|
||||
let readSeq = 0, newest = null;
|
||||
// The address the read starts from goes to prune, so a cleanup never touches a page opened since.
|
||||
const reload = () => { const n = ++readSeq, at = location.hash; return (newest = read().then(next => { if (n !== readSeq) return false; snap = next; allowed = next.takes; canWrite = next.writes; return prune(at) ? reload() : true; })); };
|
||||
async function refilter() { paint(); try { if (await reload()) paint(); } catch { /* the next poll shows the failed read */ } }
|
||||
// A filter naming an org or project that is gone falls back to all.
|
||||
function prune() {
|
||||
if (filter.org && !snap.orgs.some(o => o.id === filter.org)) filter.org = '';
|
||||
if (filter.project && filter.project !== '-' && !snap.projects.some(p => `${p.org}:${p.id}` === filter.project)) filter.project = '';
|
||||
// The address drops it too, so it never names a filter the board is not using. A dropped project
|
||||
// was filtered on the server, so it returns true and the read runs again without it.
|
||||
// A project is gone only when its organization is off the list, or answered with its projects and
|
||||
// the project is not among them and no task came back for it (with no other filter on). An organization that is down,
|
||||
// starting or timed out, or whose projects read failed, lists no projects, so its filter stays.
|
||||
// `at` is the address the read started from.
|
||||
function prune(at) {
|
||||
const gone = {};
|
||||
if (!scope && filter.org && !snap.orgs.some(o => o.id === filter.org)) { filter.org = ''; gone.org = null; }
|
||||
if (filter.project && filter.project !== '-') {
|
||||
const po = snap.orgs.find(o => o.id === filter.project.split(':')[0]);
|
||||
const answered = !po || (po.state === 'ok' && !po.errors?.projects);
|
||||
// No task back is proof only when no other filter narrowed the read on the server.
|
||||
const narrowed = filter.state || filter.assignee || filter.label;
|
||||
if (answered && !narrowed && !snap.tasks.length && !snap.projects.some(p => `${p.org}:${p.id}` === filter.project)) { filter.project = ''; gone.project = null; }
|
||||
}
|
||||
// After this render: bus.js drops a render whose address changed while it ran. Only while the
|
||||
// address is still the one the read started from, and with a repaint in case the render in
|
||||
// flight was the one dropped.
|
||||
if (Object.keys(gone).length) setTimeout(() => { if (location.hash === at) ws()?.setQuery?.(gone, true); }, 0);
|
||||
return 'project' in gone;
|
||||
}
|
||||
|
||||
// ---------- filters ----------
|
||||
@@ -126,6 +173,7 @@
|
||||
&& (!filter.assignee || (filter.assignee === '-' ? !t.assignees.length : t.assignees.includes(filter.assignee)))
|
||||
&& (!filter.state || stateOf(t) === filter.state)
|
||||
&& (!filter.label || t.labels.map(String).includes(filter.label))
|
||||
&& (!filter.done || doneThisWeek(t))
|
||||
&& (!q || !needle || `${t.title ?? ''} ${shortId(t.ref)} ${projectName(t) ?? ''}`.toLowerCase().includes(needle)));
|
||||
}
|
||||
const select = (name, label, options, focus) => `<label class="tb-sel"><span class="ws-label">${esc(label)}</span><select data-tb-filter="${name}" data-focus-key="${focus}">${options}</select></label>`;
|
||||
@@ -137,17 +185,18 @@
|
||||
const chips = [chip('', `All <span class="n">${snap.tasks.length}</span>`)].concat(snap.orgs.map(o => chip(o.id,
|
||||
`${av(o.id)}${esc(o.label)}<span class="n">${tasksRead(o) ? counts.get(o.id) : 'unavailable'}</span>`,
|
||||
`${o.label}, ${tasksRead(o) ? `${counts.get(o.id)} tasks` : 'tasks unavailable'}`))).join('');
|
||||
const scope = snap.orgs.filter(o => !filter.org || o.id === filter.org);
|
||||
const shown = snap.orgs.filter(o => !filter.org || o.id === filter.org);
|
||||
const projectOpts = opt('', `All ${snap.projects.filter(p => !filter.org || p.org === filter.org).length}`, filter.project) + opt('-', 'No project', filter.project)
|
||||
+ scope.map(o => { const ps = snap.projects.filter(p => p.org === o.id); return ps.length ? `<optgroup label="${esc(o.label)}">${ps.map(p => opt(`${p.org}:${p.id}`, p.name || p.key || p.id, filter.project)).join('')}</optgroup>` : ''; }).join('');
|
||||
const roles = [...new Set(scope.flatMap(o => o.roles).concat(snap.tasks.filter(t => !filter.org || t.org === filter.org).flatMap(t => t.assignees)))].sort();
|
||||
+ shown.map(o => { const ps = snap.projects.filter(p => p.org === o.id); return ps.length ? `<optgroup label="${esc(o.label)}">${ps.map(p => opt(`${p.org}:${p.id}`, p.name || p.key || p.id, filter.project)).join('')}</optgroup>` : ''; }).join('');
|
||||
const roles = [...new Set(shown.flatMap(o => o.roles).concat(snap.tasks.filter(t => !filter.org || t.org === filter.org).flatMap(t => t.assignees)))].sort();
|
||||
if (filter.assignee && filter.assignee !== '-' && !roles.includes(filter.assignee)) roles.push(filter.assignee);
|
||||
const assigneeOpts = opt('', 'Anyone', filter.assignee) + opt('-', 'Nobody', filter.assignee) + roles.map(r => opt(r, r, filter.assignee)).join('');
|
||||
const stateOpts = opt('', 'Any', filter.state) + states().map(([v, l]) => opt(v, l, filter.state)).join('') + (snap.tasks.some(t => stateOf(t) === UNREAD) ? opt(UNREAD, 'State not read', filter.state) : '');
|
||||
const labels = [...new Set(snap.tasks.flatMap(t => t.labels.map(String)))].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
||||
const labelSel = labels.length ? select('label', 'Label', opt('', 'Any', filter.label) + labels.map(l => opt(l, l, filter.label)).join(''), 'tb-label') : '';
|
||||
return `<div class="tb-toolbar">
|
||||
<div class="tb-chips" role="group" aria-label="Organization">${chips}</div>
|
||||
${scope ? '' : `<div class="tb-chips" role="group" aria-label="Organization">${chips}</div>`}
|
||||
${filter.done ? '<button type="button" class="tb-f plain" data-tb-done aria-pressed="true" aria-label="Clear Done this week: done tasks last changed in the past 7 days" data-focus-key="tb-done">Done this week <span aria-hidden="true">×</span></button>' : ''}
|
||||
${select('project', 'Project', projectOpts, 'tb-project')}${select('assignee', 'Assignee', assigneeOpts, 'tb-assignee')}${select('state', 'State', stateOpts, 'tb-state')}${labelSel}
|
||||
<div class="tb-right"><label class="tb-search">${icon('search')}<input type="search" placeholder="Find a task" aria-label="Find a task" data-tb-q data-focus-key="tb-q" value="${esc(filter.q)}" autocomplete="off"></label></div>
|
||||
</div>`;
|
||||
@@ -158,12 +207,15 @@
|
||||
const prio = t => (Number.isSafeInteger(t.priority) && t.priority > 0 ? `<span class="tb-chip${t.priority >= 4 ? ' warn' : ''}" title="Priority ${t.priority} of 5">P${t.priority}</span>` : '');
|
||||
const due = t => { const d = t.dueDate && dueText(t.dueDate); return d ? `<span class="tb-chip${overdue(t) ? ' warn' : ''}" title="Due ${esc(t.dueDate.slice(0, 10))} (UTC)">${overdue(t) ? 'overdue' : 'due'} ${esc(d)}</span>` : ''; };
|
||||
const title = t => esc(t.title || 'Untitled');
|
||||
// The shared chips (workspace.js), so a card reads as the Inbox's rows do.
|
||||
const orgChip = t => (snap.view && ws()?.orgChip ? ws().orgChip(snap.view, t.org, av(t.org, 'xs') || undefined) : `${av(t.org, 'xs')}<span class="ws-sr">${esc(orgOf(t.org).label)}</span>`);
|
||||
const projChip = t => (projectName(t) ? (ws()?.projChip?.(projectName(t)) ?? `<span>${esc(projectName(t))}</span>`) : '');
|
||||
function card(t) {
|
||||
const w = taskWritable(t), k = esc(key(t));
|
||||
return `<article class="tb-card ws-o${t.state === 'done' ? ' dim' : ''}" style="${colours(t.org)}" data-tb-task="${k}" data-org="${esc(t.org)}"${w ? ' draggable="true"' : ''}>
|
||||
<div class="top">${av(t.org, 'xs')}<span class="ws-sr">${esc(orgOf(t.org).label)}</span><span class="id">${esc(shortId(t.ref))}</span>${trk(t.tracker)}</div>
|
||||
<div class="top">${orgChip(t)}<span class="id">${esc(shortId(t.ref))}</span>${trk(t.tracker)}</div>
|
||||
<div class="tt"><button type="button" class="tb-open" data-tb-open="${k}" data-focus-key="open:${k}">${title(t)}</button></div>
|
||||
<div class="bot"><span>${projectName(t) ? esc(projectName(t)) : '<span class="muted">No project</span>'}</span>${who(t)}${prio(t)}${due(t)}</div>
|
||||
<div class="bot">${projChip(t) || '<span><span class="muted">No project</span></span>'}${who(t)}${prio(t)}${due(t)}</div>
|
||||
</article>`;
|
||||
}
|
||||
function kanban(rows) {
|
||||
@@ -184,10 +236,10 @@
|
||||
};
|
||||
return `<div class="ws-tbl tb-list" data-tb-list><table><thead><tr><th scope="col">Org</th><th scope="col">Id</th><th scope="col">Task</th><th scope="col" class="hide-m">Project</th><th scope="col">State</th><th scope="col" class="hide-m">Assignee</th><th scope="col" class="hide-m">Priority</th><th scope="col" class="hide-m">Due</th></tr></thead><tbody>
|
||||
${sorted.map(t => `<tr class="tb-row ws-o${t.state === 'done' ? ' dim' : ''}" style="${colours(t.org)}" data-tb-task="${esc(key(t))}" data-org="${esc(t.org)}">
|
||||
<td>${av(t.org)}<span class="ws-sr">${esc(orgOf(t.org).label)}</span></td>
|
||||
<td>${orgChip(t)}</td>
|
||||
<td class="mono">${esc(shortId(t.ref))}<span class="t2">${t.tracker === 'vikunja' ? 'Vikunja' : 'built-in'}</span></td>
|
||||
<td><button type="button" class="tb-open t1" data-tb-open="${esc(key(t))}" data-focus-key="lopen:${esc(key(t))}">${title(t)}</button><span class="t2 show-m">${esc(projectName(t) || 'No project')}</span></td>
|
||||
<td class="hide-m">${projectName(t) ? esc(projectName(t)) : '<span class="muted">none</span>'}</td>
|
||||
<td class="hide-m">${projChip(t) || '<span class="muted">none</span>'}</td>
|
||||
<td>${stateCell(t)}</td>
|
||||
<td class="hide-m">${t.assignees.length ? esc(t.assignees.join(', ')) : '<span class="muted">nobody</span>'}</td>
|
||||
<td class="hide-m">${prio(t) || '<span class="muted">none</span>'}</td>
|
||||
@@ -198,13 +250,15 @@
|
||||
const rows = filtered(), all = snap.tasks.length;
|
||||
// Project, state, assignee and label filter on the server, so the board never holds the unfiltered
|
||||
// total; with one of those on, the line says how many match rather than inventing an "of N".
|
||||
const onServer = new URLSearchParams(readQuery()).size > 1, n = rows.length;
|
||||
const line = onServer ? `<p class="tb-count">Showing ${n} ${n === 1 ? 'task that matches' : 'tasks that match'} the filters.</p>`
|
||||
: n !== all ? `<p class="tb-count">Showing ${n} of ${all} tasks.</p>` : '';
|
||||
// The scope's org= is where the board reads, not a filter.
|
||||
const onServer = new URLSearchParams(readQuery()).size > (scope ? 2 : 1), n = rows.length;
|
||||
const note = filter.done ? ` ${DONE_NOTE}` : '';
|
||||
const line = onServer ? `<p class="tb-count">Showing ${n} ${n === 1 ? 'task that matches' : 'tasks that match'} the filters.${note}</p>`
|
||||
: n !== all ? `<p class="tb-count">Showing ${n} of ${all} tasks.${note}</p>` : note ? `<p class="tb-count">${note.trim()}</p>` : '';
|
||||
// An empty result is only exhaustive when every organization's tasks were read.
|
||||
const unread = snap.orgs.filter(o => !tasksRead(o));
|
||||
const none = what => (unread.length ? `${what} in the organizations that answered. Tasks from ${unread.map(o => o.label).join(', ')} could not be read.` : `${what}.`);
|
||||
const body = !snap.orgs.length ? '<p class="ws-panel tight note">No organizations yet.</p>'
|
||||
const body = !snap.orgs.length ? `<p class="ws-panel tight note">${scope ? 'This organization is not on your list.' : 'No organizations yet.'}</p>`
|
||||
: !snap.orgs.some(tasksRead) ? '<p class="ws-panel tight note">No organization answered with its tasks, so no tasks could be read.</p>'
|
||||
: !all && !onServer ? `<p class="ws-panel tight note">${esc(none('No tasks yet'))}</p>`
|
||||
: !rows.length ? `<p class="ws-panel tight note">${esc(none('No tasks match these filters'))}</p>`
|
||||
@@ -230,17 +284,33 @@
|
||||
const seg = `<span class="ws-seg" role="group" aria-label="Layout"><button type="button" data-tb-layout="kanban" aria-pressed="${lay === 'kanban'}" data-focus-key="tb-layout-kanban">Kanban</button><button type="button" data-tb-layout="list" aria-pressed="${lay === 'list'}" data-focus-key="tb-layout-list">List</button></span>`;
|
||||
const add = `<button type="button" class="ws-btn" data-tb-new data-focus-key="tb-new"${why ? ` disabled title="${esc(why)}"` : ''}>${icon('plus')}New task</button>`;
|
||||
let sub = '';
|
||||
if (snap) {
|
||||
if (snap && scope) {
|
||||
const n = snap.tasks.length, name = snap.orgs.find(o => o.id === scope)?.label || scope;
|
||||
sub = `Tasks from ${esc(name)} only. ${n} ${n === 1 ? 'task' : 'tasks'}. <a href="#/tasks" data-focus-key="tasks-all">Every organization</a>`;
|
||||
if (snap.orgs.some(o => o.tracker === 'vikunja')) sub += ' Vikunja tasks are read only here.';
|
||||
if (!canWrite) sub += ' This Console takes no task changes, so the board is read only.';
|
||||
} else if (snap) {
|
||||
const n = snap.tasks.length, m = snap.orgs.filter(tasksRead).length, of = snap.summary?.of ?? snap.orgs.length;
|
||||
sub = `${n} ${n === 1 ? 'task' : 'tasks'} from ${m} of ${of} ${of === 1 ? 'organization' : 'organizations'}. Each organization keeps its own tasks; a change made here goes only to that organization's bus.`;
|
||||
if (snap.orgs.some(o => o.tracker === 'vikunja')) sub += ' Vikunja tasks are read only here.';
|
||||
if (!canWrite) sub += ' This Console takes no task changes, so the board is read only.';
|
||||
else {
|
||||
const no = snap.asked.filter(id => !allowed.has(id)).map(id => esc(orgOf(id).label));
|
||||
if (no.length) sub += ` This Console takes no task changes in ${no.length < 2 ? no[0] : `${no.slice(0, -1).join(', ')} and ${no.at(-1)}`}, so ${no.length < 2 ? 'its' : 'their'} tasks are read only here.`;
|
||||
}
|
||||
}
|
||||
return `<div class="ws-vh"><h1 tabindex="-1">Tasks</h1><div class="ws-right">${seg}${add}</div>${sub ? `<p class="sub">${sub}</p>` : ''}</div>`;
|
||||
}
|
||||
const noticeLine = () => (notice ? `<p class="tb-notice${notice.error ? ' is-error' : ''}" role="${notice.error ? 'alert' : 'status'}" data-tb-notice>${esc(notice.text)}</p>` : '<p class="tb-notice" role="status" data-tb-notice></p>');
|
||||
// An organization that did not answer is named first, with its reason, as on the Inbox.
|
||||
function unreadLine() {
|
||||
const v = snap?.view;
|
||||
if (!v || !ws()?.unreadNote) return '';
|
||||
const o = filter.org && !scope ? { ...v, rows: v.rows.filter(r => r.org === filter.org), unread: v.unread.filter(r => r.org === filter.org) } : v;
|
||||
return ws().unreadNote(o, ['tasks'], 'tasks');
|
||||
}
|
||||
function markup() {
|
||||
return `<section class="ws tb" data-tasks-board data-layout="${layoutNow()}">${head()}${noticeLine()}${trackers()}${toolbar()}${results()}</section>`;
|
||||
return `<section class="ws tb" data-tasks-board data-layout="${layoutNow()}"${scope ? ` data-tb-scope="${esc(scope)}"` : ''}>${head()}${noticeLine()}${unreadLine()}${trackers()}${toolbar()}${results()}</section>`;
|
||||
}
|
||||
const failed = err => `<section class="ws tb" data-tasks-board><div class="ws-vh"><h1 tabindex="-1">Tasks</h1><p class="sub">The task board could not be read${err?.message && err.message !== 'read failed' ? `: ${esc(err.message)}` : ''}. It tries again on the next refresh.</p></div></section>`;
|
||||
|
||||
@@ -266,7 +336,8 @@
|
||||
// Repaint in place, for the board's own changes (filters, layout, a write).
|
||||
function paint(part = 'all') {
|
||||
const root = document.querySelector('[data-tasks-board]');
|
||||
if (!root || !snap) return;
|
||||
// Only over a board drawn from a read: never over the not-on-your-list page or a failed read.
|
||||
if (!root || !snap || !root.querySelector('[data-tb-results]')) return;
|
||||
const kept = snapshotView(root);
|
||||
if (part === 'results') root.querySelector('[data-tb-results]').outerHTML = results();
|
||||
else root.outerHTML = markup();
|
||||
@@ -274,9 +345,44 @@
|
||||
}
|
||||
|
||||
// bus.js's view for #/tasks. A drag in progress holds the poll's repaint until it ends.
|
||||
async function view() {
|
||||
// q is the address query; opts.scope the organization of #/orgs/<id>/tasks; opts.writesIn asks one
|
||||
// organization's own config whether it takes task changes.
|
||||
async function view(q = new URLSearchParams(), opts = {}) {
|
||||
// An address that names an organization no id can match is not the system board.
|
||||
// A read still in flight from the board left behind lands nowhere and paints nothing.
|
||||
if (typeof opts.scope === 'string' && opts.scope && !ORG_ID.test(opts.scope)) { ++readSeq; snap = null; return `<section class="ws tb" data-tasks-board><div class="ws-vh"><h1 tabindex="-1">Tasks</h1></div><p class="ws-panel tight note">This organization is not on your list.</p></section>`; }
|
||||
const s = typeof opts.scope === 'string' && ORG_ID.test(opts.scope) ? opts.scope : null;
|
||||
if (s !== scope) snap = null;
|
||||
scope = s; writesIn = typeof opts.writesIn === 'function' ? opts.writesIn : null;
|
||||
// The org and project filters come from the address on every render, checked; anything else is all.
|
||||
const org = String(q.get('org') || ''), project = String(q.get('project') || '');
|
||||
filter.org = scope || (ORG_ID.test(org) ? org : '');
|
||||
filter.project = PROJECT_SELECTOR.test(project) || project === '-' ? project : '';
|
||||
filter.done = q.get('done') === 'week' ? 'week' : '';
|
||||
// SP12: `assignee` in the address (the agent drawer's link) sets the Assignee filter once per new
|
||||
// value; the select changes it after that, and a poll does not put the address's value back.
|
||||
const who = String(q.get('assignee') || '');
|
||||
// When the address drops it, the filter it set goes too, unless the select has changed it since.
|
||||
if (who !== addrAssignee) { const was = addrAssignee; addrAssignee = who; if (ROLE.test(who)) filter.assignee = who; else if (!who && was && filter.assignee === was) filter.assignee = ''; }
|
||||
if (filter.org && filter.project && filter.project !== '-' && !filter.project.startsWith(`${filter.org}:`)) filter.project = '';
|
||||
if (dragging) await Promise.race([dragEnded, new Promise(r => setTimeout(r, 30000))]);
|
||||
try { await reload(); } catch (err) { snap = null; return failed(err); }
|
||||
// Two renders at once (a first load, a poll during a filter change): the replaced one draws from the newer read.
|
||||
try { let p = reload(); while (!(await p) && newest !== p) p = newest; } catch (err) { snap = null; return failed(err); }
|
||||
// A not-on-your-list page opened meanwhile dropped every read; bus.js drops this render as not current.
|
||||
if (!snap) return '';
|
||||
// #/orgs/<id>/tasks?task=<ref> opens that task once, after bus.js has drawn the board.
|
||||
const want = scope ? String(q.get('task') || '') : '';
|
||||
if (!want) autoOpened = null;
|
||||
else if (autoOpened !== `${scope}|${want}`) {
|
||||
autoOpened = `${scope}|${want}`;
|
||||
const k = `${scope}|${want}`;
|
||||
setTimeout(() => {
|
||||
if (!document.querySelector(`[data-tasks-board][data-tb-scope="${CSS.escape(scope || '')}"]`) || autoOpened !== k) return;
|
||||
const t = TASK_REF.test(want) ? find(k) : null;
|
||||
if (t) { openDialog(editForm(t), `open:${key(t)}`, t); dialogEl().dataset.fromAddress = '1'; }
|
||||
else { notice = { error: true, text: `Task ${shortId(want)} is not on this board.` }; paint(); }
|
||||
}, 0);
|
||||
}
|
||||
// Taken after the read, just before bus.js replaces the view, so focus, caret or scroll moved while
|
||||
// the read was in flight is what is kept. bus.js puts focus back by key; this puts back the rest.
|
||||
const kept = snapshotView(document.querySelector('[data-tasks-board]'));
|
||||
@@ -317,7 +423,13 @@
|
||||
d = document.createElement('dialog');
|
||||
d.id = 'tb-dialog'; d.className = 'tb-dialog'; d.setAttribute('aria-labelledby', 'tb-dialog-h');
|
||||
document.body.append(d);
|
||||
d.addEventListener('close', () => { const back = d.dataset.return; d.innerHTML = ''; d.removeAttribute('aria-describedby'); opened = null; if (back) document.querySelector(`[data-focus-key="${CSS.escape(back)}"]`)?.focus(); });
|
||||
d.addEventListener('close', () => {
|
||||
const back = d.dataset.return, fromAddress = d.dataset.fromAddress === '1';
|
||||
d.innerHTML = ''; d.removeAttribute('aria-describedby'); delete d.dataset.fromAddress; opened = null;
|
||||
if (back) document.querySelector(`[data-focus-key="${CSS.escape(back)}"]`)?.focus();
|
||||
// A task the address opened leaves the address when it closes, so a reload does not reopen it.
|
||||
if (fromAddress && /^#\/orgs\/[^/?]+\/tasks\?(?:.*&)?task=/.test(location.hash)) ws()?.setQuery?.({ task: null }, true);
|
||||
});
|
||||
}
|
||||
return d;
|
||||
}
|
||||
@@ -348,14 +460,17 @@
|
||||
function editForm(t, refusal = null) {
|
||||
const o = orgOf(t.org), w = taskWritable(t);
|
||||
const why = t.tracker === 'vikunja' ? 'This task is in Vikunja. The board reads it but does not change it; change it in Vikunja.'
|
||||
: !canWrite ? 'This Console takes no task changes.' : o.state !== 'ok' ? 'This organization\'s bus did not answer.' : !w ? 'The bus did not give this task a version to check a change against.' : '';
|
||||
: !canWrite ? 'This Console takes no task changes.' : o.state !== 'ok' ? 'This organization\'s bus did not answer.'
|
||||
: !allowed.has(t.org) ? `This Console takes no task changes in ${o.label}.` : !w ? 'The bus did not give this task a version to check a change against.' : '';
|
||||
// On the system board the task also opens in its own organization.
|
||||
const goto = scope ? '' : `<a class="ws-btn" href="${esc(`#/orgs/${encodeURIComponent(t.org)}/tasks?${new URLSearchParams({ task: t.ref })}`)}" data-tb-goto>Open in ${esc(o.label)}</a>`;
|
||||
const facts = `<p class="tb-facts">${av(t.org, 'xs')}${esc(o.label)} · <span class="mono">${esc(shortId(t.ref))}</span> ${trk(t.tracker)}${t.requirement ? ` · <span class="mono">${esc(t.requirement)}</span>` : ''}${projectName(t) ? ` · ${esc(projectName(t))}` : ''}</p>`;
|
||||
if (!w) {
|
||||
return `<div class="tb-form" data-tb-form="view"><h2 id="tb-dialog-h">${title(t)}</h2>${facts}
|
||||
<dl class="tb-dl"><dt>State</dt><dd>${esc(stateLabel(t.state) || 'Not read')}</dd><dt>Assignees</dt><dd>${t.assignees.length ? esc(t.assignees.join(', ')) : 'nobody'}</dd><dt>Priority</dt><dd>${prio(t) || 'none'}</dd><dt>Due</dt><dd>${due(t) || 'none'}</dd></dl>
|
||||
<p class="tb-field-note">${esc(why)}</p>
|
||||
${refusal ? `<p id="tb-dialog-status" class="tb-form-status" role="status" data-tb-form-status>${esc(refusal)}</p>` : ''}
|
||||
<div class="tb-actions"><button type="button" class="ws-btn" data-tb-cancel>Close</button></div></div>`;
|
||||
<div class="tb-actions">${goto}<span class="tb-gap"></span><button type="button" class="ws-btn" data-tb-cancel>Close</button></div></div>`;
|
||||
}
|
||||
return `<form method="dialog" class="tb-form" data-tb-form="edit" data-tb-key="${esc(key(t))}">
|
||||
<h2 id="tb-dialog-h">Edit ${esc(shortId(t.ref))}</h2>${facts}
|
||||
@@ -363,11 +478,12 @@
|
||||
<div class="tb-row2">${stateSelect(t.state)}${prioSelect(t.priority)}<label>Due date<input type="date" name="due_date" value="${dateValue(t.dueDate)}"></label></div>
|
||||
${roleBoxes([...new Set([...o.roles, ...t.assignees])].sort(), t.assignees)}
|
||||
<p class="tb-form-status" role="alert" data-tb-form-status></p>
|
||||
<div class="tb-actions"><button type="button" class="ws-btn danger" data-tb-archive>Archive</button><span class="tb-gap"></span><button type="button" class="ws-btn" data-tb-cancel>Cancel</button><button type="submit" class="ws-btn primary" data-tb-submit>Save</button></div>
|
||||
<div class="tb-actions"><button type="button" class="ws-btn danger" data-tb-archive>Archive</button>${goto}<span class="tb-gap"></span><button type="button" class="ws-btn" data-tb-cancel>Cancel</button><button type="submit" class="ws-btn primary" data-tb-submit>Save</button></div>
|
||||
</form>`;
|
||||
}
|
||||
function openDialog(html, back, task = null) {
|
||||
const d = dialogEl();
|
||||
delete d.dataset.fromAddress;
|
||||
d.innerHTML = html; d.dataset.return = back || ''; opened = task ? structuredClone(task) : null;
|
||||
if (d.querySelector('#tb-dialog-status')) d.setAttribute('aria-describedby', 'tb-dialog-status'); else d.removeAttribute('aria-describedby');
|
||||
if (!d.open) d.showModal();
|
||||
@@ -474,6 +590,8 @@
|
||||
const d = e.target.closest?.('#tb-dialog');
|
||||
if (d) {
|
||||
if (e.target.closest('[data-tb-cancel]')) { e.preventDefault(); d.close(); }
|
||||
// The link navigates; the dialog closes so the organization's board opens the task itself.
|
||||
else if (e.target.closest('[data-tb-goto]')) { d.dataset.return = ''; d.close(); }
|
||||
else if (e.target.closest('[data-tb-archive]')) { e.preventDefault(); archive(e.target.closest('form')); }
|
||||
return;
|
||||
}
|
||||
@@ -482,10 +600,14 @@
|
||||
if (lay) { layouts[phone() ? 'phone' : 'wide'] = lay.dataset.tbLayout; save(LAYOUT, JSON.stringify(layouts)); paint(); return; }
|
||||
const chip = e.target.closest('[data-tb-org]');
|
||||
if (chip) {
|
||||
filter.org = chip.dataset.tbOrg;
|
||||
if (filter.project && filter.project !== '-' && filter.org && !filter.project.startsWith(`${filter.org}:`)) { filter.project = ''; refilter(); } else paint();
|
||||
// The org filter lives in the address: paint now, then bus.js re-renders from the new query.
|
||||
const org = chip.dataset.tbOrg, clear = filter.project && filter.project !== '-' && org && !filter.project.startsWith(`${org}:`);
|
||||
filter.org = org; if (clear) filter.project = '';
|
||||
paint();
|
||||
ws()?.setQuery?.({ org: org || null, ...(clear ? { project: null } : {}) }, true);
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('[data-tb-done]')) { filter.done = ''; paint(); ws()?.setQuery?.({ done: null }, true); return; }
|
||||
const open = e.target.closest('[data-tb-open]');
|
||||
if (open) { const t = find(open.dataset.tbOpen); if (t) openDialog(editForm(t), open.dataset.focusKey, t); return; }
|
||||
const add = e.target.closest('[data-tb-new]');
|
||||
@@ -500,6 +622,7 @@
|
||||
}
|
||||
if (!inBoard(e) || !snap) return;
|
||||
const f = e.target.dataset?.tbFilter;
|
||||
if (f === 'project') { filter.project = e.target.value; paint(); ws()?.setQuery?.({ project: e.target.value || null }, true); return; }
|
||||
if (f) { filter[f] = e.target.value; refilter(); return; }
|
||||
if (e.target.matches('[data-tb-move]')) move(e.target.dataset.tbMove, e.target.value);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/* SP9 (#1873): the read-only workflow pages (#/orgs/<id>/workflows[/<name>]) and the organization
|
||||
page's Workflows card. Colours come from the theme tokens, so light and dark both follow the theme. */
|
||||
.wfg{min-width:0}
|
||||
.wfg h2{font-size:15px;margin:22px 0 8px}
|
||||
.wfg-scope:first-of-type h2{margin-top:6px}
|
||||
.wfg-list{list-style:none;margin:0;padding:0;background:var(--surface);border:1px solid var(--line);border-radius:10px}
|
||||
.wfg-item{padding:12px 14px;border-top:1px solid var(--line-soft,var(--line));overflow-wrap:anywhere}
|
||||
.wfg-item:first-child{border-top:0}
|
||||
.wfg-item-h{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 10px}
|
||||
.wfg-item-h a{font-weight:600;color:var(--action,var(--accent));text-decoration:none}
|
||||
.wfg-item-h a:hover{text-decoration:underline}
|
||||
.wfg-desc{margin:4px 0 0;color:var(--muted);font-size:13.5px}
|
||||
.wfg-chain{list-style:none;display:flex;flex-wrap:wrap;gap:4px 0;margin:8px 0 0;padding:0;font-size:13px;color:var(--text)}
|
||||
.wfg-chain li{display:inline-flex;align-items:center}
|
||||
.wfg-chain li+li::before{content:"\2192";color:var(--muted);padding:0 8px}
|
||||
:is(.wfg,.wfg-card) a:focus-visible{outline:2px solid var(--focus);outline-offset:2px;border-radius:4px}
|
||||
|
||||
/* The graph scrolls inside its own frame when it is wider than the frame; the page never does. */
|
||||
.wfg-frame{max-width:100%;overflow-x:auto;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:8px}
|
||||
.wfg-graph{display:block}
|
||||
.wfg-graph.is-vertical{width:100%;max-width:340px;height:auto;margin:0 auto}
|
||||
.wfg-node{cursor:pointer;text-decoration:none}
|
||||
.wfg-node rect{fill:var(--canvas);stroke:var(--line);stroke-width:1.5}
|
||||
.wfg-node:hover rect{stroke:var(--muted)}
|
||||
.wfg-node.is-sel rect{stroke:var(--accent);stroke-width:2.5;fill:var(--raised)}
|
||||
.wfg-node:focus{outline:none}
|
||||
.wfg-node:focus-visible rect{stroke:var(--focus);stroke-width:3}
|
||||
.wfg-node text{font-family:var(--font);fill:var(--text)}
|
||||
.wfg-meta{font-size:12px;fill:var(--muted)!important;letter-spacing:.02em}
|
||||
.wfg-title{font-size:15px;font-weight:600;fill:var(--strong,var(--text))!important}
|
||||
.wfg-roles{font-size:13px;font-family:var(--mono)!important;fill:var(--text)}
|
||||
.wfg-miss{font-size:12px;fill:var(--danger)!important}
|
||||
.wfg-node .wfg-none{fill:var(--muted);font-family:var(--font)}
|
||||
.wfg-edge{fill:none;stroke:var(--muted);stroke-width:1.5}
|
||||
.wfg-arrow{fill:var(--muted)}
|
||||
|
||||
.wfg-detail{margin-top:16px;overflow-wrap:anywhere}
|
||||
.wfg-detail h2{margin:0 0 10px}
|
||||
.wfg-dl{display:grid;grid-template-columns:max-content minmax(0,1fr);align-items:baseline;gap:8px 16px;margin:0 0 12px}
|
||||
.wfg-dl dt{color:var(--muted);font-size:13px}
|
||||
.wfg-dl dd{margin:0}
|
||||
@media (max-width:700px){
|
||||
.wfg-dl{grid-template-columns:minmax(0,1fr);gap:2px}
|
||||
.wfg-dl dd{margin-bottom:8px}
|
||||
}
|
||||
|
||||
/* The organization page's card, above Settings in the right column. */
|
||||
.wfg-card>.ws-sec-h{margin-top:0}
|
||||
.wfg-card+.ws-sec-h{margin-top:22px}
|
||||
.wfg-cardlist{list-style:none;margin:0}
|
||||
.wfg-cardlist li+li{border-top:1px solid var(--line-soft,var(--line))}
|
||||
.wfg-cardlist a{display:block;padding:8px 0;color:var(--text);text-decoration:none;overflow-wrap:anywhere}
|
||||
.wfg-cardlist a:hover strong{text-decoration:underline}
|
||||
.wfg-cardlist strong{color:var(--action,var(--accent))}
|
||||
.wfg-card [data-wfg-none]{margin:0 0 8px}
|
||||
.wfg-card p:last-child{margin-bottom:0}
|
||||
|
||||
/* SP10 (#1873): editing a workflow in its graph. The bar above the graph holds Unsaved, Discard, Save and
|
||||
Delete workflow; the step panel holds the step actions and the step form; the list page holds New workflow. */
|
||||
html[data-organizations] :is(.wfg-bar,.wfg-step-acts,.wfg-form,.wfg-new) .ws-btn{min-height:32px}
|
||||
html[data-organizations] :is(.wfg-bar,.wfg-form) .ws-btn.primary{background:var(--action);color:var(--onAction);border-color:var(--action)}
|
||||
html[data-organizations] :is(.wfg-bar,.wfg-form) .ws-btn.primary:hover:not(:disabled){background:color-mix(in srgb,var(--action) 88%,var(--strong))}
|
||||
html[data-organizations] :is(.wfg-bar,.wfg-step-acts) .ws-btn.danger{color:var(--danger)}
|
||||
html[data-organizations] :is(.wfg-bar,.wfg-step-acts,.wfg-form,.wfg-new) .ws-btn:focus-visible{outline:2px solid var(--focus);outline-offset:2px}
|
||||
.wfg-bar{display:flex;flex-wrap:wrap;align-items:center;gap:8px 12px;margin:0 0 10px}
|
||||
.wfg-acts{display:inline-flex;flex-wrap:wrap;align-items:center;gap:8px;margin-left:auto}
|
||||
.wfg-bar:not(.is-dirty) .wfg-acts:empty{display:none}
|
||||
.wfg-badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px;font-weight:600;color:var(--strong,var(--text));background:color-mix(in srgb,var(--accent) 18%,var(--surface));border:1px solid color-mix(in srgb,var(--accent) 45%,var(--line))}
|
||||
.wfg-confirm{display:inline-flex;flex-wrap:wrap;align-items:center;gap:8px;font-size:13.5px}
|
||||
.wfg-status,.wfg-stale{flex-basis:100%;margin:0;font-size:13.5px;overflow-wrap:anywhere}
|
||||
.wfg-stale{color:var(--danger)}
|
||||
.wfg-ro{margin:0 0 10px}
|
||||
.wfg-step-acts{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 12px}
|
||||
.wfg-form{display:grid;gap:10px;min-width:0}
|
||||
.wfg-form h2{margin:0}
|
||||
.wfg-form h3{font-size:14px;margin:6px 0 0}
|
||||
.wfg-form>label,.wfg-form label:has(>select){display:grid;gap:4px;font-size:13px;color:var(--muted)}
|
||||
.wfg-form input:not([type=checkbox]),.wfg-form select{font:14px/1.4 var(--font);color:var(--text);background:var(--canvas);border:1px solid var(--line);border-radius:6px;padding:6px 8px;min-width:0;max-width:100%}
|
||||
.wfg-form :is(input,select):focus-visible{outline:2px solid var(--focus);outline-offset:1px}
|
||||
.wfg-form .note{margin:0}
|
||||
.wfg-roles-pick{border:1px solid var(--line);border-radius:8px;padding:8px 12px 10px;margin:0;min-width:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:4px 12px}
|
||||
.wfg-roles-pick legend{font-size:13px;color:var(--muted);padding:0 4px}
|
||||
.wfg-pick-h{grid-column:1/-1;margin:6px 0 0;font-size:12px;color:var(--muted)}
|
||||
.wfg-pick{display:flex;align-items:baseline;gap:6px;font-size:13.5px;color:var(--text);overflow-wrap:anywhere;min-width:0}
|
||||
.wfg-pick input{margin:0;flex:none}
|
||||
.wfg-why{margin:0;color:var(--danger);font-size:13.5px}
|
||||
.wfg-why:empty{display:none}
|
||||
.wfg-form-acts{display:flex;flex-wrap:wrap;gap:8px;margin:0}
|
||||
.wfg-new{margin:0 0 12px}
|
||||
.wfg-newform{margin:0 0 16px}
|
||||
@media (max-width:700px){
|
||||
.wfg-acts{margin-left:0}
|
||||
.wfg-roles-pick{grid-template-columns:minmax(0,1fr)}
|
||||
}
|
||||
@@ -33,7 +33,8 @@
|
||||
const r = await fetch(url, method === 'GET' ? { cache: 'no-store' } : { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
||||
let value = null;
|
||||
try { value = await r.json(); } catch { value = null; }
|
||||
if (!r.ok) throw Error(guidance[value?.error] ?? `The bus refused: ${value?.error ?? 'outcome unknown'}.`);
|
||||
// The refusal code rides on the error, so a caller with its own words for one (the graph editor) can use them.
|
||||
if (!r.ok) throw Object.assign(Error(guidance[value?.error] ?? `The bus refused: ${value?.error ?? 'outcome unknown'}.`), { code: value?.error ?? null });
|
||||
return value;
|
||||
}
|
||||
const plain = v => v !== null && typeof v === 'object' && !Array.isArray(v);
|
||||
@@ -122,6 +123,40 @@
|
||||
}
|
||||
const flowsIn = (s, scope) => scope ? s.data.projects[scope] : s.data.workflows;
|
||||
const target = (s, scope) => scope ? { project: scope } : {};
|
||||
// SP10: the one check, body and write both editors use, the organization page's form and the graph.
|
||||
// A step is {title, state, roles: [names]}. The order of the checks is the form's: the name, a name the
|
||||
// scope already holds, a role that names no current role, then the shape the bus checks (organization-config
|
||||
// checkWorkflows: 1 to 20 steps, each with a title and 1 to 8 distinct roles), named here before the bus
|
||||
// refuses it in general words.
|
||||
const rolesIn = text => String(text ?? '').split(',').map(r => r.trim()).filter(Boolean);
|
||||
function stepRefusal(st, n) {
|
||||
if (!String(st.title ?? '').trim()) return `Step ${n} needs a title.`;
|
||||
if (!st.roles.length) return `Step ${n} needs at least one role.`;
|
||||
if (st.roles.length > 8) return `Step ${n} names ${st.roles.length} roles. A step takes at most eight.`;
|
||||
if (new Set(st.roles).size !== st.roles.length) return `Step ${n} names the same role twice.`;
|
||||
return '';
|
||||
}
|
||||
function refusal(s, { isNew, scope, name, steps }) {
|
||||
if (!NAME.test(name)) return NAME_RULE;
|
||||
// A save is a set: a new workflow under a name the scope already holds would replace it without a word.
|
||||
if (isNew && Object.hasOwn(flowsIn(s, scope) ?? {}, name)) return `A workflow named ${name} already exists here. Edit it instead.`;
|
||||
// E2E6 P2-1: a step role that names no current role is named before the bus refuses it generically.
|
||||
const unknown = steps.flatMap((st, i) => st.roles.filter(r => !s.known.has(r)).map(r => [i + 1, r]));
|
||||
if (unknown.length) return `${unknown.map(([i, r]) => `Step ${i}: ${r}`).join('; ')} ${unknown.length === 1 ? 'names' : 'name'} no current role. Use an organization role, a role template or human.`;
|
||||
if (!steps.length) return 'A workflow needs at least one step.';
|
||||
if (steps.length > 20) return 'A workflow has at most 20 steps.';
|
||||
for (const [i, st] of steps.entries()) { const why = stepRefusal(st, i + 1); if (why) return why; }
|
||||
return '';
|
||||
}
|
||||
const shape = ({ title = '', description = '', steps }) => ({
|
||||
...(title.trim() ? { title: title.trim() } : {}),
|
||||
...(description.trim() ? { description: description.trim() } : {}),
|
||||
steps: steps.map(st => ({ title: st.title.trim(), state: st.state, roles: [...st.roles] })),
|
||||
});
|
||||
const flowUrl = (org, name) => `${base(org)}/workflows/${encodeURIComponent(name)}`;
|
||||
// The revision is the one the editor opened on: a change made since then is a revision-conflict, not overwritten.
|
||||
const saveFlow = (org, s, { scope, name, revision, workflow }) => call(flowUrl(org, name), 'POST', { revision, workflow, ...target(s, scope) });
|
||||
const removeFlow = (org, s, { scope, name, revision }) => call(flowUrl(org, name), 'DELETE', { revision, ...target(s, scope) });
|
||||
async function reload(org, s, status) {
|
||||
try { await read(org, s); s.status = status; } catch (error) { s.status = `${status} ${error.message}`; }
|
||||
}
|
||||
@@ -149,7 +184,7 @@
|
||||
} else if (button.hasAttribute('data-wf-delete-yes')) {
|
||||
button.disabled = true;
|
||||
try {
|
||||
await call(`${base(org)}/workflows/${encodeURIComponent(name)}`, 'DELETE', { revision: s.data.revision, ...target(s, scope) });
|
||||
await removeFlow(org, s, { scope, name, revision: s.data.revision });
|
||||
s.confirm = null;
|
||||
await reload(org, s, `Deleted ${name}.`);
|
||||
} catch (error) { s.status = error.message; }
|
||||
@@ -176,26 +211,550 @@
|
||||
if (!s?.editing) return;
|
||||
capture(root, s);
|
||||
const e = s.editing;
|
||||
if (!NAME.test(e.name)) { s.status = NAME_RULE; redraw(org); return; }
|
||||
// A save is a set: a new workflow under a name the scope already holds would replace it without a word.
|
||||
if (e.isNew && Object.hasOwn(flowsIn(s, e.scope) ?? {}, e.name)) { s.status = `A workflow named ${e.name} already exists here. Edit it instead.`; redraw(org); return; }
|
||||
// E2E6 P2-1: a step role that names no current role is named before the bus refuses it generically.
|
||||
const unknown = e.draft.steps.flatMap((st, i) => st.roles.split(',').map(r => r.trim()).filter(r => r && !s.known.has(r)).map(r => [i + 1, r]));
|
||||
if (unknown.length) { s.status = `${unknown.map(([i, r]) => `Step ${i}: ${r}`).join('; ')} ${unknown.length === 1 ? 'names' : 'name'} no current role. Use an organization role, a role template or human.`; redraw(org); return; }
|
||||
const workflow = {
|
||||
...(e.draft.title.trim() ? { title: e.draft.title.trim() } : {}),
|
||||
...(e.draft.description.trim() ? { description: e.draft.description.trim() } : {}),
|
||||
steps: e.draft.steps.map(st => ({ title: st.title.trim(), state: st.state, roles: st.roles.split(',').map(r => r.trim()).filter(Boolean) })),
|
||||
};
|
||||
const draft = { ...e.draft, steps: e.draft.steps.map(st => ({ ...st, roles: rolesIn(st.roles) })) };
|
||||
const why = refusal(s, { isNew: e.isNew, scope: e.scope, name: e.name, steps: draft.steps });
|
||||
if (why) { s.status = why; redraw(org); return; }
|
||||
const workflow = shape(draft);
|
||||
const button = ev.target.querySelector('button[type="submit"]');
|
||||
button.disabled = true;
|
||||
try {
|
||||
// The revision the editor opened on: a change made since then is a revision-conflict, not overwritten.
|
||||
await call(`${base(org)}/workflows/${encodeURIComponent(e.name)}`, 'POST', { revision: e.revision ?? s.data.revision, workflow, ...target(s, e.scope) });
|
||||
await saveFlow(org, s, { scope: e.scope, name: e.name, revision: e.revision ?? s.data.revision, workflow });
|
||||
s.editing = null;
|
||||
await reload(org, s, `Saved ${e.name}.`);
|
||||
} catch (error) { s.status = error.message; }
|
||||
redraw(org);
|
||||
});
|
||||
window.MosaicWorkflows = { load, section };
|
||||
|
||||
// ---------- SP9 (#1873): the read-only view ----------
|
||||
// #/orgs/<id>/workflows lists the organization's workflows; #/orgs/<id>/workflows/<name>[?project=<p>]
|
||||
// draws one as an inline-SVG graph. The data is the same read as the editor above: each step's title,
|
||||
// state and roles, in file order. A workflow has no edges or branches in the file, so a step hands on to
|
||||
// the next one. Nothing else is drawn: no gate, no inputs and no running mark, because no read gives them.
|
||||
const WS = () => window.MosaicWorkspace;
|
||||
const orgHash = org => `#/orgs/${encodeURIComponent(org)}`;
|
||||
const flowHref = (org, scope, name, step) => {
|
||||
const q = new URLSearchParams();
|
||||
if (scope) q.set('project', scope);
|
||||
if (step) q.set('step', String(step));
|
||||
return `${orgHash(org)}/workflows/${encodeURIComponent(name)}${q.size ? `?${q}` : ''}`;
|
||||
};
|
||||
const stepsOf = w => (Array.isArray(w?.steps) ? w.steps.filter(st => st && typeof st === 'object') : []);
|
||||
const rolesOf = st => (Array.isArray(st?.roles) ? st.roles.filter(r => typeof r === 'string') : []);
|
||||
const count = n => `${n} step${n === 1 ? '' : 's'}`;
|
||||
const stateLabel = st => STATE_LABEL[st?.state] ?? String(st?.state ?? '');
|
||||
const scopeLabel = scope => (scope ? `Project ${scope}` : 'Organization');
|
||||
// Each scope that holds a workflow, the organization first. Names are own keys only.
|
||||
const listed = s => scopes(s).map(([scope, label, flows]) => [scope, label, Object.keys(plain(flows) ? flows : {}).sort().map(n => [n, flows[n]])]);
|
||||
const total = s => listed(s).reduce((n, [, , rows]) => n + rows.length, 0);
|
||||
// The organization's bus state from the membership list, and for one that is down the workspace read's
|
||||
// reason in the Inbox's words (orgsOf + unreadNote, the data-ws-unread note).
|
||||
async function orgOf(org) {
|
||||
const list = await window.MosaicWorkspaceData.organizations().catch(() => null);
|
||||
const row = Array.isArray(list) ? list.find(o => o?.id === org && !o.invalid) : null;
|
||||
if (!row) return { row: null, list: Array.isArray(list) };
|
||||
let unread = '';
|
||||
if (!row.busHealthy) {
|
||||
const body = await window.MosaicWorkspaceData.workspace({ org, sections: 'projects' }).catch(() => null);
|
||||
const o = body ? WS().orgsOf({ orgs: (Array.isArray(body.orgs) ? body.orgs : []).filter(r => r?.org === org) }) : null;
|
||||
unread = o?.unread.length ? WS().unreadNote(o, [], 'workflows')
|
||||
: `<p class="note ws-unread-note" data-ws-unread>Not read: ${esc(row.label || org)} ${row.busStopped ? 'bus stopped' : 'bus not answering'}. No reason was read.</p>`;
|
||||
}
|
||||
return { row, unread, stopped: !row.busHealthy && row.busStopped === true, label: row.label || org };
|
||||
}
|
||||
const head = (title, sub) => `<div class="ws-vh"><h1 tabindex="-1">${title}</h1><p class="sub">${sub}</p></div>`;
|
||||
const applyLink = (org, s) => (s?.data?.writable
|
||||
? `<p><a class="ws-btn" href="${esc(`${orgHash(org)}/organization`)}" data-wfg-apply data-focus-key="wfg-apply">Apply a pack</a></p>`
|
||||
: '<p class="note">A starter pack brings workflows. Only the owner, on a Console started with send, can apply one.</p>');
|
||||
const none = (org, s, label) => `<p class="note" data-wfg-none>No workflows in ${esc(label)} yet.</p>${applyLink(org, s)}`;
|
||||
|
||||
// Layout: left to right on a wide screen, top to bottom on a narrow one. The graph scrolls inside its
|
||||
// own frame when it is wider than the frame; the page never does.
|
||||
const NARROW = '(max-width: 700px)';
|
||||
const narrow = () => window.matchMedia(NARROW).matches;
|
||||
// SP10 (sp9 follow-up): box text is fitted by its measured width in the font it is drawn in, not by a
|
||||
// character count, so a line ends inside its box with an ellipsis and a short name shows whole. The clip
|
||||
// path stays as a backstop. Without a canvas the old per-character estimate is used.
|
||||
let ctx;
|
||||
function measure(text, font, per) {
|
||||
if (ctx === undefined) { try { ctx = document.createElement('canvas').getContext('2d'); } catch { ctx = null; } }
|
||||
if (!ctx) return text.length * per;
|
||||
ctx.font = font;
|
||||
return ctx.measureText(text).width;
|
||||
}
|
||||
const token = (name, fallback) => getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
|
||||
const fonts = () => ({
|
||||
title: [`600 15px ${token('--font', 'sans-serif')}`, 8.6],
|
||||
roles: [`13px ${token('--mono', 'monospace')}`, 7.9],
|
||||
miss: [`12px ${token('--font', 'sans-serif')}`, 6.8],
|
||||
});
|
||||
function fit(text, [font, per], max) {
|
||||
if (measure(text, font, per) <= max) return text;
|
||||
let lo = 0, hi = text.length;
|
||||
while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (measure(`${text.slice(0, mid)}…`, font, per) <= max) lo = mid; else hi = mid - 1; }
|
||||
return `${text.slice(0, lo)}…`;
|
||||
}
|
||||
// Up to two lines broken at spaces; the second ends in … when the title is longer.
|
||||
function wrap(text, f, max) {
|
||||
const words = String(text ?? '').split(/\s+/).filter(Boolean), lines = [''];
|
||||
let over = false;
|
||||
for (const w of words) {
|
||||
const cur = lines[lines.length - 1], next = cur ? `${cur} ${w}` : w;
|
||||
if (!cur || measure(next, f[0], f[1]) <= max) lines[lines.length - 1] = next;
|
||||
else if (lines.length < 2) lines.push(w);
|
||||
else { lines[1] = `${lines[1]} ${w}`; over = true; }
|
||||
}
|
||||
if (measure(lines[0], f[0], f[1]) > max) return [fit(lines[0], f, max)];
|
||||
if (lines[1] && (over || measure(lines[1], f[0], f[1]) > max)) lines[1] = fit(lines[1], f, max);
|
||||
return lines;
|
||||
}
|
||||
function graph(org, s, scope, name, steps, sel) {
|
||||
const vertical = narrow(), f = fonts();
|
||||
const W = vertical ? 300 : 188, GAP = vertical ? 32 : 36, PAD = 14;
|
||||
// Text runs from x+12 and ends 12px before the box edge; 2px more absorbs a font that loads after the measure.
|
||||
const room = W - 26;
|
||||
// One role a line, at most three lines: with four or more, two roles and "+N more". Every role is in the label and the detail panel.
|
||||
const roleLines = st => { const r = rolesOf(st); return !r.length ? [] : r.length <= 3 ? r : [...r.slice(0, 2), `+${r.length - 2} more`]; };
|
||||
// A role that names no current role: "No current role:" and then each such role on its own line, at most
|
||||
// two lines, so the whole name shows (a name longer than the box ends in …).
|
||||
const missing = st => rolesOf(st).filter(r => !s.known.has(r));
|
||||
const missLines = st => { const m = missing(st); return m.length <= 2 ? m : [m[0], `+${m.length - 1} more`]; };
|
||||
const titleEnd = st => 46 + (wrap(st.title, f.title, room).length - 1) * 19;
|
||||
const rolesY = st => titleEnd(st) + 24;
|
||||
const missY = st => rolesY(st) + (Math.max(roleLines(st).length, 1) - 1) * 17 + 18;
|
||||
// Each box is as tall as the tallest step needs: meta, one or two title lines, its role lines, and the missing-role lines if any.
|
||||
const bottom = st => (missing(st).length ? missY(st) + missLines(st).length * 15 : missY(st) - 18);
|
||||
const H = Math.max(...steps.map(st => bottom(st) + 16));
|
||||
const pos = i => (vertical ? [PAD, PAD + i * (H + GAP)] : [PAD + i * (W + GAP), PAD]);
|
||||
const width = vertical ? W + 2 * PAD : PAD * 2 + steps.length * W + (steps.length - 1) * GAP;
|
||||
const height = vertical ? PAD * 2 + steps.length * H + (steps.length - 1) * GAP : H + 2 * PAD;
|
||||
const edges = steps.slice(1).map((_, i) => {
|
||||
const [x, y] = pos(i), [x2, y2] = pos(i + 1);
|
||||
const d = vertical ? `M${x + W / 2} ${y + H} V${y2 - 3}` : `M${x + W} ${y + H / 2} H${x2 - 3}`;
|
||||
return `<path class="wfg-edge" d="${d}" data-wfg-edge="${i + 1}-${i + 2}" marker-end="url(#wfg-arrow)"/>`;
|
||||
}).join('');
|
||||
const nodes = steps.map((st, i) => {
|
||||
const [x, y] = pos(i), n = i + 1, roles = rolesOf(st), miss = missing(st);
|
||||
const lines = wrap(st.title, f.title, room), on = sel === n;
|
||||
const label = `Step ${n} of ${steps.length}: ${st.title ?? ''}. ${stateLabel(st)}. Roles ${roles.join(', ')}${miss.length ? `; ${miss.join(', ')} ${MISSING}` : ''}.`;
|
||||
const rl = roleLines(st), more = roles.length > 3;
|
||||
const roleText = rl.length
|
||||
? rl.map((r, j) => `<tspan x="${x + 12}" dy="${j ? 17 : 0}"${more && j === rl.length - 1 ? ' data-wfg-more' : ''}>${esc(more && j === rl.length - 1 ? r : fit(r, f.roles, room))}</tspan>`).join('')
|
||||
: '<tspan class="wfg-none">No roles recorded</tspan>';
|
||||
const missText = miss.length
|
||||
? `<text class="wfg-miss" data-wfg-missing x="${x + 12}" y="${y + missY(st)}"><tspan x="${x + 12}" dy="0">No current role:</tspan>${missLines(st).map((r, j) => `<tspan x="${x + 12}" dy="15"${miss.length > 2 && j === 1 ? ' data-wfg-more' : ''}>${esc(miss.length > 2 && j === 1 ? r : fit(r, f.miss, room))}</tspan>`).join('')}</text>`
|
||||
: '';
|
||||
return `<a class="wfg-node${on ? ' is-sel' : ''}" href="${esc(flowHref(org, scope, name, n))}" data-wfg-step="${n}" data-focus-key="${esc(`wfg-step:${n}`)}" aria-label="${esc(label)}"${on ? ' aria-current="step"' : ''}>
|
||||
<title>${esc(`${st.title ?? ''}${roles.length ? ` (${roles.join(', ')})` : ''}`)}</title><rect x="${x}" y="${y}" width="${W}" height="${H}" rx="10"/>
|
||||
<clipPath id="wfg-clip-${n}"><rect x="${x + 2}" y="${y}" width="${W - 10}" height="${H}"/></clipPath>
|
||||
<g clip-path="url(#wfg-clip-${n})">
|
||||
<text class="wfg-meta" x="${x + 12}" y="${y + 22}">${esc(fit(`Step ${n} · ${stateLabel(st)}`, f.miss, room))}</text>
|
||||
<text class="wfg-title" data-wfg-title x="${x + 12}" y="${y + 46}">${lines.map((l, j) => `<tspan x="${x + 12}" dy="${j ? 19 : 0}">${esc(l)}${j < lines.length - 1 ? ' ' : ''}</tspan>`).join('')}</text>
|
||||
<text class="wfg-roles" data-wfg-roles x="${x + 12}" y="${y + rolesY(st)}">${roleText}</text>
|
||||
${missText}</g></a>`;
|
||||
}).join('');
|
||||
return `<svg class="wfg-graph${vertical ? ' is-vertical' : ''}" data-wfg-graph role="group" aria-label="${esc(`${count(steps.length)}, in order`)}" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs><marker id="wfg-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto"><path class="wfg-arrow" d="M0 0L10 5L0 10z"/></marker></defs>${edges}${nodes}</svg>`;
|
||||
}
|
||||
|
||||
// ---------- SP10 (#1873): editing a workflow in its graph ----------
|
||||
// A change is held in the page, not written, until Save: the graph draws the pending workflow, the bar says
|
||||
// Unsaved, and Save sends one POST with the revision the page read (saveFlow, the form's write). A refusal is
|
||||
// shown in words and the pending change kept. The poll redraws from the pending workflow, never over it.
|
||||
// When the file changes while a change is pending: if this workflow is as the page read it, the new revision
|
||||
// is taken (a workflow write sets only this workflow, so nothing else is overwritten); if this workflow
|
||||
// changed, the bar says so and offers Discard. Drafts live per organization until saved or discarded, and
|
||||
// the organization-switch and restart guards ask before dropping one (hasDraft).
|
||||
const drafts = new Map(), notes = new Map(), newForms = new Map();
|
||||
const dkey = (org, scope, name) => JSON.stringify([org, scope, name]);
|
||||
const norm = w => ({
|
||||
title: typeof w?.title === 'string' ? w.title : '', description: typeof w?.description === 'string' ? w.description : '',
|
||||
steps: stepsOf(w).map(st => ({ title: String(st.title ?? ''), state: st.state, roles: rolesOf(st) })),
|
||||
});
|
||||
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||||
const changed = d => d.isNew || !same(d.flow, d.base);
|
||||
const editDirty = e => !!e && !same({ title: e.title, state: e.state, roles: e.roles }, e.orig);
|
||||
const READ_ONLY = 'Only the owner, on a Console started with send, can change workflows.';
|
||||
// The graph's own words for a refusal where the form's would mislead: the change is kept, not lost.
|
||||
const kept = {
|
||||
'revision-conflict': 'Not saved: the organization file changed since this page read it. Your change is kept. Discard to load the current version.',
|
||||
'organization-busy': 'Not saved: the organization is busy with another write. Your change is kept. Try Save again shortly.',
|
||||
'no-bus-host': 'Not saved: the organization bus is not running. Your change is kept. Save again once it runs.',
|
||||
'invalid-request': 'Not saved: the bus refused the workflow. Check each step has a title, a state and one to eight distinct roles. Your change is kept.',
|
||||
};
|
||||
const say = text => { const live = document.getElementById('announce'); if (live) { live.textContent = ''; live.textContent = text; } };
|
||||
|
||||
// A pending change measured against the fresh read; drops one with nothing left to keep.
|
||||
function settle(org, s, scope, name, fresh) {
|
||||
const key = dkey(org, scope, name), d = drafts.get(key);
|
||||
if (!d) return null;
|
||||
if (d.busy) return d;
|
||||
const now = fresh ? norm(fresh) : null;
|
||||
if (d.isNew) { d.stale = !!fresh; if (!fresh) d.revision = s.data.revision; }
|
||||
else if (now && same(now, d.base)) { d.revision = s.data.revision; d.stale = false; }
|
||||
else if (now && !changed(d)) {
|
||||
Object.assign(d, { base: now, flow: structuredClone(now), revision: s.data.revision, stale: false });
|
||||
if (d.edit?.mode === 'edit' && d.edit.i >= now.steps.length) d.edit = null;
|
||||
} else d.stale = true;
|
||||
if (!changed(d) && !d.edit && !d.confirm && !d.status) { drafts.delete(key); return null; }
|
||||
return d;
|
||||
}
|
||||
function draftFor(org, s, scope, name) {
|
||||
const key = dkey(org, scope, name);
|
||||
if (drafts.has(key)) return drafts.get(key);
|
||||
const fresh = flowsIn(s, scope)?.[name];
|
||||
if (!fresh) return null;
|
||||
const base = norm(fresh), d = { isNew: false, base, flow: structuredClone(base), revision: s.data.revision, edit: null, confirm: false, status: '', busy: false, stale: false };
|
||||
drafts.set(key, d);
|
||||
notes.delete(key);
|
||||
return d;
|
||||
}
|
||||
// The role picker: human, the organization's roles, the role templates, and first any role the step names
|
||||
// that resolves to none of those (checked and marked, so leaving it in is a choice the save check names).
|
||||
function rolePick(s, chosen, prefix) {
|
||||
const defs = [...(s.data.roles.definitions || [])].sort(), tpl = [...(s.data.roles.templates || [])].filter(r => !defs.includes(r)).sort();
|
||||
const odd = chosen.filter(r => !s.known.has(r));
|
||||
const box = (r, mark = '') => `<label class="wfg-pick"><input type="checkbox" name="role" value="${esc(r)}"${chosen.includes(r) ? ' checked' : ''} data-focus-key="${esc(`${prefix}:role:${r}`)}"> <code>${esc(r)}</code>${mark}</label>`;
|
||||
const group = (title, list, mark) => (list.length ? `<p class="wfg-pick-h">${title}</p>${list.map(r => box(r, mark)).join('')}` : '');
|
||||
return `<fieldset class="wfg-roles-pick" data-wfg-roles-pick><legend>Roles, one to eight</legend>
|
||||
${group('Named in this step', odd, ` <span class="wf-missing" data-wfg-pick-missing>${MISSING}</span>`)}${box('human', ' <span class="muted">a person</span>')}
|
||||
${group('This organization\'s roles', defs.filter(r => r !== 'human'), '')}${group('Role templates', tpl.filter(r => r !== 'human'), '')}</fieldset>`;
|
||||
}
|
||||
const stateSelect = (value, key) => `<label>State<select name="state" data-focus-key="${esc(key)}">${STATES.map(([v, l]) => `<option value="${v}"${value === v ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select></label>`;
|
||||
function stepForm(org, s, scope, name, d) {
|
||||
const e = d.edit, n = e.i + 1;
|
||||
return `<form class="wfg-form" data-wfg-form data-live-key="${esc(`wfg-form:${org}:${scope}:${name}:${e.mode}:${e.i}`)}" data-live-hold novalidate>
|
||||
<h2 data-wfg-form-title>${e.mode === 'add' ? `New step ${n}` : `Edit step ${n}`}</h2>
|
||||
<label>Title<input name="title" maxlength="120" value="${esc(e.title)}" autocomplete="off" data-focus-key="wfg-f-title"></label>
|
||||
${stateSelect(e.state, 'wfg-f-state')}${rolePick(s, e.roles, 'wfg-f')}
|
||||
<p class="wfg-why" data-wfg-why>${esc(e.why ?? '')}</p>
|
||||
<p class="wfg-form-acts"><button type="submit" class="ws-btn primary" data-wfg-apply data-focus-key="wfg-f-apply">${e.mode === 'add' ? 'Add step' : 'Apply'}</button> <button type="button" class="ws-btn" data-wfg-cancel data-focus-key="wfg-f-cancel">Cancel</button></p>
|
||||
<p class="note">Apply changes the graph on this page. Save writes it to the organization file.</p></form>`;
|
||||
}
|
||||
function bar(org, s, scope, name, d) {
|
||||
if (!s.data.writable) return `<p class="note wfg-ro" data-wfg-readonly>${READ_ONLY}</p>`;
|
||||
const dirty = !!d && changed(d), busy = d?.busy ? ' disabled' : '';
|
||||
const status = d?.status || notes.get(dkey(org, scope, name)) || '';
|
||||
const stale = d?.stale ? `<p class="wfg-stale" data-wfg-stale>${d.isNew ? `A workflow named ${esc(name)} now exists here. Save will be refused. Discard to drop this one.` : 'The organization file changed this workflow since the page read it. Save will be refused. Discard to load the current version.'}</p>` : '';
|
||||
const acts = d?.confirm
|
||||
? `<span class="wfg-confirm" data-wfg-confirm>Delete ${esc(name)} from the organization file? <button type="button" class="ws-btn danger" data-wfg-delete-yes data-focus-key="wfg-delete-yes"${busy}>Delete workflow</button> <button type="button" class="ws-btn" data-wfg-delete-no data-focus-key="wfg-delete-no"${busy}>Keep</button></span>`
|
||||
: `${dirty ? `<button type="button" class="ws-btn" data-wfg-discard data-focus-key="wfg-discard"${busy}>Discard</button> <button type="button" class="ws-btn primary" data-wfg-save data-focus-key="wfg-save"${busy}>${d.busy ? 'Saving…' : 'Save'}</button>` : ''}${d?.isNew ? '' : ` <button type="button" class="ws-btn danger" data-wfg-delete data-focus-key="wfg-delete"${busy}>Delete workflow</button>`}`;
|
||||
return `<div class="wfg-bar${dirty ? ' is-dirty' : ''}" data-wfg-bar>${dirty ? `<span class="wfg-badge" data-wfg-unsaved>Unsaved${d.isNew ? ', not in the file yet' : ''}</span>` : ''}<span class="wfg-acts">${acts}</span>
|
||||
${status ? `<p class="wfg-status" data-wfg-status>${esc(status)}</p>` : ''}${stale}</div>`;
|
||||
}
|
||||
const stepRef = (org, scope, name, steps, n) => `<a href="${esc(flowHref(org, scope, name, n))}" data-wfg-go="${n}">Step ${n}, ${esc(steps[n - 1].title)}</a>`;
|
||||
function actions(steps, sel, d) {
|
||||
const busy = d?.busy ? ' disabled' : '';
|
||||
if (!sel) return `<p class="wfg-step-acts" data-wfg-actions><button type="button" class="ws-btn" data-wfg-add data-focus-key="wfg-add"${busy}>Add a step at the end</button></p>`;
|
||||
const dis = c => (c || d?.busy ? ' disabled' : '');
|
||||
return `<p class="wfg-step-acts" data-wfg-actions><button type="button" class="ws-btn" data-wfg-edit data-focus-key="wfg-edit"${busy}>Edit step</button>
|
||||
<button type="button" class="ws-btn" data-wfg-add data-focus-key="wfg-add"${dis(steps.length >= 20)}>Add a step after</button>
|
||||
<button type="button" class="ws-btn" data-wfg-up data-focus-key="wfg-up"${dis(sel === 1)}>Move earlier</button>
|
||||
<button type="button" class="ws-btn" data-wfg-down data-focus-key="wfg-down"${dis(sel === steps.length)}>Move later</button>
|
||||
<button type="button" class="ws-btn danger" data-wfg-remove data-focus-key="wfg-remove"${dis(steps.length === 1)}>Remove step</button></p>`;
|
||||
}
|
||||
function detail(org, s, scope, name, steps, sel, d) {
|
||||
const writable = !!s.data.writable;
|
||||
if (writable && d?.edit) return `<section class="panel wfg-detail" data-wfg-detail>${stepForm(org, s, scope, name, d)}</section>`;
|
||||
if (!sel) return `<section class="panel wfg-detail" data-wfg-detail><h2>Step details</h2><p class="note">Select a step to see its roles, the state it puts a task in and the step it hands on to.</p>${writable ? actions(steps, 0, d) : ''}</section>`;
|
||||
const st = steps[sel - 1], roles = rolesOf(st);
|
||||
const roleList = roles.map(r => r === 'human' ? '<code>human</code> (a person)' : s.known.has(r) ? `<code>${esc(r)}</code>` : `<code>${esc(r)}</code> <span class="wf-missing" data-wfg-missing>${MISSING}</span>`).join(', ');
|
||||
return `<section class="panel wfg-detail" data-wfg-detail><h2 data-wfg-detail-title>${esc(st.title)}</h2><dl class="wfg-dl">
|
||||
<dt>Position</dt><dd data-wfg-field="position">Step ${sel} of ${steps.length}</dd>
|
||||
<dt>Roles</dt><dd data-wfg-field="roles">${roleList || '<span class="muted">None recorded</span>'}</dd>
|
||||
<dt>Puts the task in</dt><dd data-wfg-field="state">${esc(stateLabel(st))}</dd>
|
||||
<dt>Comes after</dt><dd data-wfg-field="after">${sel > 1 ? stepRef(org, scope, name, steps, sel - 1) : 'Nothing: this is the first step'}</dd>
|
||||
<dt>Hands on to</dt><dd data-wfg-field="next">${sel < steps.length ? stepRef(org, scope, name, steps, sel + 1) : 'Nothing: this is the last step'}</dd>
|
||||
</dl>${writable ? actions(steps, sel, d) : ''}<p class="note">The workflow file records no gate or inputs for a step, and no read says which step a task is on now.</p></section>`;
|
||||
}
|
||||
function graphBody(org, s, name, q) {
|
||||
const scope = q.get('project') || '';
|
||||
if (scope && !Object.hasOwn(s.data.projects, scope)) return { title: esc(name), sub: '', body: `<p class="note" data-wfg-unknown>No code project named ${esc(scope)} in the organization file.</p>` };
|
||||
const flows = flowsIn(s, scope), fresh = plain(flows) && Object.hasOwn(flows, name) ? flows[name] : null;
|
||||
const d = s.data.writable ? settle(org, s, scope, name, fresh) : null;
|
||||
const note = notes.get(dkey(org, scope, name));
|
||||
if (!fresh && !d) return { title: esc(name), sub: '', body: `${note ? `<p class="wfg-status" data-wfg-status>${esc(note)}</p>` : ''}<p class="note" data-wfg-unknown>No workflow named ${esc(name)} in ${scope ? `project ${esc(scope)}` : 'the organization'}.</p>` };
|
||||
const w = d ? d.flow : norm(fresh), steps = w.steps;
|
||||
const raw = Number(q.get('step')), sel = Number.isInteger(raw) && raw >= 1 && raw <= steps.length ? raw : 0;
|
||||
const sub = `${esc(scopeLabel(scope))}, <code>${esc(name)}</code>, ${count(steps.length)}.${w.description ? ` ${esc(w.description)}` : ''}`;
|
||||
const body = steps.length
|
||||
? `${bar(org, s, scope, name, d)}<div class="wfg-frame" data-wfg-frame>${graph(org, s, scope, name, steps, sel)}</div>${detail(org, s, scope, name, steps, sel, d)}`
|
||||
: `${bar(org, s, scope, name, d)}<p class="note">This workflow has no steps.</p>`;
|
||||
return { title: esc(w.title || name), sub, body };
|
||||
}
|
||||
|
||||
// The list page's New workflow form: where, a name, and the first step. Create opens it in the graph, unsaved.
|
||||
function newForm(org, s) {
|
||||
const f = newForms.get(org);
|
||||
if (!f) return `<p class="wfg-new"><button type="button" class="ws-btn" data-wfg-new data-focus-key="wfg-new">New workflow</button></p>`;
|
||||
return `<form class="wfg-form wfg-newform panel" data-wfg-form data-wfg-newform data-live-key="${esc(`wfg-new:${org}`)}" data-live-hold novalidate>
|
||||
<h2>New workflow</h2>
|
||||
<label>Where<select name="scope" data-focus-key="wfg-n-scope">${scopes(s).map(([v, l]) => `<option value="${esc(v)}"${f.scope === v ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select></label>
|
||||
<label>Name<input name="name" maxlength="64" value="${esc(f.name)}" autocomplete="off" placeholder="lowercase letters, digits, dashes" data-focus-key="wfg-n-name"></label>
|
||||
<p class="note">${NAME_RULE}</p>
|
||||
<h3>First step</h3>
|
||||
<label>Title<input name="title" maxlength="120" value="${esc(f.title)}" autocomplete="off" data-focus-key="wfg-n-title"></label>
|
||||
${stateSelect(f.state, 'wfg-n-state')}${rolePick(s, f.roles, 'wfg-n')}
|
||||
<p class="wfg-why" data-wfg-why>${esc(f.why ?? '')}</p>
|
||||
<p class="wfg-form-acts"><button type="submit" class="ws-btn primary" data-wfg-create data-focus-key="wfg-n-create">Create</button> <button type="button" class="ws-btn" data-wfg-new-cancel data-focus-key="wfg-n-cancel">Cancel</button></p>
|
||||
<p class="note">Create opens it in its graph. It is written to the organization file when you save it there.</p></form>`;
|
||||
}
|
||||
// The rows each scope lists: the file's workflows, and any new one not saved yet.
|
||||
function rowsWithDrafts(org, s) {
|
||||
return listed(s).map(([scope, label, rows]) => {
|
||||
const names = new Set(rows.map(([n]) => n));
|
||||
const fresh = [...drafts.entries()].map(([k, d]) => [JSON.parse(k), d]).filter(([[o, sc, n], d]) => o === org && sc === scope && d.isNew && !names.has(n)).map(([[, , n], d]) => [n, d.flow]);
|
||||
return [scope, label, [...rows, ...fresh].sort(([p], [q]) => (p < q ? -1 : p > q ? 1 : 0))];
|
||||
});
|
||||
}
|
||||
function listBody(org, s, label) {
|
||||
const writable = !!s.data.writable, rows = writable ? rowsWithDrafts(org, s) : listed(s);
|
||||
const top = `${notes.has(dkey(org, '', '')) ? `<p class="wfg-status" data-wfg-status>${esc(notes.get(dkey(org, '', '')))}</p>` : ''}${writable ? newForm(org, s) : `<p class="note wfg-ro" data-wfg-readonly>${READ_ONLY}</p>`}`;
|
||||
if (!rows.some(([, , r]) => r.length)) return `${top}${none(org, s, label)}`;
|
||||
return top + rows.filter(([scope, , r]) => !scope || r.length).map(([scope, label2, r]) => `<section class="wfg-scope" data-wfg-scope-list="${esc(scope)}"><h2>${esc(label2)}</h2>${r.length ? `<ul class="wfg-list">${r.map(([name, w]) => {
|
||||
const steps = stepsOf(w), d = drafts.get(dkey(org, scope, name)), pending = d && changed(d);
|
||||
return `<li class="wfg-item" data-wfg-item="${esc(name)}" data-wfg-scope="${esc(scope)}"><div class="wfg-item-h"><a href="${esc(flowHref(org, scope, name))}" data-wfg-open data-focus-key="${esc(`wfg-open:${scope}:${name}`)}">${esc(w?.title || name)}</a>${w?.title ? ` <code>${esc(name)}</code>` : ''} <span class="muted">${count(steps.length)}</span>${pending ? ` <span class="wfg-badge" data-wfg-unsaved>Unsaved${d.isNew ? ', not in the file yet' : ''}</span>` : ''}</div>
|
||||
${w?.description ? `<p class="wfg-desc">${esc(w.description)}</p>` : ''}<ol class="wfg-chain" data-wfg-chain aria-label="Steps in order">${steps.map(st => `<li>${esc(st.title)}</li>`).join('')}</ol></li>`;
|
||||
}).join('')}</ul>` : '<p class="note">None at the organization level.</p>'}</section>`).join('');
|
||||
}
|
||||
|
||||
// The page for #/orgs/<org>/workflows (name null) or #/orgs/<org>/workflows/<name>. page() reads; compose()
|
||||
// draws from what was read, so an edit on the page redraws at once (paint) without waiting for a read.
|
||||
const shown = { org: null, name: null, info: null };
|
||||
function compose(org, name, q, info) {
|
||||
const attr = name === null ? 'data-wfg-list' : 'data-wfg-page';
|
||||
const wrapIt = (title, sub, body) => `<article class="ws wfg" ${attr} data-wfg-org="${esc(org)}"${name === null ? '' : ` data-wfg-name="${esc(name)}"`}>${head(title, sub)}${info.unread}${body}</article>`;
|
||||
// Review P3: a change held for this organization that the page cannot draw now is said to be kept.
|
||||
const held = [...drafts].filter(([k, d]) => JSON.parse(k)[0] === org && (changed(d) || editDirty(d.edit))).map(([k]) => JSON.parse(k)[2]);
|
||||
const keptNote = held.length ? `<p class="note" data-wfg-held>Unsaved change to ${held.map(n => esc(n)).join(', ')} is kept on this page. It comes back when the workflows can be read.</p>` : '';
|
||||
// E2E6 P2-8: a stopped bus is not read; the note above says why.
|
||||
if (info.stopped) return wrapIt(name === null ? 'Workflows' : esc(name), `Workflows in ${esc(info.label)}.`, keptNote);
|
||||
const s = states.get(org);
|
||||
if (!s?.data) return wrapIt(name === null ? 'Workflows' : esc(name), `Workflows in ${esc(info.label)}.`, `<p class="note" data-wfg-error>${esc(s?.error ?? 'Workflows could not be read.')}</p>${keptNote}`);
|
||||
if (name === null) return wrapIt('Workflows', `The steps a task goes through in ${esc(info.label)}, in order, with the roles that do each.${s.data.writable ? ' Open one to change it.' : ''}`, listBody(org, s, info.label));
|
||||
const g = graphBody(org, s, name, q);
|
||||
return wrapIt(g.title, g.sub, g.body);
|
||||
}
|
||||
async function page(org, name, q) {
|
||||
const info = await orgOf(org);
|
||||
if (!info.row) return `<article class="ws wfg" ${name === null ? 'data-wfg-list' : 'data-wfg-page'} data-wfg-org="${esc(org)}">${head('Organization unavailable', info.list ? 'This organization is not on your list.' : 'The organization list could not be read.')}</article>`;
|
||||
if (!info.stopped) await load(org);
|
||||
Object.assign(shown, { org, name, info });
|
||||
return compose(org, name, q, info);
|
||||
}
|
||||
const query = () => new URLSearchParams(location.hash.split('?')[1] || '');
|
||||
// Redraw the open workflow page from the page's state, then put focus on `focus` (a data-focus-key).
|
||||
function paint(focus) {
|
||||
const el = document.querySelector('#bus-view [data-wfg-list], #bus-view [data-wfg-page]');
|
||||
if (!el || !shown.info || el.dataset.wfgOrg !== shown.org || (el.dataset.wfgName ?? null) !== shown.name) return;
|
||||
el.outerHTML = compose(shown.org, shown.name, query(), shown.info);
|
||||
const el2 = focus && document.querySelector(`#bus-view [data-focus-key="${CSS.escape(focus)}"]`);
|
||||
if (!el2) return;
|
||||
el2.focus();
|
||||
// A text field takes focus with the caret after its text, ready to change it.
|
||||
if (el2.matches('input:not([type=checkbox])')) el2.setSelectionRange(el2.value.length, el2.value.length);
|
||||
}
|
||||
// The open graph page: its organization, scope, name, read and pending change.
|
||||
function here(el) {
|
||||
const root = el.closest('[data-wfg-page]');
|
||||
if (!root || root.dataset.wfgOrg !== shown.org) return null;
|
||||
const org = shown.org, name = shown.name, scope = query().get('project') || '', s = states.get(org);
|
||||
if (!s?.data?.writable) return null;
|
||||
return { org, name, scope, s, key: dkey(org, scope, name) };
|
||||
}
|
||||
const selected = () => { const n = Number(query().get('step')); return Number.isInteger(n) && n > 0 ? n : 0; };
|
||||
const select = step => WS()?.setQuery?.({ step: step || null }, false);
|
||||
function openForm(d, mode, i, st) {
|
||||
const roles = st ? [...st.roles] : [];
|
||||
d.edit = { mode, i, title: st?.title ?? '', state: st?.state ?? 'todo', roles, why: '', orig: { title: st?.title ?? '', state: st?.state ?? 'todo', roles: [...roles] } };
|
||||
}
|
||||
function readForm(form, into) {
|
||||
into.title = form.elements.title.value;
|
||||
into.state = form.elements.state.value;
|
||||
into.roles = [...form.querySelectorAll('input[name="role"]:checked')].map(i => i.value);
|
||||
if (form.elements.scope) into.scope = form.elements.scope.value;
|
||||
if (form.elements.name) into.name = form.elements.name.value.trim();
|
||||
}
|
||||
function why(form, into, text) {
|
||||
into.why = text;
|
||||
// Direct text: the form may be the held node a repaint grafted back.
|
||||
const p = form.querySelector('[data-wfg-why]');
|
||||
if (p) p.textContent = text;
|
||||
say(text);
|
||||
}
|
||||
|
||||
document.addEventListener('click', async ev => {
|
||||
const btn = ev.target.closest('[data-wfg-new],[data-wfg-new-cancel],[data-wfg-edit],[data-wfg-add],[data-wfg-up],[data-wfg-down],[data-wfg-remove],[data-wfg-cancel],[data-wfg-discard],[data-wfg-save],[data-wfg-delete],[data-wfg-delete-no],[data-wfg-delete-yes]');
|
||||
if (!btn || btn.disabled) return;
|
||||
if (btn.matches('[data-wfg-new],[data-wfg-new-cancel]')) {
|
||||
const org = btn.closest('[data-wfg-list]')?.dataset.wfgOrg;
|
||||
if (!org || org !== shown.org) return;
|
||||
if (btn.hasAttribute('data-wfg-new')) { newForms.set(org, { scope: '', name: '', title: '', state: 'todo', roles: [], why: '' }); notes.delete(dkey(org, '', '')); paint('wfg-n-name'); }
|
||||
else { newForms.delete(org); paint('wfg-new'); }
|
||||
return;
|
||||
}
|
||||
const at = here(btn);
|
||||
if (!at) return;
|
||||
const { org, name, scope, s, key } = at;
|
||||
const sel = selected();
|
||||
if (btn.hasAttribute('data-wfg-discard')) {
|
||||
const d = drafts.get(key);
|
||||
drafts.delete(key);
|
||||
if (d?.isNew) { location.hash = `${orgHash(org)}/workflows`; return; }
|
||||
say(`Discarded the change to ${name}.`);
|
||||
return paint('wfg-delete');
|
||||
}
|
||||
if (btn.hasAttribute('data-wfg-save')) return save(at, btn);
|
||||
if (btn.matches('[data-wfg-delete-yes]')) return remove(at);
|
||||
const d = draftFor(org, s, scope, name) ?? drafts.get(key);
|
||||
if (!d) return;
|
||||
d.status = '';
|
||||
const steps = d.flow.steps;
|
||||
if (btn.hasAttribute('data-wfg-edit') && steps[sel - 1]) { openForm(d, 'edit', sel - 1, steps[sel - 1]); return paint('wfg-f-title'); }
|
||||
if (btn.hasAttribute('data-wfg-add')) { openForm(d, 'add', sel ? sel : steps.length, null); return paint('wfg-f-title'); }
|
||||
if (btn.hasAttribute('data-wfg-cancel')) { const back = d.edit?.mode === 'edit' ? d.edit.i + 1 : sel; d.edit = null; select(back); return paint(back ? `wfg-step:${back}` : 'wfg-add'); }
|
||||
if (btn.matches('[data-wfg-up],[data-wfg-down]') && steps[sel - 1]) {
|
||||
const to = btn.hasAttribute('data-wfg-up') ? sel - 1 : sel + 1;
|
||||
if (to < 1 || to > steps.length) return;
|
||||
[steps[sel - 1], steps[to - 1]] = [steps[to - 1], steps[sel - 1]];
|
||||
select(to);
|
||||
say(`Moved step ${sel} to position ${to}.`);
|
||||
const key2 = btn.hasAttribute('data-wfg-up') ? (to > 1 ? 'wfg-up' : 'wfg-down') : (to < steps.length ? 'wfg-down' : 'wfg-up');
|
||||
return paint(key2);
|
||||
}
|
||||
if (btn.hasAttribute('data-wfg-remove') && steps[sel - 1] && steps.length > 1) {
|
||||
const [gone] = steps.splice(sel - 1, 1);
|
||||
const next = Math.min(sel, steps.length);
|
||||
select(next);
|
||||
say(`Removed step ${sel}, ${gone.title}.`);
|
||||
return paint(`wfg-step:${next}`);
|
||||
}
|
||||
if (btn.hasAttribute('data-wfg-delete')) { d.confirm = true; return paint('wfg-delete-no'); }
|
||||
if (btn.hasAttribute('data-wfg-delete-no')) { d.confirm = false; return paint('wfg-delete'); }
|
||||
});
|
||||
async function save({ org, name, scope, s, key }) {
|
||||
const d = drafts.get(key);
|
||||
if (!d || d.busy) return;
|
||||
// Review P2: the open step form's edits are not in the workflow yet; saving now would drop them unsaid.
|
||||
const refused = (editDirty(d.edit) ? `Not saved: step ${d.edit.i + 1} has edits not applied. Apply or cancel the step first.` : '')
|
||||
|| refusal(s, { isNew: d.isNew, scope, name, steps: d.flow.steps });
|
||||
if (refused) { d.status = refused; say(refused); return paint('wfg-save'); }
|
||||
d.busy = true; d.status = 'Saving…'; paint();
|
||||
try {
|
||||
await saveFlow(org, s, { scope, name, revision: d.revision, workflow: shape(d.flow) });
|
||||
drafts.delete(key);
|
||||
notes.set(key, `Saved ${name}.`);
|
||||
try { await read(org, states.get(org) ?? s); } catch { /* the next poll reads it */ }
|
||||
say(`Saved ${name}.`);
|
||||
paint(selected() ? `wfg-step:${selected()}` : 'wfg-delete');
|
||||
} catch (error) {
|
||||
d.busy = false;
|
||||
d.status = kept[error.code] ?? `Not saved: ${error.message} Your change is kept.`;
|
||||
say(d.status);
|
||||
paint('wfg-save');
|
||||
}
|
||||
}
|
||||
async function remove({ org, name, scope, s, key }) {
|
||||
const d = drafts.get(key);
|
||||
if (!d || d.busy) return;
|
||||
d.busy = true; paint();
|
||||
try {
|
||||
// The revision of the workflow the page shows (settle moves it on only while this workflow is unchanged):
|
||||
// a change made to it since is refused, not deleted over.
|
||||
await removeFlow(org, s, { scope, name, revision: d.revision });
|
||||
drafts.delete(key);
|
||||
notes.set(dkey(org, '', ''), `Deleted ${name}.`);
|
||||
say(`Deleted ${name}.`);
|
||||
try { await read(org, s); } catch { /* the next poll reads it */ }
|
||||
location.hash = `${orgHash(org)}/workflows`;
|
||||
} catch (error) {
|
||||
d.busy = false; d.confirm = false;
|
||||
// Recheck B: Discard shows only with a pending change; without one the next read draws the current version.
|
||||
d.status = error.code === 'revision-conflict' ? `Not deleted: the organization file changed this workflow since the page read it. ${changed(d) ? 'Discard to load the current version.' : 'The page shows the current version at its next read.'}` : `Not deleted: ${error.message}`;
|
||||
say(d.status);
|
||||
paint('wfg-delete');
|
||||
}
|
||||
}
|
||||
// Every change in a form goes into the page's state at once, so any repaint draws what was entered.
|
||||
document.addEventListener('input', ev => {
|
||||
const form = ev.target.closest('[data-wfg-form]');
|
||||
if (!form) return;
|
||||
if (form.matches('[data-wfg-newform]')) { const f = newForms.get(shown.org); if (f) readForm(form, f); return; }
|
||||
const at = here(form), d = at && drafts.get(at.key);
|
||||
if (d?.edit) readForm(form, d.edit);
|
||||
});
|
||||
document.addEventListener('submit', ev => {
|
||||
const form = ev.target.closest?.('[data-wfg-form]');
|
||||
if (!form) return;
|
||||
ev.preventDefault();
|
||||
if (form.matches('[data-wfg-newform]')) {
|
||||
const org = shown.org, s = states.get(org), f = newForms.get(org);
|
||||
if (!f || !s?.data?.writable) return;
|
||||
readForm(form, f);
|
||||
const step = { title: f.title, state: f.state, roles: f.roles };
|
||||
const refused = refusal(s, { isNew: true, scope: f.scope, name: f.name, steps: [step] })
|
||||
|| (drafts.get(dkey(org, f.scope, f.name))?.isNew ? `A new workflow named ${f.name} is already open here, not saved yet.` : '');
|
||||
if (refused) return why(form, f, refused);
|
||||
drafts.set(dkey(org, f.scope, f.name), { isNew: true, base: null, flow: { title: '', description: '', steps: [{ ...step, title: step.title.trim() }] }, revision: s.data.revision, edit: null, confirm: false, status: '', busy: false, stale: false });
|
||||
newForms.delete(org);
|
||||
notes.delete(dkey(org, f.scope, f.name));
|
||||
location.hash = flowHref(org, f.scope, f.name, 1);
|
||||
return;
|
||||
}
|
||||
const at = here(form), d = at && drafts.get(at.key);
|
||||
if (!d?.edit) return;
|
||||
readForm(form, d.edit);
|
||||
const e = d.edit, st = { title: e.title.trim(), state: e.state, roles: e.roles };
|
||||
const refused = stepRefusal(st, e.i + 1);
|
||||
if (refused) return why(form, e, refused);
|
||||
if (e.mode === 'add') d.flow.steps.splice(e.i, 0, st); else d.flow.steps[e.i] = st;
|
||||
d.edit = null; d.status = '';
|
||||
select(e.i + 1);
|
||||
say(`${e.mode === 'add' ? 'Added' : 'Changed'} step ${e.i + 1}. Not saved yet.`);
|
||||
paint('wfg-save');
|
||||
});
|
||||
// Escape in a step form is Cancel.
|
||||
document.addEventListener('keydown', ev => {
|
||||
if (ev.key !== 'Escape') return;
|
||||
const form = ev.target.closest?.('[data-wfg-form]:not([data-wfg-newform])');
|
||||
form?.querySelector('[data-wfg-cancel]')?.click();
|
||||
});
|
||||
// The organization-switch and restart guards (display.js, console-restart.js) ask before these are lost.
|
||||
function hasDraft() {
|
||||
return [...drafts.values()].some(d => changed(d) || editDirty(d.edit))
|
||||
|| [...newForms.values()].some(f => f.name || f.title.trim() || f.roles.length);
|
||||
}
|
||||
function forget() { drafts.clear(); newForms.clear(); }
|
||||
// The organization page's card: each workflow as a link to its graph, from the page's one workflow read.
|
||||
function card(org, { stopped = false, label = org } = {}) {
|
||||
const all = `<a href="${esc(`${orgHash(org)}/workflows`)}" data-wfg-all data-focus-key="wfg-all">All workflows</a>`;
|
||||
const h = note => `<div class="ws-sec-h"><h2>Workflows</h2>${note ? `<span class="note">${note}</span>` : ''}${all}</div>`;
|
||||
if (stopped) return `<div class="wfg-card" data-wfg-card data-bus-stopped>${h('')}<p class="ws-panel tight note">Not read: the bus is stopped.</p></div>`;
|
||||
const s = states.get(org);
|
||||
if (!s?.data) return `<div class="wfg-card" data-wfg-card>${h('')}<p class="ws-panel tight note" data-wfg-error>${esc(s?.error ?? 'Workflows could not be read.')}</p></div>`;
|
||||
const rows = listed(s).flatMap(([scope, , list]) => list.map(([name, w]) => [scope, name, w]));
|
||||
if (!rows.length) return `<div class="wfg-card" data-wfg-card>${h('none')}<div class="ws-panel tight">${none(org, s, label)}</div></div>`;
|
||||
return `<div class="wfg-card" data-wfg-card>${h(String(rows.length))}<ul class="ws-panel tight wfg-cardlist">${rows.map(([scope, name, w]) => `<li><a href="${esc(flowHref(org, scope, name))}" data-wfg-card-item data-focus-key="${esc(`wfg-card:${scope}:${name}`)}"><strong>${esc(w?.title || name)}</strong> <span class="muted">${count(stepsOf(w).length)}${scope ? `, project ${esc(scope)}` : ''}</span></a></li>`).join('')}</ul></div>`;
|
||||
}
|
||||
// A step box or a step link in the details selects that step in the address (setQuery repaints).
|
||||
document.addEventListener('click', ev => {
|
||||
const link = ev.target.closest('[data-wfg-step],[data-wfg-go]');
|
||||
if (link && WS()?.setQuery && ev.button === 0 && !ev.metaKey && !ev.ctrlKey && !ev.shiftKey && !ev.altKey) {
|
||||
ev.preventDefault();
|
||||
WS().setQuery({ step: link.dataset.wfgStep ?? link.dataset.wfgGo });
|
||||
return;
|
||||
}
|
||||
// Apply a pack: the pack picker on the organization page, scrolled into view.
|
||||
const apply = ev.target.closest('[data-wfg-apply]');
|
||||
if (!apply || ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
|
||||
const show = () => { const p = document.querySelector('[data-packs]'); if (!p) return false; p.scrollIntoView({ block: 'start' }); return true; };
|
||||
if (location.hash === apply.getAttribute('href') && show()) { ev.preventDefault(); return; }
|
||||
const until = Date.now() + 8000;
|
||||
const wait = () => { if (location.hash === apply.getAttribute('href') && document.querySelector('[data-org-dashboard] [data-packs]')) setTimeout(show, 0); else if (Date.now() < until) setTimeout(wait, 60); };
|
||||
setTimeout(wait, 60);
|
||||
});
|
||||
// Crossing the narrow breakpoint redraws the graph in the other direction.
|
||||
window.matchMedia(NARROW).addEventListener('change', () => {
|
||||
if (document.querySelector('[data-wfg-graph]')) document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));
|
||||
});
|
||||
window.MosaicWorkflows = { load, section, page, card, hasDraft, forget };
|
||||
})();
|
||||
File diff suppressed because it is too large.
Load diff
@@ -4,7 +4,7 @@ import { proxyAdminHTTP } from './proxy-admin.mjs';
|
||||
import { createServer } from 'node:http';
|
||||
import { bindTesterSocket, TESTER_ORIGIN } from './tester-socket.mjs';
|
||||
import { readFileSync, openSync, fstatSync, closeSync, constants } from 'node:fs';
|
||||
import { timingSafeEqual, randomUUID } from 'node:crypto';
|
||||
import { timingSafeEqual, randomUUID, createHash } from 'node:crypto';
|
||||
import { resolve } from 'node:path';
|
||||
import { isIP } from 'node:net';
|
||||
import { BusReadError, busStatus as coreBusStatus, subjectOk, sendOutcome } from './bus.mjs';
|
||||
@@ -65,12 +65,12 @@ const files = new Map([
|
||||
['/', ['index.html', 'text/html; charset=utf-8']],
|
||||
['/proxy.js', ['proxy.js', 'text/javascript; charset=utf-8']],
|
||||
['/project-remote.js', ['project-remote.js', 'text/javascript; charset=utf-8']],
|
||||
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js', 'org-create.js', 'console-restart.js', 'onboarding.js', 'workspace-data.js', 'workspace.js', 'tasks-board.js', 'panes.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
||||
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js', 'org-create.js', 'console-restart.js', 'onboarding.js', 'workspace-data.js', 'workspace.js', 'tasks-board.js', 'assistant.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
||||
['/files.js', ['files.js', 'text/javascript; charset=utf-8']],
|
||||
['/projects.js', ['projects.js', 'text/javascript; charset=utf-8']],
|
||||
['/projects.css', ['projects.css', 'text/css; charset=utf-8']],
|
||||
['/files.css', ['files.css', 'text/css; charset=utf-8']],
|
||||
...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'project-archive.css', 'processing.css', 'org-create.css', 'onboarding.css', 'tasks-board.css', 'panes.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
|
||||
...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'project-archive.css', 'processing.css', 'org-create.css', 'onboarding.css', 'tasks-board.css', 'assistant.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
|
||||
['/display.js', ['display.js', 'text/javascript; charset=utf-8']],
|
||||
['/inbox.js', ['inbox.js', 'text/javascript; charset=utf-8']],
|
||||
['/inbox.css', ['inbox.css', 'text/css; charset=utf-8']],
|
||||
@@ -86,6 +86,7 @@ const files = new Map([
|
||||
['/packs.js', ['packs.js', 'text/javascript; charset=utf-8']],
|
||||
['/workflows.js', ['workflows.js', 'text/javascript; charset=utf-8']],
|
||||
['/packs.css', ['packs.css', 'text/css; charset=utf-8']],
|
||||
['/workflows.css', ['workflows.css', 'text/css; charset=utf-8']],
|
||||
...[400, 500, 600, 700].map(w => [`/assets/fonts/manrope-${w}.woff2`, [`assets/fonts/manrope-${w}.woff2`, 'font/woff2']]),
|
||||
...[400, 500, 700].map(w => [`/assets/fonts/jetbrains-mono-${w}.woff2`, [`assets/fonts/jetbrains-mono-${w}.woff2`, 'font/woff2']]),
|
||||
]);
|
||||
@@ -250,6 +251,10 @@ const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.header
|
||||
// Q175 phase 1 (#1690): under --sso the writer comes from a session the handler already checked,
|
||||
// so it carries `sso: true` and the public origin. The header-preview signer is read-only.
|
||||
const signedIn = (req, p) => p.sso === true || one(req, p.header) === p.user;
|
||||
// SP14 (#1873): who is signed in, as an opaque stable name the page keys what it stores per person by
|
||||
// (the Assistant's kept pane messages). A hash of the session's subject, never the subject itself. A
|
||||
// Console with no sign-in has one fixed local viewer.
|
||||
const viewerOf = session => (typeof session?.sub === 'string' && session.sub ? createHash('sha256').update(JSON.stringify([String(session.provider ?? ''), String(session.iss ?? ''), session.sub])).digest('hex').slice(0, 32) : 'local');
|
||||
const ownOrigin = p => p.origin ?? `https://${p.host}`;
|
||||
async function busSend(req, res, { send, preview, log }) {
|
||||
if (!signedIn(req, preview)) return json(res, 401, { error: 'sign-in required' });
|
||||
@@ -1085,7 +1090,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
return templatesHTTP(req, res, { path, owner, read: requestRead, write: send ? templatesWrite : null,
|
||||
session: ssoSession, live: () => { const live = sso.session(req); return live?.rootOwner === true ? live : null; }, origin: localOrigin ?? sso?.publicOrigin });
|
||||
}
|
||||
if (tester && ['/project-remote.js', '/project-remote.css', '/files.js', '/files.css', '/packs.js', '/packs.css', '/workflows.js'].includes(path)) return json(res, 403, { error: 'owner-only' });
|
||||
if (tester && ['/project-remote.js', '/project-remote.css', '/files.js', '/files.css', '/packs.js', '/packs.css', '/workflows.js', '/workflows.css'].includes(path)) return json(res, 403, { error: 'owner-only' });
|
||||
// Row 194 (#1692, D4.1): the tester also launches and terminates.
|
||||
if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name', '/api/agent/launch', '/api/agent/terminate'].includes(path) && !TESTER_CHAT_WRITE.test(path))
|
||||
return json(res, 403, { error: 'read-only', sent: false });
|
||||
@@ -1234,7 +1239,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
// `send`: the page shows the composer only where a send can pass (the public name, --send on).
|
||||
// `edit`: the editor shows Save only where a save can pass, the same test as the route's.
|
||||
// `user` and `federation` (FED R6) only when there is one, so the owner's config is unchanged.
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream, ...(organizations && preselect ? { preselect } : {}), ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(sso?.devOwner ? { devOwner: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite), workspaceFiles: true, projectRemotes: true, projectRemoteWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), ...(organizations ? { orgSetup: !tester && !granted && named && sso !== null && owner.send !== null } : {}), ...(proxyAdmin && !tester && !granted && named && sso !== null && currentSession?.rootOwner === true ? { proxyAdmin: true, proxyAdminSend: owner.send !== null } : {}), send: !organizations && !tester && Boolean(send) && named, ...(organizations && orgId ? { chatSend: !tester && Boolean(send) && named && orgBusHealthy, settingsSend: !tester && !granted && Boolean(send) && named && orgBusHealthy } : {}), boot, consoleRestart: !tester && named && currentSession?.instanceRole === 'operator' && owner.send !== null && restart !== null && trail !== null, decide: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !organizations && !tester && !granted && Boolean(send) && Boolean(projectSave) && named, northStarApprove: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(projectSave) && Boolean(projectFile) && named });
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream, viewer: viewerOf(currentSession), ...(organizations && preselect ? { preselect } : {}), ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(sso?.devOwner ? { devOwner: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite), workspaceFiles: true, projectRemotes: true, projectRemoteWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), ...(organizations ? { orgSetup: !tester && !granted && named && sso !== null && owner.send !== null } : {}), ...(proxyAdmin && !tester && !granted && named && sso !== null && currentSession?.rootOwner === true ? { proxyAdmin: true, proxyAdminSend: owner.send !== null } : {}), send: !organizations && !tester && Boolean(send) && named, ...(organizations && orgId ? { chatSend: !tester && Boolean(send) && named && orgBusHealthy, settingsSend: !tester && !granted && Boolean(send) && named && orgBusHealthy } : {}), boot, consoleRestart: !tester && named && currentSession?.instanceRole === 'operator' && owner.send !== null && restart !== null && trail !== null, decide: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !organizations && !tester && !granted && Boolean(send) && Boolean(projectSave) && named, northStarApprove: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(projectSave) && Boolean(projectFile) && named });
|
||||
if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) });
|
||||
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
|
||||
const file = files.get(path);
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
// SP12 (#1873): selecting an agent on #/agents or in an organization page's Active agent sessions
|
||||
// table opens a detail drawer at the right of the page; the page under it stays. The address carries
|
||||
// the selection (?agent=<org>:<name>), so a reload reopens it and Back closes it. Escape and Close
|
||||
// close it and put focus back on the row. Every field comes from a read the Console already makes; a
|
||||
// field no read gives is left out. Actions show only where the agent's own organization config says
|
||||
// this Console writes there, and a refused action says why next to its button.
|
||||
// Every bus is a fake: acme and beta answer. The real GET /api/workspace and per-organization routes
|
||||
// serve the page; only the per-organization config and the agent writes are stubbed in the page.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { 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();
|
||||
const HIRE = '0f3c1a2b-4d5e-4f60-8a7b-9c0d1e2f3a4b';
|
||||
const THREAD = '7b1e2c3d-4f50-4a61-8b72-9c83d4e5f6a7';
|
||||
|
||||
function fixture() {
|
||||
const LIST = [
|
||||
{ id: 'acme', label: 'Acme Studio', theme: 'cobalt' },
|
||||
{ id: 'beta', label: 'Beta Works', theme: 'grove' },
|
||||
];
|
||||
const task = (n, title, extra = {}) => ({ task_ref: `store:${n}`, source: 'store', at: at(20 + n), fields: { title, state: 'in-progress', group: 'p-site', assignees: ['coder'], ...extra } });
|
||||
const DATA = {
|
||||
acme: {
|
||||
projects: [{ id: 'p-site', repo: 'site', name: 'Website', archived: false, at: at(30) }],
|
||||
// Seven tasks name coder: six open, one done. The drawer lists five open ones and links the rest.
|
||||
tasks: [task(1, 'Hero copy'), task(2, 'Footer links'), task(3, 'Pricing table'), task(4, 'Contact form'), task(5, 'Blog index'), task(6, 'Search box'), task(7, 'Old banner', { state: 'done', done: true })],
|
||||
agents: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'working', at: at(40), working: { since: at(40), messages: 2 } }],
|
||||
// Two decisions wait on a human: one coder raised, one chief raised.
|
||||
inbox: [
|
||||
{ id: 'dec-1', question: 'Ship the hero copy?', class: 'DECISION', raised_by_role: 'coder', task_ref: 'store:1', at: at(10) },
|
||||
{ id: 'dec-2', question: 'Hire a designer?', class: 'DECISION', raised_by_role: 'chief', task_ref: 'store:2', at: at(12) },
|
||||
],
|
||||
claims: [{ thread: THREAD, instance: 'assistant', holder_run: 'run-3', harness: 'claude', at: at(5) }],
|
||||
threads: [{ id: THREAD, project: 'p-site', name: 'Launch notes' }],
|
||||
instances: {
|
||||
coder: { definition: 'coder', running: ['run-1'] },
|
||||
chief: { definition: 'chief-of-staff', running: [] },
|
||||
assistant: { definition: 'assistant', running: ['run-3'] },
|
||||
},
|
||||
model: { coder: 'claude-opus-5-5' },
|
||||
},
|
||||
beta: {
|
||||
projects: [{ id: 'p-shop', repo: 'shop', name: 'Shop', archived: false, at: at(120) }],
|
||||
tasks: [], inbox: [], claims: [], threads: [],
|
||||
agents: [{ role: 'pm', holder_run: 'run-9', harness: 'claude', state: 'waiting', at: at(15) }],
|
||||
instances: { pm: { definition: 'pm', running: ['run-9'] }, 'research-2': { definition: `org:hire-${HIRE}`, hireDecision: HIRE, running: [], pack: { id: 'coding', label: 'Coding' } } },
|
||||
model: {},
|
||||
},
|
||||
};
|
||||
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 } }, status: async () => ({ healthy: true, pid: 4242, boot: null }), options: () => opts(org.id) });
|
||||
return {
|
||||
list: async () => LIST.map(o => ({ ...o, busHealthy: true })),
|
||||
members: async () => LIST.map(member),
|
||||
resolve: async id => {
|
||||
const org = LIST.find(o => o.id === id); if (!org) return null;
|
||||
const d = DATA[id];
|
||||
return {
|
||||
busHealthy: true,
|
||||
reads: {
|
||||
business: async () => ({ business: { id, human: 'jason', projects: d.projects.map(p => p.repo), appearance: { theme: org.theme }, instances: Object.entries(d.model).map(([instance, model]) => ({ instance, vars: { model } })) } }),
|
||||
settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}),
|
||||
},
|
||||
bus: {
|
||||
messages: async () => ({ messages: [], unread: 0 }), agents: async () => d.agents, 'thread.claims': async () => d.claims, inbox: async () => d.inbox, tasks: async () => d.tasks,
|
||||
instances: async () => ({ revision: 'a'.repeat(64), definitions: {}, instances: d.instances }),
|
||||
},
|
||||
chat: { projects: async () => d.projects, threads: async () => d.threads },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Runs before the page's scripts. Acme's config says this Console sends there (chat and settings);
|
||||
// beta's says it only reads. Agent writes are recorded and answered from a queue; nothing is written.
|
||||
const STUB = `(() => {
|
||||
const real = window.fetch.bind(window);
|
||||
window.__posts = []; window.__answers = [];
|
||||
const answer = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
||||
window.fetch = async (url, init = {}) => {
|
||||
const u = String(url);
|
||||
const cfg = u.match(/^\\/api\\/orgs\\/([^/]+)\\/config$/);
|
||||
if (cfg) {
|
||||
const c = await (await real(url, init)).json();
|
||||
const writes = cfg[1] === 'acme';
|
||||
return answer(200, { ...c, chatSend: writes, settingsSend: writes, decide: writes });
|
||||
}
|
||||
if (!/\\/agent\\//.test(u) || (init.method || 'GET') === 'GET' && !u.includes('/agent/request')) return real(url, init);
|
||||
window.__posts.push({ url: u, method: init.method || 'GET', body: init.body ?? null });
|
||||
const a = window.__answers.shift();
|
||||
if (!a) throw new TypeError('no answer queued');
|
||||
return answer(a.status ?? 200, a.body);
|
||||
};
|
||||
})();`;
|
||||
|
||||
async function setup(t, { viewport = [1440, 900], hash = '#/agents', stub = true } = {}) {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture(), log: () => {} });
|
||||
t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
await b.viewport(...viewport);
|
||||
if (stub) await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB });
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
await b.navigate(`${base}/${hash}`);
|
||||
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText.replace(/\\s+/g, " ").trim() ?? null`);
|
||||
const attr = (sel, name) => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.getAttribute(${JSON.stringify(name)}) ?? null`);
|
||||
const has = sel => b.evaluate(`!!document.querySelector(${JSON.stringify(sel)})`);
|
||||
return { b, base, wait: waitFor(b), text, attr, has };
|
||||
}
|
||||
const drawer = key => `document.querySelector('[data-ws-drawer="${key}"]')`;
|
||||
const open = key => `!!${drawer(key)} && ${drawer(key)}.querySelector("h2")?.textContent.length > 0`;
|
||||
const noDrawer = '!document.querySelector("[data-ws-drawer]")';
|
||||
const agentParam = 'new URLSearchParams(location.hash.split("?")[1] || "").get("agent")';
|
||||
const rowLink = key => `[data-ws-agent="${key}"] a[data-ws-drawer-open]`;
|
||||
const sessionLink = key => `[data-ws-session-agent="${key}"] a[data-ws-drawer-open]`;
|
||||
|
||||
test('a row on #/agents opens the drawer: a region with a heading, the page stays under it', async t => {
|
||||
const { b, wait, text, attr, has } = await setup(t);
|
||||
await wait(`document.querySelector(${JSON.stringify(rowLink('acme:coder'))})`);
|
||||
assert.equal(await attr(rowLink('acme:coder'), 'href'), '#/agents?agent=acme%3Acoder');
|
||||
await b.evaluate(`document.querySelector(${JSON.stringify(rowLink('acme:coder'))}).click()`);
|
||||
await wait(open('acme:coder'));
|
||||
assert.equal(await b.evaluate(agentParam), 'acme:coder');
|
||||
// A region, not a modal: no dialog role, no aria-modal, labelled by its heading.
|
||||
const d = '[data-ws-drawer="acme:coder"]';
|
||||
assert.equal(await attr(d, 'role'), 'region');
|
||||
assert.equal(await attr(d, 'aria-modal'), null);
|
||||
assert.equal(await b.evaluate(`document.getElementById(document.querySelector('${d}').getAttribute("aria-labelledby"))?.textContent`), 'coder');
|
||||
// The page under it stays: the agents table is still drawn and its heading is the page's.
|
||||
assert.equal(await has('[data-ws-agent-list]'), true);
|
||||
assert.equal(await text('#bus-view h1'), 'Agents');
|
||||
// At the right of the page.
|
||||
const box = await b.evaluate(`(() => { const r = document.querySelector('${d}').getBoundingClientRect(); return [Math.round(r.left), Math.round(r.right), innerWidth]; })()`);
|
||||
assert.ok(box[0] > box[2] / 2, `drawer left edge ${box[0]} should be in the right half of ${box[2]}`);
|
||||
assert.ok(box[1] >= box[2] - 20, `drawer right edge ${box[1]} should reach the right of ${box[2]}`);
|
||||
// At 1440 wide nothing on the page sits under it: the agents table ends left of the drawer.
|
||||
const table = await b.evaluate(`Math.round(document.querySelector('[data-ws-agent-list]').getBoundingClientRect().right)`);
|
||||
assert.ok(table <= box[0], `the agents table right edge ${table} should be left of the drawer at ${box[0]}`);
|
||||
});
|
||||
|
||||
test('the drawer shows each field from its read: name, organization, role and source, state, model, project, task, open tasks and decisions', async t => {
|
||||
const { b, wait, text, attr, has } = await setup(t, { hash: '#/agents?agent=acme%3Acoder' });
|
||||
await wait(open('acme:coder'));
|
||||
const d = '[data-ws-drawer="acme:coder"]';
|
||||
assert.equal(await text(`${d} h2`), 'coder');
|
||||
assert.equal(await text(`${d} [data-ws-orgchip]`), 'Acme Studio');
|
||||
assert.match(await text(`${d} [data-drawer-role]`), /coder/);
|
||||
assert.match(await text(`${d} [data-drawer-source]`), /Organization agent.*definition coder/);
|
||||
assert.match(await text(`${d} [data-drawer-state]`), /(^|\s)working$/);
|
||||
assert.match(await text(`${d} [data-drawer-model]`), /claude-opus-5-5/);
|
||||
// Project and task are links to their pages; the project is the task's, and says so.
|
||||
assert.equal(await attr(`${d} [data-drawer-project] a`, 'href'), '#/orgs/acme/projects/p-site');
|
||||
assert.match(await text(`${d} [data-drawer-project]`), /Website.*from its task/);
|
||||
assert.equal(await attr(`${d} [data-drawer-task] a`, 'href'), '#/orgs/acme/tasks?task=store%3A1');
|
||||
assert.match(await text(`${d} [data-drawer-task]`), /Hero copy/);
|
||||
// Open tasks assigned to it: five of six, newest first is not claimed; the done one is never listed.
|
||||
const tasks = await b.evaluate(`[...document.querySelectorAll('${d} [data-drawer-tasks] li a')].map(a => [a.textContent.trim(), a.getAttribute("href")])`);
|
||||
assert.equal(tasks.length, 5);
|
||||
assert.ok(tasks.every(([, href]) => /^#\/orgs\/acme\/tasks\?task=store%3A[1-6]$/.test(href)), JSON.stringify(tasks));
|
||||
assert.ok(!tasks.some(([title]) => title === 'Old banner'));
|
||||
assert.equal(await attr(`${d} [data-drawer-tasks-all]`, 'href'), '#/tasks?org=acme&assignee=coder');
|
||||
assert.match(await text(`${d} [data-drawer-tasks-all]`), /6/);
|
||||
// Decisions waiting on a human that it raised, each into the Inbox. Chief's is not its own.
|
||||
const decisions = await b.evaluate(`[...document.querySelectorAll('${d} [data-drawer-decisions] li a')].map(a => [a.textContent.trim(), a.getAttribute("href")])`);
|
||||
assert.deepEqual(decisions, [['Ship the hero copy?', '#/inbox?d=acme%3Adec-1']]);
|
||||
// No read gives the time of an agent's last activity. The sessions read's `since` is when its
|
||||
// current state began, which is not the same thing, so the drawer draws no time.
|
||||
assert.equal(await has(`${d} [data-drawer-activity]`), false);
|
||||
// The Tasks board link opens the board filtered to the agent.
|
||||
await b.evaluate(`document.querySelector('${d} [data-drawer-tasks-all]').click()`);
|
||||
await wait(`document.querySelector('[data-tasks-board] select[data-tb-filter="assignee"]')?.value === 'coder'`);
|
||||
// When the address drops the assignee, the board drops the filter it took from the address.
|
||||
await b.evaluate('location.hash = "#/tasks"');
|
||||
await wait(`document.querySelector('[data-tasks-board] select[data-tb-filter="assignee"]')?.value === ''`);
|
||||
});
|
||||
|
||||
test('a hired agent shows where its role came from; a not-running agent says not running', async t => {
|
||||
const { wait, text, has } = await setup(t, { hash: '#/agents?agent=beta%3Aresearch-2' });
|
||||
await wait(open('beta:research-2'));
|
||||
const d = '[data-ws-drawer="beta:research-2"]';
|
||||
assert.match(await text(`${d} [data-drawer-role]`), /Hired from pack Coding/);
|
||||
assert.match(await text(`${d} [data-drawer-source]`), new RegExp(`hire decision ${HIRE}`));
|
||||
assert.match(await text(`${d} [data-drawer-state]`), /(^|\s)not running$/);
|
||||
assert.equal(await has(`${d} [data-drawer-model]`), false);
|
||||
});
|
||||
|
||||
test('a field with no read is left out', async t => {
|
||||
// beta:pm: beta's business read names no model for it, and it holds no task, so no project either.
|
||||
const { wait, has, text } = await setup(t, { hash: '#/agents?agent=beta%3Apm' });
|
||||
await wait(open('beta:pm'));
|
||||
const d = '[data-ws-drawer="beta:pm"]';
|
||||
assert.match(await text(`${d} [data-drawer-state]`), /waiting on you/);
|
||||
for (const field of ['model', 'project', 'task', 'activity']) assert.equal(await has(`${d} [data-drawer-${field}]`), false, `${field} should be absent`);
|
||||
// An empty list still says so, since the read answered: no open tasks, no decisions.
|
||||
assert.match(await text(`${d} [data-drawer-tasks]`), /No open tasks/);
|
||||
assert.match(await text(`${d} [data-drawer-decisions]`), /No decisions/);
|
||||
});
|
||||
|
||||
test('the address round trip: Back closes the drawer, Forward and a reload open it again', async t => {
|
||||
const { b, base, wait } = await setup(t);
|
||||
await wait(`document.querySelector(${JSON.stringify(rowLink('beta:pm'))})`);
|
||||
await b.evaluate(`document.querySelector(${JSON.stringify(rowLink('beta:pm'))}).click()`);
|
||||
await wait(open('beta:pm'));
|
||||
await b.evaluate('history.back()');
|
||||
await wait(`${noDrawer} && location.hash === "#/agents"`);
|
||||
assert.equal(await b.evaluate('!!document.querySelector("[data-ws-agent-list]")'), true);
|
||||
await b.evaluate('history.forward()');
|
||||
await wait(open('beta:pm'));
|
||||
const hash = await b.evaluate('location.hash');
|
||||
await b.navigate(`${base}/${hash}`);
|
||||
await wait(open('beta:pm'));
|
||||
// The other filters stay in the address beside it.
|
||||
await b.navigate(`${base}/#/agents?org=beta&agent=beta%3Apm`);
|
||||
await wait(`${open('beta:pm')} && document.querySelectorAll("[data-ws-agent]").length === 2`);
|
||||
// An agent no read has says so, with Close; a malformed value opens nothing.
|
||||
await b.navigate(`${base}/#/agents?agent=beta%3Anobody`);
|
||||
await wait('document.querySelector("[data-ws-drawer] [data-drawer-missing]")');
|
||||
await b.navigate(`${base}/#/agents?agent=not-an-agent`);
|
||||
await wait(`document.querySelector("[data-ws-agent-list]") && ${noDrawer}`);
|
||||
});
|
||||
|
||||
test('the drawer holds at most one history entry: Close after switching agents, or after a shared link, stays in the Console', async t => {
|
||||
const { b, base, wait } = await setup(t);
|
||||
await wait(`document.querySelector(${JSON.stringify(rowLink('acme:coder'))})`);
|
||||
const before = await b.evaluate('history.length');
|
||||
await b.evaluate(`document.querySelector(${JSON.stringify(rowLink('acme:coder'))}).click()`);
|
||||
await wait(open('acme:coder'));
|
||||
// Another row while the drawer is open switches it in place: no second entry.
|
||||
await b.evaluate(`document.querySelector(${JSON.stringify(rowLink('beta:pm'))}).click()`);
|
||||
await wait(`${open('beta:pm')} && !${drawer('acme:coder')}`);
|
||||
assert.equal(await b.evaluate(agentParam), 'beta:pm');
|
||||
assert.equal(await b.evaluate('history.length'), before + 1);
|
||||
// The open row again does nothing to the address.
|
||||
await b.evaluate(`document.querySelector(${JSON.stringify(rowLink('beta:pm'))}).click()`);
|
||||
assert.equal(await b.evaluate('history.length'), before + 1);
|
||||
// One Close goes back to the page without the drawer.
|
||||
await b.evaluate(`${drawer('beta:pm')}.querySelector("[data-drawer-close]").click()`);
|
||||
await wait(`${noDrawer} && location.hash === "#/agents"`);
|
||||
// A shared link opened after another site: clicking its row and then Close stays here.
|
||||
await b.navigate('about:blank');
|
||||
await b.navigate(`${base}/#/agents?agent=acme%3Acoder`);
|
||||
await wait(open('acme:coder'));
|
||||
await b.evaluate(`document.querySelector(${JSON.stringify(rowLink('acme:coder'))}).click()`);
|
||||
await b.evaluate(`${drawer('acme:coder')}.querySelector("[data-drawer-close]").click()`);
|
||||
await wait(`location.href.startsWith(${JSON.stringify(base)}) && ${noDrawer} && location.hash === "#/agents"`);
|
||||
// Escape on a shared link does the same.
|
||||
await b.navigate(`${base}/#/agents?agent=beta%3Apm`);
|
||||
await wait(open('beta:pm'));
|
||||
await b.key('Escape');
|
||||
await wait(`location.href.startsWith(${JSON.stringify(base)}) && ${noDrawer} && location.hash === "#/agents"`);
|
||||
});
|
||||
|
||||
test('the keyboard path: Tab reaches the row, Enter opens, Escape closes and focus returns to the row', async t => {
|
||||
const { b, wait } = await setup(t);
|
||||
const link = JSON.stringify(rowLink('acme:coder'));
|
||||
await wait(`document.querySelector(${link})`);
|
||||
// Focus the control just before the row's link, then Tab onto it.
|
||||
await b.evaluate(`(() => { const all = [...document.querySelectorAll('a[href],button:not([disabled]),input,select,textarea,[tabindex]:not([tabindex="-1"])')].filter(e => e.getClientRects().length); const i = all.indexOf(document.querySelector(${link})); all[i - 1].focus(); })()`);
|
||||
await b.key('Tab');
|
||||
assert.equal(await b.evaluate(`document.activeElement === document.querySelector(${link})`), true);
|
||||
await b.key('Enter');
|
||||
await wait(open('acme:coder'));
|
||||
// Focus moves into the drawer, onto its heading.
|
||||
await wait(`document.activeElement === ${drawer('acme:coder')}.querySelector("h2")`);
|
||||
await b.key('Escape');
|
||||
await wait(`${noDrawer} && document.activeElement === document.querySelector(${link})`);
|
||||
assert.equal(await b.evaluate(agentParam), null);
|
||||
// Close does the same.
|
||||
await b.key('Enter');
|
||||
await wait(open('acme:coder'));
|
||||
await b.evaluate(`${drawer('acme:coder')}.querySelector("[data-drawer-close]").click()`);
|
||||
await wait(`${noDrawer} && document.activeElement === document.querySelector(${link})`);
|
||||
});
|
||||
|
||||
test('from an organization page\'s Active agent sessions the drawer opens over that page', async t => {
|
||||
const { b, base, wait, text, attr } = await setup(t, { hash: '#/orgs/acme/organization' });
|
||||
await wait(`document.querySelector(${JSON.stringify(sessionLink('acme:coder'))})`);
|
||||
assert.equal(await attr(sessionLink('acme:coder'), 'href'), '#/orgs/acme/organization?agent=acme%3Acoder');
|
||||
await b.evaluate(`document.querySelector(${JSON.stringify(sessionLink('acme:coder'))}).click()`);
|
||||
await wait(open('acme:coder'));
|
||||
// The organization page stays under it.
|
||||
assert.equal(await text('[data-org-dashboard] h1'), 'Acme Studio');
|
||||
assert.match(await text('[data-ws-drawer="acme:coder"] [data-drawer-model]'), /claude-opus-5-5/);
|
||||
await b.evaluate('history.back()');
|
||||
await wait(`${noDrawer} && location.hash === "#/orgs/acme/organization"`);
|
||||
assert.equal(await text('[data-org-dashboard] h1'), 'Acme Studio');
|
||||
await b.navigate(`${base}/#/orgs/acme/organization?agent=acme%3Aassistant`);
|
||||
await wait(open('acme:assistant'));
|
||||
});
|
||||
|
||||
test('Message opens the agent\'s thread in an Assistant pane; Stop asks first and a refusal says why', async t => {
|
||||
const { b, wait, text, attr, has } = await setup(t, { hash: '#/agents?agent=acme%3Aassistant' });
|
||||
await wait(`${open('acme:assistant')} && document.querySelector('[data-ws-drawer="acme:assistant"] [data-drawer-message]')`);
|
||||
assert.equal(await attr('[data-ws-drawer="acme:assistant"] [data-drawer-message]', 'href'), `#/assistant?add=acme%3A${THREAD}`);
|
||||
// A role agent holds no thread to open, so it has no Message; it can still be stopped.
|
||||
await b.evaluate('location.hash = "#/agents?agent=acme%3Acoder"');
|
||||
await wait(`${open('acme:coder')} && document.querySelector('[data-ws-drawer="acme:coder"] [data-drawer-stop]')`);
|
||||
assert.equal(await has('[data-ws-drawer="acme:coder"] [data-drawer-message]'), false);
|
||||
const d = '[data-ws-drawer="acme:coder"]';
|
||||
await b.evaluate(`document.querySelector('${d} [data-drawer-stop]').click()`);
|
||||
await wait(`document.querySelector('${d} [data-drawer-stop-yes]')`);
|
||||
assert.match(await text(`${d} [data-drawer-stop-confirm]`), /Stop coder in Acme Studio\?/);
|
||||
// Cancel: nothing sent, focus back on Stop.
|
||||
await b.evaluate(`document.querySelector('${d} [data-drawer-stop-no]').click()`);
|
||||
await wait(`document.activeElement === document.querySelector('${d} [data-drawer-stop]')`);
|
||||
assert.deepEqual(await b.evaluate('window.__posts'), []);
|
||||
// Yes: one terminate for this one agent, refused by the bus, in words next to the button.
|
||||
await b.evaluate(`document.querySelector('${d} [data-drawer-stop]').click()`);
|
||||
await wait(`document.querySelector('${d} [data-drawer-stop-yes]')`);
|
||||
await b.evaluate('window.__answers.push({ status: 409, body: { error: "instance-protected", message: "instance-protected: coder is protected from ending. Nothing changed.", sent: false } })');
|
||||
await b.evaluate(`document.querySelector('${d} [data-drawer-stop-yes]').click()`);
|
||||
await wait(`/coder is protected/.test(document.querySelector('${d} [data-drawer-status]')?.textContent ?? "")`);
|
||||
assert.match(await text(`${d} [data-drawer-status]`), /^Not stopped: instance-protected: coder is protected from ending\. Nothing changed\.$/);
|
||||
const posts = await b.evaluate('window.__posts');
|
||||
assert.deepEqual(posts.map(p => [p.url, p.method]), [['/api/orgs/acme/agent/terminate', 'POST']]);
|
||||
assert.deepEqual(JSON.parse(posts[0].body), { instance: 'coder', reason: 'Stopped from the agent drawer.' });
|
||||
// The status sits beside the Stop button, in the same action group.
|
||||
assert.equal(await b.evaluate(`document.querySelector('${d} [data-drawer-status]').closest('[data-drawer-actions]') === document.querySelector('${d} [data-drawer-stop]').closest('[data-drawer-actions]')`), true);
|
||||
// A read-only refusal with no message from the server still says why.
|
||||
await b.evaluate(`document.querySelector('${d} [data-drawer-stop]').click()`);
|
||||
await wait(`document.querySelector('${d} [data-drawer-stop-yes]')`);
|
||||
await b.evaluate('window.__answers.push({ status: 403, body: { error: "read-only", sent: false } })');
|
||||
await b.evaluate(`document.querySelector('${d} [data-drawer-stop-yes]').click()`);
|
||||
await wait(`/read-only|only reads/.test(document.querySelector('${d} [data-drawer-status]')?.textContent ?? "")`);
|
||||
assert.match(await text(`${d} [data-drawer-status]`), /^Not stopped: this Console only reads agents in Acme Studio \(read-only\)\.$/);
|
||||
// Escape while it asks cancels only the question: the drawer stays and focus is back on Stop.
|
||||
await b.evaluate(`document.querySelector('${d} [data-drawer-stop]').click()`);
|
||||
await wait(`document.querySelector('${d} [data-drawer-stop-no]') === document.activeElement`);
|
||||
await b.key('Escape');
|
||||
await wait(`!document.querySelector('${d} [data-drawer-stop-confirm]') && document.activeElement === document.querySelector('${d} [data-drawer-stop]')`);
|
||||
assert.equal(await b.evaluate(`document.querySelector('${d} [data-drawer-stop]').disabled`), false);
|
||||
assert.equal(await b.evaluate(agentParam), 'acme:coder');
|
||||
assert.equal(await b.evaluate('window.__posts').then(p => p.length), 2);
|
||||
// A Stop the bus takes: the request is followed until it closes, then the drawer says it stopped.
|
||||
const REQ = '0f0e0d0c-0b0a-4908-8706-050403020100';
|
||||
await b.evaluate(`window.__answers.push({ status: 200, body: { sent: true, request: '${REQ}' } }, { status: 200, body: { request: '${REQ}', state: 'closed' } })`);
|
||||
await b.evaluate(`document.querySelector('${d} [data-drawer-stop]').click()`);
|
||||
await wait(`document.querySelector('${d} [data-drawer-stop-yes]')`);
|
||||
await b.evaluate(`document.querySelector('${d} [data-drawer-stop-yes]').click()`);
|
||||
await wait(`/^Stopped coder\.$/.test(document.querySelector('${d} [data-drawer-status]')?.textContent ?? "")`);
|
||||
assert.deepEqual((await b.evaluate('window.__posts')).slice(2).map(p => [p.url, p.method]), [['/api/orgs/acme/agent/terminate', 'POST'], [`/api/orgs/acme/agent/request?id=${REQ}`, 'GET']]);
|
||||
// A not-running agent has nothing to stop.
|
||||
await b.evaluate('location.hash = "#/agents?agent=acme%3Achief"');
|
||||
await wait(open('acme:chief'));
|
||||
assert.equal(await has('[data-ws-drawer="acme:chief"] [data-drawer-stop]'), false);
|
||||
// Where Stop is not offered, the drawer says why beside the actions.
|
||||
assert.match(await text('[data-ws-drawer="acme:chief"] [data-drawer-actions] [data-drawer-nostop]'), /^chief is one of Acme Studio’s protected agents, so it is not stopped from here\.$/);
|
||||
});
|
||||
|
||||
test('no action is shown where the Console cannot write for the agent\'s organization', async t => {
|
||||
const { wait, has, text } = await setup(t, { hash: '#/agents?agent=beta%3Apm' });
|
||||
await wait(`${open('beta:pm')} && document.querySelector('[data-ws-drawer="beta:pm"] [data-drawer-readonly]')`);
|
||||
const d = '[data-ws-drawer="beta:pm"]';
|
||||
for (const action of ['message', 'stop', 'restart']) assert.equal(await has(`${d} [data-drawer-${action}]`), false, `${action} should be absent`);
|
||||
assert.match(await text(`${d} [data-drawer-readonly]`), /only reads Beta Works/);
|
||||
});
|
||||
|
||||
test('at 390 wide the drawer is a full-width sheet', async t => {
|
||||
const { b, wait } = await setup(t, { viewport: [390, 844], hash: '#/agents?agent=acme%3Acoder' });
|
||||
await wait(open('acme:coder'));
|
||||
const box = await b.evaluate(`(() => { const r = ${drawer('acme:coder')}.getBoundingClientRect(); return [Math.round(r.left), Math.round(r.width), document.documentElement.clientWidth, document.documentElement.scrollWidth]; })()`);
|
||||
assert.equal(box[0], 0);
|
||||
assert.equal(box[1], box[2]);
|
||||
assert.ok(box[3] <= box[2], `no horizontal page scroll: ${box[3]} > ${box[2]}`);
|
||||
});
|
||||
@@ -0,0 +1,579 @@
|
||||
// SP6 (#1873): the Assistant is one system page. Up to three chat threads from any organization
|
||||
// open next to each other, each the chat thread view pinned to its own organization.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
const P = { alpha: '10000000-0000-4000-8000-00000000000a', beta: '10000000-0000-4000-8000-00000000000b', home: '10000000-0000-4000-8000-00000000000c' };
|
||||
const T = {
|
||||
a1: '20000000-0000-4000-8000-0000000000a1', a2: '20000000-0000-4000-8000-0000000000a2',
|
||||
b1: '20000000-0000-4000-8000-0000000000b1', h1: '20000000-0000-4000-8000-0000000000c1',
|
||||
};
|
||||
const NEW = '20000000-0000-4000-8000-0000000000b9';
|
||||
|
||||
// Three organizations. `state.down` names the ones whose bus is stopped; `state.sent` and
|
||||
// `state.created` record every chat write with the organization whose transport received it.
|
||||
function orgs(state) {
|
||||
const ORGS = [
|
||||
{ id: 'alpha', label: 'Alpha', theme: 'rosewood' },
|
||||
{ id: 'beta', label: 'Beta', theme: 'cobalt' },
|
||||
{ id: 'home', label: 'Home', theme: 'grove' },
|
||||
];
|
||||
const PROJECTS = { alpha: [{ id: P.alpha, name: 'Spring launch' }], beta: [{ id: P.beta, name: 'API gateway' }], home: [{ id: P.home, name: 'Garden' }] };
|
||||
const THREADS = {
|
||||
alpha: [{ id: T.a1, project: P.alpha, name: 'Launch email', instance: 'assistant' }, { id: T.a2, project: P.alpha, name: 'Kickoff notes', instance: 'assistant' }],
|
||||
beta: [{ id: T.b1, project: P.beta, name: 'Gateway auth', instance: 'assistant' }],
|
||||
home: [{ id: T.h1, project: P.home, name: 'Seed order', instance: 'assistant' }],
|
||||
};
|
||||
const MESSAGES = { [T.a1]: 'Alpha draft is ready.', [T.a2]: 'Kickoff agenda.', [T.b1]: 'Beta tokens rotate nightly.', [T.h1]: 'Seeds arrive Monday.' };
|
||||
const down = () => { throw Object.assign(new Error('bus down'), { code: 'bus-unavailable' }); };
|
||||
return {
|
||||
list: async () => ORGS.filter(o => !state.gone?.has(o.id)).map(o => ({ ...o, busHealthy: !state.down.has(o.id), ...(state.down.has(o.id) ? { busStopped: true } : {}) })),
|
||||
resolve: async id => {
|
||||
const org = ORGS.find(o => o.id === id); if (!org || state.gone?.has(id)) return null;
|
||||
if (state.down.has(id)) return { busHealthy: false, reads: null, bus: {}, chat: { projects: down, threads: down, sessions: down, shares: down, messages: down } };
|
||||
const threads = () => [...THREADS[id], ...state.created.filter(c => c.org === id).map(c => ({ id: c.id, project: c.project, name: c.name, instance: c.instance }))];
|
||||
return {
|
||||
busHealthy: true,
|
||||
send: async () => ({}),
|
||||
reads: { business: async () => ({ business: { id } }), settings: async () => ({}), busStatus: async () => ({ live: true, down: false }), queue: async () => ({ rows: [] }) },
|
||||
bus: {
|
||||
inbox: async () => [], agents: async () => [], tasks: async () => [], 'thread.claims': async () => [], messages: async () => ({ messages: [], unread: 0 }),
|
||||
instances: async () => ({ business: id, revision: 'a'.repeat(64), definitions: {}, instances: { assistant: { definition: 'assistant', vars: {}, ...(state.run?.[id] ? { running: state.run[id] } : {}) } } }),
|
||||
},
|
||||
chat: {
|
||||
projects: async () => PROJECTS[id],
|
||||
threads: async ({ shared } = {}) => (shared ? [] : threads()),
|
||||
sessions: async ({ thread }) => [{ id: thread, name: 'default', default: true }],
|
||||
shares: async () => [],
|
||||
messages: async ({ thread }) => [
|
||||
...(MESSAGES[thread] ? [{ id: thread.replace(/^2/, '3'), from_role: 'assistant', body: MESSAGES[thread], at: '2026-11-15T10:00:00.000Z' }] : []),
|
||||
...(state.hideSent ? [] : state.sent).filter(s => s.org === id && s.thread === thread).map(s => ({ id: s.id, from_role: 'human', body: s.body, at: '2026-11-15T10:01:00.000Z' })),
|
||||
...(state.replies || []).filter(r => r.org === id && r.thread === thread).map((r, n) => ({ id: `50000000-0000-4000-8000-${String(n + 1).padStart(12, '0')}`, from_role: 'assistant', body: r.body, at: '2026-11-15T10:02:00.000Z' })),
|
||||
],
|
||||
send: async ({ thread, body }) => {
|
||||
const sent = { org: id, thread, body, id: `40000000-0000-4000-8000-${String(state.sent.length + 1).padStart(12, '0')}` };
|
||||
state.sent.push(sent);
|
||||
return { id: sent.id };
|
||||
},
|
||||
createThread: async ({ project, name, instance }) => {
|
||||
state.created.push({ org: id, project, name, instance, id: NEW });
|
||||
return { id: NEW, instance };
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function open(t, { width = 1440, height = 900, hash, state = { down: new Set(['home']), sent: [], created: [] }, sso = createDevOwner() } = {}) {
|
||||
const organizations = orgs(state);
|
||||
const server = await startServer({ port: 0, sso, organizations, send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {} });
|
||||
t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
await b.viewport(width, height);
|
||||
const base = `http://127.0.0.1:${server.address().port}/`;
|
||||
await b.navigate(base + hash);
|
||||
const wait = (expression, tries = 200) => b.evaluate(`(async()=>{for(let i=0;i<${tries};i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
|
||||
return { b, base, wait, state };
|
||||
}
|
||||
|
||||
const PANE = sel => `document.querySelector('[data-assist-pane="${sel}"]')`;
|
||||
const panes = `[...document.querySelectorAll('[data-assist-pane]')].map(p => p.dataset.assistPane)`;
|
||||
const shown = `[...document.querySelectorAll('[data-assist-pane]')].filter(p => p.getClientRects().length).map(p => p.dataset.assistPane)`;
|
||||
const query = `new URLSearchParams(location.hash.split('?')[1] || '').get('open')`;
|
||||
const ready = sel => `!!${PANE(sel)}?.querySelector('.chat-log li') && !!${PANE(sel)}?.querySelector('[data-ws-orgchip]')`;
|
||||
const trail = '(document.getElementById("breadcrumbs").getClientRects().length ? document.getElementById("breadcrumbs").innerText.replace(/\\s+/g, " ").trim() : null)';
|
||||
const SENT = '/Accepted message|Message shown in this conversation/';
|
||||
const pick = (b, value) => b.evaluate(`(() => { const s = document.querySelector('[data-assist-open]'); s.value = ${JSON.stringify(value)}; if (s.value !== ${JSON.stringify(value)}) throw Error('no option ' + ${JSON.stringify(value)}); s.dispatchEvent(new Event('change', { bubbles: true })); })()`);
|
||||
|
||||
test('the sidebar Assistant opens one system page, and two threads from two organizations open side by side with their chips', async t => {
|
||||
const { b, wait } = await open(t, { hash: '#/orgs/alpha/tasks' });
|
||||
await wait(`!!document.querySelector('#sections a[href="#/assistant"]')`);
|
||||
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('#sections a')].map(a => a.textContent.trim())`).then(v => v.includes('Side by side')), false, 'no Side by side item');
|
||||
await b.evaluate(`document.querySelector('#sections a[href="#/assistant"]').click()`);
|
||||
await wait(`location.hash.startsWith('#/assistant') && document.querySelector('#bus-view h1')?.textContent === 'Assistant'`);
|
||||
assert.equal(await b.evaluate('document.querySelector(\'#sections a[href="#/assistant"]\').getAttribute("aria-current")'), 'page');
|
||||
// SP7: the page name shows once, as the heading.
|
||||
assert.equal(await b.evaluate(trail), null);
|
||||
await wait(`!!document.querySelector('[data-assist-open] optgroup[label^="Beta"] option[value="beta:${T.b1}"]')`);
|
||||
await pick(b, `alpha:${T.a1}`);
|
||||
await pick(b, `beta:${T.b1}`);
|
||||
await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`);
|
||||
assert.deepEqual(await b.evaluate(panes), [`alpha:${T.a1}`, `beta:${T.b1}`]);
|
||||
assert.deepEqual(await b.evaluate(shown), [`alpha:${T.a1}`, `beta:${T.b1}`], 'both panes show at 1440');
|
||||
const head = sel => b.evaluate(`(() => { const p = ${PANE(sel)}; return { title: p.querySelector('[data-assist-title]').textContent, org: p.querySelector('[data-ws-orgchip]').textContent, project: p.querySelector('[data-ws-projchip]')?.textContent ?? null, close: !!p.querySelector('button[data-assist-close]') }; })()`);
|
||||
assert.deepEqual(await head(`alpha:${T.a1}`), { title: 'Launch email', org: 'Alpha', project: 'Spring launch', close: true });
|
||||
assert.deepEqual(await head(`beta:${T.b1}`), { title: 'Gateway auth', org: 'Beta', project: 'API gateway', close: true });
|
||||
assert.match(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.innerText`), /Alpha draft is ready\./);
|
||||
assert.match(await b.evaluate(`${PANE(`beta:${T.b1}`)}.innerText`), /Beta tokens rotate nightly\./);
|
||||
assert.doesNotMatch(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.innerText`), /Beta tokens/);
|
||||
// One level-1 heading on the page; each pane's own form, field and ids are its own.
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('#bus-view h1').length`), 1);
|
||||
const ids = await b.evaluate(`[...document.querySelectorAll('#bus-view [id]')].map(e => e.id)`);
|
||||
assert.equal(new Set(ids).size, ids.length, 'no duplicate ids across panes');
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('#bus-view form textarea').length`), 2);
|
||||
});
|
||||
|
||||
test('the open set and its order live in the address, so a reload or a pasted address restores the same panes', async t => {
|
||||
const hash = `#/assistant?open=beta:${T.b1},alpha:${T.a2}`;
|
||||
const { b, base, wait } = await open(t, { hash });
|
||||
await wait(`${ready(`beta:${T.b1}`)} && ${ready(`alpha:${T.a2}`)}`);
|
||||
assert.deepEqual(await b.evaluate(panes), [`beta:${T.b1}`, `alpha:${T.a2}`]);
|
||||
await pick(b, `alpha:${T.a1}`);
|
||||
await wait(`${ready(`alpha:${T.a1}`)}`);
|
||||
assert.equal(await b.evaluate(query), `beta:${T.b1},alpha:${T.a2},alpha:${T.a1}`);
|
||||
const address = await b.evaluate('location.hash');
|
||||
await b.navigate('about:blank');
|
||||
await b.navigate(base + address);
|
||||
await wait(`${ready(`beta:${T.b1}`)} && ${ready(`alpha:${T.a2}`)} && ${ready(`alpha:${T.a1}`)}`);
|
||||
assert.deepEqual(await b.evaluate(panes), [`beta:${T.b1}`, `alpha:${T.a2}`, `alpha:${T.a1}`]);
|
||||
// A hand-edited address is checked before use: bad selectors, repeats and a fourth pane are dropped.
|
||||
await b.navigate('about:blank');
|
||||
await b.navigate(base + `#/assistant?open=alpha:${T.a1},alpha:${T.a1},nope,../x:${T.b1},beta:not-a-uuid,beta:${T.b1},alpha:${T.a2},home:${T.h1}`);
|
||||
await wait(`document.querySelectorAll('[data-assist-pane]').length === 3`);
|
||||
assert.deepEqual(await b.evaluate(panes), [`alpha:${T.a1}`, `beta:${T.b1}`, `alpha:${T.a2}`]);
|
||||
await wait(`${query} === 'alpha:${T.a1},beta:${T.b1},alpha:${T.a2}'`);
|
||||
// With three open the picker offers no fourth.
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-assist-open]').disabled`), true);
|
||||
});
|
||||
|
||||
test('a send in organization B\'s pane reaches B\'s thread while organization A was opened last', async t => {
|
||||
const state = { down: new Set(['home']), sent: [], created: [] };
|
||||
const { b, wait } = await open(t, { hash: '#/orgs/beta/tasks', state });
|
||||
await wait(`window.MosaicOrg?.id === 'beta'`);
|
||||
await b.evaluate(`location.hash = '#/orgs/alpha/tasks'`);
|
||||
await wait(`window.MosaicOrg?.id === 'alpha'`);
|
||||
await b.evaluate(`location.hash = '#/assistant?open=beta:${T.b1},alpha:${T.a1}'`);
|
||||
await wait(`${ready(`beta:${T.b1}`)} && ${ready(`alpha:${T.a1}`)}`);
|
||||
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'alpha', 'alpha is the organization opened last');
|
||||
const field = `${PANE(`beta:${T.b1}`)}.querySelector('form textarea')`;
|
||||
await wait(`!${field}.disabled`);
|
||||
await b.evaluate(`(() => { const el = ${field}; el.value = 'rotate the beta key'; el.dispatchEvent(new Event('input', { bubbles: true })); el.form.requestSubmit(); })()`);
|
||||
await wait(`${SENT}.test(${PANE(`beta:${T.b1}`)}.innerText)`);
|
||||
assert.deepEqual(state.sent.map(({ org, thread, body }) => ({ org, thread, body })), [{ org: 'beta', thread: T.b1, body: 'rotate the beta key' }]);
|
||||
assert.doesNotMatch(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.innerText`), /Accepted message|Message shown in this conversation/, 'the other pane shows no receipt');
|
||||
assert.equal(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.querySelector('form textarea').value`), '', 'the other pane keeps its own draft');
|
||||
// A send in A's pane reaches A.
|
||||
const other = `${PANE(`alpha:${T.a1}`)}.querySelector('form textarea')`;
|
||||
await b.evaluate(`(() => { const el = ${other}; el.value = 'alpha copy'; el.dispatchEvent(new Event('input', { bubbles: true })); el.form.requestSubmit(); })()`);
|
||||
await wait(`${SENT}.test(${PANE(`alpha:${T.a1}`)}.innerText)`);
|
||||
assert.deepEqual(state.sent.map(({ org, thread }) => `${org}:${thread}`), [`beta:${T.b1}`, `alpha:${T.a1}`]);
|
||||
// Live updates: a refresh shows the sent message in its own pane only.
|
||||
await b.evaluate('window.MosaicAssistant.refresh()');
|
||||
await wait(`/rotate the beta key/.test(${PANE(`beta:${T.b1}`)}.querySelector('.chat-log').innerText)`);
|
||||
assert.doesNotMatch(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.querySelector('.chat-log').innerText`), /rotate the beta key/);
|
||||
});
|
||||
|
||||
test('closing a pane updates the address and leaves the others', async t => {
|
||||
const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}` });
|
||||
await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`);
|
||||
await b.evaluate(`${PANE(`alpha:${T.a1}`)}.querySelector('button[data-assist-close]').click()`);
|
||||
await wait(`document.querySelectorAll('[data-assist-pane]').length === 1`);
|
||||
assert.equal(await b.evaluate(query), `beta:${T.b1}`);
|
||||
assert.match(await b.evaluate(`${PANE(`beta:${T.b1}`)}.innerText`), /Beta tokens rotate nightly\./);
|
||||
await b.evaluate(`${PANE(`beta:${T.b1}`)}.querySelector('button[data-assist-close]').click()`);
|
||||
await wait(`document.querySelectorAll('[data-assist-pane]').length === 0`);
|
||||
assert.equal(await b.evaluate(`location.hash`), '#/assistant');
|
||||
assert.match(await b.evaluate(`document.getElementById('bus-view').innerText`), /No thread is open/);
|
||||
});
|
||||
|
||||
test('a stopped organization is named at the top, its pane says so, and the other panes keep working', async t => {
|
||||
const state = { down: new Set(['home']), sent: [], created: [] };
|
||||
const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},home:${T.h1},beta:${T.b1}`, state });
|
||||
await wait(`!!document.querySelector('#bus-view [data-ws-unread]') && ${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`);
|
||||
assert.match(await b.evaluate(`document.querySelector('#bus-view [data-ws-unread]').textContent`), /Home/);
|
||||
assert.doesNotMatch(await b.evaluate(`document.querySelector('#bus-view [data-ws-unread]').textContent`), /Alpha|Beta/);
|
||||
await wait(`/bus is stopped/i.test(${PANE(`home:${T.h1}`)}?.querySelector('[data-assist-line]')?.textContent || '')`);
|
||||
assert.equal(await b.evaluate(`${PANE(`home:${T.h1}`)}.querySelector('[data-ws-orgchip]').textContent`), 'Home');
|
||||
assert.equal(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.querySelector('form textarea').disabled`), false);
|
||||
// Beta's bus stops while its pane is open, with an unsent message in it: that pane says so and
|
||||
// only so, and holds the message; Alpha's still sends.
|
||||
const betaField = `${PANE(`beta:${T.b1}`)}.querySelector('form textarea')`;
|
||||
await wait(`!!${betaField} && !${betaField}.disabled`);
|
||||
await b.evaluate(`(() => { const el = ${betaField}; el.value = 'beta draft'; el.dispatchEvent(new Event('input', { bubbles: true })); })()`);
|
||||
state.down.add('beta');
|
||||
await b.evaluate('window.MosaicAssistant.refresh()');
|
||||
await wait(`/bus is stopped/i.test(${PANE(`beta:${T.b1}`)}.querySelector('[data-assist-line]')?.textContent || '') && /Beta/.test(document.querySelector('#bus-view [data-ws-unread]')?.textContent || '')`);
|
||||
await wait(`!${betaField}`);
|
||||
const betaBody = await b.evaluate(`${PANE(`beta:${T.b1}`)}.querySelector('.assist-body').innerText`);
|
||||
assert.match(betaBody, /Nothing is read for this thread while Beta's bus is stopped\. Your unsent message is kept/);
|
||||
assert.doesNotMatch(betaBody, /Refresh to try again|Read again|Conversation unavailable/, 'no line that says a refresh would help');
|
||||
assert.equal(await b.evaluate('window.MosaicAssistant.hasDraft()'), true, 'the held message counts as a draft');
|
||||
const field = `${PANE(`alpha:${T.a1}`)}.querySelector('form textarea')`;
|
||||
assert.equal(await b.evaluate(`${field}.disabled`), false);
|
||||
await b.evaluate(`(() => { const el = ${field}; el.value = 'still here'; el.dispatchEvent(new Event('input', { bubbles: true })); el.form.requestSubmit(); })()`);
|
||||
await wait(`${SENT}.test(${PANE(`alpha:${T.a1}`)}.innerText)`);
|
||||
assert.deepEqual(state.sent.map(({ org, body }) => `${org}:${body}`), ['alpha:still here']);
|
||||
// The picker names the stopped organizations and offers none of their threads.
|
||||
assert.match(await b.evaluate(`[...document.querySelectorAll('[data-assist-open] optgroup')].map(g => g.label).join('|')`), /Home · bus stopped/);
|
||||
// Beta's bus starts again: its pane reads again and the held message is back in its composer.
|
||||
state.down.delete('beta');
|
||||
await b.evaluate('window.MosaicAssistant.refresh()');
|
||||
await wait(`${ready(`beta:${T.b1}`)} && ${betaField}?.value === 'beta draft'`);
|
||||
assert.deepEqual(state.sent.map(({ org, body }) => `${org}:${body}`), ['alpha:still here'], 'the held message is not sent by itself');
|
||||
});
|
||||
|
||||
test('a pane whose organization leaves the list keeps its unsent message and does not promise it comes back', async t => {
|
||||
const state = { down: new Set(['home']), gone: new Set(), sent: [], created: [] };
|
||||
const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}`, state });
|
||||
await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`);
|
||||
const betaField = `${PANE(`beta:${T.b1}`)}.querySelector('form textarea')`;
|
||||
await wait(`!!${betaField} && !${betaField}.disabled`);
|
||||
await b.evaluate(`(() => { const el = ${betaField}; el.value = 'beta draft'; el.dispatchEvent(new Event('input', { bubbles: true })); })()`);
|
||||
state.gone.add('beta');
|
||||
await b.evaluate('window.MosaicAssistant.refresh()');
|
||||
await wait(`/not on your organization list/.test(${PANE(`beta:${T.b1}`)}.querySelector('[data-assist-line]')?.textContent || '') && !${betaField}`);
|
||||
const body = await b.evaluate(`${PANE(`beta:${T.b1}`)}.querySelector('.assist-body').innerText`);
|
||||
assert.match(body, /Your unsent message is kept while this pane stays open\./);
|
||||
assert.doesNotMatch(body, /comes back/, 'no promise that an organization off the list reads again');
|
||||
assert.equal(await b.evaluate('window.MosaicAssistant.hasDraft()'), true);
|
||||
assert.deepEqual(state.sent, []);
|
||||
});
|
||||
|
||||
test('an open pane shows a new reply on its own, with no refresh', async t => {
|
||||
const state = { down: new Set(['home']), sent: [], created: [], replies: [] };
|
||||
const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}`, state });
|
||||
await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`);
|
||||
state.replies.push({ org: 'beta', thread: T.b1, body: 'Rotation finished.' });
|
||||
// The page polls every 10 s; wait up to 15 s without calling refresh.
|
||||
await wait(`/Rotation finished\./.test(${PANE(`beta:${T.b1}`)}.querySelector('.chat-log').innerText)`, 500);
|
||||
assert.doesNotMatch(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.querySelector('.chat-log').innerText`), /Rotation finished/);
|
||||
});
|
||||
|
||||
test('the old Side by side address lands on the Assistant page', async t => {
|
||||
const { b, wait } = await open(t, { hash: '#/orgs/alpha/side-by-side?a=alpha/organization&b=beta/organization&w=50&show=a' });
|
||||
await wait(`location.hash === '#/assistant' && document.querySelector('#bus-view h1')?.textContent === 'Assistant'`);
|
||||
assert.equal(await b.evaluate(`!!window.MosaicPanes`), false, 'the Side by side module is gone');
|
||||
await b.evaluate(`location.hash = '#/side-by-side'`);
|
||||
await wait(`location.hash === '#/assistant'`);
|
||||
// The old per-organization Assistant address, the messages page, still opens.
|
||||
await b.evaluate(`location.hash = '#/orgs/alpha/messages'`);
|
||||
await wait(`document.querySelector('#bus-view h1')?.textContent === 'Messages'`);
|
||||
});
|
||||
|
||||
test('at 390 one pane shows at a time with a tab strip to switch', async t => {
|
||||
const { b, base, wait } = await open(t, { width: 390, height: 844, hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}` });
|
||||
await wait(`${ready(`alpha:${T.a1}`)} && document.querySelectorAll('[data-assist-tabs] [data-assist-show]').length === 2`);
|
||||
assert.deepEqual(await b.evaluate(shown), [`alpha:${T.a1}`]);
|
||||
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-assist-tabs] [data-assist-show]')].map(x => [x.textContent.trim(), x.getAttribute('aria-pressed')])`), [['Launch email', 'true'], ['Gateway auth', 'false']]);
|
||||
await b.evaluate(`document.querySelector('[data-assist-tabs] [data-assist-show="beta:${T.b1}"]').click()`);
|
||||
await wait(`${ready(`beta:${T.b1}`)}`);
|
||||
assert.deepEqual(await b.evaluate(shown), [`beta:${T.b1}`]);
|
||||
assert.equal(await b.evaluate(`new URLSearchParams(location.hash.split('?')[1]).get('show')`), `beta:${T.b1}`);
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), true, 'no horizontal page scroll'); // A pasted address names the shown pane however its id is cased.
|
||||
await b.navigate('about:blank');
|
||||
await b.navigate(base + `#/assistant?open=alpha:${T.a1},beta:${T.b1}&show=beta:${T.b1.toUpperCase()}`);
|
||||
await wait(`${ready(`beta:${T.b1}`)}`);
|
||||
assert.deepEqual(await b.evaluate(shown), [`beta:${T.b1}`]);
|
||||
});
|
||||
|
||||
test('"Open thread" starts a new thread in a chosen organization through that organization\'s own route', async t => {
|
||||
const state = { down: new Set(['home']), sent: [], created: [] };
|
||||
const { b, wait } = await open(t, { hash: '#/assistant', state });
|
||||
await wait(`!!document.querySelector('[data-assist-open] option[value="new:beta"]')`);
|
||||
await pick(b, 'new:beta');
|
||||
await wait(`!!document.querySelector('[data-assist-new] select[name=project] option[value="${P.beta}"]') && !!document.querySelector('[data-assist-new] select[name=instance] option[value=assistant]')`);
|
||||
await b.evaluate(`(() => { const f = document.querySelector('[data-assist-new]'); f.elements.name.value = 'Rate limits'; f.requestSubmit(); })()`);
|
||||
await wait(`${ready(`beta:${NEW}`)} || !!${PANE(`beta:${NEW}`)}?.querySelector('form textarea')`);
|
||||
assert.deepEqual(state.created.map(({ org, project, name, instance }) => ({ org, project, name, instance })), [{ org: 'beta', project: P.beta, name: 'Rate limits', instance: 'assistant' }]);
|
||||
assert.equal(await b.evaluate(query), `beta:${NEW}`);
|
||||
});
|
||||
|
||||
// SP7: a thread link anywhere opens that thread as a pane here; a typed per-organization address
|
||||
// still opens that organization's own chat view.
|
||||
const follow = (b, href) => b.evaluate(`(() => { const a = document.createElement('a'); a.href = ${JSON.stringify(href)}; a.textContent = 'thread'; document.body.append(a); a.click(); a.remove(); })()`);
|
||||
const type = (b, sel, text) => b.evaluate(`(() => { const el = ${PANE(sel)}.querySelector('form textarea'); el.value = ${JSON.stringify(text)}; el.dispatchEvent(new Event('input', { bubbles: true })); })()`);
|
||||
const field = sel => `${PANE(sel)}?.querySelector('form textarea')`;
|
||||
|
||||
test('SP7: a thread link on a project page opens the thread as a pane on the Assistant page, added to the open set', async t => {
|
||||
const { b, wait } = await open(t, { hash: `#/assistant?open=beta:${T.b1}` });
|
||||
await wait(ready(`beta:${T.b1}`));
|
||||
await b.evaluate(`location.hash = '#/orgs/alpha/projects/${P.alpha}'`);
|
||||
await wait(`!!document.querySelector('#bus-view a[href$="/threads/${T.a1}"]')`);
|
||||
await b.evaluate(`document.querySelector('#bus-view a[href$="/threads/${T.a1}"]').click()`);
|
||||
await wait(`location.hash.startsWith('#/assistant') && ${ready(`alpha:${T.a1}`)}`);
|
||||
assert.deepEqual(await b.evaluate(panes), [`beta:${T.b1}`, `alpha:${T.a1}`]);
|
||||
assert.equal(await b.evaluate(query), `beta:${T.b1},alpha:${T.a1}`);
|
||||
// A per-organization chat address typed or pasted still opens that organization's chat view.
|
||||
await b.evaluate(`location.hash = '#/orgs/beta/projects/threads/${T.b1}'`);
|
||||
await wait(`!document.querySelector('[data-assistant]') && /Beta tokens rotate nightly/.test(document.getElementById('bus-view').innerText)`);
|
||||
assert.equal(await b.evaluate('location.hash'), `#/orgs/beta/projects/threads/${T.b1}`);
|
||||
});
|
||||
|
||||
// SP11 (#1873): Resume assistant shows only when the organization's instances read says the thread's
|
||||
// agent runs no process (paused or stopped). An agent the read does not say about keeps the control.
|
||||
test('Resume assistant shows only in a pane whose agent the instances read says is not running', async t => {
|
||||
const state = { down: new Set(), sent: [], created: [], run: { alpha: ['run-1'], beta: [] } };
|
||||
const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1},home:${T.h1}`, state });
|
||||
const resume = sel => `!!${PANE(sel)}.querySelector('button.pane-resume')`;
|
||||
await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)} && ${ready(`home:${T.h1}`)}`);
|
||||
await wait(resume(`beta:${T.b1}`));
|
||||
assert.equal(await b.evaluate(resume(`alpha:${T.a1}`)), false, 'a running assistant offers no resume');
|
||||
assert.equal(await b.evaluate(resume(`home:${T.h1}`)), true, 'a read that does not say keeps the control');
|
||||
// Alpha's assistant stops: the next read shows the control.
|
||||
state.run.alpha = [];
|
||||
await b.evaluate('window.MosaicAssistant.refresh()');
|
||||
await wait(resume(`alpha:${T.a1}`));
|
||||
// Beta's starts again: its control goes.
|
||||
state.run.beta = ['run-2'];
|
||||
await b.evaluate('window.MosaicAssistant.refresh()');
|
||||
await wait(`!${resume(`beta:${T.b1}`)}`);
|
||||
});
|
||||
|
||||
test('SP7: with three open, a fourth thread replaces the first pane with no unsent message; when all three hold one, nothing is replaced and the page says why', async t => {
|
||||
const state = { down: new Set(), sent: [], created: [] };
|
||||
const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}`, state });
|
||||
await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)} && !${field(`alpha:${T.a1}`)}.disabled`);
|
||||
await type(b, `alpha:${T.a1}`, 'alpha draft');
|
||||
// On the Assistant page a thread link adds a pane in place: the typed message stays.
|
||||
await follow(b, `#/orgs/alpha/projects/threads/${T.a2}`);
|
||||
await wait(ready(`alpha:${T.a2}`));
|
||||
assert.deepEqual(await b.evaluate(panes), [`alpha:${T.a1}`, `beta:${T.b1}`, `alpha:${T.a2}`]);
|
||||
assert.equal(await b.evaluate(`${field(`alpha:${T.a1}`)}.value`), 'alpha draft');
|
||||
// A fourth: Alpha's first pane holds a message, so Beta's pane, the first without one, gives way.
|
||||
await follow(b, `#/orgs/home/projects/threads/${T.h1}`);
|
||||
await wait(ready(`home:${T.h1}`));
|
||||
assert.deepEqual(await b.evaluate(panes), [`alpha:${T.a1}`, `home:${T.h1}`, `alpha:${T.a2}`]);
|
||||
assert.equal(await b.evaluate(query), `alpha:${T.a1},home:${T.h1},alpha:${T.a2}`);
|
||||
assert.equal(await b.evaluate(`${field(`alpha:${T.a1}`)}.value`), 'alpha draft');
|
||||
// All three hold a message: nothing is replaced and the page says why.
|
||||
await wait(`!${field(`home:${T.h1}`)}.disabled && !${field(`alpha:${T.a2}`)}.disabled`);
|
||||
await type(b, `home:${T.h1}`, 'home draft');
|
||||
await type(b, `alpha:${T.a2}`, 'kickoff draft');
|
||||
await follow(b, `#/orgs/beta/projects/threads/${T.b1}`);
|
||||
await wait(`/not opened/.test(document.querySelector('[data-assist-note]').textContent)`);
|
||||
assert.match(await b.evaluate(`document.querySelector('[data-assist-note]').textContent`), /all 3 panes hold an unsent message/);
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-assist-note]').hasAttribute('data-refused')`), true);
|
||||
assert.deepEqual(await b.evaluate(panes), [`alpha:${T.a1}`, `home:${T.h1}`, `alpha:${T.a2}`]);
|
||||
assert.deepEqual(await b.evaluate(`[${field(`alpha:${T.a1}`)}.value, ${field(`home:${T.h1}`)}.value, ${field(`alpha:${T.a2}`)}.value]`), ['alpha draft', 'home draft', 'kickoff draft']);
|
||||
assert.equal(await b.evaluate(query), `alpha:${T.a1},home:${T.h1},alpha:${T.a2}`);
|
||||
// Closing a pane clears the reason; the thread then opens. SP11: the pane holds a message, so the
|
||||
// close asks first.
|
||||
await b.evaluate(`window.__asked = []; window.confirm = m => (window.__asked.push(m), true)`);
|
||||
await b.evaluate(`${PANE(`home:${T.h1}`)}.querySelector('button[data-assist-close]').click()`);
|
||||
assert.equal(await b.evaluate('window.__asked.length'), 1);
|
||||
await wait(`!/not opened/.test(document.querySelector('[data-assist-note]').textContent)`);
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-assist-note]').hasAttribute('data-refused')`), false);
|
||||
await follow(b, `#/orgs/beta/projects/threads/${T.b1}`);
|
||||
await wait(ready(`beta:${T.b1}`));
|
||||
assert.deepEqual(await b.evaluate(panes), [`alpha:${T.a1}`, `alpha:${T.a2}`, `beta:${T.b1}`]);
|
||||
assert.deepEqual(state.sent, []);
|
||||
});
|
||||
|
||||
test('SP7: each pane names its own organization\'s Messages page, and the bar no longer names the organization opened last', async t => {
|
||||
const { b, wait } = await open(t, { hash: '#/orgs/beta/tasks' });
|
||||
await wait(`window.MosaicOrg?.id === 'beta'`);
|
||||
await b.evaluate(`location.hash = '#/assistant?open=alpha:${T.a1},beta:${T.b1}'`);
|
||||
await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`);
|
||||
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-assist-pane]')].map(p => { const a = p.querySelector('.assist-pane-h a[data-assist-pane-messages]'); return a && [a.getAttribute('href'), a.getAttribute('aria-label'), a.textContent.trim()]; })`),
|
||||
[['#/orgs/alpha/messages', 'Messages in Alpha', 'Messages'], ['#/orgs/beta/messages', 'Messages in Beta', 'Messages']]);
|
||||
assert.doesNotMatch(await b.evaluate(`document.querySelector('.assist-bar').innerText`), /Messages/);
|
||||
});
|
||||
|
||||
// SP11 (#1873): a pane's unsent message is kept per thread while the tab is open. Leaving the
|
||||
// Assistant page, by the sidebar or by a pane's Full view into another organization, keeps it, and the
|
||||
// pane shows it again when it opens. Closing a pane that holds one asks first.
|
||||
test('SP11: an unsent pane message survives leaving the Assistant page, through Full view too, and closing a pane with one asks first', async t => {
|
||||
const state = { down: new Set(), sent: [], created: [] };
|
||||
const a1 = `alpha:${T.a1}`, b1 = `beta:${T.b1}`, both = `#/assistant?open=${a1},${b1}`;
|
||||
const { b, wait } = await open(t, { hash: both, state });
|
||||
await wait(`${ready(a1)} && ${ready(b1)} && !${field(a1)}.disabled && !${field(b1)}.disabled`);
|
||||
await type(b, a1, 'alpha keep');
|
||||
await type(b, b1, 'beta keep');
|
||||
// Away to Tasks and back.
|
||||
await b.evaluate(`location.hash = '#/tasks'`);
|
||||
await wait(`!document.querySelector('[data-assist-pane]') && !!document.querySelector('[data-tasks-board]')`);
|
||||
await b.evaluate(`location.hash = ${JSON.stringify(both)}`);
|
||||
await wait(`${field(a1)}?.value === 'alpha keep' && ${field(b1)}?.value === 'beta keep'`);
|
||||
// Away through Beta's Full view: no discard question, and the message is still there after.
|
||||
await b.evaluate(`${PANE(b1)}.querySelector('a[data-assist-pane-full]').click()`);
|
||||
await wait(`location.hash.startsWith('#/orgs/beta/') && !document.querySelector('[data-assist-pane]')`);
|
||||
assert.equal(await b.evaluate(`!!document.querySelector('dialog[open]')`), false, 'leaving asks nothing: the message is kept');
|
||||
await b.evaluate(`location.hash = ${JSON.stringify(both)}`);
|
||||
await wait(`${field(a1)}?.value === 'alpha keep' && ${field(b1)}?.value === 'beta keep'`);
|
||||
// Closing a pane with an unsent message asks; saying no keeps the pane.
|
||||
await b.evaluate(`window.__asked = []; window.confirm = m => (window.__asked.push(m), false)`);
|
||||
await b.evaluate(`${PANE(a1)}.querySelector('button[data-assist-close]').click()`);
|
||||
assert.deepEqual(await b.evaluate('window.__asked'), ['Close Launch email? Its unsent message is lost.']);
|
||||
assert.deepEqual(await b.evaluate(panes), [a1, b1]);
|
||||
// Saying yes closes it and drops the message: the thread opens again empty.
|
||||
await b.evaluate(`window.confirm = m => (window.__asked.push(m), true)`);
|
||||
await b.evaluate(`${PANE(a1)}.querySelector('button[data-assist-close]').click()`);
|
||||
await wait(`document.querySelectorAll('[data-assist-pane]').length === 1`);
|
||||
await follow(b, `#/orgs/alpha/projects/threads/${T.a1}`);
|
||||
await wait(`${ready(a1)} && !!${field(a1)} && !${field(a1)}.disabled`);
|
||||
assert.equal(await b.evaluate(`${field(a1)}.value`), '');
|
||||
// A pane with nothing typed closes with no question.
|
||||
await b.evaluate(`${PANE(a1)}.querySelector('button[data-assist-close]').click()`);
|
||||
await wait(`document.querySelectorAll('[data-assist-pane]').length === 1`);
|
||||
assert.equal(await b.evaluate('window.__asked.length'), 2);
|
||||
assert.deepEqual(state.sent, []);
|
||||
});
|
||||
|
||||
// SP11 review: signing out drops every kept pane message, so the next person to sign in on this tab
|
||||
// never sees text typed before, after a reload too.
|
||||
test('SP11: signing out drops kept pane messages, and a reload after it opens the pane empty', async t => {
|
||||
const state = { down: new Set(), sent: [], created: [] };
|
||||
const a1 = `alpha:${T.a1}`, hash = `#/assistant?open=${a1}`;
|
||||
const { b, base, wait } = await open(t, { hash, state });
|
||||
await wait(`${ready(a1)} && !${field(a1)}.disabled`);
|
||||
await type(b, a1, 'private note');
|
||||
await b.evaluate(`location.hash = '#/tasks'`);
|
||||
await wait(`!document.querySelector('[data-assist-pane]')`);
|
||||
assert.match(await b.evaluate(`sessionStorage.getItem('mosaic-assistant-drafts') || ''`), /private note/);
|
||||
await b.evaluate(`location.hash = ${JSON.stringify(hash)}`);
|
||||
await wait(`${field(a1)}?.value === 'private note'`);
|
||||
await b.evaluate(`document.dispatchEvent(new Event('mosaic-signed-out')), true`);
|
||||
await wait(`document.body.classList.contains('signed-out')`);
|
||||
assert.equal(await b.evaluate(`sessionStorage.getItem('mosaic-assistant-drafts')`), null);
|
||||
// A query string makes it a real load; the same address with only a hash would not reload.
|
||||
await b.navigate(base + '?again=1' + hash);
|
||||
await wait(`!document.body.classList.contains('signed-out') && ${ready(a1)} && !!${field(a1)} && !${field(a1)}.disabled`);
|
||||
assert.equal(await b.evaluate(`${field(a1)}.value`), '');
|
||||
assert.equal(await b.evaluate(`sessionStorage.getItem('mosaic-assistant-drafts')`), null);
|
||||
assert.deepEqual(state.sent, []);
|
||||
});
|
||||
|
||||
// SP11 review: the Sign out form is a plain POST that unloads the page; its submit drops the kept
|
||||
// messages and the pagehide it causes keeps nothing, so a sign-in after it opens the pane empty.
|
||||
test('SP11: the Sign out form drops kept pane messages, and the page it unloads keeps none', async t => {
|
||||
const state = { down: new Set(), sent: [], created: [] };
|
||||
const a1 = `alpha:${T.a1}`, hash = `#/assistant?open=${a1}`;
|
||||
const { b, base, wait } = await open(t, { hash, state });
|
||||
await wait(`${ready(a1)} && !${field(a1)}.disabled`);
|
||||
await type(b, a1, 'private note');
|
||||
await b.evaluate(`location.hash = '#/tasks'`);
|
||||
await wait(`!document.querySelector('[data-assist-pane]')`);
|
||||
await b.evaluate(`location.hash = ${JSON.stringify(hash)}`);
|
||||
await wait(`${field(a1)}?.value === 'private note'`);
|
||||
await b.evaluate(`document.getElementById('sign-out').requestSubmit(), true`);
|
||||
// The POST unloads the page; poll from here, since the old page's context goes away under it.
|
||||
let left = false;
|
||||
for (let i = 0; i < 200 && !left; i++) { try { left = await b.evaluate(`location.pathname === '/auth/logout'`); } catch { /* navigating */ } if (!left) await new Promise(r => setTimeout(r, 30)); }
|
||||
assert.equal(left, true, 'the Sign out form posted');
|
||||
await b.navigate(base + '?again=1' + hash);
|
||||
await wait(`${ready(a1)} && !!${field(a1)} && !${field(a1)}.disabled`);
|
||||
assert.equal(await b.evaluate(`${field(a1)}.value`), '');
|
||||
assert.equal(await b.evaluate(`sessionStorage.getItem('mosaic-assistant-drafts')`), null);
|
||||
assert.deepEqual(state.sent, []);
|
||||
});
|
||||
|
||||
// SP14 (#1873): kept pane messages belong to the person who typed them. They are stored with who is
|
||||
// signed in, and a load under anyone else, or a store that names no one (the SP11 form), opens empty.
|
||||
// Two people sign in, one after the other, on one tab; the same person keeps theirs across a reload.
|
||||
test('SP14: a kept pane message is shown again only to the person who typed it, on the same tab', async t => {
|
||||
const state = { down: new Set(), sent: [], created: [] };
|
||||
const a1 = `alpha:${T.a1}`, hash = `#/assistant?open=${a1}`;
|
||||
const dev = createDevOwner();
|
||||
const person = sub => ({ ...dev.session(), provider: 'sso', iss: 'https://id.example.test', sub, name: sub });
|
||||
let who = person('person-a');
|
||||
const { b, base, wait } = await open(t, { hash, state, sso: { ...dev, session: () => who } });
|
||||
const stored = () => b.evaluate(`sessionStorage.getItem('mosaic-assistant-drafts') || ''`);
|
||||
const leave = async () => { await b.evaluate(`location.hash = '#/tasks'`); await wait(`!document.querySelector('[data-assist-pane]') && !!document.querySelector('[data-tasks-board]')`); };
|
||||
const load = async () => { await b.navigate(`${base}?n=${Math.random().toString(36).slice(2)}${hash}`); await wait(`${ready(a1)} && !!${field(a1)} && !${field(a1)}.disabled`); };
|
||||
await wait(`${ready(a1)} && !${field(a1)}.disabled`);
|
||||
await type(b, a1, 'note from a');
|
||||
await leave();
|
||||
assert.match(await stored(), /note from a/);
|
||||
// Stored with an opaque name for the person, never their subject.
|
||||
assert.doesNotMatch(await stored(), /person-a/);
|
||||
// Person B signs in on the same tab: the pane opens empty and A's message is gone from the store.
|
||||
who = person('person-b');
|
||||
await load();
|
||||
assert.equal(await b.evaluate(`${field(a1)}.value`), '');
|
||||
assert.doesNotMatch(await stored(), /note from a/);
|
||||
await type(b, a1, 'note from b');
|
||||
await leave();
|
||||
assert.match(await stored(), /note from b/);
|
||||
// A again: B's message is not shown to A either.
|
||||
who = person('person-a');
|
||||
await load();
|
||||
assert.equal(await b.evaluate(`${field(a1)}.value`), '');
|
||||
assert.doesNotMatch(await stored(), /note from b/);
|
||||
// The same person keeps their own message across a load.
|
||||
await type(b, a1, 'a again');
|
||||
await leave();
|
||||
await load();
|
||||
assert.equal(await b.evaluate(`${field(a1)}.value`), 'a again');
|
||||
// A store in the form kept before SP14, which names no one, is dropped.
|
||||
await leave();
|
||||
await b.evaluate(`sessionStorage.setItem('mosaic-assistant-drafts', JSON.stringify({ ${JSON.stringify(a1)}: 'unowned note' })), true`);
|
||||
await load();
|
||||
assert.equal(await b.evaluate(`${field(a1)}.value`), '');
|
||||
assert.doesNotMatch(await stored(), /unowned note/);
|
||||
assert.deepEqual(state.sent, []);
|
||||
});
|
||||
|
||||
// SP11 (#1873): a pane whose message was sent but not yet shown in the conversation is not free: a
|
||||
// fourth thread replaces the first pane that holds nothing, and that pane only once its receipt is in.
|
||||
test('SP11: a pane whose send still waits for its receipt is not replaced by a fourth thread', async t => {
|
||||
const state = { down: new Set(), sent: [], created: [], hideSent: true };
|
||||
const a1 = `alpha:${T.a1}`, b1 = `beta:${T.b1}`, a2 = `alpha:${T.a2}`, h1 = `home:${T.h1}`;
|
||||
const { b, wait } = await open(t, { hash: `#/assistant?open=${a1},${b1},${a2}`, state });
|
||||
await wait(`${ready(a1)} && ${ready(b1)} && ${ready(a2)} && !${field(a1)}.disabled`);
|
||||
await b.evaluate(`(() => { const el = ${field(a1)}; el.value = 'in flight'; el.dispatchEvent(new Event('input', { bubbles: true })); el.form.requestSubmit(); })()`);
|
||||
await wait(`/Accepted message/.test(${PANE(a1)}.innerText) && ${field(a1)}.value === ''`);
|
||||
await follow(b, `#/orgs/home/projects/threads/${T.h1}`);
|
||||
await wait(ready(h1));
|
||||
assert.deepEqual(await b.evaluate(panes), [a1, h1, a2], 'the waiting pane stays; the first free one gives way');
|
||||
// The message shows: the receipt is in and the pane is free again.
|
||||
state.hideSent = false;
|
||||
await b.evaluate('window.MosaicAssistant.refresh()');
|
||||
await wait(`/Message shown in this conversation/.test(${PANE(a1)}.innerText)`);
|
||||
await follow(b, `#/orgs/beta/projects/threads/${T.b1}`);
|
||||
await wait(ready(b1));
|
||||
assert.deepEqual(await b.evaluate(panes), [b1, h1, a2]);
|
||||
});
|
||||
|
||||
// SP7 review: a pane has no Request details, sessions or sharing, so each pane links its thread's full
|
||||
// view, and a link marked data-full-view is not turned into a pane.
|
||||
test('SP7: each pane links its thread\'s full view, and one plain click reaches it', async t => {
|
||||
const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}` });
|
||||
await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`);
|
||||
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-assist-pane]')].map(p => { const a = p.querySelector('.assist-pane-h a[data-assist-pane-full]'); return a && [a.getAttribute('href'), a.textContent.trim(), a.hasAttribute('data-full-view')]; })`),
|
||||
[[`#/orgs/alpha/projects/threads/${T.a1}`, 'Full view', true], [`#/orgs/beta/projects/threads/${T.b1}`, 'Full view', true]]);
|
||||
// Each pane's Full view names its thread and organization, so a screen reader can tell three apart.
|
||||
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-assist-pane-full]')].map(a => a.getAttribute('aria-label'))`),
|
||||
['Full view of Launch email, Alpha', 'Full view of Gateway auth, Beta']);
|
||||
await b.evaluate(`${PANE(`beta:${T.b1}`)}.querySelector('a[data-assist-pane-full]').click()`);
|
||||
await wait(`!document.querySelector('[data-assistant]') && /Beta tokens rotate nightly/.test(document.getElementById('bus-view').innerText)`);
|
||||
assert.equal(await b.evaluate('location.hash'), `#/orgs/beta/projects/threads/${T.b1}`);
|
||||
});
|
||||
|
||||
test('SP7: a link marked as the full view stays in the organization\'s own view, from any page', async t => {
|
||||
const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1}` });
|
||||
await wait(ready(`alpha:${T.a1}`));
|
||||
// On the Assistant page itself: no pane is added, the browser follows the link.
|
||||
await b.evaluate(`(() => { const a = document.createElement('a'); a.href = '#/orgs/beta/projects/threads/${T.b1}'; a.setAttribute('data-full-view', ''); a.textContent = 'thread'; document.body.append(a); a.click(); a.remove(); })()`);
|
||||
await wait(`location.hash === '#/orgs/beta/projects/threads/${T.b1}'`);
|
||||
await wait(`!document.querySelector('[data-assistant]') && /Beta tokens rotate nightly/.test(document.getElementById('bus-view').innerText)`);
|
||||
// From another page: the same.
|
||||
await b.evaluate(`(() => { const a = document.createElement('a'); a.href = '#/orgs/alpha/projects/threads/${T.a1}'; a.setAttribute('data-full-view', ''); a.textContent = 'thread'; document.body.append(a); a.click(); a.remove(); })()`);
|
||||
await wait(`location.hash === '#/orgs/alpha/projects/threads/${T.a1}' && !document.querySelector('[data-assistant]')`);
|
||||
});
|
||||
|
||||
// SP7 review shots: the title and the pane's tools (Full view, Messages, close) share the header's
|
||||
// first row, the organization and project chips sit under it, and nothing spills out of the pane.
|
||||
const headerRows = `[...document.querySelectorAll('[data-assist-pane]')].map(p => {
|
||||
const h = p.querySelector('.assist-pane-h').getBoundingClientRect();
|
||||
const r = s => p.querySelector(s).getBoundingClientRect();
|
||||
const mid = q => Math.round(q.top + q.height / 2);
|
||||
const row = ['[data-assist-title]', '[data-assist-pane-full]', '[data-assist-pane-messages]', '[data-assist-close]'].map(s => mid(r(s)));
|
||||
const inside = [...p.querySelectorAll('.assist-pane-h > *, .assist-pane-h a, .assist-pane-h button')].every(e => { const q = e.getBoundingClientRect(); return q.left >= h.left - 1 && q.right <= h.right + 1; });
|
||||
return { oneRow: Math.max(...row) - Math.min(...row) <= 4, chipsBelow: r('[data-assist-chips]').top >= r('[data-assist-title]').bottom - 1, inside };
|
||||
})`;
|
||||
// At 800 three panes are too narrow for one row: the tools wrap, and none of them leaves the pane.
|
||||
const THREE = [`alpha:${T.a1}`, `alpha:${T.a2}`, `beta:${T.b1}`];
|
||||
for (const [width, height, keys, oneRow] of [[1440, 900, THREE, true], [800, 900, THREE, null], [761, 900, THREE, null], [390, 844, [`beta:${T.b1}`], true]]) {
|
||||
test(`SP7: a pane header keeps its tools inside the pane${oneRow ? ', title and tools on one row with the chips below' : ''}, at ${width}`, async t => {
|
||||
const { b, wait } = await open(t, { width, height, hash: `#/assistant?open=${keys.join(',')}` });
|
||||
await wait(keys.map(k => ready(k)).join(' && '));
|
||||
await wait(`[...document.querySelectorAll('[data-assist-pane] [data-ws-orgchip]')].length === ${keys.length}`);
|
||||
const rows = await b.evaluate(headerRows);
|
||||
assert.deepEqual(rows.map(r => (oneRow ? r : { ...r, oneRow: null })), keys.map(() => ({ oneRow, chipsBelow: true, inside: true })));
|
||||
});
|
||||
}
|
||||
@@ -18,8 +18,8 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
|
||||
assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4');
|
||||
await b.evaluate('document.querySelector("#hide-offline").click()');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4);
|
||||
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it. U2 (#1873) adds workspace-data.js and workspace.js before console-restart.js, and U4b adds tasks-board.js after workspace.js. U5 (#1873) adds panes.js (Side by side) just before bus.js, which mounts it.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/workspace-data.js', '/workspace.js', '/tasks-board.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/autonomy.js', '/packs.js', '/workflows.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/panes.js', '/bus.js', '/requests.js']);
|
||||
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it. U2 (#1873) adds workspace-data.js and workspace.js before console-restart.js, and U4b adds tasks-board.js after workspace.js. U5 (#1873) added panes.js (Side by side) just before bus.js; SP6 replaces it with assistant.js (the Assistant page), which bus.js mounts.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/workspace-data.js', '/workspace.js', '/tasks-board.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/autonomy.js', '/packs.js', '/workflows.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/assistant.js', '/bus.js', '/requests.js']);
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/processing.js"]').length`), 1);
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1);
|
||||
assert.equal(await b.evaluate('!!window.injected'), false);
|
||||
|
||||
@@ -60,7 +60,9 @@ const org = {
|
||||
bus: { instances: async () => ({ instances: {} }), agents: async () => [], 'thread.claims': async () => [], tasks: async () => [], inbox: async () => [], messages: async () => ({ messages: [] }) },
|
||||
chat: { projects: async () => [], threads: async () => [] },
|
||||
};
|
||||
for (const [width, route, where] of [[1440, 'orgs/acme/organization', 'the organization page'], [390, 'orgs/acme/organization', 'the organization page'], [1440, 'orgs/acme/side-by-side?a=acme/organization&b=acme/agents', 'a pane'], [390, 'orgs/acme/side-by-side?a=acme/agents&b=acme/organization&show=b', 'a pane']]) {
|
||||
// SP6 (#1873) removed Side by side, whose panes also held the organization page; the Assistant
|
||||
// page's panes hold chat threads only, so the organization page itself is the case left.
|
||||
for (const [width, route, where] of [[1440, 'orgs/acme/organization', 'the organization page'], [390, 'orgs/acme/organization', 'the organization page']]) {
|
||||
test(`E2E6 P2-13: Restart bus and Stop bus line up on ${where} at ${width}`, { timeout: 60000 }, async t => {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), log: () => {}, organizations: { list: async () => [{ id: 'acme', label: 'Acme', busHealthy: true }], resolve: async () => org } });
|
||||
t.after(() => close(server));
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
// SP8 (#1873, single pane slice 8): the Dashboard leads with one row of KPI tiles, each built from a
|
||||
// number an existing read returns and each a link to the page that lists what it counts, with the
|
||||
// filter in the address where that page has one. A tile whose number some organization could not give
|
||||
// names that organization; it never shows a lower number as if it were complete. An organization's
|
||||
// page leads with the same tiles scoped to it, each linking to the system page filtered to it.
|
||||
// Every bus is a fake: acme and beta answer, gamma's bus is stopped. The real GET /api/workspace and
|
||||
// the real per-organization routes serve the pages.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { 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();
|
||||
|
||||
// fail: { org: [read names] } makes those reads refuse; `none` empties the organization list.
|
||||
// split: { org: agents } is what that organization's own bus agents read answers, while the
|
||||
// cross-organization sessions read still answers the fixture's agents.
|
||||
function fixture({ fail = {}, none = false, split = {} } = {}) {
|
||||
const LIST = none ? [] : [
|
||||
{ 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 decision = (id, question, role, minutes) => ({ id, question, class: 'within-role', blocking: false, raised_by_role: role, at: at(minutes), options: [{ key: 'yes', text: 'Yes' }] });
|
||||
const DATA = {
|
||||
acme: {
|
||||
projects: [{ id: 'p-site', repo: 'site', name: 'Website', archived: false, at: at(30) }, { id: 'p-old', repo: 'old', name: 'Old', archived: true, at: at(9000) }],
|
||||
tasks: [{ task_ref: 'local:1', project_key: 'site', at: at(20), fields: { title: 'Hero copy', done: false } }],
|
||||
agents: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'working', at: at(40), working: { since: at(40) } }],
|
||||
inbox: [decision('dec-acme-1', 'Ship the hero?', 'coder', 10)],
|
||||
instances: { coder: { definition: 'coder', running: ['run-1'] }, chief: { definition: 'chief-of-staff', running: [] } },
|
||||
},
|
||||
beta: {
|
||||
projects: [{ id: 'p-shop', repo: 'shop', name: 'Shop', archived: false, at: at(120) }],
|
||||
tasks: [
|
||||
{ task_ref: 'local:7', project_key: 'shop', at: at(50), fields: { title: 'Price list', done: false } },
|
||||
{ task_ref: 'local:8', project_key: 'shop', at: at(70), fields: { title: 'Cart copy', done: false } },
|
||||
{ task_ref: 'local:9', project_key: 'shop', at: at(60 * 24), fields: { title: 'Logo', done: true } },
|
||||
],
|
||||
agents: [{ role: 'pm', holder_run: 'run-9', harness: 'claude', state: 'waiting', at: at(15) }],
|
||||
inbox: [decision('dec-beta-1', 'Pick a launch price', 'pm', 5), decision('dec-beta-2', 'Name the shop', 'pm', 90)],
|
||||
instances: { pm: { definition: 'pm', running: ['run-9'] }, writer: { definition: 'writer', running: [] } },
|
||||
},
|
||||
};
|
||||
const value = (id, name) => (fail[id]?.includes(name) ? Promise.reject(Object.assign(new Error('off'), { code: 'channel-disabled' })) : 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 } },
|
||||
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),
|
||||
});
|
||||
return {
|
||||
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), appearance: { theme: org.theme } } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
|
||||
bus: {
|
||||
messages: async () => ({ messages: [], unread: 0 }), agents: async () => { if (fail[id]?.includes('agents')) throw Object.assign(new Error('off'), { code: 'channel-disabled' }); return split[id] ?? DATA[id].agents; }, 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [],
|
||||
instances: () => (fail[id]?.includes('instances') ? Promise.reject(Object.assign(new Error('off'), { code: 'bus-unavailable' })) : Promise.resolve({ revision: 'a'.repeat(64), definitions: {}, instances: DATA[id].instances })),
|
||||
},
|
||||
chat: { projects: async () => DATA[id].projects, threads: async () => [] },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function setup(t, { viewport = [1440, 900], hash = '#/dashboard', fail, none, split } = {}) {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ fail, none, split }), 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}/${hash}`);
|
||||
return { b, base, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) };
|
||||
}
|
||||
const tileKeys = scope => `[...document.querySelectorAll(${JSON.stringify(`${scope} [data-ws-tile]`)})].map(e => e.dataset.wsTile)`;
|
||||
const hrefs = scope => `Object.fromEntries([...document.querySelectorAll(${JSON.stringify(`${scope} [data-ws-tile]`)})].map(e => [e.dataset.wsTile, e.tagName === 'A' ? e.getAttribute('href') : null]))`;
|
||||
const big = (scope, key) => `document.querySelector(${JSON.stringify(`${scope} [data-ws-tile=${key}] .ws-big`)})?.textContent.replace(/\\s+/g, ' ').trim() ?? null`;
|
||||
const homeReady = 'document.querySelector("[data-ws-home] [data-ws-tiles] [data-ws-tile=agents] .ws-big")';
|
||||
|
||||
test('The Dashboard tiles add both answering organizations\' numbers, one row, each tile a link to the page that lists it', async t => {
|
||||
const { b, wait, text } = await setup(t);
|
||||
await wait(homeReady);
|
||||
// One row: no second tile group anywhere on the page, and the row leads the page under its heading.
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-home] [data-ws-tiles]").length'), 1);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-ws-home] .ws-vh").nextElementSibling.hasAttribute("data-ws-tiles")'), true);
|
||||
assert.deepEqual(await b.evaluate(tileKeys('[data-ws-home]')), ['agents', 'orgs', 'needs', 'tasks', 'done', 'projects']);
|
||||
// Agents working of total: acme's coder works; beta's pm waits; chief and writer run nothing.
|
||||
assert.equal(await b.evaluate(big('[data-ws-home]', 'agents')), '1 of 4');
|
||||
assert.equal(await b.evaluate(big('[data-ws-home]', 'orgs')), '2 of 3 running');
|
||||
assert.equal(await b.evaluate(big('[data-ws-home]', 'needs')), '3 items');
|
||||
assert.equal(await b.evaluate(big('[data-ws-home]', 'tasks')), '3');
|
||||
assert.equal(await b.evaluate(big('[data-ws-home]', 'done')), '1');
|
||||
assert.equal(await b.evaluate(big('[data-ws-home]', 'projects')), '2 active');
|
||||
// Each organization's share, named for a screen reader.
|
||||
assert.match(await text('[data-ws-tile=needs] .ws-by'), /Acme Studio\s*1[\s\S]*Beta Works\s*2/);
|
||||
assert.match(await text('[data-ws-tile=tasks] .ws-by'), /Acme Studio\s*1[\s\S]*Beta Works\s*2/);
|
||||
assert.match(await text('[data-ws-tile=agents] .ws-by'), /Acme Studio\s*1[\s\S]*Beta Works\s*0/);
|
||||
// Every tile is a link, with the filter in the address where the page has one.
|
||||
const links = await b.evaluate(hrefs('[data-ws-home]'));
|
||||
assert.equal(links.agents, '#/agents?state=working');
|
||||
assert.equal(links.orgs, '#/dashboard');
|
||||
assert.equal(links.needs, '#/inbox');
|
||||
assert.equal(links.tasks, '#/tasks');
|
||||
assert.equal(links.done, '#/tasks?done=week');
|
||||
assert.match(links.projects, /^#\/orgs\/[a-z0-9._-]+\/projects$/);
|
||||
// No sample numbers and no trend line: no tile draws an svg sparkline.
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-tiles] svg:not(.i)").length'), 0);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-tile=spend],[data-ws-tile=sessions]").length'), 0);
|
||||
// The Open tasks link under Tasks by state goes to the system board too.
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-focus-key=home-tasks]").getAttribute("href")'), '#/tasks');
|
||||
});
|
||||
|
||||
test('A stopped organization is named on every tile its numbers would have joined, and on none it counted', async t => {
|
||||
const { b, wait, text } = await setup(t);
|
||||
await wait(homeReady);
|
||||
for (const key of ['agents', 'needs', 'tasks', 'done', 'projects']) {
|
||||
assert.match(await text(`[data-ws-tile=${key}] [data-ws-miss=${key}]`) ?? '', /Not read in Gamma Home \(bus stopped\)/, key);
|
||||
}
|
||||
// The Organizations tile counts it as not running and says which one.
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-ws-tile=orgs] [data-ws-miss]")'), null);
|
||||
assert.match(await text('[data-ws-tile=orgs]'), /Gamma Home bus stopped/);
|
||||
});
|
||||
|
||||
test('Following a tile lands on its page showing what the tile counted', async t => {
|
||||
const { b, wait, text } = await setup(t);
|
||||
await wait(homeReady);
|
||||
await b.evaluate('document.querySelector("[data-ws-tile=agents]").click()');
|
||||
await wait('location.hash === "#/agents?state=working" && document.querySelector("[data-ws-agent]")');
|
||||
await wait('JSON.stringify([...document.querySelectorAll("[data-ws-agent]")].map(r => r.dataset.wsAgent)) === JSON.stringify(["acme:coder"])');
|
||||
assert.match(await text('[data-ws-astate=""] .n'), /^4$/);
|
||||
await b.evaluate('location.hash = "#/dashboard"');
|
||||
await wait(homeReady);
|
||||
await b.evaluate('document.querySelector("[data-ws-tile=needs]").click()');
|
||||
await wait('location.hash === "#/inbox" && document.querySelectorAll("[data-ws-item]").length === 3');
|
||||
await b.evaluate('location.hash = "#/dashboard"');
|
||||
await wait(homeReady);
|
||||
// SP11: Done this week lands on the board showing the one task it counted, beta's Logo.
|
||||
await b.evaluate('document.querySelector("[data-ws-tile=done]").click()');
|
||||
await wait('location.hash === "#/tasks?done=week" && JSON.stringify([...document.querySelectorAll("[data-tasks-board] [data-tb-task]")].map(e => e.dataset.tbTask)) === JSON.stringify(["beta|local:9"])');
|
||||
await b.evaluate('location.hash = "#/dashboard"');
|
||||
await wait(homeReady);
|
||||
// The Organizations tile's list is this page's own table: following it keeps the page and moves focus there.
|
||||
await b.evaluate('document.querySelector("[data-ws-tile=orgs]").click()');
|
||||
await wait('document.activeElement?.closest?.("[data-ws-orgs-h]")');
|
||||
assert.equal(await b.evaluate('location.hash'), '#/dashboard');
|
||||
});
|
||||
|
||||
test('An answering organization whose agent read failed is named on the Agents tile, never counted as none', async t => {
|
||||
const { b, wait, text } = await setup(t, { fail: { beta: ['instances'] } });
|
||||
await wait(homeReady);
|
||||
assert.equal(await b.evaluate(big('[data-ws-home]', 'agents')), '1 of 2');
|
||||
assert.match(await text('[data-ws-tile=agents] [data-ws-miss=agents]'), /Beta Works/);
|
||||
assert.doesNotMatch(await text('[data-ws-tile=agents] .ws-by') ?? '', /Beta Works/);
|
||||
});
|
||||
|
||||
test('When no answering organization could read its agents, the Agents tile says Not read instead of a number', async t => {
|
||||
const { b, wait, text } = await setup(t, { fail: { acme: ['instances'], beta: ['instances'] } });
|
||||
await wait('document.querySelector("[data-ws-tile=agents]")');
|
||||
assert.equal(await b.evaluate(big('[data-ws-home]', 'agents')), null);
|
||||
assert.match(await text('[data-ws-tile=agents]'), /Not read/);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-ws-tile=agents]").getAttribute("href")'), '#/agents?state=working');
|
||||
});
|
||||
|
||||
// SP11 (#1873): with no organizations, #/dashboard is the Dashboard, not Not found. It says there is
|
||||
// nothing to count and offers New organization, which opens the Organizations form at its ID field.
|
||||
// #/ stays the Console's own board, with its dashes.
|
||||
test('With no organizations the Dashboard says so, shows no tile, and offers New organization', async t => {
|
||||
const { b, wait, text } = await setup(t, { none: true });
|
||||
await wait('document.querySelector("[data-ws-home] h1")');
|
||||
assert.equal(await text('#bus-view h1'), 'Dashboard');
|
||||
assert.doesNotMatch(await text('#bus-view'), /Not found/);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll(".ws-tile, .ws-big").length'), 0);
|
||||
assert.match(await text('[data-ws-home] [data-ws-tiles]'), /^No organizations yet, so there is nothing to count\./);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-ws-home] [data-ws-orgs] a")?.getAttribute("href") ?? null'), '#/settings/organizations?new=1');
|
||||
assert.equal(await text('[data-ws-home] [data-ws-orgs] a'), 'New organization');
|
||||
await b.evaluate('document.querySelector("[data-ws-home] [data-ws-orgs] a").click()');
|
||||
await wait('document.activeElement?.id === "org-new-id"');
|
||||
await b.evaluate('location.hash = "#/"');
|
||||
await wait('document.querySelector("#dashboard-kpis .kpi strong") && !document.querySelector("[data-ws-home]")');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll(".ws-tile, .ws-big").length'), 0);
|
||||
assert.deepEqual(await b.evaluate('[...new Set([...document.querySelectorAll("#dashboard-kpis .kpi strong")].map(e => e.textContent.trim()))]'), ['–']);
|
||||
});
|
||||
|
||||
test('An organization page leads with its own tiles, each linking to the system page filtered to it', async t => {
|
||||
const { b, wait, text } = await setup(t, { hash: '#/orgs/beta' });
|
||||
await wait('document.querySelector("[data-org-dashboard] [data-org-tiles] [data-ws-tile=agents] .ws-big")');
|
||||
// The tiles come straight after the header, before the docs row.
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-org-dashboard] .ws-orghead").nextElementSibling.hasAttribute("data-org-tiles")'), true);
|
||||
assert.deepEqual(await b.evaluate(tileKeys('[data-org-tiles]')), ['agents', 'tasks', 'needs', 'done']);
|
||||
assert.equal(await b.evaluate(big('[data-org-tiles]', 'agents')), '0 of 2');
|
||||
assert.equal(await b.evaluate(big('[data-org-tiles]', 'tasks')), '2');
|
||||
assert.equal(await b.evaluate(big('[data-org-tiles]', 'needs')), '2 items');
|
||||
assert.equal(await b.evaluate(big('[data-org-tiles]', 'done')), '1');
|
||||
assert.deepEqual(await b.evaluate(hrefs('[data-org-tiles]')), { agents: '#/agents?org=beta&state=working', tasks: '#/tasks?org=beta', needs: '#/inbox?org=beta', done: '#/tasks?org=beta&done=week' });
|
||||
// The empty KPI and metric placeholders are gone; there is one tile row.
|
||||
assert.doesNotMatch(await text('[data-org-dashboard]'), /No KPIs defined|No metrics recorded yet/);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("[data-org-dashboard] .ws-tiles, [data-org-dashboard] [data-org-tiles]").length'), 1);
|
||||
// Following Needs you shows beta's two items on the system Inbox.
|
||||
await b.evaluate('document.querySelector("[data-org-tiles] [data-ws-tile=needs]").click()');
|
||||
await wait('location.hash === "#/inbox?org=beta" && document.querySelectorAll("[data-ws-item]").length === 2');
|
||||
// Acme's own page counts only acme.
|
||||
await b.evaluate('location.hash = "#/orgs/acme"');
|
||||
await wait('document.querySelector("[data-org-dashboard] [data-org-tiles] [data-ws-tile=agents] .ws-big") && location.hash.startsWith("#/orgs/acme")');
|
||||
await wait(`${big('[data-org-tiles]', 'agents')} === "1 of 2"`);
|
||||
assert.equal(await b.evaluate(big('[data-org-tiles]', 'needs')), '1 item');
|
||||
assert.equal(await b.evaluate(big('[data-org-tiles]', 'done')), '0');
|
||||
await b.evaluate('document.querySelector("[data-org-tiles] [data-ws-tile=agents]").click()');
|
||||
await wait('location.hash === "#/agents?org=acme&state=working" && JSON.stringify([...document.querySelectorAll("[data-ws-agent]")].map(r => r.dataset.wsAgent)) === JSON.stringify(["acme:coder"])');
|
||||
});
|
||||
|
||||
// SP11 (#1873): "working" and "running" each name what they count. Running is an agent with a live
|
||||
// process, whatever it is doing; working is a running agent whose role session is working. The tile,
|
||||
// the Dashboard organization rows and the sessions heading count the same agent rows.
|
||||
const delta = (scope, key) => `document.querySelector(${JSON.stringify(`${scope} [data-ws-tile=${key}] .ws-delta:not(.ws-miss)`)})?.textContent.trim() ?? null`;
|
||||
const rowAgents = org => `document.querySelector("[data-ws-orgrow=${org}] td:nth-child(2)")?.innerText.replace(/\\s+/g, " ").trim() ?? null`;
|
||||
test('Agent counts say running and working, and count the same agents on the tiles, the organization rows and the sessions heading', async t => {
|
||||
const { b, wait, text } = await setup(t);
|
||||
await wait(homeReady);
|
||||
// acme's coder runs and works; beta's pm runs and waits on you; chief and writer run nothing.
|
||||
assert.equal(await b.evaluate(big('[data-ws-home]', 'agents')), '1 of 4');
|
||||
assert.equal(await b.evaluate(delta('[data-ws-home]', 'agents')), '2 running, 1 waiting on you');
|
||||
assert.equal(await b.evaluate(rowAgents('acme')), '2 agents (1 running, 1 working)');
|
||||
assert.equal(await b.evaluate(rowAgents('beta')), '2 agents (1 running, 0 working)');
|
||||
await b.evaluate('location.hash = "#/orgs/beta"');
|
||||
await wait('document.querySelector("[data-org-dashboard] [data-org-tiles] [data-ws-tile=agents] .ws-big")');
|
||||
assert.equal(await b.evaluate(big('[data-org-tiles]', 'agents')), '0 of 2');
|
||||
assert.equal(await b.evaluate(delta('[data-org-tiles]', 'agents')), '1 running, 1 waiting on you');
|
||||
assert.match(await text('[data-org-dashboard] .ws-split'), /Active agent sessions\s*1 running, 0 working/);
|
||||
await b.evaluate('location.hash = "#/orgs/acme"');
|
||||
await wait(`location.hash.startsWith("#/orgs/acme") && (${big('[data-org-tiles]', 'agents')}) === "1 of 2"`);
|
||||
assert.equal(await b.evaluate(delta('[data-org-tiles]', 'agents')), '1 running');
|
||||
assert.match(await text('[data-org-dashboard] .ws-split'), /Active agent sessions\s*1 running, 1 working/);
|
||||
});
|
||||
|
||||
test('An organization page counts working from one read: the sessions heading and rows follow the tile when the two bus reads disagree', async t => {
|
||||
// acme's own agents read says the coder is idle; the sessions read the tile counts says working.
|
||||
const split = { acme: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'idle', at: at(40) }] };
|
||||
const { b, wait, text } = await setup(t, { hash: '#/orgs/acme', split });
|
||||
await wait(`document.querySelector("[data-org-dashboard]") && (${big('[data-org-tiles]', 'agents')}) === "1 of 2"`);
|
||||
assert.match(await text('[data-org-dashboard] .ws-split'), /Active agent sessions\s*1 running, 1 working/);
|
||||
assert.match(await text('[data-org-dashboard] .ws-split tbody'), /coder[\s\S]*working/);
|
||||
});
|
||||
|
||||
test('When neither bus read gives agent activity, the sessions heading says working was not read, never a figure', async t => {
|
||||
const { b, wait, text } = await setup(t, { hash: '#/orgs/acme', fail: { acme: ['agents'] } });
|
||||
await wait('document.querySelector("[data-org-dashboard] [data-org-tiles] [data-ws-tile=agents]")');
|
||||
assert.match(await text('[data-org-tiles] [data-ws-tile=agents]'), /Not read/);
|
||||
assert.match(await text('[data-org-dashboard] .ws-split'), /Active agent sessions\s*1 running, working not read/);
|
||||
assert.doesNotMatch(await text('[data-org-dashboard] .ws-split'), /\d+ working/);
|
||||
});
|
||||
|
||||
test('A stopped organization\'s page shows its tiles as not read, still linking to its filtered pages', async t => {
|
||||
const { b, wait, text } = await setup(t, { hash: '#/orgs/gamma' });
|
||||
await wait('document.querySelector("[data-org-dashboard] [data-org-tiles]")');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("[data-org-tiles] .ws-big").length'), 0);
|
||||
for (const key of ['agents', 'tasks', 'needs', 'done']) assert.match(await text(`[data-org-tiles] [data-ws-tile=${key}]`), /Not read: the bus is stopped/, key);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-org-tiles] [data-ws-tile=needs]").getAttribute("href")'), '#/inbox?org=gamma');
|
||||
});
|
||||
|
||||
test('At 390 wide the tiles sit two to a row with no sideways scroll, on the Dashboard and an organization page', async t => {
|
||||
const { b, wait } = await setup(t, { viewport: [390, 844] });
|
||||
await wait(homeReady);
|
||||
const cols = scope => b.evaluate(`new Set([...document.querySelectorAll(${JSON.stringify(`${scope} [data-ws-tile]`)})].map(e => Math.round(e.getBoundingClientRect().left))).size`);
|
||||
assert.equal(await cols('[data-ws-home]'), 2);
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, 'dashboard');
|
||||
await b.evaluate('location.hash = "#/orgs/acme"');
|
||||
await wait('document.querySelector("[data-org-tiles] [data-ws-tile=agents] .ws-big")');
|
||||
assert.equal(await cols('[data-org-tiles]'), 2);
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, 'organization page');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
// SP13 (#1873): the organization page fits its width, and four things seen live.
|
||||
// 1. At every content width from 360 px up, with and without the agent drawer, nothing in the Settings
|
||||
// card is cut off by its card and the page does not scroll sideways.
|
||||
// 2. `#/orgs/<id>?query` is the organization page with the query kept; `?agent=` opens the drawer.
|
||||
// 3. The drawer never says "not running" before its instances read has answered: while it is open the
|
||||
// state is being read, after it fails the state was not read, and the Stop note says the same.
|
||||
// 4. (SP14 removed the Queue card; the organization page now makes no queue read.)
|
||||
// Plus the test SP12 left out: a repaint while Stop asks its question keeps the question on screen.
|
||||
// SP14 (#1873): the page has no Queue card and makes no queue read; an instances answer with no runs says
|
||||
// running was not read wherever a running or working count would show; and the drawer's instances answer
|
||||
// repaints only the drawer, with no page read made again.
|
||||
// Every bus is a fake. The real GET /api/workspace and per-organization routes serve the page; only the
|
||||
// per-organization config, the agent writes and (when a test asks) the drawer's instances read are
|
||||
// stubbed in the page.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { 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();
|
||||
|
||||
function fixture({ counts = {} } = {}) {
|
||||
const LIST = [{ id: 'acme', label: 'Acme Studio', theme: 'cobalt' }];
|
||||
const d = {
|
||||
projects: [{ id: 'p-site', repo: 'site', name: 'Website', archived: false, at: at(30) }],
|
||||
tasks: [], inbox: [], threads: [],
|
||||
agents: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'working', at: at(40), working: { since: at(40), messages: 2 } }],
|
||||
claims: [],
|
||||
instances: {
|
||||
coder: { definition: 'coder', running: ['run-1'] },
|
||||
chief: { definition: 'chief-of-staff', running: [] },
|
||||
reviewer: { definition: 'reviewer', running: [] },
|
||||
},
|
||||
// Long values, so a narrow Settings card has something to cut.
|
||||
model: { coder: 'claude-opus-5-5', chief: 'claude-sonnet-5-5', reviewer: 'gpt-5.5-codex-high-reasoning' },
|
||||
};
|
||||
const value = name => Promise.resolve(d[name] ?? (['usage', 'limits'].includes(name) ? {} : []));
|
||||
const opts = () => ({
|
||||
bus: { inbox: () => value('inbox'), agents: () => value('agents'), 'thread.claims': () => value('claims'), tasks: () => value('tasks'), usage: () => value('usage'), limits: () => value('limits'), decisions: () => value('decisions') },
|
||||
chat: { threads: () => value('threads'), projects: () => value('projects') },
|
||||
});
|
||||
const member = org => ({ id: org.id, doc: { appearance: { theme: org.theme } }, status: async () => ({ healthy: true, pid: 4242, boot: null }), options: opts });
|
||||
return {
|
||||
list: async () => LIST.map(o => ({ ...o, busHealthy: true })),
|
||||
members: async () => LIST.map(member),
|
||||
resolve: async id => {
|
||||
const org = LIST.find(o => o.id === id); if (!org) return null;
|
||||
return {
|
||||
busHealthy: true,
|
||||
reads: {
|
||||
business: async () => ({ business: { id, human: 'jason', projects: d.projects.map(p => p.repo), appearance: { theme: org.theme }, vars: { 'tracker.kind': 'gitea-issues-and-milestones' }, arbiters: { decisions: 'chief-of-staff', reviews: 'reviewer', merges: 'human-owner-of-the-organization' }, instances: Object.entries(d.model).map(([instance, model]) => ({ instance, vars: { model } })) } }),
|
||||
settings: async () => ({}),
|
||||
queue: async () => { counts.queue = (counts.queue ?? 0) + 1; return { rows: [] }; },
|
||||
busStatus: async () => ({}),
|
||||
},
|
||||
bus: {
|
||||
messages: async () => ({ messages: [], unread: 0 }), agents: async () => d.agents, 'thread.claims': async () => d.claims, inbox: async () => d.inbox, tasks: async () => d.tasks,
|
||||
instances: async () => ({ revision: 'a'.repeat(64), definitions: {}, instances: structuredClone(d.instances) }),
|
||||
},
|
||||
chat: { projects: async () => d.projects, threads: async () => d.threads },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Runs before the page's scripts. Acme's config says this Console sends there. `window.__inst` set by
|
||||
// a test holds ('hold', released by window.__release()) or fails ('fail') the first bus instances read,
|
||||
// which is the drawer's: it starts before the page's own reads. With `window.__heldIdle` the held
|
||||
// answer says nothing runs.
|
||||
// Every URL the page fetches, in order, for the tests that count reads.
|
||||
const URLS = `(() => { const real = window.fetch; window.__urls = []; window.fetch = (url, init) => { window.__urls.push(String(url)); return real(url, init); }; })();`;
|
||||
// A bus answer the bus did not add runs to (its async read path): no `running` key on any instance.
|
||||
const STRIP = `(() => { const real = window.fetch; window.fetch = async (url, init) => { const r = await real(url, init); if (!/\\/bus\\/instances$/.test(String(url)) || !r.ok) return r; const v = await r.json(); for (const row of [v, ...(v.rows || [])]) for (const i of Object.values(row?.instances || {})) delete i.running; return new Response(JSON.stringify(v), { status: 200, headers: { 'content-type': 'application/json' } }); }; })();`;
|
||||
|
||||
const STUB = `(() => {
|
||||
const real = window.fetch.bind(window);
|
||||
window.__posts = []; window.__answers = []; window.__instSeen = 0;
|
||||
const answer = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
||||
window.fetch = async (url, init = {}) => {
|
||||
const u = String(url);
|
||||
if (/\\/api\\/orgs\\/acme\\/bus\\/instances$/.test(u) && window.__instSeen++ === 0 && window.__inst) {
|
||||
if (window.__inst === 'fail') return answer(502, { error: 'read-failed', message: 'the bus did not answer' });
|
||||
await new Promise(done => { window.__release = done; });
|
||||
if (!window.__heldIdle) return real(url, init);
|
||||
// The held answer, when a test asks, says no instance runs anything.
|
||||
const v = await (await real(url, init)).json();
|
||||
for (const row of [v, ...(v.rows || [])]) for (const i of Object.values(row?.instances || {})) i.running = [];
|
||||
return answer(200, v);
|
||||
}
|
||||
const cfg = u.match(/^\\/api\\/orgs\\/([^/]+)\\/config$/);
|
||||
if (cfg) { const c = await (await real(url, init)).json(); return answer(200, { ...c, chatSend: true, settingsSend: true, decide: true }); }
|
||||
if (!/\\/agent\\//.test(u) || (init.method || 'GET') === 'GET' && !u.includes('/agent/request')) return real(url, init);
|
||||
window.__posts.push({ url: u, method: init.method || 'GET', body: init.body ?? null });
|
||||
const a = window.__answers.shift();
|
||||
if (!a) throw new TypeError('no answer queued');
|
||||
return answer(a.status ?? 200, a.body);
|
||||
};
|
||||
})();`;
|
||||
|
||||
async function setup(t, { viewport = [1440, 900], hash = '#/orgs/acme/organization', inst = null, counts = {}, scripts = [] } = {}) {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ counts }), log: () => {} });
|
||||
t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
await b.viewport(...viewport);
|
||||
if (inst) await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `window.__inst = ${JSON.stringify(inst)};` });
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB });
|
||||
for (const source of scripts) await b.call('Page.addScriptToEvaluateOnNewDocument', { source });
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
await b.navigate(`${base}/${hash}`);
|
||||
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText.replace(/\\s+/g, " ").trim() ?? null`);
|
||||
const has = sel => b.evaluate(`!!document.querySelector(${JSON.stringify(sel)})`);
|
||||
return { b, base, wait: waitFor(b), text, has };
|
||||
}
|
||||
const drawer = key => `document.querySelector('[data-ws-drawer="${key}"]')`;
|
||||
const open = key => `!!${drawer(key)} && ${drawer(key)}.querySelector("h2")?.textContent.length > 0`;
|
||||
const noDrawer = '!document.querySelector("[data-ws-drawer]")';
|
||||
const settled = 'document.querySelectorAll("[data-org-settings] [data-org-setting]").length > 10';
|
||||
|
||||
// Everything in the Settings card that is cut: a cell whose content is wider than its box, a cell that
|
||||
// reaches past the card's edge or, from 1200 px, under the drawer, and a page that scrolls sideways.
|
||||
const CLIPPED = `(() => {
|
||||
const card = document.querySelector('[data-org-settings]'), r = card.getBoundingClientRect();
|
||||
// From 1200 px the page keeps clear of the drawer (SP12); below that the drawer covers the page by design.
|
||||
const d = innerWidth >= 1200 ? document.querySelector('[data-ws-drawer]')?.getBoundingClientRect() : null;
|
||||
const edge = Math.min(r.right, d && d.left > 0 ? d.left : Infinity);
|
||||
const out = [];
|
||||
for (const li of card.querySelectorAll('[data-org-setting]')) {
|
||||
for (const el of li.querySelectorAll('.k,.v,.src-tag,.go')) {
|
||||
if (!el.textContent.trim()) continue;
|
||||
const b = el.getBoundingClientRect();
|
||||
if (el.scrollWidth > el.clientWidth + 1 || b.right > edge + 1 || b.left < r.left - 1) out.push(li.dataset.orgSetting + ' ' + el.className + ' ' + el.scrollWidth + '/' + el.clientWidth + ' right ' + Math.round(b.right) + '/' + Math.round(edge));
|
||||
}
|
||||
}
|
||||
// The sessions table beside it scrolls only as a last resort; with these rows the columns stack first.
|
||||
for (const tb of document.querySelectorAll('[data-org-dashboard] .ws-split .ws-tbl')) if (tb.scrollWidth > tb.clientWidth + 1) out.push('sessions table ' + tb.scrollWidth + '/' + tb.clientWidth);
|
||||
const page = document.documentElement;
|
||||
if (page.scrollWidth > page.clientWidth) out.push('page ' + page.scrollWidth + '/' + page.clientWidth);
|
||||
return out;
|
||||
})()`;
|
||||
|
||||
test('the Settings card fits at 1440 with the drawer open, at 735 and 390 without it, and at every width between', async t => {
|
||||
const { b, base, wait } = await setup(t, { hash: '#/orgs/acme/organization?agent=acme%3Acoder' });
|
||||
await wait(`${settled} && ${open('acme:coder')}`);
|
||||
assert.deepEqual(await b.evaluate(CLIPPED), [], 'at 1440 with the drawer open');
|
||||
// Each Open link keeps its whole word.
|
||||
assert.ok(await b.evaluate(`[...document.querySelectorAll('[data-org-settings] a.go')].every(a => a.innerText.trim() === 'Open' && a.scrollWidth <= a.clientWidth + 1)`));
|
||||
// Without the drawer: 735 and 390 wide, and a content width of about 735 (1023 with the sidebar).
|
||||
for (const [w, h] of [[735, 900], [390, 844], [1023, 900]]) {
|
||||
await b.viewport(w, h);
|
||||
await b.navigate(`${base}/#/orgs/acme/organization`);
|
||||
await wait(`${settled} && ${noDrawer}`);
|
||||
assert.deepEqual(await b.evaluate(CLIPPED), [], `at ${w} without the drawer`);
|
||||
}
|
||||
// Every 40 px from 360 to 1440, with and without the drawer.
|
||||
for (const hash of ['#/orgs/acme/organization', '#/orgs/acme/organization?agent=acme%3Acoder']) {
|
||||
await b.navigate(`${base}/${hash}`);
|
||||
await wait(`${settled} && ${hash.includes('agent') ? open('acme:coder') : noDrawer}`);
|
||||
for (let w = 360; w <= 1440; w += 40) {
|
||||
await b.viewport(w, 900);
|
||||
await b.evaluate('new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))');
|
||||
assert.deepEqual(await b.evaluate(CLIPPED), [], `at ${w}${hash.includes('agent') ? ' with the drawer' : ''}`);
|
||||
}
|
||||
}
|
||||
// Side by side, both columns' first headings start level (the Workflows card wraps its heading).
|
||||
await b.navigate(`${base}/#/orgs/acme/organization`);
|
||||
await wait(`${settled} && ${noDrawer}`);
|
||||
const tops = await b.evaluate(`[...document.querySelectorAll('#bus-view .ws-dash>.ws-split>section')].map(s => [Math.round(s.getBoundingClientRect().left), Math.round(s.querySelector('h2').getBoundingClientRect().top)])`);
|
||||
assert.equal(tops.length, 2);
|
||||
assert.notEqual(tops[0][0], tops[1][0], 'the columns sit side by side at 1440');
|
||||
assert.equal(tops[0][1], tops[1][1], 'the first headings start level');
|
||||
});
|
||||
|
||||
test('the bare organization address with a query is the organization page, query kept; ?agent= opens the drawer', async t => {
|
||||
const { b, base, wait, text } = await setup(t, { hash: '#/orgs/acme?agent=acme%3Acoder' });
|
||||
await wait(`document.querySelector('[data-org-dashboard] h1') && ${open('acme:coder')}`);
|
||||
assert.equal(await text('[data-org-dashboard] h1'), 'Acme Studio');
|
||||
assert.equal(await b.evaluate('!!document.querySelector("[data-org-unavailable]")'), false);
|
||||
assert.equal(await b.evaluate('new URLSearchParams(location.hash.split("?")[1] || "").get("agent")'), 'acme:coder');
|
||||
assert.match(await b.evaluate('location.hash'), /^#\/orgs\/acme\/organization\?agent=acme%3Acoder$/);
|
||||
// The reads are scoped to acme, never to "acme?agent=…".
|
||||
assert.equal(await b.evaluate('window.MosaicOrg?.id'), 'acme');
|
||||
// Another query, no drawer: the same page with the query kept.
|
||||
await b.navigate(`${base}/#/orgs/acme?tab=settings`);
|
||||
await wait(`document.querySelector('[data-org-dashboard] h1')?.innerText === 'Acme Studio'`);
|
||||
assert.equal(await b.evaluate('location.hash'), '#/orgs/acme/organization?tab=settings');
|
||||
assert.equal(await b.evaluate('!!document.querySelector("[data-ws-drawer]")'), false);
|
||||
// Typed into the address bar of a page already open.
|
||||
await b.evaluate('location.hash = "#/orgs/acme?agent=acme%3Achief"');
|
||||
await wait(open('acme:chief'));
|
||||
assert.equal(await text('[data-org-dashboard] h1'), 'Acme Studio');
|
||||
});
|
||||
|
||||
test('while the drawer\'s instances read is open it says the state is being read, never not running, then shows it', async t => {
|
||||
const { b, wait, text, has } = await setup(t, { hash: '#/orgs/acme/organization?agent=acme%3Acoder', inst: 'hold', scripts: [URLS] });
|
||||
const d = '[data-ws-drawer="acme:coder"]';
|
||||
await wait(`${settled} && ${open('acme:coder')}`);
|
||||
assert.equal(await has(`${d}[data-drawer-pending]`), true);
|
||||
assert.equal(await text(`${d} [data-drawer-state]`), 'Reading state…');
|
||||
assert.doesNotMatch(await text(d), /not running/);
|
||||
// Stop is not offered yet, and its note says why without saying not running.
|
||||
assert.equal(await has(`${d} [data-drawer-stop]`), false);
|
||||
assert.match(await text(`${d} [data-drawer-nostop]`), /^Its state is being read\./);
|
||||
// The page under it painted from its own reads: coder is a running session there.
|
||||
assert.match(await text('[data-ws-session-agent="acme:coder"]'), /working/);
|
||||
// When the read answers, the drawer repaints with the state, and Stop. SP14: only the drawer repaints,
|
||||
// from the reads it already has: the page and its reads are left alone, and focus stays where it was.
|
||||
await b.evaluate(`document.querySelector('[data-org-dashboard]').dataset.before = '1'; document.querySelector('${d} [data-drawer-close]').focus(); window.__urls.length = 0`);
|
||||
await b.evaluate('window.__release()');
|
||||
await wait(`!document.querySelector('${d}[data-drawer-pending]') && document.querySelector('${d} [data-drawer-stop]')`);
|
||||
assert.match(await text(`${d} [data-drawer-state]`), /(^|\s)working$/);
|
||||
assert.equal(await has(`${d} [data-drawer-nostop]`), false);
|
||||
await b.evaluate('new Promise(r => setTimeout(r, 400))');
|
||||
assert.deepEqual(await b.evaluate(`window.__urls.filter(u => /^\\/api\\/(workspace|orgs\\/)/.test(u))`), [], 'no read is made again after the drawer\'s read answers');
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-org-dashboard]').dataset.before`), '1', 'the page under the drawer was not repainted');
|
||||
assert.equal(await b.evaluate('document.activeElement?.dataset?.focusKey'), 'drawer-close');
|
||||
});
|
||||
|
||||
test('SP14 review: another agent opened while the drawer\'s instances read is open repaints when that read answers', async t => {
|
||||
const { b, wait, text, has } = await setup(t, { hash: '#/orgs/acme/organization?agent=acme%3Acoder', inst: 'hold' });
|
||||
await wait(`${settled} && document.querySelector('[data-ws-drawer="acme:coder"][data-drawer-pending]')`);
|
||||
// Chief opens before the held read answers; its drawer waits on the same read.
|
||||
await b.evaluate('location.hash = "#/orgs/acme/organization?agent=acme%3Achief"');
|
||||
const d = '[data-ws-drawer="acme:chief"]';
|
||||
await wait(`document.querySelector('${d}[data-drawer-pending]')`);
|
||||
assert.equal(await text(`${d} [data-drawer-state]`), 'Reading state…');
|
||||
// The read answers. Chief's drawer repaints from it at once, not at the next poll.
|
||||
await b.evaluate('window.__release()');
|
||||
await b.evaluate('new Promise(r => setTimeout(r, 1000))');
|
||||
assert.equal(await has(`${d}[data-drawer-pending]`), false, 'chief\'s drawer still says Reading state… after its read answered');
|
||||
assert.match(await text(`${d} [data-drawer-state]`), /not running/);
|
||||
assert.equal(await has('[data-ws-drawer="acme:coder"]'), false);
|
||||
});
|
||||
|
||||
test('a drawer closed before its instances read answers keeps no answer for the next open', async t => {
|
||||
const idle = 'window.__heldIdle = true;';
|
||||
const { b, wait, text } = await setup(t, { hash: '#/orgs/acme/organization?agent=acme%3Acoder', inst: 'hold', scripts: [idle] });
|
||||
const d = '[data-ws-drawer="acme:coder"]';
|
||||
await wait(`${settled} && document.querySelector('${d}[data-drawer-pending]')`);
|
||||
await b.evaluate(`document.querySelector('${d} [data-drawer-close]').click()`);
|
||||
await wait(noDrawer);
|
||||
// The held read answers after the drawer closed, saying coder runs nothing.
|
||||
await b.evaluate('window.__release()');
|
||||
await b.evaluate('new Promise(r => setTimeout(r, 300))');
|
||||
// Reopened from its row, the drawer reads again and shows coder working, not the closed drawer's answer.
|
||||
await b.evaluate(`document.querySelector('[data-ws-drawer-open="acme:coder"]').click()`);
|
||||
await wait(`${open('acme:coder')} && !document.querySelector('${d}[data-drawer-pending]') && document.querySelector('${d} [data-drawer-state]')`);
|
||||
assert.match(await text(`${d} [data-drawer-state]`), /(^|\s)working$/);
|
||||
assert.doesNotMatch(await text(d), /not running/);
|
||||
});
|
||||
|
||||
test('after the drawer\'s instances read fails it says the state was not read, and Stop says so too', async t => {
|
||||
const { b, wait, text, has } = await setup(t, { hash: '#/orgs/acme/organization?agent=acme%3Areviewer', inst: 'fail' });
|
||||
const d = '[data-ws-drawer="acme:reviewer"]';
|
||||
await wait(`${settled} && ${open('acme:reviewer')}`);
|
||||
assert.equal(await has(`${d}[data-drawer-pending]`), false);
|
||||
assert.match(await text(`${d} [data-drawer-state]`), /^State not read: /);
|
||||
assert.doesNotMatch(await text(d), /not running/);
|
||||
assert.equal(await has(`${d} [data-drawer-stop]`), false);
|
||||
assert.equal(await text(`${d} [data-drawer-nostop]`), 'Its state was not read, so Stop is not offered.');
|
||||
// A later repaint reads again: reviewer runs nothing, so now it says not running, with that reason.
|
||||
await b.evaluate('document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))');
|
||||
await wait(`/not running/.test(document.querySelector('${d} [data-drawer-state]')?.innerText ?? '')`);
|
||||
assert.equal(await text(`${d} [data-drawer-nostop]`), 'It is not running, so there is nothing to stop.');
|
||||
});
|
||||
|
||||
test('an instances answer that carries no runs says state not read, in the drawer and on the Agents table', async t => {
|
||||
const { wait, text, has } = await setup(t, { hash: '#/agents?agent=acme%3Acoder', scripts: [STRIP] });
|
||||
const d = '[data-ws-drawer="acme:coder"]';
|
||||
await wait(`${open('acme:coder')} && document.querySelector('[data-ws-agent="acme:coder"]')`);
|
||||
assert.match(await text(`${d} [data-drawer-state]`), /state not read$/);
|
||||
assert.equal(await text(`${d} [data-drawer-nostop]`), 'Its state was not read, so Stop is not offered.');
|
||||
assert.match(await text('[data-ws-agent="acme:coder"]'), /state not read/);
|
||||
assert.doesNotMatch(await text('[data-ws-agent-list]'), /not running/);
|
||||
assert.equal(await has('[data-ws-astate="not-read"]'), true);
|
||||
});
|
||||
|
||||
test('SP14: the organization page has no Queue card and makes no queue read', async t => {
|
||||
const counts = {};
|
||||
const { b, wait, has } = await setup(t, { counts, scripts: [URLS] });
|
||||
await wait(`${settled} && document.querySelector('[data-ws-session-agent="acme:coder"]')`);
|
||||
await b.evaluate('new Promise(r => setTimeout(r, 400))');
|
||||
assert.equal(await has('[data-org-queue]'), false);
|
||||
assert.equal(await has('[data-org-queue-why]'), false);
|
||||
const heads = await b.evaluate(`[...document.querySelectorAll('[data-org-dashboard] h2, [data-org-dashboard] h3')].map(h => h.textContent.trim())`);
|
||||
assert.ok(heads.includes('Active agent sessions'), heads.join(' | '));
|
||||
assert.ok(!heads.some(h => /^Queue\b/.test(h)), heads.join(' | '));
|
||||
assert.deepEqual(await b.evaluate('window.__urls.filter(u => /queue/i.test(u))'), []);
|
||||
assert.equal(counts.queue ?? 0, 0, 'the server read no queue');
|
||||
});
|
||||
|
||||
test('SP14: an instances answer with no runs says running was not read on the organization page and the Dashboard, never 0', async t => {
|
||||
const { b, base, wait, text, has } = await setup(t, { scripts: [STRIP] });
|
||||
await wait(`${settled} && document.querySelector('[data-org-dashboard] [data-ws-tile="agents"]')`);
|
||||
const sec = await b.evaluate(`[...document.querySelectorAll('[data-org-dashboard] .ws-sec-h')].find(h => h.querySelector('h2')?.textContent === 'Active agent sessions')?.innerText.replace(/\\s+/g, ' ').trim()`);
|
||||
assert.match(sec, /^Active agent sessions running not read /);
|
||||
assert.doesNotMatch(sec, /none running|\b0 running/);
|
||||
assert.equal(await text('[data-runs-unread]'), 'Not read: the instance read did not say which agents run.');
|
||||
assert.equal(await text('[data-org-dashboard] [data-ws-tile="agents"]'), 'Agents working Not read: the instance read did not say which agents run');
|
||||
assert.equal(await has('[data-org-dashboard] [data-ws-tile="agents"] .ws-big'), false);
|
||||
assert.equal(await text('[data-org-setting="Agents and roles"] .v'), '3 agents · running not read');
|
||||
// The Dashboard: the tile counts no organization whose runs were not read and names it; its row says so.
|
||||
await b.navigate(`${base}/#/dashboard`);
|
||||
await wait(`document.querySelector('[data-ws-tile="agents"]') && document.querySelector('[data-ws-orgrow="acme"]')`);
|
||||
const tile = await text('[data-ws-tile="agents"]');
|
||||
assert.match(tile, /^Agents working Not read Acme Studio \(running not read\)$/);
|
||||
assert.equal(await has('[data-ws-tile="agents"] .ws-big'), false);
|
||||
const row = await text('[data-ws-orgrow="acme"]');
|
||||
assert.match(row, /3 agents \(running not read\)/);
|
||||
assert.doesNotMatch(row, /\b0 running|0 working/);
|
||||
});
|
||||
|
||||
test('a repaint while Stop asks its question keeps the question on screen and Stop disabled', async t => {
|
||||
const { b, wait, has } = await setup(t, { hash: '#/orgs/acme/organization?agent=acme%3Acoder' });
|
||||
const d = '[data-ws-drawer="acme:coder"]';
|
||||
await wait(`${open('acme:coder')} && document.querySelector('${d} [data-drawer-stop]')`);
|
||||
await b.evaluate(`document.querySelector('${d} [data-drawer-stop]').click()`);
|
||||
await wait(`document.querySelector('${d} [data-drawer-stop-yes]')`);
|
||||
// Mark the drawer, so the test knows the repaint replaced it.
|
||||
await b.evaluate(`document.querySelector('${d}').dataset.before = '1'`);
|
||||
await b.evaluate('document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))');
|
||||
await wait(`document.querySelector('${d}') && !document.querySelector('${d}').dataset.before`);
|
||||
assert.equal(await has(`${d} [data-drawer-stop-confirm]`), true);
|
||||
assert.equal(await has(`${d} [data-drawer-stop-yes]`), true);
|
||||
assert.equal(await b.evaluate(`document.querySelector('${d} [data-drawer-stop]').disabled`), true);
|
||||
assert.deepEqual(await b.evaluate('window.__posts'), []);
|
||||
});
|
||||
@@ -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,18 +143,28 @@ 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.match(await b.evaluate(`document.querySelector('body').textContent`), /Bus unavailable/);
|
||||
assert.equal(switched, true, 'switch completed in place');
|
||||
assert.equal(await b.evaluate('window.__sameDocument'), true, 'the switch did not reload the page');
|
||||
// #1873 SP2: the organization's own page says the bus is down once, in its own banner; the shell's banner gives way there.
|
||||
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('[data-org-down]'))return;await new Promise(r=>setTimeout(r,30))}throw Error('down banner missing')})()`);
|
||||
await new Promise(r => setTimeout(r, 400));
|
||||
assert.equal(await b.evaluate(`document.querySelector('#bus-down').hidden`), true, 'the organization page shows one bus banner');
|
||||
// On the organization's other pages the shell's banner still says it, and stays after the boot-status read.
|
||||
await b.evaluate(`location.hash = '#/orgs/beta/tasks'`);
|
||||
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(!document.querySelector('#bus-down').hidden)return;await new Promise(r=>setTimeout(r,30))}throw Error('offline banner missing')})()`);
|
||||
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');
|
||||
assert.match(await b.evaluate(`document.querySelector('#bus-down').textContent`), /Bus unavailable/);
|
||||
@@ -193,7 +204,7 @@ test('a chat draft is kept on Stay and discarded on an organization switch', asy
|
||||
await b.evaluate(`document.querySelector('[data-discard]').click()`).catch(() => {});
|
||||
let switched = false;
|
||||
for (let i = 0; i < 150 && !switched; i++) {
|
||||
try { switched = await b.evaluate(`location.hash.startsWith('#/orgs/beta/') && document.querySelector('header')?.textContent.includes('Beta')`); } catch {}
|
||||
try { switched = await b.evaluate(`location.hash.startsWith('#/orgs/beta/') && document.querySelector('#breadcrumbs [data-crumb-org]')?.textContent === 'Beta'`); } catch {}
|
||||
if (!switched) await new Promise(r => setTimeout(r, 30));
|
||||
}
|
||||
assert.equal(switched, true);
|
||||
@@ -212,15 +223,9 @@ test('organization switch isolates browser board preferences', async t => {
|
||||
await b.navigate(base + '#/orgs/alpha/');
|
||||
await b.evaluate(`document.querySelector('#show-machine').click()`);
|
||||
assert.equal(await b.evaluate(`document.querySelector('#show-machine').checked`), true);
|
||||
// The switch reloads the page. The navigate helper's load checks can be in flight when that
|
||||
// reload commits, so set the hash in the page and poll one short check at a time.
|
||||
// #1873 SP1: the switch happens in place, with no reload, and beta's own board setting applies.
|
||||
await b.evaluate(`location.hash = '#/orgs/beta/'`);
|
||||
let loaded = false;
|
||||
for (let i = 0; i < 150 && !loaded; i++) {
|
||||
try { loaded = await b.evaluate(`window.MosaicOrg?.id === 'beta' && !!document.querySelector('#show-machine')`); } catch {}
|
||||
if (!loaded) await new Promise(r => setTimeout(r, 30));
|
||||
}
|
||||
assert.equal(loaded, true, 'beta did not load');
|
||||
await b.evaluate(`(async () => { for (let i = 0; i < 150; i++) { if (window.MosaicOrg?.id === 'beta' && !!document.querySelector('#show-machine')) return; await new Promise(r => setTimeout(r, 30)); } throw Error('beta did not load'); })()`);
|
||||
assert.equal(await b.evaluate(`document.querySelector('#show-machine').checked`), false);
|
||||
});
|
||||
|
||||
|
||||
@@ -165,3 +165,9 @@ test('S4 fix stale project gather cannot enrich a newer request revision', async
|
||||
const x=await b.evaluate(`({revision:document.querySelector('.project-request-intro').dataset.requestRevision,prd:document.querySelector('[data-work-prd]').textContent,files:document.querySelector('[data-work-files]').textContent})`);
|
||||
assert.equal(x.revision,'2');assert.doesNotMatch(x.prd,/v3|Approved/);assert.doesNotMatch(x.files,/4 draft/);
|
||||
});
|
||||
// SP7 review: the requirement view sends the person to the conversation's Request details, which a pane
|
||||
// does not have, so both of its "Open conversation" links are marked as the full view.
|
||||
test('SP7 requirement view links the conversation\'s full view, not a pane',async t=>{const {b,wait}=await setup(t,'/projects/'+P+'/requirements');await wait(`document.querySelector('[data-prd]')`);const links=await b.evaluate(`[...document.querySelectorAll('[data-project-content] a')].filter(a=>a.textContent.trim()==='Open conversation').map(a=>a.hasAttribute('data-full-view'))`);assert.deepEqual(links,[true,true]);});
|
||||
// SP7 re-check: the overview's "Review decision" and its two request counts lead to a request, which only
|
||||
// the conversation's full view shows, so each link that names a conversation is marked as the full view.
|
||||
test('SP7 overview decision and request links go to the conversation\'s full view',async t=>{const {b,wait}=await setup(t,'/projects');await b.evaluate(`reviewStates['request-1'].wait={kind:'decision'};document.querySelector('#refresh').click();location.hash='#/projects/${P}'`);await wait(`document.querySelector('.project-decision a')`);const got=await b.evaluate(`({decision:[...document.querySelectorAll('.project-decision a')].map(a=>[a.textContent.trim(),a.hasAttribute('data-full-view'),/\\/threads\\//.test(a.getAttribute('href'))]),metrics:[...document.querySelectorAll('.project-metrics a')].map(a=>[a.querySelector('span').textContent,/\\/threads\\//.test(a.getAttribute('href')),a.hasAttribute('data-full-view')])})`);assert.deepEqual(got,{decision:[['Review decision',true,true]],metrics:[['Requests in progress',true,true],['Needs your input',true,true],['Conversations',false,false],['Files',false,false]]});});
|
||||
@@ -68,7 +68,13 @@ test('shipped strings are unchanged, and the web keeps its write boundary', () =
|
||||
assert.deepEqual(writes, ["fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });", "fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), cache: 'no-store', redirect: 'manual' });", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});", "fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/proxy/plugins/set',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision:harnessSettings.revision,plugins:{harness:selected}}),cache:'no-store',redirect:'manual'});", "fetch('/api/proxy/plugins/set',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision:settings.revision,plugins:{'model-provider':next}}),cache:'no-store',redirect:'manual'});", "fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/project-file', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(sent), cache: 'no-store', redirect: 'manual' });", "fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });"]);
|
||||
assert.equal(read('bus.js').toString().split(/method:/).length - 1, 11);
|
||||
// Ruling 2026-11-14 (#1873): Fire on an Agents card is a second hire.start caller, {op:'fire'}.
|
||||
assert.deepEqual([...read('bus.js').toString().matchAll(/decidePost\(([^,]*),/g)].map(m => m[1]), ["`/api/decisions/hire/${templatesOnly?'templates':'start'}`", "'/api/decisions/hire/start'", 'url', '`/api/decisions/${enc(a.id)}/confirmation`', '`/api/decisions/${enc(a.id)}/resolve`']);
|
||||
// SP3 (#1873): an answer from the system Inbox goes to its row's organization. decideBase is
|
||||
// /api/orgs/<org> for such an answer and /api otherwise, so the route is still a decision's
|
||||
// confirmation or resolve, only pinned to one organization.
|
||||
// SP5 (#1873): Fire from the system Agents page goes to its row's organization the same way:
|
||||
// decideBase({org}) is /api/orgs/<org>, and an agent card's Fire (no org) keeps /api.
|
||||
assert.deepEqual([...read('bus.js').toString().matchAll(/decidePost\(([^,]*),/g)].map(m => m[1]), ["`/api/decisions/hire/${templatesOnly?'templates':'start'}`", "`${decideBase({ org: named })}/decisions/hire/start`", 'url', '`${decideBase(a)}/decisions/${enc(a.id)}/confirmation`', '`${decideBase(a)}/decisions/${enc(a.id)}/resolve`']);
|
||||
assert.deepEqual([...read('bus.js').toString().matchAll(/const decideBase = [^;]*;/g)].map(m => m[0]), ["const decideBase = a => a.org ? `/api/orgs/${enc(a.org)}` : '/api';"]);
|
||||
// Row 155 (#1649): the one fetch(url, ...) posts only to these three routes, each a literal.
|
||||
// Row 176 (#1674): the tester name form adds a second /api/name caller.
|
||||
assert.deepEqual([...read('bus.js').toString().matchAll(/assistantPost\(([^,]*),/g)].map(m => m[1]), ['url', "'/api/settings'", "'/api/name'", "'/api/assistant-stop'", "'/api/name'"]);
|
||||
|
||||
@@ -1,432 +0,0 @@
|
||||
// U5 (#1873, sample 07): Side by side shows two panes, each holding one page of any organization.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { 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)})})()`);
|
||||
|
||||
// Three organizations: two running buses and one stopped (home). `state.flip` changes what the
|
||||
// running organizations report, so a refresh repaints both panes.
|
||||
function orgs(state = { flip: false, seen: [] }) {
|
||||
const ORGS = [
|
||||
{ id: 'ddigital', label: 'Distinctly Digital', theme: 'rosewood', busHealthy: true },
|
||||
{ id: 'forge', label: 'Forge', theme: 'cobalt', busHealthy: true },
|
||||
{ id: 'home', label: 'Home', theme: 'grove', busHealthy: false },
|
||||
];
|
||||
const PROJECTS = { ddigital: [{ id: 'spring-launch', name: 'Spring launch' }, { id: 'brand-refresh', name: 'Brand refresh' }], forge: [{ id: 'api-gateway', name: 'API gateway' }], home: [] };
|
||||
const INSTANCES = { ddigital: ['chief', 'copywriter', 'producer', 'editor', 'researcher', 'designer', 'analyst', 'scheduler'], forge: ['chief', 'builder', 'tester', 'reviewer', 'operator', 'scribe'] };
|
||||
const THREADS = { ddigital: [{ id: 't0', name: 'Kickoff notes', project: 'spring-launch', instance: 'chief' }, { id: 't1', name: 'Launch email', project: 'spring-launch', instance: 'copywriter' }], forge: [{ id: 'f0', name: 'Gateway auth', project: 'api-gateway', instance: 'chief' }] };
|
||||
const instances = id => ({ business: id, revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {},
|
||||
instances: Object.fromEntries(INSTANCES[id].map((n, i) => [n, { definition: n, vars: {}, launched: true, launcher: false, protected: false, kept: false, running: [`r${i}`], alive: [] }])) });
|
||||
const agents = id => INSTANCES[id].map((role, i) => ({ role, holder_run: `r${i}`, state: (i % 2 === 0) === state.flip ? 'working' : 'waiting' }));
|
||||
const business = id => ({ business: { id, human: 'jason', arbiters: ['chief'], projects: PROJECTS[id].map(p => p.id), vars: {}, instances: INSTANCES[id].map(n => ({ instance: n, vars: {} })), appearance: { theme: ORGS.find(o => o.id === id).theme } } });
|
||||
return {
|
||||
list: async () => ORGS,
|
||||
resolve: async id => {
|
||||
state.seen.push(id);
|
||||
const org = ORGS.find(o => o.id === id); if (!org) return null;
|
||||
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } };
|
||||
return {
|
||||
busHealthy: true,
|
||||
reads: { business: async () => business(id), settings: async () => ({ release: '0.0.42', launch: { limits: { total: 4 } } }), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
|
||||
bus: { instances: async () => instances(id), messages: async () => ({ messages: [], unread: 0 }), agents: async () => agents(id), 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [] },
|
||||
chat: { projects: async () => PROJECTS[id], threads: async () => THREADS[id] },
|
||||
projectFiles: async () => ({ head: 'a'.repeat(40), files: [] }),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function open(t, { width = 1440, height = 900, hash = '#/orgs/ddigital/side-by-side', state } = {}) {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: orgs(state), log: () => {} }); t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
await b.viewport(width, height);
|
||||
const base = `http://127.0.0.1:${server.address().port}/`;
|
||||
await b.navigate(base + hash);
|
||||
return { b, base, wait: waitFor(b) };
|
||||
}
|
||||
const text = key => `(document.querySelector("#pane-${key} [data-pane-body]")?.innerText || "")`;
|
||||
const title = key => `(document.querySelector("#pane-${key} [data-pane-ttl]")?.textContent || "")`;
|
||||
const query = '(() => Object.fromEntries(new URLSearchParams(location.hash.split("?")[1] || "")))()';
|
||||
const pick = (b, key, value) => b.evaluate(`(() => { const s = document.querySelector("#pane-${key}-pick"); s.value = ${JSON.stringify(value)}; if (s.value !== ${JSON.stringify(value)}) throw Error("no option " + ${JSON.stringify(value)}); s.dispatchEvent(new Event("change", { bubbles: true })); })()`);
|
||||
|
||||
test('side by side stays dark by default under light media, and applying system or light really draws light', async t => {
|
||||
const { b, wait, base } = await open(t);
|
||||
await wait(`${title('a')}.length > 0 && ${title('b')}.length > 0`);
|
||||
await b.evaluate('window.MosaicAppearance.ready.then(() => true)');
|
||||
await b.call('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: 'light' }] });
|
||||
const drawn = () => b.evaluate(`({ mode: document.documentElement.getAttribute('data-mode'),
|
||||
palette: document.documentElement.dataset.palette, canvas: getComputedStyle(document.body).backgroundColor,
|
||||
source: window.MosaicAppearance.state().modeSource })`);
|
||||
assert.deepEqual(await drawn(), { mode: 'dark', palette: 'rosewood', canvas: 'rgb(26, 20, 23)', source: 'default' });
|
||||
for (const mode of ['system', 'light']) {
|
||||
// U5's corrected shot recipe seeds the per-org key, then loads a fresh document.
|
||||
const before = await drawn();
|
||||
await b.evaluate(`localStorage.setItem('mosaic-console-appearance:ddigital', JSON.stringify({ mode: ${JSON.stringify(mode)} }))`);
|
||||
assert.deepEqual(await drawn(), before, 'writing storage alone does not apply an override to this document');
|
||||
await b.navigate('about:blank');
|
||||
await b.navigate(`${base}?appearance=${mode}#/orgs/ddigital/side-by-side`);
|
||||
await wait(`${title('a')}.length > 0 && ${title('b')}.length > 0`);
|
||||
await b.evaluate('window.MosaicAppearance.ready.then(() => true)');
|
||||
const result = await drawn();
|
||||
assert.deepEqual(result, { mode: mode === 'system' ? null : 'light', palette: 'rosewood',
|
||||
canvas: 'rgb(246, 243, 245)', source: 'browser' });
|
||||
t.diagnostic(JSON.stringify({ scheme: 'light', requested: mode, ...result }));
|
||||
}
|
||||
});
|
||||
|
||||
test('the Side by side nav item opens two panes with different organizations, and the URL restores both after a reload', async t => {
|
||||
const state = { flip: false, seen: [] };
|
||||
const { b, base, wait } = await open(t, { hash: '#/orgs/ddigital/organization', state });
|
||||
await wait('document.querySelector("[data-org-dashboard]")');
|
||||
await b.evaluate('[...document.querySelectorAll("#sections > li > a")].find(a => a.textContent.trim() === "Side by side").click()');
|
||||
await wait(`document.querySelectorAll(".panes > .pane:not([hidden])").length === 2 && ${title('a')}.includes("Distinctly Digital") && ${title('b')}.includes("Forge")`);
|
||||
assert.doesNotMatch(await b.evaluate('document.getElementById("bus-view").innerText'), /Side by side is not built yet/i);
|
||||
assert.equal(await b.evaluate('document.querySelector("#bus-view h1").textContent'), 'Side by side');
|
||||
assert.equal(await b.evaluate('document.title'), 'Side by side · Mosaic Console');
|
||||
assert.deepEqual(await b.evaluate(query), { a: 'ddigital/organization', b: 'forge/organization', w: '50', show: 'a' });
|
||||
await wait(`${text('a')}.includes("Distinctly Digital") && ${text('b')}.includes("Forge")`);
|
||||
// Each pane's links name its own organization.
|
||||
assert.equal(await b.evaluate('[...document.querySelectorAll("#pane-b [data-pane-body] a[href^=\\"#/\\"]")].every(a => a.getAttribute("href").startsWith("#/orgs/forge/"))'), true);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#pane-a [data-pane-body] h1[aria-level=\\"2\\"]").length > 0'), true);
|
||||
// The picker lists every organization and its projects.
|
||||
const groups = await b.evaluate('[...document.querySelectorAll("#pane-b-pick optgroup")].map(g => g.label)');
|
||||
assert.deepEqual(groups, ['Distinctly Digital', 'Forge', 'Home · bus unavailable']);
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#pane-b-pick option")].map(o => o.value).filter(Boolean)'),
|
||||
['ddigital/organization', 'ddigital/agents', 'ddigital/projects/spring-launch', 'ddigital/projects/brand-refresh', 'forge/organization', 'forge/agents', 'forge/projects/api-gateway', 'home/organization', 'home/agents']);
|
||||
// Pick a project in the right pane; the left keeps its page.
|
||||
await pick(b, 'b', 'forge/projects/api-gateway');
|
||||
await wait(`${title('b')}.startsWith("API gateway") && ${text('b')}.includes("API gateway") && location.hash.includes("b=forge/projects/api-gateway")`);
|
||||
assert.match(await b.evaluate(title('a')), /^Dashboard/);
|
||||
// A project tab opens in the pane, not the full view.
|
||||
await b.evaluate('[...document.querySelectorAll("#pane-b nav.project-tabs a")].find(a => /Tasks/.test(a.textContent)).click()');
|
||||
await wait(`location.hash.includes("b=forge/projects/api-gateway/tasks") && ${title('b')}.includes("Tasks")`);
|
||||
assert.match(await b.evaluate('location.hash'), /^#\/orgs\/ddigital\/side-by-side\?/);
|
||||
// Reload: both panes come back from the URL.
|
||||
await b.call('Page.reload');
|
||||
await wait('document.readyState === "complete"');
|
||||
await wait(`${title('a')}.startsWith("Dashboard") && ${title('a')}.includes("Distinctly Digital") && ${title('b')}.includes("API gateway") && ${title('b')}.includes("Tasks")`);
|
||||
assert.deepEqual(await b.evaluate(query), { a: 'ddigital/organization', b: 'forge/projects/api-gateway/tasks', w: '50', show: 'a' });
|
||||
// A shared link opens exactly what it names, whatever this browser remembered.
|
||||
await b.navigate(`${base}#/orgs/forge/side-by-side?a=forge/agents&b=ddigital/projects/spring-launch&w=40&show=b`);
|
||||
await wait(`${title('a')}.startsWith("Agents") && ${title('a')}.includes("Forge") && ${title('b')}.startsWith("Spring launch")`);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-sbs-divider]").getAttribute("aria-valuenow")'), '40');
|
||||
// A link with a route a pane does not draw falls back to the remembered layout or the default.
|
||||
await b.navigate(`${base}#/orgs/ddigital/side-by-side?a=ddigital/settings&b=nope!/organization`);
|
||||
await wait(`document.querySelectorAll(".panes > .pane:not([hidden])").length === 2 && ${title('a')}.length > 0 && !location.hash.includes("settings")`);
|
||||
// Leaving the page stops both panes reading.
|
||||
await b.evaluate('location.hash = "#/orgs/ddigital/agents"');
|
||||
await wait('!document.querySelector("[data-side-by-side]") && [...document.querySelectorAll("#bus-view h1")].some(h => h.textContent.trim() === "Agents")');
|
||||
assert.ok(state.seen.includes('forge') && state.seen.includes('ddigital'));
|
||||
});
|
||||
|
||||
test('the divider resizes the panes by mouse and by keyboard', async t => {
|
||||
const { b, wait } = await open(t);
|
||||
await wait(`${title('a')}.length > 0 && ${title('b')}.length > 0`);
|
||||
const sep = '[role=separator][data-sbs-divider]';
|
||||
assert.deepEqual(await b.evaluate(`(() => { const d = document.querySelector("${sep}"); return [d.getAttribute("aria-valuenow"), d.getAttribute("aria-valuemin"), d.getAttribute("aria-valuemax"), d.getAttribute("aria-orientation"), d.tabIndex]; })()`), ['50', '25', '75', 'vertical', 0]);
|
||||
const ratio = `(() => { const a = document.getElementById("pane-a").getBoundingClientRect().width, b = document.getElementById("pane-b").getBoundingClientRect().width; return Math.round(a / (a + b) * 100); })()`;
|
||||
// Mouse: drag the divider to 30% of the panes' width.
|
||||
const box = await b.evaluate(`(() => { const p = document.querySelector("[data-sbs-panes]").getBoundingClientRect(), d = document.querySelector("${sep}").getBoundingClientRect(); return { left: p.left, width: p.width, x: d.left + d.width / 2, y: d.top + 200 }; })()`);
|
||||
const target = box.left + box.width * 0.3;
|
||||
await b.call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: box.x, y: box.y });
|
||||
await b.call('Input.dispatchMouseEvent', { type: 'mousePressed', x: box.x, y: box.y, button: 'left', buttons: 1, clickCount: 1 });
|
||||
for (const step of [0.25, 0.5, 0.75, 1]) await b.call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: box.x + (target - box.x) * step, y: box.y, button: 'left', buttons: 1 });
|
||||
await b.call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: target, y: box.y, button: 'left', buttons: 0, clickCount: 1 });
|
||||
await wait(`document.querySelector("${sep}").getAttribute("aria-valuenow") === "30" && location.hash.includes("w=30")`);
|
||||
assert.ok(Math.abs(await b.evaluate(ratio) - 30) <= 1);
|
||||
// Keyboard: the separator has focus after the drag; arrows step by 5, Home and End reach the limits.
|
||||
assert.equal(await b.evaluate('document.activeElement.getAttribute("role")'), 'separator');
|
||||
await b.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
|
||||
await b.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
|
||||
await wait(`document.querySelector("${sep}").getAttribute("aria-valuenow") === "35"`);
|
||||
await b.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
|
||||
await b.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
|
||||
await b.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
|
||||
await b.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
|
||||
await wait(`document.querySelector("${sep}").getAttribute("aria-valuenow") === "25"`);
|
||||
await b.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
|
||||
await b.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
|
||||
assert.equal(await b.evaluate(`document.querySelector("${sep}").getAttribute("aria-valuenow")`), '25');
|
||||
await b.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'End', code: 'End', windowsVirtualKeyCode: 35 });
|
||||
await b.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'End', code: 'End', windowsVirtualKeyCode: 35 });
|
||||
await wait(`document.querySelector("${sep}").getAttribute("aria-valuenow") === "75" && location.hash.includes("w=75")`);
|
||||
assert.ok(Math.abs(await b.evaluate(ratio) - 75) <= 1);
|
||||
// Closing a pane goes back to one; the divider leaves with it. Add pane brings a second back.
|
||||
await b.evaluate('document.querySelector("#pane-a [data-pane-close]").click()');
|
||||
await wait(`document.getElementById("pane-b").hidden && document.querySelector("${sep}").hidden && !new URLSearchParams(location.hash.split("?")[1]).has("b") && ${title('a')}.includes("Forge")`);
|
||||
assert.equal(await b.evaluate('document.activeElement.id'), 'pane-a-pick');
|
||||
assert.equal(await b.evaluate('document.querySelector("#pane-a [data-pane-close]").hidden'), true);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-sbs-note]").textContent'), /^1 pane ·/);
|
||||
await b.evaluate('document.querySelector("[data-pane-add]").click()');
|
||||
await wait(`!document.getElementById("pane-b").hidden && ${title('b')}.length > 0 && new URLSearchParams(location.hash.split("?")[1]).has("b")`);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-pane-add]").disabled'), true);
|
||||
});
|
||||
|
||||
test('each pane keeps its own focus and scroll through a refresh', async t => {
|
||||
const state = { flip: false, seen: [] };
|
||||
const { b, wait } = await open(t, { height: 520, state, hash: '#/orgs/ddigital/side-by-side?a=ddigital/agents&b=forge/agents' });
|
||||
await wait(`${text('a')}.includes("copywriter") && ${text('b')}.includes("builder")`);
|
||||
const scrollable = await b.evaluate('[...document.querySelectorAll("[data-pane-body]")].map(el => el.scrollHeight - el.clientHeight)');
|
||||
assert.ok(scrollable.every(n => n > 60), `both panes scroll: ${scrollable}`);
|
||||
const focused = await b.evaluate('(() => { const a = document.querySelector("#pane-a [data-pane-body] a[href]"); a.focus(); return a.getAttribute("href"); })()');
|
||||
await b.evaluate('document.querySelector("#pane-a [data-pane-body]").scrollTop = 40; document.querySelector("#pane-b [data-pane-body]").scrollTop = 60;');
|
||||
const before = await b.evaluate(`[${text('a')}, ${text('b')}]`);
|
||||
state.flip = true;
|
||||
await b.evaluate('document.getElementById("refresh").click()');
|
||||
await wait(`${text('a')} !== ${JSON.stringify(before[0])} && ${text('b')} !== ${JSON.stringify(before[1])}`);
|
||||
assert.equal(await b.evaluate('document.querySelector("#pane-a").contains(document.activeElement) && document.activeElement.getAttribute("href")'), focused);
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-pane-body]")].map(el => el.scrollTop)'), [40, 60]);
|
||||
// Focus in the right pane stays there while the left repaints.
|
||||
const right = await b.evaluate('(() => { const a = document.querySelector("#pane-b [data-pane-body] a[href]"); a.focus({ preventScroll: true }); return a.getAttribute("href"); })()');
|
||||
state.flip = false;
|
||||
await b.evaluate('document.getElementById("refresh").click()');
|
||||
await wait(`${text('a')} === ${JSON.stringify(before[0])}`);
|
||||
assert.equal(await b.evaluate('document.querySelector("#pane-b").contains(document.activeElement) && document.activeElement.getAttribute("href")'), right);
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-pane-body]")].map(el => el.scrollTop)'), [40, 60]);
|
||||
});
|
||||
|
||||
test('a pane whose organization is down shows its state and the other pane still works', async t => {
|
||||
const { b, wait } = await open(t, { hash: '#/orgs/ddigital/side-by-side?a=home/organization&b=forge/organization' });
|
||||
await wait(`${title('a')}.includes("Home") && ${title('b')}.includes("Forge") && ${text('b')}.includes("Forge")`);
|
||||
await wait('/Home: bus unavailable/.test(document.querySelector("#pane-a [data-pane-line]").textContent)');
|
||||
assert.equal(await b.evaluate('document.querySelector("#pane-a [data-pane-line]").getAttribute("role")'), 'status');
|
||||
assert.equal(await b.evaluate('document.querySelector("#pane-a [data-pane-state]").innerText.trim().length > 0'), true);
|
||||
assert.equal(await b.evaluate('document.querySelector("#pane-b [data-pane-line]").hidden'), true);
|
||||
await pick(b, 'b', 'forge/agents');
|
||||
await wait(`${title('b')}.startsWith("Agents") && ${text('b')}.includes("builder")`);
|
||||
await pick(b, 'b', 'forge/projects/api-gateway');
|
||||
await wait(`${title('b')}.startsWith("API gateway") && ${text('b')}.includes("API gateway")`);
|
||||
assert.match(await b.evaluate(title('a')), /Home/);
|
||||
// An organization that is not on the list is one honest line; nothing is read for it.
|
||||
await b.evaluate('location.hash = "#/orgs/ddigital/side-by-side?a=ghost/organization&b=forge/agents"');
|
||||
await wait(`/ghost is not on your organization list/.test(document.querySelector("#pane-a [data-pane-line]").textContent) && ${text('b')}.includes("builder")`);
|
||||
});
|
||||
|
||||
test('on a phone one pane shows at a time, switched from the bar, with no horizontal scroll', async t => {
|
||||
const { b, wait } = await open(t, { width: 390, height: 844 });
|
||||
await wait(`${title('a')}.length > 0 && ${title('b')}.length > 0 && ${text('a')}.length > 0`);
|
||||
const visible = '[...document.querySelectorAll(".panes > .pane")].filter(p => p.getBoundingClientRect().height > 0).map(p => p.id)';
|
||||
assert.deepEqual(await b.evaluate(visible), ['pane-a']);
|
||||
assert.notEqual(await b.evaluate('getComputedStyle(document.querySelector("[data-sbs-tabs]")).display'), 'none');
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("[data-sbs-divider]")).display'), 'none');
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("[data-pane-add]")).display'), 'none');
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-sbs-tabs] button")].map(x => x.getAttribute("aria-pressed"))'), ['true', 'false']);
|
||||
assert.ok(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), 'no horizontal scroll');
|
||||
await b.evaluate('document.querySelector("[data-sbs-tabs] [data-pane-show=b]").click()');
|
||||
await wait(`JSON.stringify(${visible}) === '["pane-b"]' && location.hash.includes("show=b")`);
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-sbs-tabs] button")].map(x => x.getAttribute("aria-pressed"))'), ['false', 'true']);
|
||||
assert.ok(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), 'no horizontal scroll on the second pane');
|
||||
await pick(b, 'b', 'ddigital/projects/spring-launch');
|
||||
await wait(`${text('b')}.includes("Spring launch")`);
|
||||
assert.ok(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), 'no horizontal scroll on a project page');
|
||||
});
|
||||
|
||||
// Review fixes (#1873 U5 review, findings 1-4). `hold(url)` makes the page's next read of that URL
|
||||
// wait until `release()`; the test then changes the pane while the read is pending.
|
||||
const hold = url => `(() => { const real = window.fetch; window.held = { url: ${JSON.stringify(url)}, pending: false };
|
||||
window.fetch = async (u, o) => { if (window.held?.url === u && !window.held.done) { window.held.pending = true; await new Promise(r => { window.held.release = () => { window.held.done = true; r(); }; }); } return real(u, o); }; })()`;
|
||||
const settled = b => b.evaluate('new Promise(r => setTimeout(r, 400))');
|
||||
|
||||
test('a pane closed and opened again rewrites new links for its current organization only', async t => {
|
||||
const { b, wait } = await open(t);
|
||||
await wait(`${title('b')}.includes("Forge") && document.querySelector("#pane-b-pick")?.options.length > 4`);
|
||||
await b.evaluate('document.querySelector("#pane-b [data-pane-close]").click()');
|
||||
await wait('document.querySelector("#pane-b").hidden && !document.querySelector("[data-pane-add]").disabled');
|
||||
await b.evaluate('document.querySelector("[data-pane-add]").click()');
|
||||
await wait(`!document.querySelector("#pane-b").hidden && ${title('b')}.length > 0`);
|
||||
await pick(b, 'b', 'ddigital/organization');
|
||||
await wait(`${title('b')}.includes("Distinctly Digital") && location.hash.includes("b=ddigital/organization")`);
|
||||
// Content a page appends after its own reads (projects.js does this) gets the pane's organization.
|
||||
await b.evaluate(`document.querySelector("#pane-b [data-pane-body]").insertAdjacentHTML("beforeend", '<a data-late href="#/docs/example">late</a><a data-late-api href="/api/files/x">file</a>')`);
|
||||
await wait('document.querySelector("#pane-b [data-late]").getAttribute("href").startsWith("#/orgs/")');
|
||||
assert.equal(await b.evaluate('document.querySelector("#pane-b [data-late]").getAttribute("href")'), '#/orgs/ddigital/docs/example');
|
||||
assert.equal(await b.evaluate('document.querySelector("#pane-b [data-late-api]").getAttribute("href")'), '/api/orgs/ddigital/files/x');
|
||||
// Leaving the page stops every pane observer: later content in the old element is left alone.
|
||||
await b.evaluate('location.hash = "#/orgs/ddigital/agents"');
|
||||
await wait('!document.querySelector("[data-side-by-side]")');
|
||||
});
|
||||
|
||||
test('a slow project read never paints over the page or organization chosen since', async t => {
|
||||
const { b, wait } = await open(t);
|
||||
await wait(`${title('b')}.includes("Forge") && document.querySelector("#pane-b-pick")?.options.length > 4`);
|
||||
// Page change while the project's thread read is pending.
|
||||
await b.evaluate(hold('/api/orgs/forge/chat/threads'));
|
||||
await pick(b, 'b', 'forge/projects/api-gateway');
|
||||
await wait('window.held.pending');
|
||||
await pick(b, 'b', 'forge/agents');
|
||||
await wait(`${title('b')}.startsWith("Agents") && ${text('b')}.includes("builder")`);
|
||||
await b.evaluate('window.held.release()');
|
||||
await settled(b);
|
||||
assert.equal(await b.evaluate(`${text('b')}.includes("API gateway")`), false, 'the old project did not replace Agents');
|
||||
assert.match(await b.evaluate(title('b')), /^Agents/);
|
||||
// Organization change while the read is pending: the old organization's project is not painted.
|
||||
await b.evaluate(hold('/api/orgs/forge/chat/threads'));
|
||||
await pick(b, 'b', 'forge/projects/api-gateway');
|
||||
await wait('window.held.pending');
|
||||
await pick(b, 'b', 'ddigital/projects/spring-launch');
|
||||
await wait(`${title('b')}.startsWith("Spring launch") && ${text('b')}.includes("Spring launch")`);
|
||||
await b.evaluate('window.held.release()');
|
||||
await settled(b);
|
||||
assert.equal(await b.evaluate(`${text('b')}.includes("API gateway")`), false, 'the old organization did not replace Spring launch');
|
||||
assert.equal(await b.evaluate('[...document.querySelectorAll("#pane-b [data-pane-body] a[href^=\\"#/orgs/\\"]")].every(a => a.getAttribute("href").startsWith("#/orgs/ddigital/"))'), true);
|
||||
});
|
||||
|
||||
test('the same project in both panes keeps a request choice per pane', async t => {
|
||||
const { b, wait } = await open(t);
|
||||
await wait(`${title('b')}.includes("Forge")`);
|
||||
const { stub, P } = await import('./helpers/projects-review-fixture.mjs');
|
||||
// The fixture answers the project reads of both organizations; the organization list stays real.
|
||||
await b.evaluate(stub.replace('const path=String(url),method', `if(url==='/api/orgs'||url==='/api/themes')return real(url,init);const path=String(url).replace(/^\\/api\\/orgs\\/(?:ddigital|forge)\\//,'/api/'),method`));
|
||||
await b.evaluate('window.MosaicPanes.refresh()');
|
||||
await wait(`document.querySelector('#pane-a-pick option[value="ddigital/projects/${P}"]')`);
|
||||
await b.evaluate(`location.hash = '#/orgs/ddigital/side-by-side?a=ddigital/projects/${P}/requirements&b=forge/projects/${P}/requirements'`);
|
||||
const values = `[...document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]')].map(e => e.value)`;
|
||||
await wait(`document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]').length === 2`);
|
||||
assert.deepEqual(await b.evaluate(values), ['request-1', 'request-1']);
|
||||
await b.evaluate(`(() => { const e = document.querySelector('#pane-a-project-request-select'); e.value = 'request-2'; e.dispatchEvent(new Event('change', { bubbles: true })); })()`);
|
||||
await wait(`${values}[0] === 'request-2'`);
|
||||
// The redrawn select gets focus back through the pane's own id.
|
||||
assert.equal(await b.evaluate('document.activeElement.id'), 'pane-a-project-request-select');
|
||||
await b.evaluate('window.MosaicPanes.refresh()');
|
||||
await settled(b);
|
||||
await wait(`document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]').length === 2`);
|
||||
assert.deepEqual(await b.evaluate(values), ['request-2', 'request-1']);
|
||||
});
|
||||
|
||||
test('a pane keeps its request choice and PRD disclosure when a new conversation repaints the project', async t => {
|
||||
const { b, wait } = await open(t);
|
||||
await wait(`${title('b')}.includes("Forge")`);
|
||||
const { stub, P } = await import('./helpers/projects-review-fixture.mjs');
|
||||
await b.evaluate(stub.replace('const path=String(url),method', `if(url==='/api/orgs'||url==='/api/themes')return real(url,init);const path=String(url).replace(/^\\/api\\/orgs\\/(?:ddigital|forge)\\//,'/api/'),method`));
|
||||
await b.evaluate('window.MosaicPanes.refresh()');
|
||||
await wait(`document.querySelector('#pane-a-pick option[value="ddigital/projects/${P}"]')`);
|
||||
// The same organization and project in both panes.
|
||||
await b.evaluate(`location.hash = '#/orgs/ddigital/side-by-side?a=ddigital/projects/${P}/requirements&b=ddigital/projects/${P}/requirements'`);
|
||||
await wait(`document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]').length === 2 && document.querySelectorAll('[data-pane-body] .project-prd-toggle').length === 2`);
|
||||
const state = `({ selected: [...document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]')].map(e => e.value), expanded: [...document.querySelectorAll('[data-pane-body] [data-prd]')].map(e => e.classList.contains('expanded')) })`;
|
||||
await b.evaluate(`(() => { const e = document.querySelector('#pane-a-project-request-select'); e.value = 'request-2'; e.dispatchEvent(new Event('change', { bubbles: true })); })()`);
|
||||
await wait(`document.querySelector('#pane-a-project-request-select').value === 'request-2'`);
|
||||
await b.evaluate(`document.querySelector('#pane-a .project-prd-toggle').click()`);
|
||||
await wait(`document.querySelector('#pane-a [data-prd]')?.classList.contains('expanded')`);
|
||||
// A new conversation changes the header's count, so each pane replaces its project content.
|
||||
const before = await b.evaluate(`document.querySelector('#pane-a .project-header p')?.textContent || ''`);
|
||||
await b.evaluate(`window.fixtureThreads.push({ ...window.fixtureThreads[0], id: '20000000-0000-4000-8000-000000000004', name: 'A new conversation' }); window.MosaicPanes.refresh()`);
|
||||
await wait(`(() => { const t = document.querySelector('#pane-a .project-header p')?.textContent || ''; return t !== ${JSON.stringify(before)} && /4 conversations/.test(t) && /4 conversations/.test(document.querySelector('#pane-b .project-header p')?.textContent || ''); })()`);
|
||||
await settled(b);
|
||||
await wait(`document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]').length === 2 && document.querySelectorAll('[data-pane-body] [data-prd]').length === 2`);
|
||||
assert.deepEqual(await b.evaluate(state), { selected: ['request-2', 'request-1'], expanded: [true, false] });
|
||||
});
|
||||
|
||||
test('a pane navigation that finishes late does not take focus from the other pane', async t => {
|
||||
const { b, wait } = await open(t);
|
||||
await wait(`document.querySelector('#pane-a [data-focus-key="agents-all"]') && document.querySelector('#pane-b-pick')?.options.length > 4`);
|
||||
await b.evaluate(hold('/api/orgs/ddigital/bus/instances'));
|
||||
await b.evaluate(`document.querySelector('#pane-a [data-focus-key="agents-all"]').click()`);
|
||||
await wait('window.held.pending');
|
||||
await b.evaluate(`document.querySelector('#pane-b-pick').focus(); window.held.release()`);
|
||||
await wait(`${title('a')}.startsWith('Agents')`);
|
||||
await settled(b);
|
||||
assert.equal(await b.evaluate('document.activeElement.id'), 'pane-b-pick');
|
||||
// Without a move elsewhere, the same navigation still lands on the new page's heading.
|
||||
await pick(b, 'a', 'ddigital/organization');
|
||||
await wait(`document.querySelector('#pane-a [data-focus-key="agents-all"]')`);
|
||||
await b.evaluate(`document.querySelector('#pane-a [data-focus-key="agents-all"]').click()`);
|
||||
await wait(`${title('a')}.startsWith('Agents') && document.activeElement?.tagName === 'H1' && document.querySelector('#pane-a').contains(document.activeElement)`);
|
||||
});
|
||||
|
||||
// A1's "Available in a project" chooser, with an Agents pane per organization: each pane's choice
|
||||
// redraws its own table and survives a refresh, keyed by that pane's organization.
|
||||
test('each Agents pane keeps its own project choice for its organization', async t => {
|
||||
const { b, wait } = await open(t, { hash: '#/orgs/ddigital/side-by-side?a=ddigital/agents&b=forge/agents' });
|
||||
const a = (name, level = 'org', definition = name) => ({ name, level, definition, model: null, scope: { kind: 'org' }, overrides: [], narrowed: [] });
|
||||
const set = agents => ({ state: 'ok', agents, excluded: [] });
|
||||
const levels = { root: { state: 'ok', assistant: null, agents: [] }, orgs: [
|
||||
{ id: 'ddigital', state: 'ok', base: set([a('chief')]), defined: { 'spring-launch': [{ name: 'social-scheduler', form: 'define' }] },
|
||||
projects: { 'spring-launch': set([a('chief'), a('social-scheduler', 'project', 'coder')]), 'brand-refresh': set([a('chief')]) } },
|
||||
{ id: 'forge', state: 'ok', base: set([a('chief')]), defined: { 'api-gateway': [{ name: 'load-tester', form: 'define' }] },
|
||||
projects: { 'api-gateway': set([a('chief'), a('load-tester', 'project', 'coder')]), ops: set([a('chief')]) } },
|
||||
] };
|
||||
await b.evaluate(`(() => { const real = window.fetch; window.fetch = (url, init) => String(url).endsWith('/api/agents/levels') ? Promise.resolve(new Response(${JSON.stringify(JSON.stringify(levels))}, { headers: { 'content-type': 'application/json' } })) : real(url, init); })()`);
|
||||
await b.evaluate('window.MosaicPanes.refresh()');
|
||||
const select = key => `document.querySelector('#pane-${key} [data-agents-project]')`;
|
||||
const rows = key => `[...document.querySelectorAll('#pane-${key} [data-agents-available] tr[data-agent]')].map(r => r.dataset.agent)`;
|
||||
await wait(`${select('a')} && ${select('b')} && ${rows('a')}.includes('social-scheduler') && ${rows('b')}.includes('load-tester')`);
|
||||
await b.evaluate(`(() => { const s = ${select('b')}; s.value = 'ops'; s.dispatchEvent(new Event('change', { bubbles: true })); })()`);
|
||||
await wait(`!${rows('b')}.includes('load-tester')`);
|
||||
assert.equal(await b.evaluate(`${rows('a')}.includes('social-scheduler')`), true);
|
||||
assert.equal(await b.evaluate(`${rows('a')}.includes('load-tester')`), false);
|
||||
await b.evaluate('window.MosaicPanes.refresh()');
|
||||
await settled(b);
|
||||
await wait(`${select('a')} && ${select('b')}`);
|
||||
assert.deepEqual(await b.evaluate(`[${select('a')}.value, ${select('b')}.value]`), ['spring-launch', 'ops']);
|
||||
assert.equal(await b.evaluate(`!${rows('b')}.includes('load-tester') && ${rows('a')}.includes('social-scheduler')`), true);
|
||||
});
|
||||
|
||||
// Polish 1 (#1873): every id a pane draws is its own. With the same page in both panes no id
|
||||
// appears twice, and every label, ARIA reference and in-page link resolves inside its own pane.
|
||||
const idAudit = `(() => {
|
||||
const count = new Map();
|
||||
for (const el of document.querySelectorAll('[id]')) count.set(el.id, (count.get(el.id) || 0) + 1);
|
||||
const duplicates = [...count].filter(([, n]) => n > 1).map(([id]) => id).sort();
|
||||
const stray = [];
|
||||
for (const pane of document.querySelectorAll('.panes > .pane:not([hidden])')) {
|
||||
for (const attr of ['for', 'aria-labelledby', 'aria-describedby', 'aria-controls']) {
|
||||
for (const el of pane.querySelectorAll('[' + attr + ']')) {
|
||||
for (const id of el.getAttribute(attr).split(/\\s+/).filter(Boolean)) {
|
||||
const target = document.getElementById(id);
|
||||
if (!target || !pane.contains(target)) stray.push(pane.id + ' ' + attr + '=' + id);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const a of pane.querySelectorAll('a[href^="#"]:not([href^="#/"])')) {
|
||||
const id = decodeURIComponent(a.getAttribute('href').slice(1));
|
||||
const target = id && document.getElementById(id);
|
||||
if (id && (!target || !pane.contains(target))) stray.push(pane.id + ' href=#' + id);
|
||||
}
|
||||
}
|
||||
return { duplicates, stray: stray.sort() };
|
||||
})()`;
|
||||
|
||||
test('the same project, dashboard and Agents page in both panes draw no duplicate ids, and every reference stays in its pane', async t => {
|
||||
const { b, wait } = await open(t);
|
||||
await wait(`${title('b')}.includes("Forge")`);
|
||||
const { stub, P } = await import('./helpers/projects-review-fixture.mjs');
|
||||
await b.evaluate(stub.replace('const path=String(url),method', `if(url==='/api/orgs'||url==='/api/themes')return real(url,init);const path=String(url).replace(/^\\/api\\/orgs\\/(?:ddigital|forge)\\//,'/api/'),method`));
|
||||
const a = (name, level = 'org', definition = name) => ({ name, level, definition, model: null, scope: { kind: 'org' }, overrides: [], narrowed: [] });
|
||||
const set = agents => ({ state: 'ok', agents, excluded: [] });
|
||||
const levels = { root: { state: 'ok', assistant: null, agents: [] }, orgs: [
|
||||
{ id: 'ddigital', state: 'ok', base: set([a('chief')]), defined: { [P]: [{ name: 'social-scheduler', form: 'define' }] },
|
||||
projects: { [P]: set([a('chief'), a('social-scheduler', 'project', 'coder')]) } },
|
||||
] };
|
||||
await b.evaluate(`(() => { const real = window.fetch; window.fetch = (url, init) => String(url).endsWith('/api/agents/levels') ? Promise.resolve(new Response(${JSON.stringify(JSON.stringify(levels))}, { headers: { 'content-type': 'application/json' } })) : real(url, init); })()`);
|
||||
await b.evaluate('window.MosaicPanes.refresh()');
|
||||
await wait(`document.querySelector('#pane-a-pick option[value="ddigital/projects/${P}"]')`);
|
||||
const both = route => `location.hash = '#/orgs/ddigital/side-by-side?a=ddigital${route}&b=ddigital${route}'`;
|
||||
const pages = [
|
||||
['/organization', `${title('a')}.startsWith('Dashboard') && ${title('b')}.startsWith('Dashboard') && document.querySelectorAll('[data-pane-body] [data-org-settings]').length === 2`],
|
||||
['/agents', `${title('a')}.startsWith('Agents') && ${title('b')}.startsWith('Agents') && document.querySelectorAll('[data-pane-body] [data-agents-available] tr[data-agent]').length >= 2`],
|
||||
[`/projects/${P}`, `${title('a')}.startsWith('Pomodoro Timer') && ${title('b')}.startsWith('Pomodoro Timer') && !${text('a')}.includes('Reading…') && !${text('b')}.includes('Reading…')`],
|
||||
[`/projects/${P}/conversations`, `${title('a')}.includes('Conversations') && ${title('b')}.includes('Conversations')`],
|
||||
[`/projects/${P}/tasks`, `${title('a')}.includes('Tasks') && ${title('b')}.includes('Tasks') && !${text('a')}.includes('Reading…') && !${text('b')}.includes('Reading…')`],
|
||||
[`/projects/${P}/requirements`, `document.querySelectorAll('[data-pane-body] [data-prd]').length === 2 && document.querySelectorAll('[data-pane-body] select').length >= 2`],
|
||||
[`/projects/${P}/files`, `${title('a')}.includes('Files') && ${title('b')}.includes('Files') && !${text('a')}.includes('Reading…') && !${text('b')}.includes('Reading…')`],
|
||||
];
|
||||
const found = {}, drawn = {};
|
||||
for (const [route, ready] of pages) {
|
||||
await b.evaluate(both(route));
|
||||
await wait(ready);
|
||||
await settled(b);
|
||||
const page = route.replace(P, 'P');
|
||||
found[page] = await b.evaluate(idAudit);
|
||||
drawn[page] = await b.evaluate(`[...document.querySelectorAll('#pane-a [data-pane-body] [id]')].map(e => e.id)`);
|
||||
}
|
||||
assert.deepEqual(found, Object.fromEntries(pages.map(([route]) => [route.replace(P, 'P'), { duplicates: [], stray: [] }])));
|
||||
// The audit is not vacuous: the dashboard draws its reload note and the requirements page its
|
||||
// request select, north star and PRD headings, each with the pane's prefix.
|
||||
assert.deepEqual(drawn['/organization'], ['pane-a-ws-reload-why']);
|
||||
assert.deepEqual(drawn['/projects/P/requirements'], ['pane-a-project-request-select', 'pane-a-project-north-star', 'pane-a-project-prd']);
|
||||
});
|
||||
@@ -0,0 +1,435 @@
|
||||
// SP1 (#1873, single pane slice 1): #/dashboard is the system landing route, an organization page is a
|
||||
// drill-in route inside the same shell (no reload, the sidebar and top bar stay), and an organization
|
||||
// whose bus is down says so with its reason. Every bus is a fake: acme and beta answer, gamma's bus is
|
||||
// stopped. The real GET /api/orgs, /api/workspace and /api/agents/levels serve the page.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
|
||||
const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString();
|
||||
|
||||
async function fixture({ empty = false, bootless = false } = {}) {
|
||||
const { checkRootAgents } = await import('../../organization/src/index.mjs');
|
||||
const rolesDir = fileURLToPath(new URL('../../../roles', import.meta.url));
|
||||
const root = { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: {} }, { rolesDir }) };
|
||||
const LIST = [
|
||||
{ id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true },
|
||||
{ id: 'beta', label: 'Beta Works', theme: 'grove', busHealthy: true },
|
||||
{ id: 'gamma', label: 'Gamma Home', theme: 'rosewood', busHealthy: false, busStopped: true },
|
||||
];
|
||||
const ROLES = {
|
||||
acme: { chief: { definition: 'chief-of-staff' }, coder: { definition: 'coder' }, reviewer: { definition: 'reviewer' } },
|
||||
beta: { chief: { definition: 'chief-of-staff' } },
|
||||
gamma: { chief: { definition: 'chief-of-staff' }, researcher: { definition: 'research' } },
|
||||
};
|
||||
const DATA = {
|
||||
acme: {
|
||||
projects: [{ id: 'p-site', repo: 'site', name: 'Website', archived: false, at: at(30) }, { id: 'p-zeta', repo: 'zeta', name: 'Zeta API', archived: false, at: at(300) }],
|
||||
agents: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'working', at: at(40), working: { since: at(40) } }],
|
||||
inbox: [{ id: 'dec-acme-1', question: 'Ship the hero?', class: 'gated', blocking: true, raised_by_role: 'coder', at: at(10), options: [{ key: 'yes', text: 'Ship it' }] }],
|
||||
},
|
||||
beta: { projects: [{ id: 'p-shop', repo: 'shop', name: 'Shop', archived: false, at: at(120) }], agents: [], inbox: [] },
|
||||
};
|
||||
// Launch settings differ per organization so a value carried from one to the other shows.
|
||||
const LAUNCH = id => ({ business: id, revision: (id === 'acme' ? 'a' : 'b').repeat(64), models: { opus: { provider: 'anthropic', model: 'claude-opus-5-5' } }, limits: { total: id === 'acme' ? 4 : 7 }, instances: {}, usage: { total: 0, providers: {}, models: {} } });
|
||||
const value = (id, name) => Promise.resolve(DATA[id]?.[name] ?? (['usage', 'limits'].includes(name) ? {} : []));
|
||||
const opts = id => ({
|
||||
bus: { inbox: () => value(id, 'inbox'), agents: () => value(id, 'agents'), 'thread.claims': () => value(id, 'claims'), tasks: () => value(id, 'tasks'), usage: () => value(id, 'usage'), limits: () => value(id, 'limits'), decisions: () => value(id, 'decisions') },
|
||||
chat: { threads: () => value(id, 'threads'), projects: () => value(id, 'projects') },
|
||||
});
|
||||
const member = org => ({
|
||||
id: org.id, doc: { appearance: { theme: org.theme }, roles: ROLES[org.id], projects: {} },
|
||||
status: async () => (org.busHealthy ? { healthy: true, pid: 4242, boot: null } : bootless ? { healthy: false, boot: null } : { healthy: false, boot: { ok: false, at: at(90), lastGood: null, reason: 'stopped by the owner' } }),
|
||||
options: () => opts(org.id),
|
||||
});
|
||||
const instances = id => Object.fromEntries(Object.entries(ROLES[id]).map(([n, r]) => [n, { definition: r.definition, vars: {}, launched: true, launcher: false, protected: false, kept: false, running: n === 'coder' ? ['run-1'] : [], alive: [] }]));
|
||||
const organizations = {
|
||||
list: async () => (empty ? [] : LIST),
|
||||
members: async () => (empty ? [] : LIST.map(member)),
|
||||
resolve: async id => {
|
||||
const org = LIST.find(o => o.id === id); if (!org) return null;
|
||||
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } };
|
||||
return {
|
||||
busHealthy: true,
|
||||
reads: { business: async () => ({ business: { id, human: 'jason', projects: DATA[id].projects.map(p => p.repo), appearance: { theme: org.theme } } }), settings: async () => ({ business: id, release: 'fixture', notifier: {}, launch: LAUNCH(id) }), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
|
||||
bus: { messages: async () => ({ messages: [], unread: 0 }), agents: async () => DATA[id].agents, 'thread.claims': async () => [], inbox: async () => DATA[id].inbox.map(d => ({ ...d, business: id, raised_by_run: 'run', routed_to: 'human' })), tasks: async () => [], instances: async () => ({ business: id, revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {}, instances: instances(id) }) },
|
||||
chat: { projects: async () => DATA[id].projects, threads: async () => [] },
|
||||
};
|
||||
},
|
||||
};
|
||||
return { organizations, agentLevels: { loadRoot: () => root, rolesDir, ctx: { configDir: '/cfg', dataRoot: '/data', instance: 'test' } } };
|
||||
}
|
||||
|
||||
async function setup(t, { viewport = [1440, 900], path = '/', empty = false, init = null, bootless = false } = {}) {
|
||||
const { organizations, agentLevels } = await fixture({ empty, bootless });
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, agentLevels, log: () => {} });
|
||||
t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
await b.viewport(...viewport);
|
||||
// `init` runs before the page's own scripts, under the display.js fetch wrapper, so it sees the scoped URL.
|
||||
if (init) await b.call('Page.addScriptToEvaluateOnNewDocument', { source: init });
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
await b.navigate(`${base}${path}`);
|
||||
return { b, base, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) };
|
||||
}
|
||||
|
||||
const rowText = org => `document.querySelector("[data-ws-orgrow=${org}]")?.innerText.replace(/\\s+/g, " ").trim() ?? ""`;
|
||||
|
||||
test('the bare address lands on #/dashboard without a reload and lists every organization with only the figures read', async t => {
|
||||
const { b, wait, text } = await setup(t);
|
||||
await wait('location.hash === "#/dashboard" && document.querySelectorAll("[data-ws-orgrow]").length === 3');
|
||||
// One page load: the landing route is set in place, never by location.reload().
|
||||
assert.equal(await b.evaluate('performance.getEntriesByType("navigation").length'), 1);
|
||||
assert.equal(await b.evaluate('performance.getEntriesByType("navigation")[0].type'), 'navigate');
|
||||
assert.equal(await text('#bus-view h1'), 'Dashboard');
|
||||
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]")?.getAttribute("href")'), '#/dashboard');
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-orgrow]")].map(r => r.dataset.wsOrgrow)'), ['acme', 'beta', 'gamma']);
|
||||
// Agents from the levels read, projects and needs-you from the workspace read.
|
||||
await wait(`${rowText('acme')}.includes("3 agents")`);
|
||||
const acme = await b.evaluate(rowText('acme'));
|
||||
assert.match(acme, /Acme Studio/);
|
||||
assert.match(acme, /bus running/);
|
||||
assert.match(acme, /2 projects/);
|
||||
assert.match(acme, /1 needs you/);
|
||||
assert.match(await b.evaluate(rowText('beta')), /1 agent\b/);
|
||||
// gamma: down with its reason; its agent count comes from its file, the bus figures are not read.
|
||||
const gamma = await b.evaluate(rowText('gamma'));
|
||||
assert.match(gamma, /bus stopped/);
|
||||
assert.match(gamma, /stopped by the owner/);
|
||||
assert.match(gamma, /2 agents/);
|
||||
assert.doesNotMatch(gamma, /\d+ projects?|\d+ needs you/);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-ws-orgrow=beta] a")?.getAttribute("href")'), '#/orgs/beta/organization');
|
||||
// A system route marks no organization in the sidebar and shows no breadcrumb.
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#workspace-nav .sel, #workspace-nav [aria-current]").length'), 0);
|
||||
assert.equal(await b.evaluate('document.querySelector(".ws-crumb")'), null);
|
||||
// The top bar names no organization.
|
||||
assert.equal(await b.evaluate('document.getElementById("current-organization")'), null);
|
||||
});
|
||||
|
||||
test('an organization opens inside the same shell with a breadcrumb, and its pages keep reading that organization', async t => {
|
||||
const { b, wait, text } = await setup(t);
|
||||
await wait('document.querySelectorAll("[data-ws-orgrow]").length === 3');
|
||||
await b.evaluate('window.__marker = 1; window.__bar = document.querySelector(".cmdbar"); window.__side = document.getElementById("workspace-nav")');
|
||||
await b.evaluate('document.querySelector("[data-ws-orgrow=acme] a").click()');
|
||||
await wait('location.hash === "#/orgs/acme/organization" && document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"');
|
||||
assert.equal(await b.evaluate('window.__marker'), 1);
|
||||
assert.equal(await b.evaluate('window.__bar === document.querySelector(".cmdbar") && window.__side === document.getElementById("workspace-nav")'), true);
|
||||
assert.equal(await b.evaluate('performance.getEntriesByType("navigation").length'), 1);
|
||||
assert.equal(await b.evaluate('document.querySelector(".ws-crumb")?.innerText.replace(/\\s+/g, " ").trim()'), 'Dashboard › Acme Studio');
|
||||
assert.equal(await b.evaluate('document.querySelector(".ws-crumb a")?.getAttribute("href")'), '#/dashboard');
|
||||
assert.equal(await b.evaluate('document.getElementById("current-organization")'), null);
|
||||
// The sidebar marks the open organization.
|
||||
await wait('document.querySelector("#workspace-nav a[aria-current=page]")?.getAttribute("href") === "#/orgs/acme/organization"');
|
||||
|
||||
// The sidebar opens another organization in place; its page reads that organization's routes.
|
||||
await b.evaluate('performance.clearResourceTimings(); document.querySelector("#workspace-nav a[href=\'#/orgs/beta/organization\']").click()');
|
||||
await wait('location.hash === "#/orgs/beta/organization" && document.querySelector("[data-org-dashboard] h1")?.textContent === "Beta Works"');
|
||||
assert.equal(await b.evaluate('window.__marker'), 1);
|
||||
// #1873 SP5: #/agents is the system Agents page, not beta's; it keeps the address and opens no organization.
|
||||
await b.evaluate('location.hash = "#/agents"');
|
||||
await wait('location.hash === "#/agents" && document.querySelector("[data-ws-agents]:not([data-ws-scope])")');
|
||||
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'beta');
|
||||
// A page inside beta scopes its reads to beta.
|
||||
await b.evaluate('location.hash = "#/orgs/beta/agents"');
|
||||
await wait('location.hash === "#/orgs/beta/agents" && document.querySelector("[data-org-agents]")');
|
||||
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'beta');
|
||||
assert.equal(await b.evaluate('window.__marker'), 1);
|
||||
// The organization's control board is named as such after the organization, not as a second Dashboard.
|
||||
await b.evaluate('location.hash = "#/orgs/beta/board"');
|
||||
await wait('/^Dashboard\\/.+\\/Control board$/.test(document.getElementById("breadcrumbs")?.textContent ?? "")');
|
||||
assert.equal(await b.evaluate('document.querySelector("#breadcrumbs [data-crumb-org]")?.getAttribute("href")'), '#/orgs/beta/organization');
|
||||
|
||||
// The breadcrumb goes back to the system dashboard, still without a reload.
|
||||
await b.evaluate('location.hash = "#/orgs/beta/organization"');
|
||||
await wait('document.querySelector(".ws-crumb a")');
|
||||
await b.evaluate('document.querySelector(".ws-crumb a").click()');
|
||||
await wait('location.hash === "#/dashboard" && document.querySelectorAll("[data-ws-orgrow]").length === 3');
|
||||
assert.equal(await b.evaluate('window.__marker'), 1);
|
||||
assert.equal(await b.evaluate('performance.getEntriesByType("navigation").length'), 1);
|
||||
assert.equal(await text('#bus-view h1'), 'Dashboard');
|
||||
});
|
||||
|
||||
test('an old #/orgs/<id>/... link still opens that page', async t => {
|
||||
const { b, wait } = await setup(t, { path: '/#/orgs/beta/agents' });
|
||||
await wait('document.querySelector("[data-org-agents]")');
|
||||
assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/agents');
|
||||
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'beta');
|
||||
assert.equal(await b.evaluate('performance.getEntriesByType("navigation").length'), 1);
|
||||
});
|
||||
|
||||
test('a stopped organization says so on its page and on its agents, with the reason', async t => {
|
||||
const { b, wait } = await setup(t, { path: '/#/orgs/gamma/organization' });
|
||||
await wait('document.querySelector("[data-org-dashboard] [data-org-down]")');
|
||||
const down = await b.evaluate('document.querySelector("[data-org-down]").innerText.replace(/\\s+/g, " ")');
|
||||
assert.match(down, /The bus is not running/);
|
||||
assert.match(down, /stopped by the owner/);
|
||||
await b.evaluate('location.hash = "#/orgs/gamma/agents"');
|
||||
await wait('document.querySelector("[data-org-agents] [data-org=gamma] [data-agents-why]")');
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-org=gamma] [data-agents-why]").innerText.trim()'), 'Agents not read: the bus is stopped');
|
||||
const agents = await b.evaluate('document.querySelector("[data-org-agents]").innerText.replace(/\\s+/g, " ")');
|
||||
assert.doesNotMatch(agents, /Agents unavailable/);
|
||||
});
|
||||
|
||||
test('at 390 px the dashboard lists the organizations without a horizontal scroll', async t => {
|
||||
const { b, wait } = await setup(t, { viewport: [390, 844] });
|
||||
await wait('document.querySelectorAll("[data-ws-orgrow]").length === 3');
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= window.innerWidth'), true);
|
||||
});
|
||||
|
||||
// Review round finding 1: with no organization yet the page keeps its first-run addresses, so the
|
||||
// wordmark (href "#/") opens the first-run board, not a #/dashboard the first-run page has no view for.
|
||||
test('first run: the wordmark opens the first-run board, not a Not found page', async t => {
|
||||
const { b, wait } = await setup(t, { path: '/#/settings/appearance', empty: true });
|
||||
await wait('document.querySelector("#bus-view h1")');
|
||||
await b.evaluate('document.querySelector(".wordmark").click()');
|
||||
// The board route empties the page view; give the router and render time to settle before checking.
|
||||
await wait('location.hash !== "#/settings/appearance"');
|
||||
await new Promise(r => setTimeout(r, 800));
|
||||
assert.equal(await b.evaluate('location.hash'), '#/');
|
||||
assert.equal(await b.evaluate('!!document.querySelector("#bus-view .nf")'), false);
|
||||
assert.equal(await b.evaluate('window.MosaicOrg'), undefined);
|
||||
});
|
||||
|
||||
test('a switch stops following an agent request: no poll reaches the next organization and its page shows no receipt', async t => {
|
||||
const ID = '11111111-2222-4333-8444-555555555555';
|
||||
const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })';
|
||||
const init = `window.__reads = []; window.__posts = []; const original = window.fetch;
|
||||
window.fetch = async (url, options = {}) => { const s = String(url); let x;
|
||||
if (/^\\/api\\/orgs\\/[^/]+\\/config$/.test(s)) { const r = await original(url, options), body = await r.json(); x = { ...body, send: true }; return ${json}; }
|
||||
if (options.method === 'POST' && /\\/agent\\/launch$/.test(s)) { __posts.push(s); x = { request: '${ID}', at: 'x' }; return ${json}; }
|
||||
if (/\\/agent\\/request\\?/.test(s)) { __reads.push(s); x = { request: '${ID}', action: 'launch', instance: 'reviewer', state: 'open', done: null, cancelled: null }; return ${json}; }
|
||||
return original(url, options); };`;
|
||||
const { b, wait } = await setup(t, { path: '/#/orgs/acme/settings/agents', init });
|
||||
const launch = '#agents-section [data-agent-action=launch][data-agent-name=reviewer]';
|
||||
await wait(`document.querySelector(${JSON.stringify(launch)}) && !document.querySelector(${JSON.stringify(launch)}).disabled`);
|
||||
await b.evaluate(`document.querySelector(${JSON.stringify(launch)}).click()`);
|
||||
await wait('window.__reads.length >= 1');
|
||||
assert.deepEqual(await b.evaluate('window.__posts'), ['/api/orgs/acme/agent/launch']);
|
||||
assert.match(await b.evaluate('window.__reads[0]'), /^\/api\/orgs\/acme\/agent\/request\?id=/);
|
||||
await b.evaluate('location.hash = "#/orgs/beta/settings/agents"');
|
||||
await wait('location.hash === "#/orgs/beta/settings/agents" && document.querySelector("#agents-section [data-agent-name=chief]") && !document.querySelector("#agents-section [data-agent-name=reviewer]")');
|
||||
const seen = await b.evaluate('window.__reads.length');
|
||||
// Two poll periods pass on beta's page.
|
||||
await new Promise(r => setTimeout(r, 4500));
|
||||
const reads = await b.evaluate('window.__reads');
|
||||
assert.equal(reads.filter(u => u.includes('/orgs/beta/')).length, 0, reads.join(' '));
|
||||
assert.equal(reads.length, seen, 'the poll stopped at the switch');
|
||||
assert.equal(await b.evaluate('document.querySelector("#agents-receipt")?.textContent.trim() ?? ""'), '');
|
||||
assert.equal(await b.evaluate('document.querySelector("#agents-section [data-agent-action=launch][data-agent-name=chief]")?.disabled'), false);
|
||||
});
|
||||
|
||||
test('a save still in flight at a switch: its answer is not drawn on the next organization and its Save is not held', async t => {
|
||||
const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })';
|
||||
// acme's settings POST is held until the test releases it, then answers with acme's saved values.
|
||||
const init = `window.__posts = []; const original = window.fetch;
|
||||
window.fetch = async (url, options = {}) => { const s = String(url); let x;
|
||||
if (/^\\/api\\/orgs\\/[^/]+\\/config$/.test(s)) { const r = await original(url, options), body = await r.json(); x = { ...body, send: true }; return ${json}; }
|
||||
if (options.method === 'POST' && s === '/api/orgs/acme/settings') { __posts.push(JSON.parse(options.body)); await new Promise(r => { window.__release = r; });
|
||||
x = { business: 'acme', revision: 'c'.repeat(64), models: { opus: { provider: 'anthropic', model: 'claude-opus-5-5' } }, limits: { total: 5 }, instances: {}, usage: { total: 0, providers: {}, models: {} } }; return ${json}; }
|
||||
return original(url, options); };`;
|
||||
const { b, wait } = await setup(t, { path: '/#/orgs/acme/settings/limits', init });
|
||||
const total = '#launch-settings-form input[data-setting=\'["total"]\']';
|
||||
const submit = '#launch-settings-form button[type=submit]';
|
||||
await wait(`document.querySelector(${JSON.stringify(total)})?.value === "4" && !document.querySelector(${JSON.stringify(submit)}).disabled`);
|
||||
await b.evaluate(`(() => { const i = document.querySelector(${JSON.stringify(total)}); i.value = "5"; i.dispatchEvent(new Event("input", { bubbles: true })); document.querySelector(${JSON.stringify(submit)}).click(); })()`);
|
||||
await wait('typeof window.__release === "function"');
|
||||
assert.equal(await b.evaluate('window.__posts[0]?.revision'), 'a'.repeat(64));
|
||||
await b.evaluate('location.hash = "#/orgs/beta/settings/limits"');
|
||||
await wait(`location.hash === "#/orgs/beta/settings/limits" && document.querySelector(${JSON.stringify(total)})?.value === "7"`);
|
||||
// beta's Save is its own: not held by acme's write.
|
||||
assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(submit)}).disabled`), false);
|
||||
await b.evaluate('window.__release()');
|
||||
await new Promise(r => setTimeout(r, 700));
|
||||
assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/settings/limits');
|
||||
assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(total)})?.value`), '7');
|
||||
assert.equal(await b.evaluate('document.getElementById("launch-settings-receipt")?.textContent.trim() ?? ""'), '');
|
||||
assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(submit)}).disabled`), false);
|
||||
});
|
||||
|
||||
test('a down banner does not repeat its heading as the reason', async t => {
|
||||
// No boot record: the server's reason is its fallback, "the bus is not running", the heading's own words.
|
||||
const { b, wait } = await setup(t, { path: '/#/orgs/gamma/organization', bootless: true });
|
||||
await wait('document.querySelector("[data-org-dashboard] [data-org-down]")');
|
||||
const down = await b.evaluate('document.querySelector("[data-org-down]").innerText.replace(/\\s+/g, " ")');
|
||||
assert.match(down, /The bus is not running\./);
|
||||
assert.equal((down.match(/the bus is not running/gi) || []).length, 1, down);
|
||||
assert.doesNotMatch(down, /Reason:|No reason was read/);
|
||||
});
|
||||
|
||||
test('a restart started on one organization finishes on that organization after a switch', { timeout: 60000 }, async t => {
|
||||
const END = '11111111-2222-4333-8444-5555555555e1', LAUNCHED = '11111111-2222-4333-8444-5555555555a1';
|
||||
const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })';
|
||||
// Every agent request read and write is recorded, whichever organization it names; the bus answers
|
||||
// are stubs (terminate, closed requests, one launch). Only acme's instances read is stubbed and
|
||||
// recorded (the session gone): beta's own page reads beta's instances, which is not the chain.
|
||||
const init = `window.__chain = []; const original = window.fetch;
|
||||
window.fetch = async (url, options = {}) => { const s = String(url); let x;
|
||||
if (options.method === 'POST' && /\\/agent\\/terminate$/.test(s)) { __chain.push(s); x = { request: '${END}' }; return ${json}; }
|
||||
if (options.method === 'POST' && /\\/agent\\/launch$/.test(s)) { __chain.push(s); x = { request: '${LAUNCHED}' }; return ${json}; }
|
||||
if (/\\/agent\\/request\\?/.test(s)) { __chain.push(s.split('?')[0]); const id = new URL(s, location.href).searchParams.get('id');
|
||||
x = { request: id, instance: 'coder', action: id === '${END}' ? 'end' : 'launch', state: 'closed', done: { run: id === '${END}' ? null : 'run-9' }, cancelled: null }; return ${json}; }
|
||||
if (s === '/api/orgs/acme/bus/instances' && window.__restarting) { __chain.push(s); x = { rows: [{ instances: { coder: { running: [] } } }] }; return ${json}; }
|
||||
return original(url, options); };`;
|
||||
const { b, wait } = await setup(t, { path: '/#/orgs/acme/organization', init });
|
||||
await wait('window.MosaicAgents && document.querySelector("[data-org-dashboard] h1")');
|
||||
// The button the Routing receipt offers (agents.js restartMarkup), placed on acme's page.
|
||||
await b.evaluate('window.__restarting = true; document.querySelector("[data-org-dashboard]").insertAdjacentHTML("beforeend", window.MosaicAgents.restartMarkup("coder")); document.querySelector("[data-agent-restart=coder]").click()');
|
||||
await wait('window.__chain.length >= 1');
|
||||
await b.evaluate('location.hash = "#/orgs/beta/organization"');
|
||||
await wait('location.hash === "#/orgs/beta/organization"');
|
||||
// Done when the chain has launched and read the launch request back, or as soon as any of it names beta.
|
||||
await wait('window.__chain.some(u => u.includes("/orgs/beta/")) || (window.__chain.some(u => /\\/agent\\/launch$/.test(u)) && window.__chain.filter(u => /\\/agent\\/request$/.test(u)).length >= 2)', 30000);
|
||||
const chain = await b.evaluate('window.__chain');
|
||||
assert.deepEqual([...new Set(chain)], ['/api/orgs/acme/agent/terminate', '/api/orgs/acme/agent/request', '/api/orgs/acme/bus/instances', '/api/orgs/acme/agent/launch'], chain.join(' '));
|
||||
// beta's page draws nothing of acme's chain.
|
||||
assert.equal(await b.evaluate('!!document.querySelector("[data-agent-restart-status=coder]")'), false);
|
||||
});
|
||||
|
||||
test('a file save still in flight at a switch: its outcome is not announced on the next organization', async t => {
|
||||
const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })';
|
||||
// acme's file reads are stubbed; its save POST is held until the test releases it, then answers with a commit.
|
||||
const init = `window.__posts = []; const original = window.fetch;
|
||||
window.fetch = async (url, options = {}) => { const s = String(url); let x;
|
||||
if (/^\\/api\\/orgs\\/[^/]+\\/config$/.test(s)) { const r = await original(url, options), body = await r.json(); x = { ...body, send: true, edit: true }; return ${json}; }
|
||||
if (s.startsWith('/api/orgs/acme/project-file?')) { x = { path: 'docs/a.md', commit: 'a'.repeat(40), content: '# A\\n', at: new Date().toISOString() }; return ${json}; }
|
||||
if (options.method === 'POST' && s === '/api/orgs/acme/project-file') { __posts.push(JSON.parse(options.body)); await new Promise(r => { window.__release = r; });
|
||||
x = { path: 'docs/a.md', commit: 'c'.repeat(40) }; return ${json}; }
|
||||
return original(url, options); };`;
|
||||
const { b, wait } = await setup(t, { path: `/#/orgs/acme/docs/site/${encodeURIComponent('docs/a.md')}`, init });
|
||||
await wait('document.querySelector("#doc-form")');
|
||||
if (!await b.evaluate('!!document.querySelector("#doc-text")')) await b.evaluate('document.querySelector("#doc-mode-source").click(), true');
|
||||
await wait('document.querySelector("#doc-text")?.value === "# A\\n"');
|
||||
await b.evaluate('(() => { const a = document.querySelector("#doc-text"); a.value = "# A\\n\\nmore\\n"; a.dispatchEvent(new Event("input", { bubbles: true })); document.querySelector("#doc-form").requestSubmit(); })()');
|
||||
await wait('typeof window.__release === "function"');
|
||||
assert.equal(await b.evaluate('window.__posts[0]?.base'), 'a'.repeat(40));
|
||||
await b.evaluate('location.hash = "#/orgs/beta/organization"');
|
||||
await wait('location.hash === "#/orgs/beta/organization" && document.querySelector("[data-org-dashboard] h1")?.textContent === "Beta Works"');
|
||||
await b.evaluate('window.__release()');
|
||||
await new Promise(r => setTimeout(r, 700));
|
||||
assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/organization');
|
||||
assert.doesNotMatch(await b.evaluate('document.getElementById("announce")?.textContent ?? ""'), /Saved as commit|Draft kept/);
|
||||
});
|
||||
|
||||
// SP2 item 1: an organization's own bare address is its page, not the system dashboard.
|
||||
test('a bare #/orgs/<id> opens that organization\'s page', async t => {
|
||||
const { b, wait } = await setup(t, { path: '/#/orgs/beta' });
|
||||
await wait('location.hash === "#/orgs/beta/organization" && document.querySelector("[data-org-dashboard] h1")?.textContent === "Beta Works"');
|
||||
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'beta');
|
||||
// In place too, from another organization's page.
|
||||
await b.evaluate('location.hash = "#/orgs/acme"');
|
||||
await wait('location.hash === "#/orgs/acme/organization" && document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"');
|
||||
assert.equal(await b.evaluate('performance.getEntriesByType("navigation").length'), 1);
|
||||
// The old per-organization dashboard address is still the system dashboard.
|
||||
await b.evaluate('location.hash = "#/orgs/acme/dashboard"');
|
||||
await wait('location.hash === "#/dashboard" && document.querySelectorAll("[data-ws-orgrow]").length === 3');
|
||||
});
|
||||
|
||||
// SP2 item 2: the system dashboard shows the system theme, never the organization opened last.
|
||||
test('the dashboard keeps the system theme after an organization with its own theme was opened', async t => {
|
||||
const { b, base, wait } = await setup(t, { path: '/#/orgs/acme/organization' });
|
||||
await wait('document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio" && document.documentElement.dataset.palette === "cobalt"');
|
||||
await b.evaluate('location.hash = "#/dashboard"');
|
||||
await wait('document.querySelectorAll("[data-ws-orgrow]").length === 3');
|
||||
await wait('document.documentElement.dataset.palette === "harbor"');
|
||||
assert.equal(await b.evaluate('window.MosaicAppearance.state().organization'), null);
|
||||
assert.equal(await b.evaluate('document.getElementById("org-cue").hidden'), true);
|
||||
// A fresh load of the bare address, with acme stored as the last organization shown, starts on the system theme.
|
||||
await b.navigate(`${base}/`);
|
||||
await wait('location.hash === "#/dashboard" && document.querySelectorAll("[data-ws-orgrow]").length === 3');
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
assert.equal(await b.evaluate('document.documentElement.dataset.palette'), 'harbor');
|
||||
// A choice made on the dashboard is the system's own: acme keeps its theme, and the dashboard keeps the choice.
|
||||
await b.evaluate('window.MosaicAppearance.set({ mode: "light" })');
|
||||
assert.equal(await b.evaluate('document.documentElement.dataset.mode'), 'light');
|
||||
await b.evaluate('location.hash = "#/orgs/acme/organization"');
|
||||
await wait('document.documentElement.dataset.palette === "cobalt"');
|
||||
assert.equal(await b.evaluate('document.documentElement.dataset.mode'), 'dark');
|
||||
await b.evaluate('location.hash = "#/dashboard"');
|
||||
await wait('document.documentElement.dataset.palette === "harbor" && document.documentElement.dataset.mode === "light"');
|
||||
});
|
||||
|
||||
// SP2 item 3: a stopped organization's page shows one bus banner, with the restart action and what can't be read.
|
||||
test('a stopped organization\'s page shows one bus banner, with the restart action', async t => {
|
||||
const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })';
|
||||
const init = `const original = window.fetch;
|
||||
window.fetch = async (url, options = {}) => { const s = String(url); let x;
|
||||
if (/^\\/api\\/(orgs\\/[^/]+\\/)?config$/.test(s)) { const r = await original(url, options), body = await r.json(); x = { ...body, orgSetup: true }; return ${json}; }
|
||||
return original(url, options); };`;
|
||||
const { b, wait } = await setup(t, { path: '/#/orgs/gamma/organization', init });
|
||||
await wait('document.querySelector("[data-org-dashboard] [data-org-down] [data-bus-restart]")');
|
||||
// Both shell painters (display.js on the list read, app.js on its own read) have had their turn.
|
||||
await b.evaluate('window.MosaicOrg.list');
|
||||
await new Promise(r => setTimeout(r, 800));
|
||||
const banners = 'Array.from(document.querySelectorAll(".banner")).filter(e => !e.hidden && e.getClientRects().length && /bus/i.test(e.textContent)).map(e => e.innerText.replace(/\\s+/g, " ").trim())';
|
||||
const shown = await b.evaluate(banners);
|
||||
assert.equal(shown.length, 1, shown.join(' | '));
|
||||
assert.match(shown[0], /The bus is not running\. Nothing about Gamma Home's agents or packs can be read until it starts\./);
|
||||
assert.match(shown[0], /Restart the bus/);
|
||||
assert.equal(await b.evaluate('document.getElementById("bus-down").hidden'), true);
|
||||
// SP7 item 7: one restart control on the page, the banner's; the header draws no second one.
|
||||
const restarts = 'Array.from(document.querySelectorAll("[data-bus-restart], [data-bus-restart-off]")).filter(e => e.getClientRects().length).map(e => e.closest("[data-org-down]") ? "banner" : "header")';
|
||||
assert.deepEqual(await b.evaluate(restarts), ['banner']);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-org-dashboard] .ws-orghead [data-dashboard-bus]")'), null);
|
||||
// The organization's other pages keep the shell's banner, with its restart.
|
||||
await b.evaluate('location.hash = "#/orgs/gamma/agents"');
|
||||
await wait('!document.getElementById("bus-down").hidden && /Bus unavailable/.test(document.getElementById("bus-down").textContent)');
|
||||
// And back on the organization page it gives way again.
|
||||
await b.evaluate('location.hash = "#/orgs/gamma/organization"');
|
||||
await wait('document.querySelector("[data-org-dashboard] [data-org-down]") && document.getElementById("bus-down").hidden');
|
||||
});
|
||||
|
||||
// SP2 item 4: the dashboard row for a stopped organization says it once.
|
||||
test('a stopped organization\'s dashboard row does not repeat its state as the reason', async t => {
|
||||
const { b, wait } = await setup(t, { bootless: true });
|
||||
await wait('document.querySelectorAll("[data-ws-orgrow]").length === 3');
|
||||
await wait(`${rowText('gamma')}.includes("bus stopped")`);
|
||||
const gamma = await b.evaluate(rowText('gamma'));
|
||||
assert.doesNotMatch(gamma, /the bus is not running/i, gamma);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-ws-orgrow=gamma] .why")'), null);
|
||||
});
|
||||
|
||||
// SP2 item 6: no status-badge glyph ("?") is drawn before the live dot in the dashboard's Active agents.
|
||||
test('the dashboard\'s active agents draw no stray glyph before the status dot', async t => {
|
||||
const { b, wait } = await setup(t);
|
||||
await wait('document.querySelector("[data-ws-sessions] tbody tr")');
|
||||
const cells = await b.evaluate('Array.from(document.querySelectorAll("[data-ws-sessions] tbody td")).map(td => getComputedStyle(td, "::before").content)');
|
||||
assert.ok(cells.length >= 2, String(cells));
|
||||
for (const c of cells) assert.ok(c === 'none' || c === 'normal', `generated content ${c}`);
|
||||
});
|
||||
|
||||
// SP2 item 7 (the test SP1 lacks): a read still in flight when the organization switches keeps nothing.
|
||||
// acme's settings read is held across a switch to beta and then answers with a figure only it has (99);
|
||||
// beta's own read then fails, so beta's page draws from what was kept. Nothing of acme's may be there.
|
||||
test('a read still in flight at a switch: its answer is not kept for the next organization', async t => {
|
||||
const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })';
|
||||
const init = `window.__acme = []; window.__beta = []; const original = window.fetch;
|
||||
window.fetch = async (url, options = {}) => { const s = String(url); let x;
|
||||
if (s === '/api/orgs/acme/settings' && !options.method && window.__holdAcme) { await new Promise(r => window.__acme.push(r));
|
||||
const r = await original(url, options), body = await r.json(); x = { ...body, launch: { ...body.launch, limits: { total: 99 } } }; return ${json}; }
|
||||
if (s === '/api/orgs/beta/settings' && !options.method && window.__holdBeta) { await new Promise(r => window.__beta.push(r));
|
||||
return new Response(JSON.stringify({ error: 'unavailable', message: 'beta settings read failed' }), { status: 500, headers: { 'content-type': 'application/json' } }); }
|
||||
return original(url, options); };`;
|
||||
const { b, wait } = await setup(t, { path: '/#/orgs/acme/settings/limits', init });
|
||||
const total = '#launch-settings-form input[data-setting=\'["total"]\']';
|
||||
await wait(`document.querySelector(${JSON.stringify(total)})?.value === "4"`);
|
||||
// A fresh read of acme's settings starts and is held.
|
||||
await b.evaluate('window.__holdAcme = true; location.hash = "#/orgs/acme/settings/tokens"');
|
||||
await wait('window.__acme.length >= 1');
|
||||
await b.evaluate('window.__holdBeta = true; location.hash = "#/orgs/beta/settings/limits"');
|
||||
await wait('location.hash === "#/orgs/beta/settings/limits" && window.__beta.length >= 1');
|
||||
// acme answers after the switch; then beta's read fails.
|
||||
await b.evaluate('window.__acme.forEach(r => r())');
|
||||
await new Promise(r => setTimeout(r, 400));
|
||||
await b.evaluate('window.__beta.forEach(r => r())');
|
||||
await wait('document.querySelector("#bus-view .banner")');
|
||||
await new Promise(r => setTimeout(r, 400));
|
||||
assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/settings/limits');
|
||||
assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(total)})?.value ?? null`), null, 'acme\'s held answer was drawn on beta');
|
||||
assert.equal(await b.evaluate('!!document.querySelector("#bus-view .s1-stale")'), false);
|
||||
assert.match(await b.evaluate('document.getElementById("bus-view").innerText'), /beta settings read failed/);
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
// SP5 (#1873, single pane slice 5): one Agents page for the whole system at #/agents. Every
|
||||
// organization that answers adds its agents to one table: name, role, state, what it works on, its
|
||||
// organization chip and a project chip when the work has a project. Filters by organization and state
|
||||
// live in the hash query. Fire on a row goes to the row's own organization, never to the one opened
|
||||
// last, and is offered only where that organization's own config says this Console decides. A stopped
|
||||
// organization is named at the top with its reason. #/orgs/<id>/agents shows the same table, scoped.
|
||||
// Every bus is a fake: acme and beta answer, gamma's bus is stopped. The real GET /api/workspace and
|
||||
// the real per-organization routes serve the page.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { 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();
|
||||
|
||||
const HIRE = '0f3c1a2b-4d5e-4f60-8a7b-9c0d1e2f3a4b';
|
||||
function fixture({ empty = false, unheld = false } = {}) {
|
||||
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 },
|
||||
];
|
||||
const DATA = {
|
||||
acme: {
|
||||
projects: [{ id: 'p-site', repo: 'site', name: 'Website', archived: false, at: at(30) }],
|
||||
// A built-in store task assigned to coder, filed under the Website project.
|
||||
tasks: [{ task_ref: 'store:1', source: 'store', at: at(20), fields: { title: 'Hero copy', state: 'in-progress', group: 'p-site', assignees: ['coder'] } }],
|
||||
agents: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'working', at: at(40), working: { since: at(40), messages: 2 } }],
|
||||
instances: { coder: { definition: 'coder', running: ['run-1'] }, chief: { definition: 'chief-of-staff', running: [] } },
|
||||
},
|
||||
beta: {
|
||||
projects: [{ id: 'p-shop', repo: 'shop', name: 'Shop', archived: false, at: at(120) }],
|
||||
tasks: [], agents: [{ role: 'pm', holder_run: 'run-9', harness: 'claude', state: 'waiting', at: at(15) }],
|
||||
instances: { pm: { definition: 'pm', running: ['run-9'] }, 'research-2': { definition: `org:hire-${HIRE}`, hireDecision: HIRE, running: [], pack: { id: 'coding', label: 'Coding' } } },
|
||||
},
|
||||
};
|
||||
if (empty) for (const d of Object.values(DATA)) { d.instances = {}; d.agents = []; }
|
||||
// `unheld`: a live designer run that holds no role session, so the bus reports no state for it.
|
||||
if (unheld) DATA.acme.instances.designer = { definition: 'designer', running: ['run-5'] };
|
||||
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 } },
|
||||
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),
|
||||
});
|
||||
return {
|
||||
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), appearance: { theme: org.theme } } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
|
||||
bus: {
|
||||
messages: async () => ({ messages: [], unread: 0 }), agents: async () => DATA[id].agents, 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [],
|
||||
instances: async () => ({ revision: 'a'.repeat(64), definitions: {}, instances: DATA[id].instances }),
|
||||
},
|
||||
chat: { projects: async () => DATA[id].projects, threads: async () => [] },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Runs before the page's scripts, so display.js wraps it and it sees each request's scoped URL. Every
|
||||
// organization's config says this Console decides there, except those named in `noDecide`; decision
|
||||
// POSTs are recorded and answered from a queue, so no real write happens.
|
||||
const stubFor = (noDecide = []) => `window.__noDecide = ${JSON.stringify(noDecide)};\n${STUB}`;
|
||||
const STUB = `(() => {
|
||||
const real = window.fetch.bind(window);
|
||||
window.__posts = []; window.__answers = [];
|
||||
const answer = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
||||
window.fetch = async (url, init = {}) => {
|
||||
const u = String(url);
|
||||
if (/^\\/api\\/(?:orgs\\/[^/]+\\/)?config$/.test(u)) {
|
||||
const c = await (await real(url, init)).json();
|
||||
const org = (u.match(/^\\/api\\/orgs\\/([^/]+)\\//) || [])[1];
|
||||
return answer(200, { ...c, send: true, decide: !window.__noDecide.includes(org) });
|
||||
}
|
||||
if (!u.includes('/decisions/')) return real(url, init);
|
||||
window.__posts.push({ url: u, method: init.method, body: init.body });
|
||||
const a = window.__answers.shift();
|
||||
if (!a) throw new TypeError('no answer queued');
|
||||
return answer(a.status ?? 200, a.body);
|
||||
};
|
||||
})();`;
|
||||
|
||||
async function setup(t, { viewport = [1440, 900], hash = '#/agents', empty = false, unheld = false, stub = false, noDecide = [] } = {}) {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ empty, unheld }), log: () => {} });
|
||||
t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
await b.viewport(...viewport);
|
||||
if (stub) await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stubFor(noDecide) });
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
await b.navigate(`${base}/${hash}`);
|
||||
return { b, base, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) };
|
||||
}
|
||||
const rows = 'JSON.stringify([...document.querySelectorAll("[data-ws-agent]")].map(r => r.dataset.wsAgent))';
|
||||
const are = list => `${rows} === ${JSON.stringify(JSON.stringify(list))}`;
|
||||
const trail = '(document.getElementById("breadcrumbs").getClientRects().length ? document.getElementById("breadcrumbs").innerText.replace(/\\s+/g, " ").trim() : null)';
|
||||
const ALL = ['acme:chief', 'acme:coder', 'beta:pm', 'beta:research-2'];
|
||||
|
||||
test('#/agents lists both organizations\' agents in one table, each row with its organization chip', async t => {
|
||||
const { b, wait, text } = await setup(t);
|
||||
await wait(are(ALL));
|
||||
// A system route: the address keeps no organization and the sidebar Agents link is this page.
|
||||
assert.equal(await b.evaluate('location.hash.split("?")[0]'), '#/agents');
|
||||
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]")?.getAttribute("href")'), '#/agents');
|
||||
assert.equal(await text('#bus-view h1'), 'Agents');
|
||||
// SP7: the page name shows once, as the heading; a trail with nothing above it is not drawn.
|
||||
assert.equal(await b.evaluate(trail), null);
|
||||
assert.equal(await text('[data-ws-agent="acme:coder"] [data-ws-orgchip]'), 'Acme Studio');
|
||||
assert.equal(await text('[data-ws-agent="beta:pm"] [data-ws-orgchip]'), 'Beta Works');
|
||||
// State from the bus, and what the agent works on: its task, with the task's project chip.
|
||||
assert.match(await text('[data-ws-agent="acme:coder"]'), /working/);
|
||||
assert.match(await text('[data-ws-agent="beta:pm"]'), /waiting on you/);
|
||||
assert.match(await text('[data-ws-agent="acme:coder"] [data-ws-work]'), /Hero copy/);
|
||||
assert.equal(await text('[data-ws-agent="acme:coder"] [data-ws-projchip]'), 'Website');
|
||||
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] [data-ws-projchip]\')'), null);
|
||||
// A hired agent reads by its name and role; the definition id stays as secondary text.
|
||||
const hired = await b.evaluate('document.querySelector(\'[data-ws-agent="beta:research-2"] .t1\')?.firstChild?.textContent');
|
||||
assert.equal(hired, 'research-2');
|
||||
assert.match(await text('[data-ws-agent="beta:research-2"] [data-ws-role]'), /Hired.*Coding/);
|
||||
assert.match(await text('[data-ws-agent="beta:research-2"] .t2'), new RegExp(`org:hire-${HIRE}`));
|
||||
assert.match(await text('[data-ws-agent="acme:chief"] [data-ws-role]'), /Chief of staff/);
|
||||
// Each row opens the agent in its own organization.
|
||||
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] a[data-ws-open]\')?.getAttribute("href")'), '#/orgs/beta/agents?agent=pm');
|
||||
});
|
||||
|
||||
// SP11 (#1873): the page says how many of its rows run and how many work, in the words the Dashboard
|
||||
// tiles use. A live agent with no reported state is "Running, state unknown", not a bare "Running"
|
||||
// that reads as every running agent.
|
||||
test('#/agents counts its running and working agents, and names a running agent with no reported state', async t => {
|
||||
const { b, wait, text } = await setup(t, { unheld: true });
|
||||
await wait(are(['acme:chief', 'acme:coder', 'acme:designer', 'beta:pm', 'beta:research-2']));
|
||||
assert.match(await text('[data-ws-agents] .sub'), /^Every agent on the system, by organization\. 5 agents in 2 organizations: 3 running, 1 working\./);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-ws-astate=running]")?.innerText.replace(/\\s+/g, " ").trim()'), 'Running, state unknown 1');
|
||||
await b.evaluate('document.querySelector("[data-ws-astate=working]").click()');
|
||||
await wait(are(['acme:coder']));
|
||||
assert.match(await text('[data-ws-agents] .sub'), /1 agent in 1 organization: 1 running, 1 working\./);
|
||||
});
|
||||
|
||||
test('the organization and state filters live in the hash and survive a reload', async t => {
|
||||
const { b, base, wait } = await setup(t);
|
||||
await wait(are(ALL));
|
||||
await b.evaluate('document.querySelector("[data-ws-agents] [data-ws-org=beta]").click()');
|
||||
await wait(`location.hash.startsWith("#/agents?") && new URLSearchParams(location.hash.split("?")[1]).get("org") === "beta" && ${are(['beta:pm', 'beta:research-2'])}`);
|
||||
await b.navigate(`${base}/${await b.evaluate('location.hash')}`);
|
||||
await wait(`${are(['beta:pm', 'beta:research-2'])} && document.querySelector("[data-ws-org=beta]")?.getAttribute("aria-pressed") === "true"`);
|
||||
// State: one chip per state the rows carry.
|
||||
await b.evaluate('document.querySelector("[data-ws-agents] [data-ws-org=\'\']").click()');
|
||||
await wait(are(ALL));
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-astate]")].map(x => x.dataset.wsAstate)'), ['', 'working', 'waiting', 'not-running']);
|
||||
await b.evaluate('document.querySelector("[data-ws-astate=not-running]").click()');
|
||||
await wait(`new URLSearchParams(location.hash.split("?")[1]).get("state") === "not-running" && ${are(['acme:chief', 'beta:research-2'])}`);
|
||||
await b.navigate(`${base}/${await b.evaluate('location.hash')}`);
|
||||
await wait(`${are(['acme:chief', 'beta:research-2'])} && document.querySelector("[data-ws-astate=not-running]")?.getAttribute("aria-pressed") === "true"`);
|
||||
// A value the page does not know is dropped, never read as a filter.
|
||||
await b.evaluate('location.hash = "#/agents?state=bogus&org=Not%20An%20Org"');
|
||||
await wait(are(ALL));
|
||||
await b.evaluate('location.hash = "#/agents?org=beta&state=working"');
|
||||
await wait('document.querySelector("[data-ws-agent-list]")?.textContent === "Nothing matches these filters"');
|
||||
});
|
||||
|
||||
test('firing beta\'s agent from #/agents while acme was opened last reaches beta', async t => {
|
||||
const { b, wait, text } = await setup(t, { hash: '#/orgs/acme/organization', stub: true });
|
||||
await wait('document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"');
|
||||
await b.evaluate('location.hash = "#/agents"');
|
||||
await wait(`${are(ALL)} && document.querySelector('[data-ws-agent="beta:pm"] [data-agent-fire]')`);
|
||||
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'acme');
|
||||
// Never on the chief.
|
||||
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="acme:chief"] [data-agent-fire]\')'), null);
|
||||
await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] [data-agent-fire]\').click()');
|
||||
await wait('document.querySelector(\'[data-ws-agent="beta:pm"] [data-agent-fire-yes]\')');
|
||||
await b.evaluate('window.__answers.push({ body: { id: "dec-fire-1", op: "resolved", choice: "yes" } })');
|
||||
await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] [data-agent-fire-yes]\').click()');
|
||||
await wait('/Fired pm/.test(document.querySelector("[data-ws-agents] [data-agents-receipt]")?.textContent ?? "")');
|
||||
// The receipt names the organization by its label, not its id.
|
||||
assert.match(await text('[data-ws-agents] [data-agents-receipt]'), /^Fired pm in Beta Works\./);
|
||||
const posts = await b.evaluate('window.__posts');
|
||||
assert.deepEqual(posts.map(p => p.url), ['/api/orgs/beta/decisions/hire/start']);
|
||||
assert.deepEqual(JSON.parse(posts[0].body), { business: 'beta', hire: { op: 'fire', level: 'org', name: 'pm', reason: 'Fired from the Agents page.' } });
|
||||
assert.match(await text('[data-ws-agents] [data-agents-receipt]'), /dec-fire-1/);
|
||||
assert.equal(await b.evaluate('location.hash.split("?")[0]'), '#/agents');
|
||||
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'acme');
|
||||
});
|
||||
|
||||
test('Fire follows each organization\'s own config, not the organization opened last', async t => {
|
||||
// Acme is opened last and this Console decides there; beta's own config says it does not.
|
||||
const { b, wait } = await setup(t, { hash: '#/orgs/acme/organization', stub: true, noDecide: ['beta'] });
|
||||
await wait('document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"');
|
||||
await b.evaluate('location.hash = "#/agents"');
|
||||
await wait(`${are(ALL)} && document.querySelector('[data-ws-agent="acme:coder"] [data-agent-fire]')`);
|
||||
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] [data-agent-fire]\')'), null);
|
||||
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:research-2"] [data-agent-fire]\')'), null);
|
||||
});
|
||||
|
||||
test('a stopped organization is named at the top with its reason', async t => {
|
||||
const { b, wait, text } = await setup(t);
|
||||
await wait(are(ALL));
|
||||
const note = await text('[data-ws-unread]');
|
||||
assert.match(note, /Gamma Home/);
|
||||
assert.match(note, /stopped by the owner/);
|
||||
assert.equal(await b.evaluate('!!(document.querySelector("[data-ws-unread]").compareDocumentPosition(document.querySelector("[data-ws-agent-list]")) & Node.DOCUMENT_POSITION_FOLLOWING)'), true);
|
||||
});
|
||||
|
||||
test('with no agents anywhere the table says so and still names the stopped organization', async t => {
|
||||
const { wait, text } = await setup(t, { empty: true });
|
||||
await wait('document.querySelector("[data-ws-agent-list]")');
|
||||
assert.equal(await text('[data-ws-agent-list]'), 'No agents');
|
||||
assert.match(await text('[data-ws-unread]'), /Gamma Home.*stopped by the owner/s);
|
||||
});
|
||||
|
||||
test('#/orgs/<id>/agents shows that organization\'s rows only, with a link to every organization', async t => {
|
||||
const { b, wait, text } = await setup(t, { hash: '#/orgs/beta/agents' });
|
||||
await wait(are(['beta:pm', 'beta:research-2']));
|
||||
assert.match(await text('[data-ws-agents] .sub'), /Beta Works/);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-ws-agents] a[href=\'#/agents\']") !== null'), true);
|
||||
assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/agents');
|
||||
// SP7: a drill-in keeps its trail back to the dashboard and the organization.
|
||||
await wait(`${trail} === 'Dashboard / Beta Works / Agents'`);
|
||||
// The organization's own levels grid and held roles still follow, under the one page heading.
|
||||
await wait('document.querySelector("[data-org-agents]")');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view h1").length'), 1);
|
||||
});
|
||||
|
||||
test('Open addresses the agent: the scoped page puts it first, marks it and draws no Open of its own', async t => {
|
||||
const { b, wait } = await setup(t);
|
||||
await wait(are(ALL));
|
||||
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:research-2"] [data-ws-open]\').getAttribute("href")'), '#/orgs/beta/agents?agent=research-2');
|
||||
await b.evaluate('document.querySelector(\'[data-ws-agent="beta:research-2"] [data-ws-open]\').click()');
|
||||
await wait(are(['beta:research-2', 'beta:pm']));
|
||||
assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/agents?agent=research-2');
|
||||
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:research-2"]\').getAttribute("aria-current")'), 'true');
|
||||
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"]\').hasAttribute("aria-current")'), false);
|
||||
// No self-link on the scoped page.
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-agents] [data-ws-open]").length'), 0);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-agents] thead th").length'), 4);
|
||||
// An agent the organization does not have marks nothing.
|
||||
await b.evaluate('location.hash = "#/orgs/beta/agents?agent=nobody"');
|
||||
await wait(`${are(['beta:pm', 'beta:research-2'])} && !document.querySelector('[data-ws-agents] [aria-current]')`);
|
||||
});
|
||||
|
||||
test('Cancel puts focus back on the row\'s Fire, which is labelled with the organization\'s name', async t => {
|
||||
const { b, wait } = await setup(t, { stub: true });
|
||||
await wait(`${are(ALL)} && document.querySelector('[data-ws-agent="beta:pm"] [data-agent-fire]')`);
|
||||
const fire = '\'[data-ws-agent="beta:pm"] [data-agent-fire]\'';
|
||||
assert.equal(await b.evaluate(`document.querySelector(${fire}).getAttribute('aria-label')`), 'Fire pm in Beta Works');
|
||||
await b.evaluate(`document.querySelector(${fire}).click()`);
|
||||
await wait('document.querySelector(\'[data-ws-agent="beta:pm"] [data-agent-fire-no]\')');
|
||||
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] [data-agent-fire-confirm]\').getAttribute("aria-label")'), 'Fire pm in Beta Works?');
|
||||
await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] [data-agent-fire-no]\').click()');
|
||||
await wait(`document.activeElement === document.querySelector(${fire})`);
|
||||
assert.deepEqual(await b.evaluate('window.__posts'), []);
|
||||
});
|
||||
|
||||
test('a picked agent stays in the address through the state filter, and a scope that is not an organization id is refused', async t => {
|
||||
const { b, wait } = await setup(t, { hash: '#/orgs/beta/agents?agent=research-2' });
|
||||
await wait(`${are(['beta:research-2', 'beta:pm'])} && document.querySelector('[data-ws-agents] [data-ws-astate="waiting"]')`);
|
||||
await b.evaluate('document.querySelector(\'[data-ws-agents] [data-ws-astate="waiting"]\').click()');
|
||||
await wait(are(['beta:pm']));
|
||||
const q = new URLSearchParams((await b.evaluate('location.hash')).split('?')[1]);
|
||||
assert.equal(q.get('agent'), 'research-2');
|
||||
assert.equal(q.get('state'), 'waiting');
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-ws-agents] [aria-current]")'), null);
|
||||
await b.evaluate('document.querySelector(\'[data-ws-agents] [data-ws-astate=""]\').click()');
|
||||
await wait(`${are(['beta:research-2', 'beta:pm'])} && document.querySelector('[data-ws-agent="beta:research-2"][aria-current="true"]')`);
|
||||
const refused = await b.evaluate('window.MosaicWorkspace.agentsPage(new URLSearchParams(), { scope: "../beta" })');
|
||||
assert.match(refused, /data-ws-failed/);
|
||||
assert.doesNotMatch(refused, /data-ws-agent=/);
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
// SP3 (#1873, single pane slice 3): one Inbox for the whole system at #/inbox. Every organization
|
||||
// that answers adds its items to one list, newest first, each row with its organization chip and a
|
||||
// project chip when it has one. Filters by organization and type live in the hash query. An answer
|
||||
// given from the list goes to the row's own organization, never to the one opened last. A stopped
|
||||
// organization is named at the top with its reason. #/orgs/<id>/inbox is the same list, filtered.
|
||||
// Every bus is a fake: acme and beta answer, gamma's bus is stopped. The real GET /api/workspace
|
||||
// and the real per-organization routes serve the page.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { 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();
|
||||
|
||||
const ACME_DECISION = 'dec-acme-1';
|
||||
const BETA_DECISION = 'dec-beta-1';
|
||||
const DIGEST = 'd'.repeat(64);
|
||||
function fixture({ empty = false } = {}) {
|
||||
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 },
|
||||
];
|
||||
const DATA = {
|
||||
acme: {
|
||||
projects: [{ id: 'p-site', repo: 'site', name: 'Website', archived: false, at: at(30) }],
|
||||
tasks: [{ task_ref: 'local:1', project_key: 'site', at: at(20), fields: { title: 'Hero copy', done: false, bucket: 'Doing' } }],
|
||||
agents: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'working', at: at(40), working: { since: at(40) } }],
|
||||
inbox: [{ id: ACME_DECISION, question: 'Ship the hero?', class: 'gated', blocking: true, raised_by_role: 'coder', task_ref: 'local:1', at: at(10), options: [{ key: 'yes', text: 'Ship it' }, { key: 'no', text: 'Hold' }] }],
|
||||
},
|
||||
beta: {
|
||||
projects: [{ id: 'p-shop', repo: 'shop', name: 'Shop', archived: false, at: at(120) }],
|
||||
tasks: [], agents: [{ role: 'pm', holder_run: 'run-9', harness: 'claude', state: 'waiting', at: at(15) }],
|
||||
// Eligible for an answer in the Console: the bus marks it with its `console` field.
|
||||
inbox: [{ id: BETA_DECISION, authorization: { action: 'x.y', target: 'shop', approvalChoice: 'high' }, question: 'Pick a launch price', class: 'within-role', blocking: false, raised_by_role: 'pm', at: at(5), options: [{ key: 'low', text: 'Low' }, { key: 'high', text: 'High' }], console: { kind: 'decision', options: [{ key: 'low', text: 'Low' }, { key: 'high', text: 'High' }], digest: DIGEST } }],
|
||||
},
|
||||
};
|
||||
if (empty) { DATA.acme.inbox = []; DATA.beta.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 } },
|
||||
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),
|
||||
});
|
||||
return {
|
||||
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), appearance: { theme: org.theme } } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
|
||||
// The broker's inbox row: the decisions row, so `route_to`, plus `authorization`.
|
||||
bus: { messages: async () => ({ messages: [], unread: 0 }), agents: async () => [], 'thread.claims': async () => [], inbox: async () => DATA[id].inbox.map(d => ({ ...d, business: id, raised_by_run: 'run', route_to: 'human', authorization: d.authorization ?? null })), tasks: async () => [], instances: async () => ({ business: id, instances: {} }) },
|
||||
chat: { projects: async () => DATA[id].projects, threads: async () => [] },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Runs before the page's scripts, so display.js wraps it and it sees each request's scoped URL. Every
|
||||
// organization's config says this Console answers decisions, except those named in `noDecide`;
|
||||
// decision POSTs are recorded and answered from a queue, so no real write happens.
|
||||
const stubFor = (noDecide = []) => `window.__noDecide = ${JSON.stringify(noDecide)};\n${STUB}`;
|
||||
const STUB = `(() => {
|
||||
const real = window.fetch.bind(window);
|
||||
window.__posts = []; window.__answers = [];
|
||||
const answer = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
||||
window.fetch = async (url, init = {}) => {
|
||||
const u = String(url);
|
||||
if (/^\\/api\\/(?:orgs\\/[^/]+\\/)?config$/.test(u)) {
|
||||
const c = await (await real(url, init)).json();
|
||||
const org = (u.match(/^\\/api\\/orgs\\/([^/]+)\\//) || [])[1];
|
||||
return answer(200, { ...c, send: true, decide: !window.__noDecide.includes(org) });
|
||||
}
|
||||
if (!u.includes('/decisions/')) return real(url, init);
|
||||
window.__posts.push({ url: u, method: init.method, body: init.body });
|
||||
const a = window.__answers.shift();
|
||||
if (!a) throw new TypeError('no answer queued');
|
||||
return answer(a.status ?? 200, a.body);
|
||||
};
|
||||
})();`;
|
||||
|
||||
async function setup(t, { viewport = [1440, 900], hash = '#/inbox', empty = false, stub = false, noDecide = [] } = {}) {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ empty }), log: () => {} });
|
||||
t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
await b.viewport(...viewport);
|
||||
if (stub) await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stubFor(noDecide) });
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
await b.navigate(`${base}/${hash}`);
|
||||
return { b, base, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) };
|
||||
}
|
||||
const trail = '(document.getElementById("breadcrumbs").getClientRects().length ? document.getElementById("breadcrumbs").innerText.replace(/\\s+/g, " ").trim() : null)';
|
||||
const items = 'JSON.stringify([...document.querySelectorAll("[data-ws-item]")].map(r => r.dataset.wsItem))';
|
||||
|
||||
test('#/inbox lists both organizations in one list, newest first, each row with its organization and project chips', async t => {
|
||||
const { b, wait, text } = await setup(t);
|
||||
await wait(`${items} === ${JSON.stringify(JSON.stringify([`beta:${BETA_DECISION}`, `acme:${ACME_DECISION}`]))}`);
|
||||
// A system route: the address keeps no organization and the sidebar Inbox is this page.
|
||||
assert.equal(await b.evaluate('location.hash.split("?")[0]'), '#/inbox');
|
||||
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]")?.getAttribute("href")'), '#/inbox');
|
||||
assert.equal(await text('#bus-view h1'), 'Inbox');
|
||||
// SP7: the page name shows once, as the heading.
|
||||
assert.equal(await b.evaluate(trail), null);
|
||||
// Organization chips on every row; the project chip only where the item has a project.
|
||||
assert.equal(await text(`[data-ws-item="acme:${ACME_DECISION}"] [data-ws-orgchip]`), 'Acme Studio');
|
||||
assert.equal(await text(`[data-ws-item="beta:${BETA_DECISION}"] [data-ws-orgchip]`), 'Beta Works');
|
||||
assert.equal(await text(`[data-ws-item="acme:${ACME_DECISION}"] [data-ws-projchip]`), 'Website');
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-ws-item="beta:${BETA_DECISION}"] [data-ws-projchip]')`), null);
|
||||
// SP7: the class chip says the decision class in words, from the one map of class words.
|
||||
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-ws-item]')].map(r => r.querySelector('td.hide-m .ws-chip')?.textContent)`), ['Within role', 'Gated']);
|
||||
// The stopped organization is named at the top, before the list, with its reason.
|
||||
const note = await text('[data-ws-unread]');
|
||||
assert.match(note, /Gamma Home/);
|
||||
assert.match(note, /stopped by the owner/);
|
||||
assert.equal(await b.evaluate('!!(document.querySelector("[data-ws-unread]").compareDocumentPosition(document.querySelector("[data-ws-items]")) & Node.DOCUMENT_POSITION_FOLLOWING)'), true);
|
||||
// The sidebar Inbox count and the dashboard read the same figure.
|
||||
await wait('document.querySelector("#sections a[href=\'#/inbox\'] .ws-n")?.textContent === "2"');
|
||||
await b.evaluate('location.hash = "#/dashboard"');
|
||||
await wait('document.querySelector("[data-ws-tile=needs]")');
|
||||
assert.match(await text('[data-ws-tile=needs]'), /Needs you\s*2\s*items/i);
|
||||
const needs = await b.evaluate('[...document.querySelectorAll("[data-ws-needs] a.q")].map(a => a.getAttribute("href"))');
|
||||
assert.ok(needs.includes(`#/inbox?d=${encodeURIComponent(`beta:${BETA_DECISION}`)}`), JSON.stringify(needs));
|
||||
assert.ok(await b.evaluate('[...document.querySelectorAll("[data-ws-home] a")].some(a => a.getAttribute("href") === "#/inbox")'));
|
||||
});
|
||||
|
||||
test('the organization and type filters live in the hash and survive a reload', async t => {
|
||||
const { b, base, wait } = await setup(t);
|
||||
await wait('document.querySelectorAll("[data-ws-item]").length === 2');
|
||||
await b.evaluate('document.querySelector("[data-ws-org=beta]").click()');
|
||||
await wait(`location.hash.startsWith("#/inbox?") && new URLSearchParams(location.hash.split("?")[1]).get("org") === "beta" && ${items} === ${JSON.stringify(JSON.stringify([`beta:${BETA_DECISION}`]))}`);
|
||||
const filtered = await b.evaluate('location.hash');
|
||||
await b.navigate(`${base}/${filtered}`);
|
||||
await wait(`${items} === ${JSON.stringify(JSON.stringify([`beta:${BETA_DECISION}`]))} && document.querySelector("[data-ws-org=beta]")?.getAttribute("aria-pressed") === "true"`);
|
||||
// Type: the classes the read carries, each a chip; choosing one narrows the list.
|
||||
await b.evaluate('document.querySelector("[data-ws-org=\'\']").click()');
|
||||
await wait('document.querySelectorAll("[data-ws-item]").length === 2');
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-type]")].map(x => x.dataset.wsType)'), ['', 'gated', 'within-role']);
|
||||
await b.evaluate('document.querySelector("[data-ws-type=gated]").click()');
|
||||
await wait(`new URLSearchParams(location.hash.split("?")[1]).get("type") === "gated" && ${items} === ${JSON.stringify(JSON.stringify([`acme:${ACME_DECISION}`]))}`);
|
||||
await b.navigate(`${base}/${await b.evaluate('location.hash')}`);
|
||||
await wait(`${items} === ${JSON.stringify(JSON.stringify([`acme:${ACME_DECISION}`]))} && document.querySelector("[data-ws-type=gated]")?.getAttribute("aria-pressed") === "true"`);
|
||||
// Both at once with nothing left says so plainly.
|
||||
await b.evaluate('location.hash = "#/inbox?org=beta&type=gated"');
|
||||
await wait('document.querySelector("[data-ws-items]")?.textContent === "Nothing matches these filters"');
|
||||
});
|
||||
|
||||
test('answering beta\'s row from the list while acme was opened last reaches beta', async t => {
|
||||
const { b, wait, text } = await setup(t, { hash: '#/orgs/acme/organization', stub: true });
|
||||
await wait('document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"');
|
||||
await b.evaluate('location.hash = "#/inbox"');
|
||||
await wait('document.querySelectorAll("[data-ws-item]").length === 2');
|
||||
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'acme');
|
||||
await b.evaluate(`document.querySelector('[data-ws-select="beta:${BETA_DECISION}"]').click()`);
|
||||
await wait(`document.querySelector("[data-ws-insp]")?.dataset.wsInsp === "beta:${BETA_DECISION}" && document.querySelector("#decide-section [data-decide]")`);
|
||||
assert.equal(await text('[data-ws-insp] header .ws-chip'), 'Within role');
|
||||
// The item also opens in its own organization.
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-ws-insp] [data-ws-open]")?.getAttribute("href")'), `#/orgs/beta/inbox/${BETA_DECISION}`);
|
||||
await b.evaluate(`window.__answers.push({ body: { id: '${BETA_DECISION}', action: 'answer', choice: 'high', text: 'High', confirmation: 'c-1', kind: 'decision' } }, { body: { id: '${BETA_DECISION}', op: 'resolved', choice: 'high' } })`);
|
||||
await b.evaluate('[...document.querySelectorAll("#decide-section [data-decide]")].find(x => x.textContent === "High").click()');
|
||||
await wait('document.querySelector("#decide-section [data-decide=confirm]")');
|
||||
// The confirm step names who raised the item, from beta's bus row, as the decision page does.
|
||||
const confirm = await text('#decide-section');
|
||||
assert.match(confirm, /Raised by pm · run run/);
|
||||
assert.match(confirm, /Decision routed to: human/);
|
||||
assert.doesNotMatch(confirm, /date unknown/);
|
||||
assert.match(confirm, /Affected object: shop/);
|
||||
await b.evaluate('document.querySelector("#decide-section [data-decide=confirm]").click()');
|
||||
await wait('/Recorded/.test(document.getElementById("decide-status")?.textContent ?? "")');
|
||||
const posts = await b.evaluate('window.__posts');
|
||||
assert.deepEqual(posts.map(p => p.url), [`/api/orgs/beta/decisions/${BETA_DECISION}/confirmation`, `/api/orgs/beta/decisions/${BETA_DECISION}/resolve`]);
|
||||
assert.deepEqual(JSON.parse(posts[1].body), { choice: 'high', confirmation: 'c-1' });
|
||||
// Still a system route, still scoped to acme for everything else.
|
||||
assert.equal(await b.evaluate('location.hash.split("?")[0]'), '#/inbox');
|
||||
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'acme');
|
||||
});
|
||||
|
||||
test('#/orgs/<id>/inbox is the same list filtered to that organization', async t => {
|
||||
const { b, wait, text } = await setup(t, { hash: '#/orgs/beta/inbox' });
|
||||
await wait(`${items} === ${JSON.stringify(JSON.stringify([`beta:${BETA_DECISION}`]))}`);
|
||||
assert.match(await text('[data-ws-inbox] .sub'), /Beta Works/);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-ws-inbox] a[href=\'#/inbox\']") !== null'), true);
|
||||
// SP7: a drill-in keeps its trail.
|
||||
await wait(`${trail} === 'Dashboard / Beta Works / Inbox'`);
|
||||
// Selecting a row keeps the organization address.
|
||||
await b.evaluate(`document.querySelector('[data-ws-select="beta:${BETA_DECISION}"]').click()`);
|
||||
await wait(`location.hash.startsWith("#/orgs/beta/inbox?") && document.querySelector("[data-ws-insp]")?.dataset.wsInsp === "beta:${BETA_DECISION}"`);
|
||||
});
|
||||
|
||||
test('with nothing waiting the list says so and still names the stopped organization', async t => {
|
||||
const { wait, text } = await setup(t, { empty: true });
|
||||
await wait('document.querySelector("[data-ws-items]")');
|
||||
assert.equal(await text('[data-ws-items]'), 'Nothing needs you');
|
||||
assert.match(await text('[data-ws-unread]'), /Gamma Home.*stopped by the owner/s);
|
||||
});
|
||||
|
||||
test('the hire, fire and change form is reached by clicks from #/inbox, one link per answering organization', async t => {
|
||||
const { b, wait } = await setup(t, { stub: true });
|
||||
await wait('document.querySelector("[data-ws-hire-in]")');
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-hire-in] a")].map(a => [a.textContent, a.getAttribute("href")])'), [['Acme Studio', '#/orgs/acme/inbox'], ['Beta Works', '#/orgs/beta/inbox']]);
|
||||
// The system list has no form of its own: a hire goes to one organization's bus.
|
||||
assert.equal(await b.evaluate('document.getElementById("hire-start")'), null);
|
||||
await b.evaluate('[...document.querySelectorAll("[data-ws-hire-in] a")].find(a => a.textContent === "Beta Works").click()');
|
||||
await wait('location.hash === "#/orgs/beta/inbox" && document.getElementById("hire-start")');
|
||||
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'beta');
|
||||
assert.equal(await b.evaluate('document.querySelector("#hire-start input[readonly]")?.value'), 'beta');
|
||||
});
|
||||
|
||||
test('the hire links follow each organization\'s own config, not the organization opened last', async t => {
|
||||
// Acme is opened last and this Console does not decide there; beta's config still says it does.
|
||||
const { b, wait } = await setup(t, { hash: '#/orgs/acme/organization', stub: true, noDecide: ['acme'] });
|
||||
await wait('document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"');
|
||||
await b.evaluate('location.hash = "#/inbox"');
|
||||
await wait('document.querySelector("[data-ws-hire-in]")');
|
||||
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'acme');
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-hire-in] a")].map(a => a.getAttribute("href"))'), ['#/orgs/beta/inbox']);
|
||||
});
|
||||
|
||||
test('with no organization deciding here, #/inbox shows no hire line', async t => {
|
||||
const { b, wait } = await setup(t, { stub: true, noDecide: ['acme', 'beta'] });
|
||||
await wait('document.querySelectorAll("[data-ws-item]").length === 2');
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-ws-hire-in]")'), null);
|
||||
});
|
||||
|
||||
test('the sidebar Inbox count on an organization\'s board is every organization\'s, as #/inbox lists', async t => {
|
||||
const { b, wait } = await setup(t, { hash: '#/orgs/acme/board' });
|
||||
await wait('document.querySelector("#sections a[href$=\'/inbox\'] .ws-n")?.textContent === "2"');
|
||||
// Held across a second board render, which reads only acme's inbox.
|
||||
const reads = 'performance.getEntriesByType("resource").filter(e => new URL(e.name).pathname === "/api/orgs/acme/bus/inbox").length';
|
||||
const before = await b.evaluate(reads);
|
||||
assert.ok(before >= 1, String(before));
|
||||
await b.evaluate('window.dispatchEvent(new HashChangeEvent("hashchange"))');
|
||||
await wait(`${reads} > ${before}`);
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
assert.equal(await b.evaluate('document.querySelector("#sections a[href$=\'/inbox\'] .ws-n")?.textContent'), '2');
|
||||
});
|
||||
|
||||
// SP2 gave the system dashboard the system theme. #/inbox is a system page too: it shows the system
|
||||
// theme, and a choice made there is the system's own, never written to the organization opened last.
|
||||
test('#/inbox keeps the system theme, and a choice made there stays off the organization opened last', async t => {
|
||||
const { b, wait } = await setup(t, { hash: '#/orgs/acme/organization' });
|
||||
await wait('document.documentElement.dataset.palette === "cobalt"');
|
||||
await b.evaluate('location.hash = "#/inbox"');
|
||||
await wait(`${items} !== "[]" && document.documentElement.dataset.palette === "harbor"`);
|
||||
assert.equal(await b.evaluate('window.MosaicAppearance.state().organization'), null);
|
||||
assert.equal(await b.evaluate('document.getElementById("org-cue").hidden'), true);
|
||||
await b.evaluate('window.MosaicAppearance.set({ mode: "light" })');
|
||||
assert.equal(await b.evaluate('document.documentElement.dataset.mode'), 'light');
|
||||
assert.equal(await b.evaluate('localStorage.getItem("mosaic-console-appearance:acme")'), null);
|
||||
// The organization's own Inbox is its page: its theme, and not the system's choice.
|
||||
await b.evaluate('location.hash = "#/orgs/acme/inbox"');
|
||||
await wait('document.documentElement.dataset.palette === "cobalt"');
|
||||
assert.notEqual(await b.evaluate('document.documentElement.dataset.mode'), 'light');
|
||||
await b.evaluate('location.hash = "#/inbox"');
|
||||
await wait('document.documentElement.dataset.palette === "harbor" && document.documentElement.dataset.mode === "light"');
|
||||
});
|
||||
@@ -0,0 +1,330 @@
|
||||
// SP4 (#1873, single pane slice 4): one task board for the whole system at #/tasks. Every
|
||||
// organization that answers adds its tasks to one board, in the per-organization page's columns,
|
||||
// each card with its organization chip and its project chip. The organization and project filters
|
||||
// live in the hash query. A move goes to the row's own organization, offered only where that
|
||||
// organization's own config takes task changes, never judged by the organization opened last.
|
||||
// A stopped organization is named at the top with its reason. #/orgs/<id>/tasks is the same board,
|
||||
// scoped to one organization, and a card opened from the system board opens there.
|
||||
// Every bus is a fake: acme and beta keep their tasks in the built-in store, gamma's bus is stopped.
|
||||
// The real GET /api/workspace and the real POST /api/workspace/tasks serve the page.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { 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();
|
||||
const D = n => String(n).repeat(64);
|
||||
|
||||
function fixture({ empty = false, archived = [], done = false } = {}) {
|
||||
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 },
|
||||
];
|
||||
const PROJECTS = {
|
||||
acme: [{ id: 'site', repo: 'site', name: 'Website', archived: false, at: at(30) }],
|
||||
beta: [{ id: 'shop', repo: 'shop', name: 'Shop', archived: false, at: at(60) }, { id: 'ads', repo: 'ads', name: 'Ads', archived: false, at: at(90) }],
|
||||
};
|
||||
const STORE = {
|
||||
acme: [{ n: 1, title: 'Hero copy', state: 'todo', group: 'site', assignees: ['coder'] }],
|
||||
beta: [{ n: 2, title: 'Price page', state: 'in-progress', group: 'shop', assignees: [] }, { n: 3, title: 'Spring banner', state: 'todo', group: 'ads', assignees: [] }],
|
||||
};
|
||||
if (empty) { STORE.acme = []; STORE.beta = []; }
|
||||
// `done`: one task finished two days ago and one ten days ago; only the first is done this week.
|
||||
if (done) { STORE.acme.push({ n: 4, title: 'Old logo', state: 'done', group: 'site', assignees: [], ago: 60 * 24 * 10 }); STORE.beta.push({ n: 5, title: 'Launch email', state: 'done', group: 'shop', assignees: [], ago: 60 * 24 * 2 }); }
|
||||
for (const [org, list] of Object.entries(PROJECTS)) for (const p of list) p.archived = archived.includes(`${org}:${p.id}`);
|
||||
const tasks = id => (STORE[id] || []).map(t => ({ source: 'store', task_ref: `task:${t.n}`, at: at(t.ago ?? 10), priority: 0, digest: D(t.n), requirement: 'REQ-WEB-1',
|
||||
fields: { title: t.title, state: t.state, group: t.group, assignees: t.assignees, due_date: null, ...(t.state === 'done' ? { done: true } : {}) } }));
|
||||
const writes = [];
|
||||
const taskWrite = org => async (verb, args) => { writes.push([org, verb, args]); return { task_ref: args.task_ref, digest: D(9) }; };
|
||||
const opts = id => ({
|
||||
bus: { inbox: async () => [], agents: async () => [], 'thread.claims': async () => [], tasks: async () => tasks(id), messages: async () => ({ messages: [], unread: 0 }) },
|
||||
chat: { projects: async () => PROJECTS[id] || [], threads: async () => [] },
|
||||
});
|
||||
const member = org => ({
|
||||
id: org.id, doc: { appearance: { theme: org.theme }, roles: { coder: {} } },
|
||||
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),
|
||||
});
|
||||
return {
|
||||
writes,
|
||||
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 () => [] }, taskWrite: taskWrite(id) };
|
||||
return {
|
||||
busHealthy: true,
|
||||
reads: { business: async () => ({ business: { id, human: 'jason', projects: PROJECTS[id].map(p => p.repo), appearance: { theme: org.theme } } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
|
||||
...opts(id),
|
||||
bus: { ...opts(id).bus, instances: async () => ({ business: id, instances: {} }) },
|
||||
taskWrite: taskWrite(id),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Runs before the page's scripts, so display.js wraps it and it sees each request's scoped URL.
|
||||
// Every organization's config says this Console takes task changes, except those named in `noWrite`.
|
||||
// The real server sets orgSetup for the whole Console, so this per-organization difference is faked:
|
||||
// it proves the board asks each row's own organization, which matters where one config read fails.
|
||||
const STUB = noWrite => `window.__noWrite = ${JSON.stringify(noWrite)};
|
||||
(() => {
|
||||
const real = window.fetch.bind(window);
|
||||
window.fetch = async (url, init = {}) => {
|
||||
const u = String(url);
|
||||
if (!/^\\/api\\/(?:orgs\\/[^/]+\\/)?config$/.test(u)) return real(url, init);
|
||||
const c = await (await real(url, init)).json();
|
||||
const org = (u.match(/^\\/api\\/orgs\\/([^/]+)\\//) || [])[1];
|
||||
return new Response(JSON.stringify({ ...c, orgSetup: c.orgSetup === true && !window.__noWrite.includes(org) }), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
};
|
||||
})();`;
|
||||
|
||||
async function setup(t, { viewport = [1440, 900], hash = '#/tasks', empty = false, noWrite = [], archived = [], done = false } = {}) {
|
||||
const orgs = fixture({ empty, archived, done });
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: orgs, send: async () => ({}), log: () => {} });
|
||||
t.after(() => close(server));
|
||||
const b = await browser({ machine: false, args: ['--disable-extensions'] }); t.after(() => b.close());
|
||||
await b.viewport(...viewport);
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB(noWrite) });
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
await b.navigate(`${base}/${hash}`);
|
||||
return { b, base, orgs, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) };
|
||||
}
|
||||
const trail = '(document.getElementById("breadcrumbs").getClientRects().length ? document.getElementById("breadcrumbs").innerText.replace(/\\s+/g, " ").trim() : null)';
|
||||
const cards = 'JSON.stringify([...document.querySelectorAll("[data-tasks-board] [data-tb-task]")].map(e => e.dataset.tbTask).sort())';
|
||||
const ALL = JSON.stringify(JSON.stringify(['acme|task:1', 'beta|task:2', 'beta|task:3']));
|
||||
const BETA = JSON.stringify(JSON.stringify(['beta|task:2', 'beta|task:3']));
|
||||
|
||||
test('#/tasks shows both organizations\' tasks on one board, each card with its organization and project chips', async t => {
|
||||
const { b, wait, text } = await setup(t);
|
||||
await wait(`${cards} === ${ALL}`);
|
||||
// A system route: the address keeps no organization and the sidebar Tasks is this page.
|
||||
assert.equal(await b.evaluate('location.hash'), '#/tasks');
|
||||
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]")?.getAttribute("href")'), '#/tasks');
|
||||
assert.equal(await text('#bus-view h1'), 'Tasks');
|
||||
// SP7: the page name shows once, as the heading.
|
||||
assert.equal(await b.evaluate(trail), null);
|
||||
// The per-organization page's columns.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".tb-col h3")].map(h => h.textContent)'), ['To do', 'In progress', 'Review', 'Blocked', 'Done']);
|
||||
// One chip names the organization, with its avatar in place of the dot.
|
||||
assert.equal(await b.evaluate('[...document.querySelectorAll(\'[data-tb-task="acme|task:1"] [data-ws-orgchip]\')].map(c => [!!c.querySelector(".ws-av"), c.lastChild.textContent]).join("|")'), 'true,Acme Studio');
|
||||
assert.equal(await b.evaluate('[...document.querySelectorAll(\'[data-tb-task="beta|task:2"] [data-ws-orgchip]\')].map(c => [!!c.querySelector(".ws-av"), c.lastChild.textContent]).join("|")'), 'true,Beta Works');
|
||||
assert.equal(await text('[data-tb-task="acme|task:1"] [data-ws-projchip]'), 'Website');
|
||||
assert.equal(await text('[data-tb-task="beta|task:3"] [data-ws-projchip]'), 'Ads');
|
||||
assert.equal(await b.evaluate('document.getElementById("bus-down").hidden'), true);
|
||||
// The first load draws the board once, with no "last read failed" banner from a render the next one replaced.
|
||||
assert.equal(await b.evaluate('document.querySelector("#bus-view .s1-stale")'), null);
|
||||
});
|
||||
|
||||
// SP11 (#1873): the Dashboard's Done this week tile opens the board on the tasks it counted: done, and
|
||||
// changed in the last seven days. The bus keeps no time a task was done, so the filter says it reads
|
||||
// the last change, the time the tile counts by. The filter lives in the address and clears.
|
||||
test('#/tasks?done=week shows only the tasks Done this week counted, says what it counts, and clears', async t => {
|
||||
const { b, base, wait, text } = await setup(t, { hash: '#/tasks?done=week', done: true });
|
||||
await wait(`${cards} === ${JSON.stringify(JSON.stringify(['beta|task:5']))}`);
|
||||
assert.match(await text('[data-tb-done]'), /^Done this week\s+×$/);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-tb-done]").getAttribute("aria-label")'), /^Clear Done this week: done tasks last changed in the past 7 days$/);
|
||||
assert.match(await text('[data-tasks-board] .tb-count'), /^Showing 1 of 5 tasks\. Done this week counts done tasks by their last change; the bus records no time a task was done\.$/);
|
||||
// It survives a reload and joins the organization filter.
|
||||
await b.navigate(`${base}/#/tasks?org=acme&done=week`);
|
||||
await wait('document.querySelector("[data-tb-done]") && document.querySelector("[data-tasks-board] .ws-panel")?.textContent.includes("No tasks match these filters")');
|
||||
await b.evaluate('location.hash = "#/tasks?done=week"');
|
||||
await wait(`${cards} === ${JSON.stringify(JSON.stringify(['beta|task:5']))}`);
|
||||
// Clearing it drops it from the address and shows every task again.
|
||||
await b.evaluate('document.querySelector("[data-tb-done]").click()');
|
||||
await wait(`location.hash === "#/tasks" && ${cards} === ${JSON.stringify(JSON.stringify(['acme|task:1', 'acme|task:4', 'beta|task:2', 'beta|task:3', 'beta|task:5']))}`);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tb-done]")'), null);
|
||||
});
|
||||
|
||||
test('the organization and project filters live in the address and survive a reload', async t => {
|
||||
const { b, base, wait } = await setup(t);
|
||||
await wait(`${cards} === ${ALL}`);
|
||||
await b.evaluate('document.querySelector("[data-tb-org=beta]").click()');
|
||||
await wait(`location.hash === "#/tasks?org=beta" && ${cards} === ${BETA}`);
|
||||
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=project]"); s.value = "beta:shop"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
||||
await wait(`location.hash === "#/tasks?org=beta&project=beta%3Ashop" && ${cards} === ${JSON.stringify(JSON.stringify(['beta|task:2']))}`);
|
||||
await b.navigate(`${base}/?reload=1#/tasks?org=beta&project=beta%3Ashop`);
|
||||
await wait(`${cards} === ${JSON.stringify(JSON.stringify(['beta|task:2']))}`);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tb-org=beta]").getAttribute("aria-pressed")'), 'true');
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tb-filter=project]").value'), 'beta:shop');
|
||||
// A hand-edited value that is not an organization id or a project is ignored, not read as a filter.
|
||||
await b.navigate(`${base}/?reload=2#/tasks?org=Not%20An%20Id&project=nope`);
|
||||
await wait(`${cards} === ${ALL}`);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tb-org=\'\']").getAttribute("aria-pressed")'), 'true');
|
||||
// An organization or project that is not on the list falls back to all, and the address drops it.
|
||||
await b.navigate(`${base}/?reload=3#/tasks?org=gone&project=beta%3Agone`);
|
||||
await wait(`${cards} === ${ALL} && location.hash === "#/tasks"`);
|
||||
});
|
||||
|
||||
test('a move on beta\'s row while acme was opened last reaches beta, and acme\'s own config keeps its rows read only', async t => {
|
||||
const { b, wait, orgs } = await setup(t, { hash: '#/orgs/acme/organization', noWrite: ['acme'] });
|
||||
await wait('document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"');
|
||||
await b.evaluate('location.hash = "#/tasks"');
|
||||
await wait(`${cards} === ${ALL}`);
|
||||
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'acme');
|
||||
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
|
||||
await wait('document.querySelector("[data-tb-move=\'beta|task:2\']")');
|
||||
// Acme's config takes no task changes, so its row has no State control and its card does not drag.
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tb-move^=acme]")'), null);
|
||||
await b.evaluate('(() => { const s = document.querySelector("[data-tb-move=\'beta|task:2\']"); s.value = "in-review"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
||||
await wait('/moved to Review in Beta Works/.test(document.querySelector("[data-tb-notice]").textContent)');
|
||||
assert.deepEqual(orgs.writes, [['beta', 'tasks.edit', { task_ref: 'task:2', expect: D(2), state: 'in-review' }]]);
|
||||
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'acme');
|
||||
});
|
||||
|
||||
test('a stopped organization is named at the top of the board with its reason', async t => {
|
||||
const { b, wait, text } = await setup(t);
|
||||
await wait(`${cards} === ${ALL}`);
|
||||
const note = await text('[data-ws-unread]');
|
||||
assert.match(note, /Gamma Home/);
|
||||
assert.match(note, /stopped by the owner/);
|
||||
assert.equal(await b.evaluate('!!(document.querySelector("[data-ws-unread]").compareDocumentPosition(document.querySelector(".tb-toolbar")) & Node.DOCUMENT_POSITION_FOLLOWING)'), true);
|
||||
});
|
||||
|
||||
test('with no tasks anywhere the board says so and still names the stopped organization', async t => {
|
||||
const { b, wait, text } = await setup(t, { empty: true });
|
||||
await wait('/No tasks yet/.test(document.querySelector("[data-tb-results]")?.textContent || "")');
|
||||
assert.match(await text('[data-tb-results]'), /Gamma Home could not be read/);
|
||||
assert.match(await text('[data-ws-unread]'), /Gamma Home/);
|
||||
});
|
||||
|
||||
test('#/orgs/beta/tasks shows beta\'s tasks only, with a link to every organization', async t => {
|
||||
const { b, base, wait } = await setup(t, { hash: '#/orgs/beta/tasks' });
|
||||
await wait(`${cards} === ${BETA}`);
|
||||
assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/tasks');
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tasks-board]").dataset.tbScope'), 'beta');
|
||||
// SP7: a drill-in keeps its trail.
|
||||
await wait(`${trail} === 'Dashboard / Beta Works / Tasks'`);
|
||||
// No filter is on, so no "match the filters" line: the scope is not a filter.
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tasks-board] .tb-count")'), null);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tb-org]")'), null);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-tasks-board] .sub").textContent'), /Tasks from Beta Works only/);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tasks-board] .sub a")?.getAttribute("href")'), '#/tasks');
|
||||
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]")?.getAttribute("href")'), '#/tasks');
|
||||
// The sidebar Tasks link opens the system board.
|
||||
await b.evaluate('document.querySelector("#sections a[href=\'#/tasks\']").click()');
|
||||
await wait(`location.hash === "#/tasks" && ${cards} === ${ALL}`);
|
||||
// An address that names no possible organization is not the system board.
|
||||
await b.navigate(`${base}/?reload=1#/orgs/Not%20An%20Id/tasks`);
|
||||
await wait('/not on your list/.test(document.querySelector("[data-tasks-board]")?.textContent || "")');
|
||||
assert.equal(await b.evaluate(cards), '[]');
|
||||
});
|
||||
|
||||
test('a card opened from the system board opens the task in its organization', async t => {
|
||||
const { b, wait } = await setup(t, { hash: '#/orgs/acme/organization' });
|
||||
await wait('document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"');
|
||||
await b.evaluate('location.hash = "#/tasks"');
|
||||
await wait(`${cards} === ${ALL}`);
|
||||
await b.evaluate('document.querySelector("[data-tb-open=\'beta|task:3\']").click()');
|
||||
await wait('document.querySelector("#tb-dialog[open] [data-tb-goto]")');
|
||||
assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-goto]").getAttribute("href")'), '#/orgs/beta/tasks?task=task%3A3');
|
||||
assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-goto]").textContent'), 'Open in Beta Works');
|
||||
await b.evaluate('document.querySelector("#tb-dialog [data-tb-goto]").click()');
|
||||
await wait('location.hash.startsWith("#/orgs/beta/tasks") && window.MosaicOrg.id === "beta" && document.querySelector("[data-tasks-board]")?.dataset.tbScope === "beta"');
|
||||
await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")?.dataset.tbKey === "beta|task:3"');
|
||||
// The scoped page offers no second hop to the organization it is already in.
|
||||
assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-goto]")'), null);
|
||||
// Closing the task takes it off the address, so a reload does not open it again.
|
||||
await b.evaluate('document.querySelector("#tb-dialog [data-tb-cancel]").click()');
|
||||
await wait('location.hash === "#/orgs/beta/tasks" && !document.querySelector("#tb-dialog").open');
|
||||
});
|
||||
|
||||
// Holds each task board read for ?hold= ms (or window.__hold, set later) and counts the reads that
|
||||
// started and the ones that came back, so a test can act while a read is in flight. A task write is
|
||||
// held for window.__holdW ms and counted the same way.
|
||||
const HOLD = `window.__hold = Number(new URLSearchParams(location.search).get('hold')) || 0; window.__started = 0; window.__landed = 0;
|
||||
window.__holdW = 0; window.__wStarted = 0; window.__wLanded = 0;
|
||||
(() => {
|
||||
const real = window.fetch.bind(window);
|
||||
window.fetch = async (url, init = {}) => {
|
||||
if (String(url) === '/api/workspace/tasks' && init.method === 'POST' && window.__holdW) {
|
||||
window.__wStarted++;
|
||||
await new Promise(r => setTimeout(r, window.__holdW));
|
||||
try { return await real(url, init); } finally { window.__wLanded++; }
|
||||
}
|
||||
if (!/^\\/api\\/workspace\\?sections=tasks(?:%2C|,)projects/.test(String(url))) return real(url, init);
|
||||
window.__started++;
|
||||
if (window.__hold) await new Promise(r => setTimeout(r, window.__hold));
|
||||
try { return await real(url, init); } finally { window.__landed++; }
|
||||
};
|
||||
})();`;
|
||||
const settle = b => b.evaluate('new Promise(r => setTimeout(r, 200))');
|
||||
|
||||
test('a project filter stays in the address while its organization does not answer', async t => {
|
||||
const { b, orgs, wait } = await setup(t, { hash: '#/tasks?project=beta%3Ashop' });
|
||||
const SHOP = JSON.stringify(JSON.stringify(['beta|task:2']));
|
||||
await wait(`${cards} === ${SHOP}`);
|
||||
const list = await orgs.list();
|
||||
list[1].busHealthy = false;
|
||||
await b.evaluate('document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))');
|
||||
await wait('/Beta Works/.test(document.querySelector("[data-tasks-board] [data-ws-unread]")?.textContent || "")');
|
||||
await settle(b);
|
||||
assert.equal(await b.evaluate('location.hash'), '#/tasks?project=beta%3Ashop');
|
||||
list[1].busHealthy = true;
|
||||
await b.evaluate('document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))');
|
||||
await wait(`${cards} === ${SHOP} && !/Beta Works/.test(document.querySelector("[data-tasks-board] [data-ws-unread]")?.textContent || "")`);
|
||||
assert.equal(await b.evaluate('location.hash'), '#/tasks?project=beta%3Ashop');
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tb-filter=project]").value'), 'beta:shop');
|
||||
});
|
||||
|
||||
test('a gone filter is cleaned from the tasks address only, never from a page opened while it was read', async t => {
|
||||
const { b, base, wait } = await setup(t);
|
||||
await wait(`${cards} === ${ALL}`);
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: HOLD });
|
||||
await b.navigate(`${base}/?hold=800#/tasks?org=gone`);
|
||||
await wait('window.__started >= 1');
|
||||
await b.evaluate('location.hash = "#/inbox?org=acme"');
|
||||
await wait('window.__landed >= 1');
|
||||
await settle(b);
|
||||
assert.equal(await b.evaluate('location.hash'), '#/inbox?org=acme');
|
||||
});
|
||||
|
||||
test('a read in flight does not paint the board over the page for an address no organization matches', async t => {
|
||||
const { b, base, wait } = await setup(t);
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: HOLD });
|
||||
await b.navigate(`${base}/?reload=1#/tasks`);
|
||||
await wait(`${cards} === ${ALL}`);
|
||||
await b.evaluate('window.__hold = 800; window.__started = 0; window.__landed = 0');
|
||||
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=state]"); s.value = "todo"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
||||
await wait('window.__started >= 1');
|
||||
await b.evaluate('location.hash = "#/orgs/Not%20An%20Id/tasks"');
|
||||
await wait('/not on your list/.test(document.querySelector("[data-tasks-board]")?.textContent || "")');
|
||||
await wait('window.__landed >= window.__started');
|
||||
await settle(b);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-tasks-board]")?.textContent || ""'), /not on your list/);
|
||||
assert.equal(await b.evaluate(cards), '[]');
|
||||
});
|
||||
|
||||
test('a move still writing when the not-on-your-list page opens does not paint the board over it', async t => {
|
||||
const { b, base, wait } = await setup(t);
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: HOLD });
|
||||
await b.navigate(`${base}/?reload=1#/tasks`);
|
||||
await wait(`${cards} === ${ALL}`);
|
||||
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
|
||||
await wait('document.querySelector("[data-tb-move=\'beta|task:2\']")');
|
||||
await b.evaluate('window.__holdW = 800; window.__started = 0; window.__landed = 0');
|
||||
await b.evaluate('(() => { const s = document.querySelector("[data-tb-move=\'beta|task:2\']"); s.value = "in-review"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
||||
await wait('window.__wStarted >= 1');
|
||||
await b.evaluate('location.hash = "#/orgs/Not%20An%20Id/tasks"');
|
||||
await wait('/not on your list/.test(document.querySelector("[data-tasks-board]")?.textContent || "")');
|
||||
await wait('window.__wLanded >= 1 && window.__landed >= window.__started');
|
||||
await settle(b);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-tasks-board]")?.textContent || ""'), /not on your list/);
|
||||
assert.equal(await b.evaluate(cards), '[]');
|
||||
});
|
||||
|
||||
test('an archived project keeps its filter while it has tasks, and while another filter matches none of them', async t => {
|
||||
const { b, wait } = await setup(t, { hash: '#/tasks?project=beta%3Ashop', archived: ['beta:shop'] });
|
||||
await wait(`${cards} === ${JSON.stringify(JSON.stringify(['beta|task:2']))}`);
|
||||
await settle(b);
|
||||
assert.equal(await b.evaluate('location.hash'), '#/tasks?project=beta%3Ashop');
|
||||
// No shop task is done: the read comes back empty because of the state filter, not because shop is gone.
|
||||
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=state]"); s.value = "done"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
||||
await wait(`${cards} === "[]" && /No tasks match/.test(document.querySelector("[data-tb-results]")?.textContent || "")`);
|
||||
await settle(b);
|
||||
assert.equal(await b.evaluate('location.hash'), '#/tasks?project=beta%3Ashop');
|
||||
});
|
||||
@@ -73,7 +73,7 @@ async function open(t, { refuse = null, tasksFail = [], send = true, width = 144
|
||||
t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
await b.viewport(width, height);
|
||||
const url = `http://127.0.0.1:${server.address().port}/#/orgs/forge/tasks`;
|
||||
const url = `http://127.0.0.1:${server.address().port}/#/tasks`;
|
||||
await b.navigate(url);
|
||||
const wait = waitFor(b);
|
||||
await wait('document.querySelector("[data-tasks-board] [data-tb-tracker=home]")');
|
||||
|
||||
@@ -68,7 +68,7 @@ async function open(t, fixture = orgs()) {
|
||||
t.after(() => close(server));
|
||||
const b = await browser({ machine: false, args: ['--disable-extensions'] }); t.after(() => b.close());
|
||||
await b.viewport(1440, 900);
|
||||
const url = `http://127.0.0.1:${server.address().port}/#/orgs/forge/tasks`;
|
||||
const url = `http://127.0.0.1:${server.address().port}/#/tasks`;
|
||||
await b.navigate(url);
|
||||
const wait = waitFor(b);
|
||||
await wait('document.querySelector("[data-tasks-board] [data-tb-tracker=forge]")');
|
||||
|
||||
@@ -90,14 +90,14 @@ test('workspace navigation exposes all organizations and remembers project group
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'),true);
|
||||
});
|
||||
|
||||
test('sidebar follows sample 01 and Queue opens from the organization dashboard, not the sidebar', async t => {
|
||||
test('sidebar follows sample 01; the Queue is in neither the sidebar nor the organization dashboard, and #/queue still opens', async t => {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: workspaceOrgs(), log: () => {} }); t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
const wait = waitFor(b);
|
||||
await b.viewport(1440, 900);
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/organization`);
|
||||
await wait('document.querySelector("[data-org-dashboard] [data-org-settings]") && document.querySelectorAll("#workspace-nav .ws-proj").length === 3');
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#sections > li > a .t")].map(e => e.textContent)'), ['Dashboard', 'Inbox', 'Tasks', 'Projects', 'Side by side', 'Agents', 'Assistant']);
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#sections > li > a .t")].map(e => e.textContent)'), ['Dashboard', 'Inbox', 'Tasks', 'Projects', 'Agents', 'Assistant']);
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#sections-foot a")].map(e => e.textContent.trim())'), ['Settings']);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-org-toggle]").textContent.replace(/\\s+/g, " ").trim()'), /^Organizations\s*3$/);
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-workspace-org]")].map(li => [li.dataset.workspaceOrg, !!li.querySelector(".ws-av"), [...li.querySelector(".ws-dot").classList].find(c => c.startsWith("s-"))])'),
|
||||
@@ -114,12 +114,13 @@ test('sidebar follows sample 01 and Queue opens from the organization dashboard,
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#console-nav a[href]")].filter(a => /\\/(queue|docs)$/.test(a.getAttribute("href"))).length'), 0);
|
||||
// Settings stays pinned to the foot of the sidebar.
|
||||
assert.equal(await b.evaluate('(() => { const nav = document.getElementById("console-nav").getBoundingClientRect(), foot = document.getElementById("sections-foot").getBoundingClientRect(); return foot.bottom >= nav.bottom - 24; })()'), true);
|
||||
const queue = await b.evaluate('document.querySelector("[data-org-dashboard] [data-org-queue]").getAttribute("href")');
|
||||
assert.equal(queue, '#/queue');
|
||||
await b.evaluate('document.querySelector("[data-org-queue]").click()');
|
||||
// SP14 (#1873): the organization dashboard has no Queue card; the Queue page stays at its address.
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-org-dashboard] a[href=\'#/queue\'], [data-org-queue]")'), null);
|
||||
await b.evaluate('location.hash = "#/orgs/ddigital/queue"');
|
||||
await wait('location.hash === "#/orgs/ddigital/queue" && [...document.querySelectorAll("#bus-view h1")].some(h => h.textContent.trim() === "Queue")');
|
||||
// Every sidebar view is built: Inbox (U3, workspace-pages-browser.test.mjs), Tasks (U4b,
|
||||
// tasks-board-browser.test.mjs) and Side by side (U5, side-by-side-browser.test.mjs).
|
||||
// tasks-board-browser.test.mjs) and Assistant (SP6, assistant-threads-browser.test.mjs), which
|
||||
// replaced Side by side.
|
||||
});
|
||||
|
||||
test('organization dashboard renders every section from the per-organization reads', async t => {
|
||||
@@ -135,9 +136,10 @@ test('organization dashboard renders every section from the per-organization rea
|
||||
assert.match(head.colour, /^#[0-9a-f]{6}$/i);
|
||||
// Docs come from the project files reads: only documents, named by project.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".ws-doc")].map(a => a.innerText.replace(/\\s+/g, " ").trim())'), ['North star Spring launch', 'Launch plan Spring launch', 'Brand voice guide Brand refresh']);
|
||||
// KPIs and metrics: one honest empty tile each, with no invented number.
|
||||
const tiles = await b.evaluate('[...document.querySelectorAll(".ws-tiles")].map(t => [t.querySelectorAll(".ws-tile").length, t.querySelector(".ws-tile").classList.contains("is-empty"), t.innerText.replace(/\\s+/g, " ").trim()])');
|
||||
assert.deepEqual(tiles, [[1, true, 'No KPIs defined in the organization file'], [1, true, 'No metrics recorded yet']]);
|
||||
// SP8 (#1873): one row of figure tiles. This fixture gives no cross-organization read (no members),
|
||||
// so each tile says it was not read and shows no number.
|
||||
const tiles = await b.evaluate('[...document.querySelectorAll(".ws-tiles")].map(t => [[...t.querySelectorAll("[data-ws-tile]")].map(e => e.dataset.wsTile), t.querySelectorAll(".ws-big").length, t.innerText.match(/Not read: the read did not answer/g)?.length ?? 0])');
|
||||
assert.deepEqual(tiles, [[['agents', 'tasks', 'needs', 'done'], 0, 4]]);
|
||||
// Active agent sessions: the running instances, the thread their live run holds (chief's older
|
||||
// Kickoff notes is not it), the model with its Change link, and the state the bus reports. With no
|
||||
// reported state (montage-producer) the row says only running.
|
||||
@@ -150,7 +152,7 @@ test('organization dashboard renders every section from the per-organization rea
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".ws-tbl a.ws-change")].map(a => [a.getAttribute("href"), a.getAttribute("aria-label")])'),
|
||||
['chief', 'copywriter', 'montage-producer'].map(n => ['#/settings/routing', `Change the model for ${n}`]));
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".ws-tbl thead th")].map(th => th.textContent)'), ['Agent', 'Project and task', 'Model', 'Status']);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-org-dashboard]").innerText'), /Queue\s+3 rows, 2 not done\s+Open queue/);
|
||||
assert.doesNotMatch(await b.evaluate('document.querySelector("[data-org-dashboard]").innerText'), /Open queue|\d+ rows?, \d+ not done/);
|
||||
// Settings: one row per setting, each with an icon, a value, a source tag and, where a page exists, Open.
|
||||
const settings = await b.evaluate(`[...document.querySelectorAll('[data-org-settings] > li')].map(li => ({ name: li.dataset.orgSetting, icon: !!li.querySelector('svg'), value: li.querySelector('.v').textContent, tag: li.querySelector('.src-tag').textContent, href: li.querySelector('a.go')?.getAttribute('href') ?? null, danger: li.classList.contains('danger') }))`);
|
||||
assert.deepEqual(settings.map(s => s.name), SETTINGS);
|
||||
@@ -176,7 +178,7 @@ test('organization dashboard empty states say so in one short line', async t =>
|
||||
await waitFor(b)('document.querySelector("[data-org-dashboard] [data-org-settings]")');
|
||||
const text = await b.evaluate('document.querySelector("[data-org-dashboard]").innerText');
|
||||
// Section labels are uppercased by CSS, so innerText carries the uppercase form.
|
||||
for (const line of [/Docs\s+No docs yet/i, /No KPIs defined in the organization file/, /No metrics recorded yet/, /Active agent sessions\s+none running/i, /No active agent sessions/, /Queue\s+no rows/i, /Tracker: not set/])
|
||||
for (const line of [/Docs\s+No docs yet/i, /Agents working\s+Not read/, /Done this week\s+Not read/, /Active agent sessions\s+none running/i, /No active agent sessions/, /Tracker: not set/])
|
||||
assert.match(text, line);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("[data-org-settings] > li").length'), SETTINGS.length);
|
||||
});
|
||||
@@ -188,9 +190,9 @@ test('organization dashboard presents sections and honest unavailable controls',
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/organization`);
|
||||
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('[data-org-dashboard]'))return;await new Promise(r=>setTimeout(r,30))}throw Error('dashboard missing')})()`);
|
||||
const text=await b.evaluate('document.querySelector("[data-org-dashboard]").innerText');
|
||||
for(const label of ['Docs','KPIs','Metrics','Active agent sessions','Settings','Queue']) assert.match(text,new RegExp(label,'i'));
|
||||
for(const label of ['Docs','Agents working','Open tasks','Needs you','Done this week','Active agent sessions','Settings']) assert.match(text,new RegExp(label,'i'));
|
||||
assert.match(text,/reload organization file/i);
|
||||
assert.match(text,/no metrics recorded yet/i);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("[data-org-tiles] .ws-big").length'),0);
|
||||
assert.match(text,/bus unavailable, read \d/);
|
||||
assert.match(text,/Sessions unavailable/);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-org-reload]").disabled'),true);
|
||||
@@ -209,7 +211,8 @@ test('each header control is present, reload is disabled, and restart and stop a
|
||||
stop: stop ? [stop.textContent, stop.disabled, stop.closest('.bus-restart') === live.closest('.bus-restart')] : null, stopOff: stopOff ? [stopOff.textContent, stopOff.disabled, !!stopOff.title] : null,
|
||||
reload: [reload.textContent, reload.disabled, reload.title, document.getElementById(reload.getAttribute('aria-describedby'))?.textContent] };
|
||||
})()`));
|
||||
const RELOAD = ['Reload organization file', true, 'Reload arrives with the cross-org update', 'Restart is a trailed human action; reload arrives with the cross-org update.'];
|
||||
// SP11 (#1873): the line beside the disabled reload says in plain words how a change to the file loads.
|
||||
const RELOAD = ['Reload organization file', true, 'Reloading without a restart is not available yet', 'The bus reads the organization file when it starts. To load a change to the file, restart the bus.'];
|
||||
// The owner on a Console started with --send gets console-restart.js's restart button.
|
||||
// Stop bus sits beside it, in the same control, and is one press like Restart bus.
|
||||
assert.deepEqual(await header(owner), { live: ['Restart bus', false], off: null, stop: ['Stop bus', false, true], stopOff: null, reload: RELOAD });
|
||||
@@ -299,11 +302,12 @@ test('keyboard focus follows visual order and survives a data refresh', async t
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/organization`);
|
||||
await wait('document.querySelectorAll("[data-org-dashboard] .ws-doc").length > 0 && document.querySelectorAll("#workspace-nav .ws-proj").length === 3');
|
||||
const wide = await tabStops(b, 'document.querySelector("#sections a")');
|
||||
const regions = ['nav', 'head', 'Measures and sessions', 'Settings'];
|
||||
// The right column holds the Workflows card (SP9) above Settings.
|
||||
const regions = ['nav', 'head', 'Measures and sessions', 'Workflows and settings'];
|
||||
assert.deepEqual([...new Set(wide.map(s => s.region))].filter(r => r !== 'other'), regions);
|
||||
assertVisualOrder(wide.filter(s => s.region !== 'other'), regions, '1440');
|
||||
const keys = wide.map(s => s.key);
|
||||
for (const key of ['org:forge', 'proj:ddigital:spring-launch', 'doc:spring-launch:docs/north-star.md', 'docs-all', 'agents-all', 'queue-open', 'set:Identity', 'set:Archive or delete']) assert.ok(keys.includes(key), `Tab never reaches ${key}`);
|
||||
for (const key of ['org:forge', 'proj:ddigital:spring-launch', 'doc:spring-launch:docs/north-star.md', 'docs-all', 'agents-all', 'wfg-all', 'set:Identity', 'set:Archive or delete']) assert.ok(keys.includes(key), `Tab never reaches ${key}`);
|
||||
// The disabled reload is not a tab stop; the restart button is, and Stop bus follows it.
|
||||
assert.ok(!keys.includes('org-reload')); assert.ok(keys.includes('Restart bus'));
|
||||
assert.equal(keys.indexOf('Stop bus'), keys.indexOf('Restart bus') + 1);
|
||||
@@ -315,7 +319,7 @@ test('keyboard focus follows visual order and survives a data refresh', async t
|
||||
await b.viewport(1440, 900);
|
||||
|
||||
// A refresh repaints the dashboard, the sidebar and the restart button; focus stays on the same control.
|
||||
for (const key of ['set:Projects', 'queue-open', 'doc:spring-launch:docs/launch-plan.md']) {
|
||||
for (const key of ['set:Projects', 'agents-all', 'doc:spring-launch:docs/launch-plan.md']) {
|
||||
await wait(`document.querySelector('[data-focus-key="${key}"]')`);
|
||||
await b.evaluate(`window.oldDashboard = document.querySelector('[data-org-dashboard]'); document.querySelector('[data-focus-key="${key}"]').focus(); document.getElementById('refresh').click()`);
|
||||
await wait('document.querySelector("[data-org-dashboard]") && document.querySelector("[data-org-dashboard]") !== window.oldDashboard');
|
||||
@@ -370,7 +374,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 => {
|
||||
@@ -484,7 +488,8 @@ test('agents grid shows each agent\'s level and where it is defined, per project
|
||||
assert.match(await b.evaluate('document.querySelector("[data-agents-available] .ws-away").innerText'), /social-scheduler \(Spring launch only\), copywriter \(excluded in this project\) and every Forge or Home agent/);
|
||||
// Home, where the reviewer is turned off: the table says so.
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/home/agents`);
|
||||
await waitFor(b)('document.querySelector("[data-org-agents] h2")?.innerText === "Agents in Home"');
|
||||
// #1873 SP5: the scoped page puts the levels grid under its own "Agents by level" h2; this is the section's title.
|
||||
await waitFor(b)('document.querySelector("[data-org-agents] .ws-split-agents h2")?.innerText === "Agents in Home"');
|
||||
assert.match(await b.evaluate('document.querySelector("[data-agents-available]").innerText'), /reviewer \(turned off for Home\)/);
|
||||
});
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ export async function w15Fixture(t, { scenario = {}, slice = 'shell' } = {}) {
|
||||
if(/^\\/api\\/chat\\/threads\\/[^/]+\\/sessions$/.test(path)) return data({rows:[{id:ids.session,name:'Default',default:true}]});
|
||||
if(/^\\/api\\/chat\\/threads\\/[^/]+\\/messages(?:\\?.*)?$/.test(path)) return data({rows:[],after:null,hasMore:false});
|
||||
if(path==='/api/chat/threads' || /^\\/api\\/chat\\/threads\\?(shared=true|project=)/.test(path)) return data({rows:[{id:ids.thread,project:ids.project,name:'Project conversation',instance:'assistant'}]});
|
||||
if(/^\\/api\\/bus\\/(inbox|messages|agents|tasks|queue|trail)(\\?|$)/.test(path)) return data({rows:[],unread:0});
|
||||
if(/^\\/api\\/bus\\/(inbox|messages|agents|instances|tasks|queue|trail)(\\?|$)/.test(path)) return data({rows:[],unread:0});
|
||||
__unexpected.push(method+' '+path); return new Response(JSON.stringify({error:'fixture-unexpected-api'}),{status:403,headers:{'content-type':'application/json'}});
|
||||
};
|
||||
})();` });
|
||||
|
||||
@@ -0,0 +1,669 @@
|
||||
// Single pane SP9 (#1873): an organization's workflows, read only. #/orgs/<id>/workflows lists them,
|
||||
// #/orgs/<id>/workflows/<name> draws one as an inline-SVG graph (one box per step, an arrowed line per
|
||||
// hand-off), and the organization page carries a small card. The data is what GET /api/orgs/<id>/workflows
|
||||
// returns from the organization file: steps in order, each with a title, a state and its roles. Nothing
|
||||
// else is drawn. A stopped organization is not read and says why.
|
||||
// SP10 (#1873): the graph page edits the workflow. The change is held in the page until Save, which sends one
|
||||
// POST with the revision the page read; a refusal is shown in words and the change kept; the poll never draws
|
||||
// over a pending change. The fixture's write stub keeps the organization file and its revision, as the bus does.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
const XSS = '<img src=x onerror=window.__sp9pwn=1>';
|
||||
const at = minutes => new Date(Date.now() - minutes * 60000).toISOString();
|
||||
const waitFor = b => (expression, what = expression) => b.evaluate(`(async()=>{for(let i=0;i<300;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(what)}+': '+document.body.innerText.slice(0,1500))})()`);
|
||||
|
||||
// The coding starter pack's workflow as pack.apply writes it (the pack's step ids are not kept).
|
||||
const CHANGE = {
|
||||
description: 'One change from task to merge.',
|
||||
steps: [
|
||||
{ title: 'Write the task', roles: ['pm'], state: 'todo' },
|
||||
{ title: 'Implement the change', roles: ['coder'], state: 'in-progress' },
|
||||
{ title: 'Review the change', roles: ['reviewer'], state: 'in-review' },
|
||||
{ title: 'Approve the merge', roles: ['human', 'merge-gate'], state: 'done' },
|
||||
],
|
||||
};
|
||||
const DOCS = {
|
||||
acme: {
|
||||
definitions: { pm: {}, coder: {}, reviewer: {} },
|
||||
workflows: { change: CHANGE, intake: { title: `Intake ${XSS}`, steps: [{ title: `Sort ${XSS}`, state: 'todo', roles: ['human'] }] } },
|
||||
projects: { site: { workflows: { change: { title: 'Site release', steps: [{ title: 'Build', state: 'in-progress', roles: ['coder'] }, { title: 'Ship', state: 'done', roles: ['human'] }] } } }, notes: { kind: 'library', workflows: { hidden: { steps: [{ title: 'x', state: 'todo', roles: ['human'] }] } } } },
|
||||
},
|
||||
bare: { definitions: {}, projects: {} },
|
||||
};
|
||||
const LIST = [
|
||||
{ id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true },
|
||||
{ id: 'bare', label: 'Bare Works', theme: 'grove', busHealthy: true },
|
||||
{ id: 'gamma', label: 'Gamma Home', theme: 'rosewood', busHealthy: false, busStopped: true },
|
||||
];
|
||||
|
||||
// The organization files as the bus holds them: each organization's document and revision, the writes the
|
||||
// page sent, and a refusal to give the next write. A write with another revision is a revision-conflict.
|
||||
const hex = n => n.toString(16).padStart(64, '0');
|
||||
function store(writable) {
|
||||
const st = { writable, docs: structuredClone(DOCS), revs: { acme: 'a'.repeat(64), bare: 'b'.repeat(64), gamma: 'c'.repeat(64) }, writes: [], refuse: null, n: 0 };
|
||||
const fail = code => Object.assign(new Error(code), { code });
|
||||
st.write = (id, verb, args) => {
|
||||
st.writes.push({ id, verb, args: structuredClone(args) });
|
||||
if (verb !== 'workflow.set.console') throw fail('not-configured');
|
||||
if (st.refuse) { const code = st.refuse; st.refuse = null; throw fail(code); }
|
||||
if (args.revision !== st.revs[id]) throw fail('revision-conflict');
|
||||
const doc = st.docs[id], scope = args.project ? doc.projects[args.project] : doc;
|
||||
scope.workflows ??= {};
|
||||
if (args.workflow === null) delete scope.workflows[args.name]; else scope.workflows[args.name] = structuredClone(args.workflow);
|
||||
st.revs[id] = hex(++st.n);
|
||||
return { revision: st.revs[id], added: [] };
|
||||
};
|
||||
// Another writer changes the file outside the page.
|
||||
st.change = (id, fn) => { fn(st.docs[id]); st.revs[id] = hex(1000 + ++st.n); };
|
||||
return st;
|
||||
}
|
||||
function organizations(reads, st) {
|
||||
const member = org => ({
|
||||
id: org.id, doc: { appearance: { theme: org.theme }, roles: {}, 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: () => ({ bus: { inbox: async () => [], agents: async () => [], 'thread.claims': async () => [], tasks: async () => [] }, chat: { threads: async () => [], projects: async () => [] } }),
|
||||
});
|
||||
return {
|
||||
list: async () => LIST,
|
||||
members: async () => LIST.map(member),
|
||||
resolve: async id => {
|
||||
const org = LIST.find(o => o.id === id); if (!org) return null;
|
||||
const workflowsRead = async () => { reads.push(id); if (st.failRead) throw Object.assign(new Error('org-unavailable'), { code: 'org-unavailable' }); return { revision: st.revs[id], document: structuredClone(st.docs[id] ?? {}), templates: ['writer'] }; };
|
||||
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, workflowsRead, chat: { projects: async () => [], threads: async () => [] } };
|
||||
return {
|
||||
busHealthy: true, workflowsRead, packsWrite: st.writable ? async (verb, args) => st.write(id, verb, args) : null, decisionWrite: async () => ({}), send: async () => ({}),
|
||||
requestRead: async verb => { if (verb === 'packs.console') return { packs: [] }; if (verb === 'autonomy.get.console') return { revision: 'e'.repeat(64), autonomy: {}, levels: {} }; throw Object.assign(new Error('not-configured'), { code: 'not-configured' }); },
|
||||
reads: { business: async () => ({ business: { id, projects: [], instances: [] } }), settings: async () => ({}), queue: async () => ({ rows: [] }) },
|
||||
bus: { instances: async () => ({ instances: {} }), agents: async () => [], 'thread.claims': async () => [], tasks: async () => [], inbox: async () => [], messages: async () => ({ messages: [] }) },
|
||||
chat: { projects: async () => [], threads: async () => [] },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function setup(t, { path, viewport = [1440, 900], mode = null, writable = true } = {}) {
|
||||
const reads = [], st = store(writable);
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), log: () => {}, organizations: organizations(reads, st) });
|
||||
t.after(() => close(server));
|
||||
const b = await browser({ machine: false, args: ['--disable-extensions'] });
|
||||
t.after(() => b.close());
|
||||
await b.viewport(...viewport);
|
||||
// The Console's own mode wins over the OS setting (Harbor in the dark by default), so a theme test sets it per organization.
|
||||
if (mode) await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `try { for (const o of ['acme','bare','gamma']) localStorage.setItem('mosaic-console-appearance:' + o, JSON.stringify({ theme: 'harbor', mode: ${JSON.stringify(mode)} })); } catch {}` });
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/${path}`);
|
||||
return { b, reads, st, wait: waitFor(b) };
|
||||
}
|
||||
|
||||
const boxes = `[...document.querySelectorAll('[data-wfg-graph] [data-wfg-step]')]`;
|
||||
|
||||
test('the list names each workflow by scope with its steps in order, links to its graph, and keeps titles as text', { timeout: 60000 }, async t => {
|
||||
const { b, wait } = await setup(t, { path: '#/orgs/acme/workflows' });
|
||||
await wait(`document.querySelector('[data-wfg-list] [data-wfg-item="change"]')`, 'workflow list');
|
||||
assert.equal(await b.evaluate(`document.querySelector('#bus-view h1').textContent`), 'Workflows');
|
||||
// Organization first, then each code project; a library project has no workflows.
|
||||
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-wfg-scope-list]')].map(n=>[n.dataset.wfgScopeList,n.querySelector('h2').textContent])`), [['', 'Organization'], ['site', 'Project site']]);
|
||||
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-wfg-scope-list=""] [data-wfg-item]')].map(n=>n.dataset.wfgItem)`), ['change', 'intake']);
|
||||
const change = `[data-wfg-scope-list=""] [data-wfg-item="change"]`;
|
||||
assert.equal(await b.evaluate(`document.querySelector('${change} a[data-wfg-open]').getAttribute('href')`), '#/orgs/acme/workflows/change');
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-scope-list="site"] [data-wfg-item="change"] a[data-wfg-open]').getAttribute('href')`), '#/orgs/acme/workflows/change?project=site');
|
||||
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('${change} [data-wfg-chain] li')].map(l=>l.textContent)`), ['Write the task', 'Implement the change', 'Review the change', 'Approve the merge']);
|
||||
assert.match(await b.evaluate(`document.querySelector('${change}').textContent`), /4 steps/);
|
||||
assert.match(await b.evaluate(`document.querySelector('${change}').textContent`), /One change from task to merge\./);
|
||||
// The title is shown as written.
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-item="intake"] a[data-wfg-open]').textContent`), `Intake ${XSS}`);
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('#bus-view img').length`), 0);
|
||||
assert.equal(await b.evaluate(`window.__sp9pwn===undefined`), true);
|
||||
assert.equal(await b.evaluate(`document.querySelector('#bus-view [data-ws-unread]')`), null, 'a running bus has no unread note');
|
||||
// The trail names the organization and the page.
|
||||
await wait(`document.querySelector('#breadcrumbs [data-crumb-org]')?.textContent === 'Acme Studio'`, 'org crumb');
|
||||
// A click opens the graph.
|
||||
await b.evaluate(`document.querySelector('${change} a[data-wfg-open]').click();true`);
|
||||
await wait(`location.hash === '#/orgs/acme/workflows/change' && document.querySelectorAll('[data-wfg-graph] [data-wfg-step]').length === 4`, 'graph after click');
|
||||
});
|
||||
|
||||
test('the graph draws one box per step in order with its roles and one arrowed edge per hand-off', { timeout: 60000 }, async t => {
|
||||
const { b, wait } = await setup(t, { path: '#/orgs/acme/workflows/change' });
|
||||
await wait(`${boxes}.length === 4`, 'four boxes');
|
||||
assert.equal(await b.evaluate(`document.querySelector('#bus-view h1').textContent`), 'change');
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-graph]').tagName.toLowerCase()`), 'svg');
|
||||
assert.deepEqual(await b.evaluate(`${boxes}.map(g=>g.dataset.wfgStep)`), ['1', '2', '3', '4']);
|
||||
assert.deepEqual(await b.evaluate(`${boxes}.map(g=>g.querySelector('[data-wfg-title]').textContent)`), ['Write the task', 'Implement the change', 'Review the change', 'Approve the merge']);
|
||||
// One role per line.
|
||||
assert.deepEqual(await b.evaluate(`${boxes}.map(g=>[...g.querySelectorAll('[data-wfg-roles] tspan')].map(t=>t.textContent))`), [['pm'], ['coder'], ['reviewer'], ['human', 'merge-gate']]);
|
||||
// A role that resolves to no definition or template (and is not human) is marked on its box.
|
||||
assert.deepEqual(await b.evaluate(`${boxes}.map(g=>g.querySelectorAll('[data-wfg-missing]').length)`), [0, 0, 0, 1]);
|
||||
// sp9 follow-up: the missing role's whole name shows at 1440, on its own line under "No current role:".
|
||||
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-wfg-step="4"] [data-wfg-missing] tspan')].map(t=>t.textContent)`), ['No current role:', 'merge-gate']);
|
||||
// At 1440 the steps run left to right.
|
||||
const xs = await b.evaluate(`${boxes}.map(g=>g.querySelector('rect').getBoundingClientRect().left)`);
|
||||
assert.deepEqual([...xs].sort((p, q) => p - q), xs);
|
||||
assert.equal(new Set(xs).size, 4);
|
||||
// One edge per hand-off, from each box to the next, with an arrowhead.
|
||||
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-wfg-graph] [data-wfg-edge]')].map(e=>e.dataset.wfgEdge)`), ['1-2', '2-3', '3-4']);
|
||||
const touches = await b.evaluate(`[...document.querySelectorAll('[data-wfg-graph] [data-wfg-edge]')].map(e=>{
|
||||
const [f,t]=e.dataset.wfgEdge.split('-'), box=n=>document.querySelector('[data-wfg-graph] [data-wfg-step="'+n+'"] rect').getBBox();
|
||||
const p=e.getPointAtLength(0), q=e.getPointAtLength(e.getTotalLength()), a=box(f), z=box(t), near=(pt,r)=>pt.x>=r.x-14&&pt.x<=r.x+r.width+14&&pt.y>=r.y-14&&pt.y<=r.y+r.height+14;
|
||||
return near(p,a)&&near(q,z)&&!!e.getAttribute('marker-end');})`);
|
||||
assert.deepEqual(touches, [true, true, true]);
|
||||
// The graph scrolls inside its own box, never the page.
|
||||
assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=innerWidth`), true);
|
||||
// Nothing running is drawn: no read links a task to a step.
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-running]')`), null);
|
||||
});
|
||||
|
||||
test('selecting a step shows what the data holds for it and survives a repaint', { timeout: 60000 }, async t => {
|
||||
const { b, wait, reads } = await setup(t, { path: '#/orgs/acme/workflows/change' });
|
||||
await wait(`${boxes}.length === 4`, 'four boxes');
|
||||
assert.match(await b.evaluate(`document.querySelector('[data-wfg-detail]').textContent`), /Select a step/);
|
||||
await b.evaluate(`document.querySelector('[data-wfg-step="2"]').dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true}));true`);
|
||||
await wait(`/[?&]step=2/.test(location.hash) && document.querySelector('[data-wfg-detail] [data-wfg-detail-title]')?.textContent === 'Implement the change'`, 'detail for step 2');
|
||||
assert.equal(await b.evaluate(`location.hash`), '#/orgs/acme/workflows/change?step=2');
|
||||
const field = name => b.evaluate(`document.querySelector('[data-wfg-detail] [data-wfg-field="${name}"]')?.textContent.replace(/\\s+/g,' ').trim() ?? null`);
|
||||
assert.equal(await field('position'), 'Step 2 of 4');
|
||||
assert.equal(await field('roles'), 'coder');
|
||||
assert.equal(await field('state'), 'In progress');
|
||||
assert.equal(await field('after'), 'Step 1, Write the task');
|
||||
assert.equal(await field('next'), 'Step 3, Review the change');
|
||||
// The workflow file holds no gate or inputs for a step; the panel says so once instead of inventing them.
|
||||
assert.match(await b.evaluate(`document.querySelector('[data-wfg-detail]').textContent`), /records no gate or inputs/);
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-step="2"]').getAttribute('aria-current')`), 'step');
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('[data-wfg-graph] [aria-current]').length`), 1);
|
||||
// The last step hands on to nothing; an unknown role is named in the panel too.
|
||||
await b.evaluate(`document.querySelector('[data-wfg-step="4"]').dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true}));true`);
|
||||
await wait(`document.querySelector('[data-wfg-detail] [data-wfg-detail-title]')?.textContent === 'Approve the merge'`, 'detail for step 4');
|
||||
assert.equal(await field('next'), 'Nothing: this is the last step');
|
||||
assert.match(await field('roles'), /human \(a person\)/);
|
||||
assert.match(await field('roles'), /merge-gate names no current role/);
|
||||
// Keyboard: a step box takes focus and opens on Enter, and keeps focus through the repaint.
|
||||
await b.evaluate(`document.querySelector('[data-wfg-step="1"]').focus();true`);
|
||||
assert.equal(await b.evaluate(`document.activeElement.dataset.focusKey`), 'wfg-step:1');
|
||||
await b.key('Enter');
|
||||
await wait(`document.querySelector('[data-wfg-detail] [data-wfg-detail-title]')?.textContent === 'Write the task' && document.activeElement?.dataset.focusKey === 'wfg-step:1'`, 'Enter opens step 1 and keeps focus');
|
||||
assert.equal(await b.evaluate(`location.hash`), '#/orgs/acme/workflows/change?step=1');
|
||||
await b.evaluate(`document.querySelector('[data-wfg-step="4"]').focus();true`);
|
||||
await b.key('Enter');
|
||||
await wait(`document.querySelector('[data-wfg-detail] [data-wfg-detail-title]')?.textContent === 'Approve the merge'`, 'Enter opens step 4');
|
||||
// A repaint (the poll) keeps the selection.
|
||||
const before = reads.length;
|
||||
await b.evaluate(`document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));true`);
|
||||
await wait(`document.querySelector('[data-wfg-detail] [data-wfg-detail-title]')?.textContent === 'Approve the merge'`, 'kept after repaint');
|
||||
assert.ok(reads.length >= before);
|
||||
// A project's workflow of the same name is its own graph.
|
||||
await b.evaluate(`location.hash = '#/orgs/acme/workflows/change?project=site'`);
|
||||
await wait(`${boxes}.length === 2`, 'project graph');
|
||||
assert.match(await b.evaluate(`document.querySelector('#bus-view').textContent`), /Project site/);
|
||||
// A name the scope does not hold says so.
|
||||
await b.evaluate(`location.hash = '#/orgs/acme/workflows/nope'`);
|
||||
await wait(`/No workflow named nope/.test(document.querySelector('#bus-view').textContent)`, 'unknown workflow');
|
||||
});
|
||||
|
||||
test('a step with more roles than fit shows three lines, the last counting the rest, all inside its box', { timeout: 60000 }, async t => {
|
||||
// The business-management pack's first step names four long roles.
|
||||
const roles = ['chief-executive-officer', 'chief-financial-officer', 'chief-information-officer', 'chief-information-security-officer'];
|
||||
DOCS.acme.workflows.brief = { steps: [{ title: 'Draft the brief', state: 'todo', roles }, { title: 'WWWWWWWWWWWWWWWWWWWWWWWW WWWWWWWWWWWWWWWWWWWWWWWW WWWWWWWWWWWWWWWWWWWW', state: 'done', roles: ['human'] }] };
|
||||
t.after(() => { delete DOCS.acme.workflows.brief; });
|
||||
for (const viewport of [[1440, 900], [390, 844]]) {
|
||||
await t.test(String(viewport[0]), async t2 => {
|
||||
const { b, wait } = await setup(t2, { path: '#/orgs/acme/workflows/brief', viewport });
|
||||
await wait(`${boxes}.length === 2`, 'two boxes');
|
||||
const one = `document.querySelector('[data-wfg-step="1"]')`;
|
||||
// A role longer than the box is shortened with an ellipsis (the full name is in the box's title and the step panel).
|
||||
const lines = await b.evaluate(`[...${one}.querySelectorAll('[data-wfg-roles] tspan')].map(t=>t.textContent)`);
|
||||
assert.equal(lines.length, 3);
|
||||
assert.equal(lines[2], '+2 more');
|
||||
lines.slice(0, 2).forEach((line, i) => assert.ok(line === roles[i] || (line.endsWith('…') && roles[i].startsWith(line.slice(0, -1))), line));
|
||||
if (viewport[0] === 390) assert.deepEqual(lines.slice(0, 2), roles.slice(0, 2), 'the 390 column is wide enough for the whole name');
|
||||
assert.equal(await b.evaluate(`${one}.querySelector('[data-wfg-roles] [data-wfg-more]').textContent`), '+2 more');
|
||||
// The box names every role for a pointer or a screen reader.
|
||||
const named = await b.evaluate(`${one}.querySelector('title').textContent + ' ' + (${one}.getAttribute('aria-label') ?? '')`);
|
||||
for (const r of roles) assert.ok(named.includes(r), r);
|
||||
// Every text line ends inside its own box: its drawn extent, not what the clip path lets show, ends left
|
||||
// of the clip edge (8px inside the box) and above the box bottom.
|
||||
const out = await b.evaluate(`${boxes}.flatMap(g=>{const r=g.querySelector('rect'),right=r.x.baseVal.value+r.width.baseVal.value-8,bottom=r.y.baseVal.value+r.height.baseVal.value;
|
||||
return [...g.querySelectorAll('text')].flatMap(t=>{const spans=[...t.querySelectorAll('tspan')];return (spans.length?spans:[t]).filter(e=>e.getNumberOfChars()>0).filter(e=>{let n=e.getNumberOfChars();while(n>1&&/\\s/.test(e.textContent[n-1]))n--;const z=e.getExtentOfChar(n-1),a=e.getExtentOfChar(0);return z.x+z.width>right+0.5||z.y+z.height>bottom+0.5||a.x<r.x.baseVal.value}).map(e=>e.textContent)})})`);
|
||||
assert.deepEqual(out, []);
|
||||
assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=innerWidth`), true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('an organization with no workflows says so on its page and its card, and links to apply a pack', { timeout: 60000 }, async t => {
|
||||
const { b, wait } = await setup(t, { path: '#/orgs/bare/workflows' });
|
||||
await wait(`document.querySelector('[data-wfg-list] [data-wfg-none]')`, 'none note');
|
||||
assert.match(await b.evaluate(`document.querySelector('[data-wfg-none]').textContent`), /No workflows in Bare Works yet/);
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-list] [data-wfg-apply]').getAttribute('href')`), '#/orgs/bare/organization');
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-list] [data-wfg-item]')`), null);
|
||||
// The organization page's card says the same and links the list.
|
||||
await b.evaluate(`location.hash = '#/orgs/bare/organization'`);
|
||||
await wait(`document.querySelector('[data-org-dashboard] [data-wfg-card] [data-wfg-none]')`, 'card none');
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-card] [data-wfg-all]').getAttribute('href')`), '#/orgs/bare/workflows');
|
||||
// Apply a pack takes the person to the pack picker on the same page.
|
||||
await wait(`document.querySelector('[data-packs]')`, 'packs section');
|
||||
await b.evaluate(`document.querySelector('[data-wfg-card] [data-wfg-apply]').click();true`);
|
||||
await wait(`(()=>{const r=document.querySelector('[data-packs]').getBoundingClientRect();return r.top>=0&&r.top<innerHeight})()`, 'packs in view');
|
||||
assert.equal(await b.evaluate(`location.hash`), '#/orgs/bare/organization');
|
||||
});
|
||||
|
||||
test('the organization page card lists each workflow as a link to its graph', { timeout: 60000 }, async t => {
|
||||
const { b, wait, reads } = await setup(t, { path: '#/orgs/acme/organization' });
|
||||
await wait(`document.querySelectorAll('[data-org-dashboard] [data-wfg-card] [data-wfg-card-item]').length === 3`, 'card items');
|
||||
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-wfg-card] [data-wfg-card-item]')].map(a=>[a.getAttribute('href'),a.textContent.replace(/\\s+/g,' ').trim()])`), [
|
||||
['#/orgs/acme/workflows/change', 'change 4 steps'],
|
||||
['#/orgs/acme/workflows/intake', `Intake ${XSS} 1 step`],
|
||||
['#/orgs/acme/workflows/change?project=site', 'Site release 2 steps, project site'],
|
||||
]);
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-card] [data-wfg-all]').getAttribute('href')`), '#/orgs/acme/workflows');
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('[data-org-dashboard] img').length`), 0);
|
||||
assert.equal(await b.evaluate(`window.__sp9pwn===undefined`), true);
|
||||
// The card reuses the page's one workflow read; it sends no second one.
|
||||
assert.equal(reads.filter(id => id === 'acme').length, 1);
|
||||
await b.evaluate(`document.querySelector('[data-wfg-card] [data-wfg-card-item]').click();true`);
|
||||
await wait(`location.hash === '#/orgs/acme/workflows/change' && document.querySelectorAll('[data-wfg-graph] [data-wfg-step]').length === 4`, 'graph from card');
|
||||
});
|
||||
|
||||
test('a stopped organization is not read: the page and card say the bus is stopped with the reason, never "no workflows"', { timeout: 60000 }, async t => {
|
||||
const { b, wait, reads } = await setup(t, { path: '#/orgs/gamma/workflows' });
|
||||
await wait(`document.querySelector('[data-wfg-list] [data-ws-unread]')`, 'unread note');
|
||||
const note = await b.evaluate(`document.querySelector('[data-wfg-list] [data-ws-unread]').textContent`);
|
||||
assert.match(note, /^Not read: Gamma Home/);
|
||||
assert.match(note, /stopped by the owner/);
|
||||
assert.doesNotMatch(await b.evaluate(`document.querySelector('#bus-view').textContent`), /No workflows/);
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-none],[data-wfg-item]')`), null);
|
||||
// The graph address says the same.
|
||||
await b.evaluate(`location.hash = '#/orgs/gamma/workflows/change'`);
|
||||
await wait(`document.querySelector('[data-wfg-page] [data-ws-unread]')`, 'graph unread note');
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-graph]')`), null);
|
||||
assert.doesNotMatch(await b.evaluate(`document.querySelector('#bus-view').textContent`), /No workflow/);
|
||||
// The organization page card.
|
||||
await b.evaluate(`location.hash = '#/orgs/gamma/organization'`);
|
||||
await wait(`document.querySelector('[data-org-dashboard] [data-wfg-card][data-bus-stopped]')`, 'stopped card');
|
||||
assert.match(await b.evaluate(`document.querySelector('[data-wfg-card]').textContent`), /Not read: the bus is stopped\./);
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-card] [data-wfg-none]')`), null);
|
||||
assert.equal(reads.filter(id => id === 'gamma').length, 0, 'a stopped organization is not read');
|
||||
});
|
||||
|
||||
test('at 390 the graph runs top to bottom inside its box and the page does not scroll sideways', { timeout: 60000 }, async t => {
|
||||
for (const mode of ['light', 'dark']) {
|
||||
const dark = mode === 'dark';
|
||||
await t.test(mode, async t2 => {
|
||||
const { b, wait } = await setup(t2, { path: '#/orgs/acme/workflows/change?step=3', viewport: [390, 844], mode });
|
||||
await wait(`${boxes}.length === 4 && document.querySelector('[data-wfg-detail-title]')`, 'graph at 390');
|
||||
assert.equal(await b.evaluate(`document.documentElement.dataset.mode`), mode, 'the page shows the mode under test');
|
||||
const ys = await b.evaluate(`${boxes}.map(g=>g.querySelector('rect').getBoundingClientRect().top)`);
|
||||
assert.deepEqual([...ys].sort((p, q) => p - q), ys);
|
||||
assert.equal(new Set(ys).size, 4);
|
||||
assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=innerWidth`), true, 'no sideways page scroll');
|
||||
// Every box fits the width of its own box.
|
||||
assert.equal(await b.evaluate(`(()=>{const w=document.querySelector('[data-wfg-graph]').closest('[data-wfg-frame]').getBoundingClientRect();return ${boxes}.every(g=>{const r=g.querySelector('rect').getBoundingClientRect();return r.left>=w.left-1&&r.right<=w.right+1})})()`), true);
|
||||
// The box text and the edges read in the theme's colours, not the browser default black on dark.
|
||||
const fill = await b.evaluate(`getComputedStyle(document.querySelector('[data-wfg-step="1"] [data-wfg-title]')).fill`);
|
||||
const bg = await b.evaluate(`getComputedStyle(document.body).backgroundColor`);
|
||||
assert.notEqual(fill, bg);
|
||||
assert.notEqual(fill, dark ? 'rgb(0, 0, 0)' : 'rgb(255, 255, 255)');
|
||||
// An SVG rect with no theme fill is drawn black.
|
||||
assert.notEqual(await b.evaluate(`getComputedStyle(document.querySelector('[data-wfg-step="1"] rect')).fill`), 'rgb(0, 0, 0)');
|
||||
// The list and the card fit too.
|
||||
await b.evaluate(`location.hash = '#/orgs/acme/workflows'`);
|
||||
await wait(`document.querySelector('[data-wfg-list] [data-wfg-item]')`, 'list at 390');
|
||||
assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=innerWidth`), true, 'list: no sideways page scroll');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- SP10: editing a workflow in its graph ----------
|
||||
const REV_A = 'a'.repeat(64);
|
||||
const titles = `${boxes}.map(g=>g.querySelector('[data-wfg-title]').textContent)`;
|
||||
const press = sel => `(()=>{const e=document.querySelector(${JSON.stringify(sel)});if(!e)throw Error('no '+${JSON.stringify(sel)});if(e.disabled)throw Error('disabled '+${JSON.stringify(sel)});e.click();return true})()`;
|
||||
const pick = step => `document.querySelector('[data-wfg-step="${step}"]').dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true}))`;
|
||||
// Sets a form field as typing would: the value, then the input event the page listens for.
|
||||
const fill = (sel, value) => `(()=>{const e=document.querySelector(${JSON.stringify(sel)});e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('input',{bubbles:true}));return true})()`;
|
||||
const role = (form, name) => `${form} input[name="role"][value="${name}"]`;
|
||||
const text = sel => `(document.querySelector(${JSON.stringify(sel)})?.textContent ?? '').replace(/\\s+/g,' ').trim()`;
|
||||
|
||||
test('editing a step changes the graph on the page, and Save sends one POST with the revision the page read', { timeout: 60000 }, async t => {
|
||||
const { b, wait, st } = await setup(t, { path: '#/orgs/acme/workflows/change?step=4' });
|
||||
await wait(`document.querySelector('[data-wfg-detail-title]')?.textContent === 'Approve the merge' && document.querySelector('[data-wfg-edit]')`, 'step 4 with Edit');
|
||||
// Nothing pending: no Unsaved, no Save.
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-unsaved],[data-wfg-save],[data-wfg-discard]')`), null);
|
||||
await b.evaluate(press('[data-wfg-edit]'));
|
||||
await wait(`document.querySelector('[data-wfg-form] [data-wfg-form-title]')?.textContent === 'Edit step 4'`, 'step form');
|
||||
assert.equal(await b.evaluate(`document.activeElement.dataset.focusKey`), 'wfg-f-title');
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-form] input[name="title"]').value`), 'Approve the merge');
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-form] select[name="state"]').value`), 'done');
|
||||
// The picker offers human, the organization's roles and the templates; the step's unknown role is first, checked and marked.
|
||||
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-wfg-form] input[name="role"]')].map(i=>[i.value,i.checked])`), [['merge-gate', true], ['human', true], ['coder', false], ['pm', false], ['reviewer', false], ['writer', false]]);
|
||||
assert.ok(await b.evaluate(`!!document.querySelector('[data-wfg-form] [data-wfg-pick-missing]')`));
|
||||
await b.evaluate(fill('[data-wfg-form] input[name="title"]', 'Approve and merge'));
|
||||
await b.evaluate(press(role('[data-wfg-form]', 'merge-gate')));
|
||||
await b.evaluate(press(role('[data-wfg-form]', 'reviewer')));
|
||||
await b.evaluate(press('[data-wfg-apply]'));
|
||||
await wait(`${titles}[3] === 'Approve and merge' && document.querySelector('[data-wfg-unsaved]')`, 'pending change drawn');
|
||||
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-wfg-step="4"] [data-wfg-roles] tspan')].map(t=>t.textContent)`), ['human', 'reviewer']);
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-step="4"] [data-wfg-missing]')`), null);
|
||||
assert.equal(await b.evaluate(text('[data-wfg-unsaved]')), 'Unsaved');
|
||||
assert.equal(await b.evaluate(`document.activeElement.dataset.focusKey`), 'wfg-save');
|
||||
assert.equal(st.writes.length, 0, 'Apply writes nothing');
|
||||
await b.evaluate(press('[data-wfg-save]'));
|
||||
await wait(`/Saved change\\./.test(${text('[data-wfg-status]')}) && !document.querySelector('[data-wfg-unsaved]')`, 'saved');
|
||||
assert.equal(st.writes.length, 1);
|
||||
assert.deepEqual(st.writes[0], { id: 'acme', verb: 'workflow.set.console', args: { revision: REV_A, name: 'change', workflow: {
|
||||
description: 'One change from task to merge.',
|
||||
steps: [
|
||||
{ title: 'Write the task', state: 'todo', roles: ['pm'] },
|
||||
{ title: 'Implement the change', state: 'in-progress', roles: ['coder'] },
|
||||
{ title: 'Review the change', state: 'in-review', roles: ['reviewer'] },
|
||||
{ title: 'Approve and merge', state: 'done', roles: ['human', 'reviewer'] },
|
||||
] } } });
|
||||
// The page now draws the file as saved, read again.
|
||||
await wait(`document.querySelector('[data-wfg-detail-title]')?.textContent === 'Approve and merge'`, 'detail after save');
|
||||
assert.deepEqual(st.docs.acme.workflows.change.steps[3], { title: 'Approve and merge', state: 'done', roles: ['human', 'reviewer'] });
|
||||
});
|
||||
|
||||
test('add, move, remove and discard change only the page until Save', { timeout: 60000 }, async t => {
|
||||
const { b, wait, st } = await setup(t, { path: '#/orgs/acme/workflows/change?step=2' });
|
||||
await wait(`document.querySelector('[data-wfg-detail-title]')?.textContent === 'Implement the change'`, 'step 2');
|
||||
// Add after the selected step.
|
||||
await b.evaluate(press('[data-wfg-add]'));
|
||||
await wait(`document.querySelector('[data-wfg-form-title]')?.textContent === 'New step 3'`, 'add form');
|
||||
await b.evaluate(fill('[data-wfg-form] input[name="title"]', 'Test the change'));
|
||||
await b.evaluate(`(()=>{const e=document.querySelector('[data-wfg-form] select[name="state"]');e.value='in-review';e.dispatchEvent(new Event('input',{bubbles:true}));return true})()`);
|
||||
// Apply with no role is refused in words, and the form stays.
|
||||
await b.evaluate(press('[data-wfg-apply]'));
|
||||
await wait(`${text('[data-wfg-why]')} === 'Step 3 needs at least one role.'`, 'no role refused');
|
||||
await b.evaluate(press(role('[data-wfg-form]', 'coder')));
|
||||
await b.evaluate(press('[data-wfg-apply]'));
|
||||
await wait(`${boxes}.length === 5 && ${titles}[2] === 'Test the change'`, 'step added');
|
||||
assert.equal(await b.evaluate(`location.hash`), '#/orgs/acme/workflows/change?step=3');
|
||||
assert.equal(await b.evaluate(text('[data-wfg-field="state"]')), 'Review');
|
||||
assert.ok(await b.evaluate(`!!document.querySelector('[data-wfg-unsaved]')`));
|
||||
// Move earlier and later.
|
||||
await b.evaluate(press('[data-wfg-up]'));
|
||||
await wait(`${titles}[1] === 'Test the change' && /step=2/.test(location.hash)`, 'moved earlier');
|
||||
assert.equal(await b.evaluate(`document.activeElement.dataset.focusKey`), 'wfg-up');
|
||||
await b.evaluate(press('[data-wfg-down]'));
|
||||
await wait(`${titles}[2] === 'Test the change' && /step=3/.test(location.hash)`, 'moved later');
|
||||
// Selecting another step goes through a full render (a read); the pending change stays.
|
||||
await b.evaluate(pick(1));
|
||||
await wait(`document.querySelector('[data-wfg-detail-title]')?.textContent === 'Write the task' && ${boxes}.length === 5`, 'step 1, change kept');
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-up]').disabled`), true, 'the first step cannot move earlier');
|
||||
await b.evaluate(press('[data-wfg-remove]'));
|
||||
await wait(`${boxes}.length === 4`, 'step removed');
|
||||
assert.deepEqual(await b.evaluate(titles), ['Implement the change', 'Test the change', 'Review the change', 'Approve the merge']);
|
||||
// With no step selected, Add puts a step at the end.
|
||||
await b.evaluate(`location.hash = '#/orgs/acme/workflows/change'`);
|
||||
await wait(`${text('[data-wfg-add]')} === 'Add a step at the end'`, 'add at end');
|
||||
await b.evaluate(press('[data-wfg-add]'));
|
||||
await wait(`document.querySelector('[data-wfg-form-title]')?.textContent === 'New step 5'`, 'add-at-end form');
|
||||
await b.evaluate(fill('[data-wfg-form] input[name="title"]', 'Announce it'));
|
||||
await b.evaluate(press(role('[data-wfg-form]', 'pm')));
|
||||
await b.evaluate(press('[data-wfg-apply]'));
|
||||
await wait(`${boxes}.length === 5 && ${titles}[4] === 'Announce it' && /step=5/.test(location.hash)`, 'added at end');
|
||||
// Discard drops all of it; nothing was written.
|
||||
await b.evaluate(press('[data-wfg-discard]'));
|
||||
await wait(`${boxes}.length === 4 && !document.querySelector('[data-wfg-unsaved]')`, 'discarded');
|
||||
assert.deepEqual(await b.evaluate(titles), ['Write the task', 'Implement the change', 'Review the change', 'Approve the merge']);
|
||||
assert.equal(st.writes.length, 0);
|
||||
});
|
||||
|
||||
test('a refused save says why next to Save and keeps the pending change', { timeout: 60000 }, async t => {
|
||||
const { b, wait, st } = await setup(t, { path: '#/orgs/acme/workflows/change?step=1' });
|
||||
await wait(`document.querySelector('[data-wfg-edit]')`, 'step 1');
|
||||
await b.evaluate(press('[data-wfg-edit]'));
|
||||
await wait(`document.querySelector('[data-wfg-form]')`, 'form');
|
||||
await b.evaluate(fill('[data-wfg-form] input[name="title"]', 'Write the brief'));
|
||||
await b.evaluate(press('[data-wfg-apply]'));
|
||||
await wait(`${titles}[0] === 'Write the brief'`, 'pending');
|
||||
// Step 4 still names merge-gate: the page refuses before sending, in the same words as the form editor.
|
||||
await b.evaluate(press('[data-wfg-save]'));
|
||||
await wait(`${text('[data-wfg-status]')} === 'Step 4: merge-gate names no current role. Use an organization role, a role template or human.'`, 'unknown role refused');
|
||||
assert.equal(st.writes.length, 0);
|
||||
assert.ok(await b.evaluate(`${titles}[0] === 'Write the brief' && !!document.querySelector('[data-wfg-unsaved]')`));
|
||||
// Fix step 4, then the bus is down.
|
||||
await b.evaluate(pick(4));
|
||||
await wait(`document.querySelector('[data-wfg-detail-title]')?.textContent === 'Approve the merge'`, 'step 4');
|
||||
await b.evaluate(press('[data-wfg-edit]'));
|
||||
await wait(`document.querySelector('[data-wfg-form-title]')?.textContent === 'Edit step 4'`, 'form 4');
|
||||
await b.evaluate(press(role('[data-wfg-form]', 'merge-gate')));
|
||||
await b.evaluate(press('[data-wfg-apply]'));
|
||||
await wait(`!document.querySelector('[data-wfg-step="4"] [data-wfg-missing]')`, 'step 4 fixed');
|
||||
st.refuse = 'no-bus-host';
|
||||
await b.evaluate(press('[data-wfg-save]'));
|
||||
await wait(`/bus is not running/.test(${text('[data-wfg-status]')})`, 'bus down refused');
|
||||
assert.match(await b.evaluate(text('[data-wfg-status]')), /Your change is kept/);
|
||||
assert.equal(st.writes.length, 1);
|
||||
assert.equal(st.writes[0].args.revision, REV_A);
|
||||
assert.ok(await b.evaluate(`${titles}[0] === 'Write the brief' && !!document.querySelector('[data-wfg-unsaved]') && !!document.querySelector('[data-wfg-save]')`));
|
||||
assert.equal(st.docs.acme.workflows.change.steps[0].title, 'Write the task', 'nothing written');
|
||||
// The status sits in the bar with Save.
|
||||
assert.ok(await b.evaluate(`!!document.querySelector('[data-wfg-bar] [data-wfg-status]') && !!document.querySelector('[data-wfg-bar] [data-wfg-save]')`));
|
||||
// Another writer changes this workflow: the save is refused as stale, the change kept.
|
||||
st.change('acme', doc => { doc.workflows.change.description = 'Changed elsewhere.'; });
|
||||
await b.evaluate(press('[data-wfg-save]'));
|
||||
await wait(`/changed since this page read it/.test(${text('[data-wfg-status]')})`, 'stale refused');
|
||||
assert.match(await b.evaluate(text('[data-wfg-status]')), /Your change is kept\. Discard to load the current version\./);
|
||||
assert.ok(await b.evaluate(`${titles}[0] === 'Write the brief' && !!document.querySelector('[data-wfg-unsaved]')`));
|
||||
assert.equal(st.writes.length, 2);
|
||||
});
|
||||
|
||||
test('the poll does not draw over a pending change, takes a new revision when only other workflows changed, and says when this one changed', { timeout: 60000 }, async t => {
|
||||
const { b, wait, st, reads } = await setup(t, { path: '#/orgs/acme/workflows/change?project=site&step=2' });
|
||||
// The site project's workflow names only current roles, so a save goes to the bus.
|
||||
await wait(`document.querySelector('[data-wfg-edit]')`, 'step 2');
|
||||
const repaint = async () => { const n = reads.length; await b.evaluate(`document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));true`); await wait(`true`); for (let i = 0; i < 100 && reads.length === n; i++) await new Promise(r => setTimeout(r, 30)); await new Promise(r => setTimeout(r, 150)); };
|
||||
// A form being typed in survives a repaint as typed.
|
||||
await b.evaluate(press('[data-wfg-edit]'));
|
||||
await wait(`document.querySelector('[data-wfg-form]')`, 'form');
|
||||
await b.evaluate(fill('[data-wfg-form] input[name="title"]', 'Ship the release'));
|
||||
await repaint();
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-form] input[name="title"]')?.value`), 'Ship the release');
|
||||
await b.evaluate(press('[data-wfg-apply]'));
|
||||
await wait(`${titles}[1] === 'Ship the release' && document.querySelector('[data-wfg-unsaved]')`, 'pending');
|
||||
await repaint();
|
||||
assert.equal(await b.evaluate(`${titles}[1]`), 'Ship the release', 'the poll keeps the pending change');
|
||||
assert.ok(await b.evaluate(`!!document.querySelector('[data-wfg-unsaved]')`));
|
||||
// Another workflow changes in the file: this one is as read, so the page takes the new revision and no warning shows.
|
||||
st.change('acme', doc => { doc.workflows.intake.title = 'Intake, renamed'; });
|
||||
const fresh = st.revs.acme;
|
||||
await repaint();
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-stale]')`), null);
|
||||
assert.equal(await b.evaluate(`${titles}[1]`), 'Ship the release');
|
||||
await b.evaluate(press('[data-wfg-save]'));
|
||||
await wait(`/Saved change\\./.test(${text('[data-wfg-status]')})`, 'saved on the new revision');
|
||||
assert.equal(st.writes.at(-1).args.revision, fresh);
|
||||
assert.equal(st.docs.acme.workflows.intake.title, 'Intake, renamed', 'the other workflow is untouched');
|
||||
// Now this workflow changes underneath a pending change: the page says so and offers Discard.
|
||||
await b.evaluate(press('[data-wfg-edit]'));
|
||||
await wait(`document.querySelector('[data-wfg-form]')`, 'form again');
|
||||
await b.evaluate(fill('[data-wfg-form] input[name="title"]', 'Build it'));
|
||||
await b.evaluate(press('[data-wfg-apply]'));
|
||||
await wait(`${titles}[1] === 'Build it'`, 'pending again');
|
||||
st.change('acme', doc => { doc.projects.site.workflows.change.description = 'Changed elsewhere.'; });
|
||||
await repaint();
|
||||
await wait(`!!document.querySelector('[data-wfg-stale]')`, 'stale note');
|
||||
assert.match(await b.evaluate(text('[data-wfg-stale]')), /changed this workflow since the page read it.*Discard to load the current version/);
|
||||
assert.equal(await b.evaluate(`${titles}[1]`), 'Build it');
|
||||
assert.ok(await b.evaluate(`!!document.querySelector('[data-wfg-discard]')`));
|
||||
await b.evaluate(press('[data-wfg-discard]'));
|
||||
await wait(`${titles}[1] === 'Ship the release' && !document.querySelector('[data-wfg-stale],[data-wfg-unsaved]')`, 'discarded to current');
|
||||
assert.match(await b.evaluate(`document.querySelector('#bus-view .sub').textContent`), /Changed elsewhere\./);
|
||||
});
|
||||
|
||||
test('a reader who cannot write sees no edit controls and one line saying who can', { timeout: 60000 }, async t => {
|
||||
const { b, wait } = await setup(t, { path: '#/orgs/acme/workflows/change?step=2', writable: false });
|
||||
await wait(`document.querySelector('[data-wfg-detail-title]')?.textContent === 'Implement the change'`, 'step 2');
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-bar],[data-wfg-actions],[data-wfg-edit],[data-wfg-add],[data-wfg-save],[data-wfg-delete],[data-wfg-form]')`), null);
|
||||
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('#bus-view [data-wfg-readonly]')].map(n=>n.textContent)`), ['Only the owner, on a Console started with send, can change workflows.']);
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('#bus-view button').length`), 0);
|
||||
await b.evaluate(`location.hash = '#/orgs/acme/workflows'`);
|
||||
await wait(`document.querySelector('[data-wfg-list] [data-wfg-item]')`, 'list');
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-new]')`), null);
|
||||
assert.ok(await b.evaluate(`!!document.querySelector('[data-wfg-list] [data-wfg-readonly]')`));
|
||||
});
|
||||
|
||||
test('a new workflow starts on the list page, opens in its graph unsaved, saves, and deletes after a confirm', { timeout: 60000 }, async t => {
|
||||
const { b, wait, st } = await setup(t, { path: '#/orgs/acme/workflows' });
|
||||
await wait(`document.querySelector('[data-wfg-new]')`, 'New workflow');
|
||||
await b.evaluate(press('[data-wfg-new]'));
|
||||
await wait(`document.querySelector('[data-wfg-newform]')`, 'new form');
|
||||
assert.equal(await b.evaluate(`document.activeElement.dataset.focusKey`), 'wfg-n-name');
|
||||
const create = async () => b.evaluate(press('[data-wfg-create]'));
|
||||
await b.evaluate(fill('[data-wfg-newform] input[name="name"]', 'Release Plan'));
|
||||
await create();
|
||||
await wait(`${text('[data-wfg-newform] [data-wfg-why]')} === 'A workflow name is 1 to 64 lowercase letters, digits or dashes, starting with a letter.'`, 'name refused');
|
||||
await b.evaluate(fill('[data-wfg-newform] input[name="name"]', 'change'));
|
||||
await create();
|
||||
await wait(`${text('[data-wfg-newform] [data-wfg-why]')} === 'A workflow named change already exists here. Edit it instead.'`, 'taken refused');
|
||||
await b.evaluate(fill('[data-wfg-newform] input[name="name"]', 'release'));
|
||||
await b.evaluate(fill('[data-wfg-newform] input[name="title"]', 'Cut the release'));
|
||||
await create();
|
||||
await wait(`${text('[data-wfg-newform] [data-wfg-why]')} === 'Step 1 needs at least one role.'`, 'role refused');
|
||||
await b.evaluate(press(role('[data-wfg-newform]', 'human')));
|
||||
await create();
|
||||
await wait(`location.hash === '#/orgs/acme/workflows/release?step=1' && ${boxes}.length === 1`, 'opens in the graph');
|
||||
assert.equal(await b.evaluate(text('[data-wfg-unsaved]')), 'Unsaved, not in the file yet');
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-delete]')`), null, 'nothing to delete yet');
|
||||
assert.equal(st.writes.length, 0);
|
||||
// The list shows it, marked.
|
||||
await b.evaluate(`location.hash = '#/orgs/acme/workflows'`);
|
||||
await wait(`document.querySelector('[data-wfg-item="release"] [data-wfg-unsaved]')`, 'listed unsaved');
|
||||
await b.evaluate(press('[data-wfg-item="release"] a[data-wfg-open]'));
|
||||
await wait(`${boxes}.length === 1 && document.querySelector('[data-wfg-save]')`, 'back in the graph');
|
||||
await b.evaluate(press('[data-wfg-save]'));
|
||||
await wait(`/Saved release\\./.test(${text('[data-wfg-status]')}) && document.querySelector('[data-wfg-delete]')`, 'saved');
|
||||
assert.deepEqual(st.writes[0].args, { revision: REV_A, name: 'release', workflow: { steps: [{ title: 'Cut the release', state: 'todo', roles: ['human'] }] } });
|
||||
// Delete asks first; Keep keeps it.
|
||||
await b.evaluate(press('[data-wfg-delete]'));
|
||||
await wait(`document.querySelector('[data-wfg-confirm]')`, 'confirm');
|
||||
assert.match(await b.evaluate(text('[data-wfg-confirm]')), /Delete release from the organization file\?/);
|
||||
assert.equal(await b.evaluate(`document.activeElement.dataset.focusKey`), 'wfg-delete-no');
|
||||
await b.evaluate(press('[data-wfg-delete-no]'));
|
||||
await wait(`!document.querySelector('[data-wfg-confirm]') && document.activeElement?.dataset.focusKey === 'wfg-delete'`, 'kept');
|
||||
assert.equal(st.writes.length, 1);
|
||||
const saved = st.revs.acme;
|
||||
await b.evaluate(press('[data-wfg-delete]'));
|
||||
await wait(`document.querySelector('[data-wfg-delete-yes]')`, 'confirm again');
|
||||
await b.evaluate(press('[data-wfg-delete-yes]'));
|
||||
await wait(`location.hash === '#/orgs/acme/workflows' && /Deleted release\\./.test(${text('[data-wfg-list] [data-wfg-status]')}) && !document.querySelector('[data-wfg-item="release"]')`, 'deleted');
|
||||
assert.deepEqual(st.writes[1].args, { revision: saved, name: 'release', workflow: null });
|
||||
assert.equal(Object.hasOwn(st.docs.acme.workflows, 'release'), false);
|
||||
});
|
||||
|
||||
test('the keyboard alone opens a step, edits it and saves', { timeout: 60000 }, async t => {
|
||||
const { b, wait, st } = await setup(t, { path: '#/orgs/acme/workflows/change?project=site' });
|
||||
await wait(`${boxes}.length === 2`, 'graph');
|
||||
await b.evaluate(`document.querySelector('[data-wfg-step="1"]').focus();true`);
|
||||
await b.key('Enter');
|
||||
await wait(`document.querySelector('[data-wfg-detail-title]')?.textContent === 'Build' && document.activeElement?.dataset.focusKey === 'wfg-step:1'`, 'Enter opens step 1');
|
||||
const tabTo = async (sel, what) => {
|
||||
for (let i = 0; i < 20; i++) { if (await b.evaluate(`document.activeElement?.matches(${JSON.stringify(sel)}) ?? false`)) return; await b.key('Tab'); }
|
||||
throw new Error(`Tab never reached ${what}`);
|
||||
};
|
||||
await tabTo('[data-wfg-edit]', 'Edit step');
|
||||
await b.key('Enter');
|
||||
await wait(`document.querySelector('[data-wfg-form-title]')?.textContent === 'Edit step 1' && document.activeElement?.dataset.focusKey === 'wfg-f-title'`, 'form with focus on the title');
|
||||
await b.call('Input.insertText', { text: ' now' });
|
||||
await tabTo(`[data-wfg-form] input[name="role"][value="reviewer"]`, 'the reviewer box');
|
||||
await b.key(' ', 'Space');
|
||||
await tabTo('[data-wfg-apply]', 'Apply');
|
||||
await b.key('Enter');
|
||||
await wait(`${titles}[0] === 'Build now' && document.activeElement?.dataset.focusKey === 'wfg-save'`, 'applied, focus on Save');
|
||||
await b.key('Enter');
|
||||
await wait(`/Saved change\\./.test(${text('[data-wfg-status]')})`, 'saved');
|
||||
assert.deepEqual(st.writes[0].args, { revision: REV_A, name: 'change', project: 'site', workflow: { title: 'Site release', steps: [
|
||||
{ title: 'Build now', state: 'in-progress', roles: ['coder', 'reviewer'] }, { title: 'Ship', state: 'done', roles: ['human'] }] } });
|
||||
});
|
||||
|
||||
test('switching organization with a pending workflow change asks first, through the existing draft guard', { timeout: 60000 }, async t => {
|
||||
const { b, wait, st } = await setup(t, { path: '#/orgs/acme/workflows/change?project=site&step=1' });
|
||||
await wait(`document.querySelector('[data-wfg-edit]') && document.querySelector('#breadcrumbs [data-crumb-org]')`, 'step 1');
|
||||
await b.evaluate(press('[data-wfg-remove]'));
|
||||
await wait(`${boxes}.length === 1 && document.querySelector('[data-wfg-unsaved]')`, 'pending removal');
|
||||
assert.equal(await b.evaluate(`window.MosaicWorkflows.hasDraft()`), true);
|
||||
await b.evaluate(`location.hash = '#/orgs/bare/workflows'`);
|
||||
await wait(`document.querySelector('dialog[aria-label="Unsaved draft"][open]')`, 'guard dialog');
|
||||
await b.evaluate(press('dialog [data-stay]'));
|
||||
await wait(`!document.querySelector('dialog[open]') && /#\\/orgs\\/acme\\/workflows\\/change/.test(location.hash) && ${boxes}.length === 1`, 'stayed with the change');
|
||||
await b.evaluate(`location.hash = '#/orgs/bare/workflows'`);
|
||||
await wait(`document.querySelector('dialog[aria-label="Unsaved draft"][open]')`, 'guard dialog again');
|
||||
await b.evaluate(press('dialog [data-discard]'));
|
||||
await wait(`location.hash === '#/orgs/bare/workflows' && document.querySelector('[data-wfg-list][data-wfg-org="bare"]')`, 'switched');
|
||||
assert.equal(await b.evaluate(`window.MosaicWorkflows.hasDraft()`), false);
|
||||
assert.equal(st.writes.length, 0);
|
||||
});
|
||||
|
||||
test('Save with a step form holding edits not applied refuses in words and sends nothing; after Apply it sends them', { timeout: 60000 }, async t => {
|
||||
const { b, wait, st } = await setup(t, { path: '#/orgs/acme/workflows/change?project=site&step=2' });
|
||||
await wait(`document.querySelector('[data-wfg-up]')`, 'step 2');
|
||||
// One change pending, so Save shows; then a step form is opened and typed in, not applied.
|
||||
await b.evaluate(press('[data-wfg-up]'));
|
||||
await wait(`${titles}[0] === 'Ship' && document.querySelector('[data-wfg-save]')`, 'moved');
|
||||
await b.evaluate(press('[data-wfg-edit]'));
|
||||
await wait(`document.querySelector('[data-wfg-form]')`, 'form');
|
||||
await b.evaluate(fill('[data-wfg-form] input[name="title"]', 'Ship the release'));
|
||||
await b.evaluate(press('[data-wfg-save]'));
|
||||
await wait(`/Apply or cancel the step first/.test(${text('[data-wfg-status]')})`, 'save refused');
|
||||
assert.equal(await b.evaluate(text('[data-wfg-status]')), 'Not saved: step 1 has edits not applied. Apply or cancel the step first.');
|
||||
assert.equal(st.writes.length, 0);
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-form] input[name="title"]')?.value`), 'Ship the release', 'the typed title stays');
|
||||
await b.evaluate(press('[data-wfg-apply]'));
|
||||
await wait(`${titles}[0] === 'Ship the release'`, 'applied');
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-status]')`), null, 'Apply clears the refusal');
|
||||
await b.evaluate(press('[data-wfg-save]'));
|
||||
await wait(`/Saved change\\./.test(${text('[data-wfg-status]')})`, 'saved');
|
||||
assert.equal(st.writes.length, 1);
|
||||
assert.deepEqual(st.writes[0].args.workflow.steps.map(s => s.title), ['Ship the release', 'Build']);
|
||||
});
|
||||
|
||||
test('Delete on a view another writer has changed is refused, not deleted over', { timeout: 60000 }, async t => {
|
||||
const { b, wait, st, reads } = await setup(t, { path: '#/orgs/acme/workflows/change?project=site&step=1' });
|
||||
await wait(`document.querySelector('[data-wfg-remove]')`, 'step 1');
|
||||
await b.evaluate(press('[data-wfg-remove]'));
|
||||
await wait(`${boxes}.length === 1 && document.querySelector('[data-wfg-unsaved]')`, 'pending removal');
|
||||
st.change('acme', doc => { doc.projects.site.workflows.change.description = 'Changed elsewhere.'; });
|
||||
const n = reads.length;
|
||||
await b.evaluate(`document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));true`);
|
||||
await wait(`!!document.querySelector('[data-wfg-stale]')`, 'stale note');
|
||||
assert.ok(reads.length > n);
|
||||
await b.evaluate(press('[data-wfg-delete]'));
|
||||
await wait(`document.querySelector('[data-wfg-delete-yes]')`, 'confirm');
|
||||
await b.evaluate(press('[data-wfg-delete-yes]'));
|
||||
await wait(`/^Not deleted/.test(${text('[data-wfg-status]')})`, 'delete refused');
|
||||
assert.equal(await b.evaluate(text('[data-wfg-status]')), 'Not deleted: the organization file changed this workflow since the page read it. Discard to load the current version.');
|
||||
assert.equal(st.writes.length, 1);
|
||||
assert.equal(st.writes[0].args.revision, REV_A);
|
||||
assert.equal(st.writes[0].args.workflow, null);
|
||||
assert.equal(st.docs.acme.projects.site.workflows.change.description, 'Changed elsewhere.', 'the newer workflow is still in the file');
|
||||
assert.ok(await b.evaluate(`${boxes}.length === 1 && !!document.querySelector('[data-wfg-unsaved]')`), 'the pending change stays');
|
||||
// With no pending change there is no Discard, so the refusal does not point at one.
|
||||
await b.evaluate(press('[data-wfg-discard]'));
|
||||
await wait(`${boxes}.length === 2 && !document.querySelector('[data-wfg-unsaved]')`, 'discarded');
|
||||
await b.evaluate(press('[data-wfg-delete]'));
|
||||
await wait(`document.querySelector('[data-wfg-delete-yes]')`, 'confirm again');
|
||||
st.change('acme', doc => { doc.projects.site.workflows.change.description = 'Changed again.'; });
|
||||
await b.evaluate(press('[data-wfg-delete-yes]'));
|
||||
await wait(`/^Not deleted/.test(${text('[data-wfg-status]')})`, 'second delete refused');
|
||||
assert.equal(await b.evaluate(text('[data-wfg-status]')), 'Not deleted: the organization file changed this workflow since the page read it. The page shows the current version at its next read.');
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-discard]')`), null);
|
||||
assert.equal(st.docs.acme.projects.site.workflows.change.description, 'Changed again.');
|
||||
});
|
||||
|
||||
test('a failed read says a pending change is kept, and it comes back when the read works', { timeout: 60000 }, async t => {
|
||||
const { b, wait, st } = await setup(t, { path: '#/orgs/acme/workflows/change?project=site&step=1' });
|
||||
await wait(`document.querySelector('[data-wfg-remove]')`, 'step 1');
|
||||
await b.evaluate(press('[data-wfg-remove]'));
|
||||
await wait(`${boxes}.length === 1 && document.querySelector('[data-wfg-unsaved]')`, 'pending removal');
|
||||
st.failRead = true;
|
||||
await b.evaluate(`document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));true`);
|
||||
await wait(`!!document.querySelector('[data-wfg-error]') && !!document.querySelector('[data-wfg-held]')`, 'error with kept note');
|
||||
assert.equal(await b.evaluate(text('[data-wfg-held]')), 'Unsaved change to change is kept on this page. It comes back when the workflows can be read.');
|
||||
assert.equal(await b.evaluate(`window.MosaicWorkflows.hasDraft()`), true);
|
||||
st.failRead = false;
|
||||
await b.evaluate(`document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));true`);
|
||||
await wait(`${boxes}.length === 1 && !!document.querySelector('[data-wfg-unsaved]') && !document.querySelector('[data-wfg-held]')`, 'back');
|
||||
assert.equal(st.writes.length, 0);
|
||||
});
|
||||
@@ -14,7 +14,8 @@ const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString()
|
||||
// acme and beta answer; gamma's bus is stopped. Decision ids are long so a shortened id would show.
|
||||
const ACME_DECISION = 'dec-acme-7f3c9a21-5b8e-4d1f-9c2a-0e6b7d8f9a10';
|
||||
const BETA_DECISION = 'dec-beta-1a2b3c4d-5e6f-4a1b-8c9d-0e1f2a3b4c5d';
|
||||
function fixture({ pollMs, fail = {}, down = [] } = {}) {
|
||||
// messages: { org: rows } is what that organization's bus messages read answers.
|
||||
function fixture({ pollMs, fail = {}, down = [], messages = {} } = {}) {
|
||||
const LIST = [
|
||||
{ id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true },
|
||||
{ id: 'beta', label: 'Beta Works', theme: 'grove', busHealthy: true },
|
||||
@@ -61,7 +62,9 @@ function fixture({ pollMs, fail = {}, down = [] } = {}) {
|
||||
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 () => [], '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, instances: {} }) },
|
||||
bus: { messages: async () => ({ messages: messages[id] ?? [], unread: 0 }), agents: async () => [], '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, instances: {} }),
|
||||
// SP11: a decision's own page reads its trail.
|
||||
trail: async subject => DATA[id].inbox.filter(d => d.id === subject).map((d, i) => ({ table: 'decisions', seq: i + 1, ...d, business: id, raised_by_run: 'run', route_to: 'human', action: 'price.set', recommendation: null })) },
|
||||
chat: { projects: async () => DATA[id].projects, threads: async () => [] },
|
||||
};
|
||||
},
|
||||
@@ -69,35 +72,35 @@ function fixture({ pollMs, fail = {}, down = [] } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
async function setup(t, { viewport = [1440, 900], hash = '/', workspaceTimeoutMs, fail, down } = {}) {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ fail, down }), log: () => {}, ...(workspaceTimeoutMs ? { workspaceTimeoutMs } : {}) });
|
||||
async function setup(t, { viewport = [1440, 900], hash = '/', workspaceTimeoutMs, fail, down, messages } = {}) {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ fail, down, messages }), log: () => {}, ...(workspaceTimeoutMs ? { workspaceTimeoutMs } : {}) });
|
||||
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}/#/orgs/acme${hash}`);
|
||||
// SP3: a hash that starts with # is a whole address (#/inbox is a system route).
|
||||
await b.navigate(`${base}/${hash.startsWith('#') ? hash : `#/orgs/acme${hash}`}`);
|
||||
return { b, base, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) };
|
||||
}
|
||||
|
||||
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']);
|
||||
// SP8 (#1873): one row of figure tiles, none for spend or any figure no read returns.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-tile]")].map(e => e.dataset.wsTile)'), ['agents', 'orgs', 'needs', 'tasks', 'done', 'projects']);
|
||||
assert.match(await text('[data-ws-tile=needs]'), /Needs you\s*2\s*items/i);
|
||||
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');
|
||||
assert.match(await text('[data-ws-tile=projects]'), /Projects\s*3 active[\s\S]*1 archived/i);
|
||||
assert.doesNotMatch(await text('[data-ws-tiles]'), /spend|\$/i);
|
||||
// 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.
|
||||
@@ -107,7 +110,7 @@ test('Dashboard renders every section across organizations, and a stopped one sh
|
||||
// Needs you lists both organizations' decisions, newest raised first as the read orders them.
|
||||
const needs = await b.evaluate('[...document.querySelectorAll("[data-ws-needs] a.q")].map(a => [a.textContent, a.getAttribute("href")])');
|
||||
assert.equal(needs.length, 2);
|
||||
assert.ok(needs.some(([q, h]) => q === 'Ship the hero?' && h === `#/orgs/acme/inbox?d=${encodeURIComponent(`acme:${ACME_DECISION}`)}`), JSON.stringify(needs));
|
||||
assert.ok(needs.some(([q, h]) => q === 'Ship the hero?' && h === `#/inbox?d=${encodeURIComponent(`acme:${ACME_DECISION}`)}`), JSON.stringify(needs));
|
||||
assert.ok(needs.some(([q]) => q === 'Pick a launch price'));
|
||||
// Sessions now: the working and the waiting role, each with its organization.
|
||||
assert.match(await text('[data-ws-sessions]'), /coder/);
|
||||
@@ -179,7 +182,7 @@ test('Projects sorts and filters by each key, remembers the grouping, and rows o
|
||||
const location = hash => decodeURIComponent(hash);
|
||||
|
||||
test('Inbox lists both organizations, copies a command with --org and the full id, and deciding needs human proof', async t => {
|
||||
const { b, base, wait, text } = await setup(t, { hash: '/inbox' });
|
||||
const { b, base, wait, text } = await setup(t, { hash: '#/inbox' });
|
||||
await wait('document.querySelectorAll("[data-ws-item]").length === 2 && document.querySelector("[data-ws-insp]")');
|
||||
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page] .t")?.textContent'), 'Inbox');
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-not-built]")'), null);
|
||||
@@ -187,13 +190,15 @@ test('Inbox lists both organizations, copies a command with --org and the full i
|
||||
assert.deepEqual(items.map(([k]) => k).sort(), [`acme:${ACME_DECISION}`, `beta:${BETA_DECISION}`]);
|
||||
// Each row names its organization, project and session.
|
||||
const acme = items.find(([k]) => k.startsWith('acme'))[1];
|
||||
assert.match(acme, /Acme Studio/); assert.match(acme, /Website/); assert.match(acme, /coder/); assert.match(acme, /gated/);
|
||||
assert.match(acme, /Acme Studio/); assert.match(acme, /Website/); assert.match(acme, /coder/); assert.match(acme, /Gated/);
|
||||
const beta = items.find(([k]) => k.startsWith('beta'))[1];
|
||||
assert.match(beta, /Beta Works/); assert.match(beta, /No project/); assert.match(beta, /pm/);
|
||||
assert.match(beta, /Beta Works/); assert.match(beta, /pm/);
|
||||
// SP3: a project chip only where the item has a project.
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-ws-item="beta:${BETA_DECISION}"] [data-ws-projchip]')`), null);
|
||||
assert.match(await text('[data-ws-unread]'), /Gamma Home down/);
|
||||
// Tabs the data can't back say so.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-inbox] .ws-seg button")].map(x => [x.textContent, x.disabled])'), [['Needs you', false], ['Seen', true], ['Answered', true]]);
|
||||
assert.match(await text('[data-ws-inbox]'), /not built yet/);
|
||||
// SP3: the prototype's Org and Type filter rows; the Seen and Answered placeholders are gone.
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-ws-inbox] .ws-seg")'), null);
|
||||
assert.ok(await b.evaluate('document.querySelectorAll("[data-ws-type]").length > 1'));
|
||||
// Select the acme decision; the inspector shows it, and the selection stays in the hash.
|
||||
// A pointer click focuses the link before it activates; .click() alone does not.
|
||||
await b.evaluate(`(() => { const a = document.querySelector('[data-ws-select="acme:${ACME_DECISION}"]'); a.focus(); a.click(); })()`);
|
||||
@@ -216,12 +221,42 @@ test('Inbox lists both organizations, copies a command with --org and the full i
|
||||
await wait('[...document.querySelectorAll("#bus-view h1")].length > 0 && !document.querySelector("#bus-view .loading")');
|
||||
assert.equal(await b.evaluate('document.querySelector("#decide-section [data-decide]")'), null);
|
||||
// Filter to one organization.
|
||||
await b.navigate(`${base}/#/orgs/acme/inbox`);
|
||||
await b.navigate(`${base}/#/inbox`);
|
||||
await wait('document.querySelectorAll("[data-ws-item]").length === 2');
|
||||
await b.evaluate('document.querySelector("[data-ws-org=beta]").click()');
|
||||
await wait(`document.querySelectorAll("[data-ws-item]").length === 1 && document.querySelector("[data-ws-item]").dataset.wsItem === ${JSON.stringify(`beta:${BETA_DECISION}`)}`);
|
||||
});
|
||||
|
||||
// SP11 (#1873): an organization's own decision page names the class in the words the Inbox uses,
|
||||
// from the same map, not the raw class.
|
||||
test('SP11: the per-organization decision page names the class as the Inbox does', async t => {
|
||||
const { b, base, wait } = await setup(t, { hash: '#/inbox' });
|
||||
const item = `[data-ws-item="beta:${BETA_DECISION}"]`;
|
||||
await wait(`!!document.querySelector(${JSON.stringify(`${item} .ws-chip.within`)})`);
|
||||
const word = await b.evaluate(`document.querySelector(${JSON.stringify(`${item} .ws-chip.within`)}).textContent`);
|
||||
assert.equal(word, 'Within role');
|
||||
await b.navigate(`${base}/#/orgs/beta/inbox/${BETA_DECISION}`);
|
||||
await wait('!!document.querySelector("#bus-view .tag.tag-within-role")');
|
||||
const chip = await b.evaluate('document.querySelector("#bus-view .tag.tag-within-role").textContent');
|
||||
assert.match(chip, new RegExp(`^${word} · `), chip);
|
||||
assert.doesNotMatch(await b.evaluate('document.getElementById("bus-view").innerText'), /within-role/);
|
||||
});
|
||||
|
||||
test('SP11: the per-organization Messages page names a known class as the Inbox does and shows an unknown one as it is', async t => {
|
||||
const at = '2026-11-15T10:00:00.000Z';
|
||||
const messages = { beta: [
|
||||
{ id: 'msg-beta-1a2b3c4d-0001', from_role: 'pm', class: 'within-role', body: 'Price list is ready.', at, read: false },
|
||||
{ id: 'msg-beta-1a2b3c4d-0002', from_role: 'pm', class: 'NOTE', body: 'Logo is in.', at, read: true },
|
||||
] };
|
||||
const { b, base, wait } = await setup(t, { hash: '#/inbox', messages });
|
||||
await b.navigate(`${base}/#/orgs/beta/messages`);
|
||||
await wait('document.querySelectorAll("#message-list li").length === 2');
|
||||
const lines = await b.evaluate('JSON.stringify([...document.querySelectorAll("#message-list li p.small")].map(p => p.textContent))');
|
||||
const [known, unknown] = JSON.parse(lines);
|
||||
assert.match(known, /^pm · Within role · /, known);
|
||||
assert.match(unknown, /^pm · NOTE · /, unknown);
|
||||
});
|
||||
|
||||
test('A poll repaints in place: focus, caret and scroll stay where they were', async t => {
|
||||
const { b, wait } = await setup(t, { hash: '/projects', viewport: [1000, 420] });
|
||||
await wait('document.querySelector("[data-ws-table]") && document.querySelectorAll("[data-ws-project]").length === 3');
|
||||
@@ -243,18 +278,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);
|
||||
@@ -270,18 +305,20 @@ test('Phone width: no horizontal scroll on any of the three pages, and the drawe
|
||||
await b.evaluate('document.querySelector("#sections a[href=\'#/projects\']").click()');
|
||||
await wait('location.hash === "#/orgs/acme/projects" && document.querySelector("[data-ws-project]")');
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true);
|
||||
// An inbox row at phone width puts its source on its own line above the question.
|
||||
await b.evaluate('location.hash = "#/orgs/acme/inbox"');
|
||||
await wait('document.querySelector("[data-ws-item] .ws-msrc")');
|
||||
assert.equal(await b.evaluate(`(() => { const r = document.querySelector('[data-ws-item]'), s = r.querySelector('.ws-msrc').getBoundingClientRect(), q = r.querySelector('a.q').getBoundingClientRect(); return s.height > 0 && s.bottom <= q.top + 1; })()`), true, 'source above question');
|
||||
// An inbox row at phone width puts its organization chip on its own line below the question.
|
||||
await b.evaluate('location.hash = "#/inbox"');
|
||||
await wait('document.querySelectorAll("[data-ws-item]").length === 2');
|
||||
assert.equal(await b.evaluate(`(() => { const r = document.querySelector('[data-ws-item]'), s = r.querySelector('[data-ws-orgchip]').getBoundingClientRect(), q = r.querySelector('a.q').getBoundingClientRect(); return s.height > 0 && s.top >= q.bottom - 1; })()`), true, 'organization below question');
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, 'system inbox');
|
||||
});
|
||||
|
||||
test('A read that an answering organization could not make says not read, never zero', async t => {
|
||||
// beta answers, but its project and inbox reads refuse.
|
||||
const { b, wait, text } = await setup(t, { fail: { beta: ['projects', 'inbox'] } });
|
||||
await wait('document.querySelector("[data-ws-tiles]")');
|
||||
assert.match(await text('[data-ws-tile=projects] [data-ws-miss=projects]'), /Not read in Beta Works \(the chat channel is off\)/);
|
||||
assert.match(await text('[data-ws-tile=needs] [data-ws-miss=needs]'), /Not read in Beta Works/);
|
||||
// The tiles also name Gamma Home, whose bus is down.
|
||||
assert.match(await text('[data-ws-tile=projects] [data-ws-miss=projects]'), /Not read in Gamma Home \(bus down\) and Beta Works \(the chat channel is off\)/);
|
||||
assert.match(await text('[data-ws-tile=needs] [data-ws-miss=needs]'), /Not read in .*Beta Works/);
|
||||
assert.match(await text('[data-ws-miss=recent]'), /Not read in Beta Works/);
|
||||
assert.match(await text('[data-ws-miss=needs]:not([data-ws-tile] *)'), /Not read in Beta Works/);
|
||||
assert.doesNotMatch(await text('.ws-home .sub'), /and \d+ projects?/, 'no project total when a project read failed');
|
||||
@@ -297,7 +334,7 @@ test('A read that an answering organization could not make says not read, never
|
||||
await wait('document.querySelector("[data-ws-miss=projects]")');
|
||||
assert.match(await text('[data-ws-miss=projects]'), /Not read in Beta Works/);
|
||||
// Inbox: the note names it, and the chip carries no count.
|
||||
await b.evaluate('location.hash = "#/orgs/acme/inbox"');
|
||||
await b.evaluate('location.hash = "#/inbox"');
|
||||
await wait('document.querySelector("[data-ws-miss=inbox]")');
|
||||
assert.match(await text('[data-ws-miss=inbox]'), /Not read in Beta Works \(the chat channel is off\)/);
|
||||
assert.match(await text('[data-ws-org=beta] .n'), /not read/i);
|
||||
@@ -325,10 +362,10 @@ test('Project rows and per-organization figures say not read when the reads behi
|
||||
// Dashboard: the per-organization figures leave Beta out instead of showing its 0.
|
||||
await b.evaluate('location.hash = "#/orgs/acme/"');
|
||||
await wait('document.querySelector("[data-ws-tiles]")');
|
||||
for (const tile of ['needs', 'sessions', 'tasks']) {
|
||||
for (const tile of ['needs', 'agents', 'tasks', 'done']) {
|
||||
const by = await b.evaluate(`document.querySelector('[data-ws-tile=${tile}] .ws-by')?.textContent ?? ''`);
|
||||
assert.doesNotMatch(by, /Beta Works/, tile);
|
||||
assert.match(await text(`[data-ws-tile=${tile}] [data-ws-miss=${tile}]`), /Not read in Beta Works/, tile);
|
||||
assert.match(await text(`[data-ws-tile=${tile}] [data-ws-miss=${tile}]`), /Beta Works \(the chat channel is off\)/, tile);
|
||||
}
|
||||
assert.doesNotMatch(await b.evaluate(`[...document.querySelectorAll('[data-ws-states] .ws-srow')].find(r => /Done this week/.test(r.textContent))?.querySelector('.ws-by')?.textContent ?? ''`), /Beta Works/);
|
||||
});
|
||||
@@ -336,13 +373,15 @@ test('Project rows and per-organization figures say not read when the reads behi
|
||||
test('With every organization down the dashboard and inbox show no totals and no empty-list claims', async t => {
|
||||
const { b, wait, text } = await setup(t, { down: ['acme', 'beta'] });
|
||||
await wait('document.querySelector("[data-ws-tiles]")');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-home] .ws-big").length'), 0);
|
||||
assert.match(await text('[data-ws-tiles]'), /No organization answered/);
|
||||
// Only the Organizations tile has a figure, and it is complete: none of the three runs.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-home] .ws-big")].map(e => e.closest("[data-ws-tile]").dataset.wsTile)'), ['orgs']);
|
||||
assert.match(await text('[data-ws-tile=orgs] .ws-big'), /^0 of 3 running$/);
|
||||
for (const tile of ['agents', 'needs', 'tasks', 'done', 'projects']) assert.match(await text(`[data-ws-tile=${tile}]`), /Not read[\s\S]*Acme Studio \(bus down\), Beta Works \(bus down\) and Gamma Home \(bus /, tile);
|
||||
assert.equal(await text('[data-ws-needs]'), 'Decisions not read');
|
||||
assert.equal(await text('[data-ws-states]'), 'Tasks not read');
|
||||
assert.equal(await text('[data-ws-recent]'), 'Projects not read');
|
||||
assert.equal(await text('[data-ws-sessions]'), 'Sessions not read');
|
||||
await b.evaluate('location.hash = "#/orgs/acme/inbox"');
|
||||
await b.evaluate('location.hash = "#/inbox"');
|
||||
await wait('document.querySelector("[data-ws-items]")');
|
||||
assert.equal(await text('[data-ws-items]'), 'Decisions not read');
|
||||
await b.evaluate('location.hash = "#/orgs/acme/projects?grouping=flat"');
|
||||
|
||||
Reference in new issue
Block a user