fix(webui): the organization page fits its width with the agent drawer open, #/orgs/<id>?query opens the organization page, the drawer waits for the state read before it names a state, the Queue card says plainly why it shows nothing (#1873) #1977

Merged
jason.woltje merged 3 commits from sp13-org-page-fit into refactor 2026-11-15 19:20:36 +00:00
4 changed files with 365 additions and 25 deletions

No files matched your search

+3 -2
View File
@@ -5,7 +5,8 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
// Requests already sent keep the organization they were sent with.
(() => {
const nativeFetch = window.fetch.bind(window);
const orgOf = hash => /^#\/orgs\/([^/]+)/.exec(hash)?.[1] ?? null;
// 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
@@ -16,7 +17,7 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
const settle = seg => { current = seg; settled = true; markReady(seg ? decodeURIComponent(seg) : null); };
// An address that names its organization is settled at load: anything waiting on ready goes on.
if (atLoad) markReady(decodeURIComponent(atLoad));
const tail = hash => { const m = /^#\/orgs\/[^/]+(.*)$/.exec(hash); return m ? (m[1] || '/') : hash.startsWith('#/') ? hash.slice(1) : '/'; };
const 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
+14 -4
View File
@@ -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}
@@ -522,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). */
@@ -773,8 +785,6 @@ html[data-organizations] .ws-drawer-acts .note{flex-basis:100%}
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}
+63 -19
View File
@@ -57,7 +57,7 @@
const dot = (cls, glyph, word, shown = false) => `<span class="ws-dot ${cls}" title="${esc(word)}"><b aria-hidden="true">${glyph}</b>${shown ? esc(word) : `<span class="ws-sr">${esc(word)}</span>`}</span>`;
const orgHref = id => `#/orgs/${enc(id)}`;
// SP3: a system route such as #/inbox has no organization prefix; both forms give the path.
const route = () => location.hash.replace(/^#(?:\/orgs\/[^/]+)?/, '') || '/';
const route = () => location.hash.replace(/^#(?:\/orgs\/[^/?]+)?/, '') || '/';
const ensureThemes = async () => { if (!themes) themes = await data.themes().then(b => (Array.isArray(b?.themes) ? b.themes : [])).catch(() => []); return themes; };
const instancesOf = value => { const v = value?.rows?.[0]?.instances ?? value?.instances; return v && typeof v === 'object' && !Array.isArray(v) ? v : null; };
// #1873 A3: the root and project agent copies the bus booted with, from the same instances read.
@@ -74,7 +74,13 @@
const runningOf = rows => rows.filter(a => LIVE_STATES.has(a.state)).length;
const workingOf = rows => rows.filter(a => a.state === 'working').length;
const agentNote = (run, waiting) => `${run} running${waiting ? `, ${waiting} waiting on you` : ''}`;
// SP13 (#1873): an instances answer that carries no runs for an agent (the bus gives `running` on
// every answer it enriches) has not said whether the agent runs, so its state reads as not read,
// never as not running.
const STATE_UNREAD = ['s-unknown', '?', 'state not read'];
const runsRead = item => Array.isArray(item?.running);
const activity = (name, item, agents) => {
if (!runsRead(item)) return STATE_UNREAD;
const on = running(item) > 0;
const held = on && agents ? agents.find(a => a?.role === name && item.running.includes(a.holder_run)) : null;
return ACTIVITY[held?.state] || (on ? ['s-working', '●', 'running'] : ['s-idle', '–', 'not running']);
@@ -267,8 +273,11 @@
// organization's own reads, not after them.
const wsRead = data.workspace({ org: id, sections: ['sessions', 'projects', 'metrics'] }).catch(() => null);
// SP12: an agent row opens its drawer over this page; only an agent of this organization.
const drawerRead = agentDrawer(opts.q ?? queryOf(), { only: id, configIn: opts.configIn }).catch(() => '');
// SP13: it waits on its own instances read only until this page's reads have answered.
let pageRead; const page = new Promise(done => { pageRead = done; });
const drawerRead = agentDrawer(opts.q ?? queryOf(), { only: id, configIn: opts.configIn, page }).catch(() => '');
const reads = await Promise.allSettled([data.business(id), data.settings(id), data.instances(id), data.threads(id), data.projects(id), data.queue(id), data.agents(id), data.claims(id), ...(stopped ? [] : [window.MosaicAutonomy?.load(id), window.MosaicPacks?.load(id), window.MosaicWorkflows?.load(id)])]);
pageRead();
const value = i => (reads[i].status === 'fulfilled' ? reads[i].value : null);
const business = value(0), settings = value(1), instances = instancesOf(value(2));
const threads = rowsOf(value(3)), projectRows = rowsOf(value(4)), queue = rowsOf(value(5));
@@ -343,7 +352,12 @@
const queueNote = queue === null ? 'unavailable' : queue.length ? `${plural(queue.length, 'row')}, ${open} not done` : 'no rows';
// E2E6 P2-6: a failed queue read says why, in the words the Queue page uses (server-redacted).
const qerr = queue === null && reads[5].status === 'rejected' ? reads[5].reason : null;
const queueWhy = queue !== null ? '' : `<p class="ws-panel tight note" data-org-queue-why>${qerr?.code === 'queue-refused' ? `The queue refused the read.${qerr.detail ? ` ${esc(qerr.detail.replace(/<path>/g, 'This checkout'))}` : ''}`
// SP13 (#1873): the store refuses any checkout that is a linked worktree. Said plainly: this Console
// runs from another checkout than the one holding the queue. The Queue page reads the same queue
// and would only refuse again, so the card drops its link. Any other refusal keeps its own words.
const queueElsewhere = qerr?.code === 'queue-refused' && /is a linked worktree; the queue runs only in the canonical checkout/.test(qerr.detail || '');
const queueWhy = queue !== null ? '' : `<p class="ws-panel tight note" data-org-queue-why>${queueElsewhere ? 'This Console does not run from the checkout that holds the queue, so the queue is not shown here.'
: qerr?.code === 'queue-refused' ? `The queue refused the read.${qerr.detail ? ` ${esc(qerr.detail.replace(/<path>/g, 'This checkout'))}` : ''}`
: qerr?.status === 0 ? 'You are signed out. Reload the page to sign in again.'
: qerr?.code === 'not-configured' ? 'This Console has no queue to read for this organization.'
: `The queue read did not answer${qerr?.code ? ` (${esc(qerr.code)})` : ''}.`}</p>`;
@@ -359,7 +373,7 @@
<section aria-label="Measures and sessions">
${secH('Active agent sessions', live ? (live.length ? `${live.length} running${tileAgents ? `, ${liveWorking} working` : ', working not read'}` : 'none running') : '', `<a href="#/agents?${oq}" data-focus-key="agents-all">This organization's agents</a>`)}
${sessions}
${secH('Queue', queueNote, '<a href="#/queue" data-org-queue data-focus-key="queue-open">Open queue</a>')}
${secH('Queue', queueNote, queueElsewhere ? '' : '<a href="#/queue" data-org-queue data-focus-key="queue-open">Open queue</a>')}
${queueWhy}
</section>
<section aria-label="Workflows and settings">
@@ -974,8 +988,8 @@
// `opts.scope` names one organization (#/orgs/<id>/agents): the same table, that organization only.
// `opts.fireIn(org)` says from that organization's own config whether this Console decides there;
// Fire is drawn only on the system page and only there, and posts to the row's organization (bus.js).
const AGENT_STATE = [['working', 'Working'], ['waiting', 'Waiting on you'], ['idle', 'Idle'], ['running', 'Running, state unknown'], ['not-running', 'Not running'], ['cant-launch', "Can't launch"]];
const AGENT_STATE_KEY = { working: 'working', 'waiting on you': 'waiting', idle: 'idle', running: 'running', 'not running': 'not-running', "can't launch": 'cant-launch' };
const AGENT_STATE = [['working', 'Working'], ['waiting', 'Waiting on you'], ['idle', 'Idle'], ['running', 'Running, state unknown'], ['not-running', 'Not running'], ['not-read', 'State not read'], ['cant-launch', "Can't launch"]];
const AGENT_STATE_KEY = { working: 'working', 'waiting on you': 'waiting', idle: 'idle', running: 'running', 'not running': 'not-running', 'state not read': 'not-read', "can't launch": 'cant-launch' };
// Each named organization's instances read, and whether this Console decides there when `fireIn` is given.
const readInstances = (ids, fireIn) => Promise.all(ids.map(id => Promise.all([
data.instances(id).then(v => ({ inst: instancesOf(v), copies: copiesOf(v) }), err => ({ inst: null, copies: {}, err })),
@@ -1017,7 +1031,9 @@
const want = String(q?.get?.('state') || '');
const stateF = AGENT_STATE.some(([k]) => k === want) ? want : null;
// SP12: the system page's agent drawer reads beside the page.
const drawerRead = scope ? Promise.resolve('') : agentDrawer(q, { configIn: opts.configIn }).catch(() => '');
// SP13: it waits on its own instances read only until this page's reads have answered.
let pageRead = () => {}; const page = new Promise(done => { pageRead = done; });
const drawerRead = scope ? Promise.resolve('') : agentDrawer(q, { configIn: opts.configIn, page }).catch(() => '');
let body;
try { [body] = await Promise.all([data.workspace({ sections: ['sessions', 'projects'] }), ensureThemes()]); took(body); }
catch (err) { return failedPage('ws-agents-all', 'data-ws-agents', 'Agents', err); }
@@ -1026,6 +1042,7 @@
const askable = (scope ? o.rows : every.rows).filter(r => r.state === 'ok');
// Each answering organization's instances, and (system page only) whether this Console decides there.
const reads = await readInstances(askable.map(r => r.org), scope ? null : opts.fireIn);
pageRead();
const all = agentRows(reads, body);
const counts = new Map();
for (const a of all) counts.set(a.org, (counts.get(a.org) || 0) + 1);
@@ -1124,25 +1141,49 @@
const who = `${a.name} in ${orgName}`;
return `<button type="button" class="ws-btn" data-drawer-stop data-focus-key="drawer-stop"${st?.busy || st?.confirm ? ' disabled' : ''}>${st?.busy ? 'Stopping…' : 'Stop'}<span class="ws-sr"> ${esc(who)}</span></button>${st?.confirm ? stopConfirm(a.name, who) : ''}`;
}
// SP13 (#1873): the drawer's instances read gives the agent's state. The drawer does not hold the
// page for it: once the page's own reads have answered (`opts.page`), a drawer whose instances read
// has not says its state is being read, and repaints when the read answers. That answer is kept for
// the one repaint it asks for; a repaint while the read is still open waits on the same read.
let drawerInst = null; // { org, done, value, p }
const drawerInstRead = org => {
if (drawerInst?.org === org) return drawerInst;
const r = { org, done: false, value: null };
r.p = data.instances(org).then(v => ({ inst: instancesOf(v), copies: copiesOf(v) }), err => ({ inst: null, copies: {}, err }))
.then(v => { r.done = true; r.value = v; return v; });
drawerInst = r;
return r;
};
async function agentDrawer(q, opts = {}) {
const pick = agentPick(q);
if (!pick || (opts.only && pick.org !== opts.only)) return '';
const { org, key, id } = pick;
const [body, instRead, business, config] = await Promise.all([
const ir = drawerInstRead(org);
const [body, business, config] = await Promise.all([
data.workspace({ org, sections: ['sessions', 'projects', 'inbox', 'tasks'] }).catch(() => null),
data.instances(org).then(v => ({ inst: instancesOf(v), copies: copiesOf(v) }), () => null),
data.business(org).catch(() => null),
typeof opts.configIn === 'function' ? Promise.resolve(opts.configIn(org)).catch(() => null) : null,
ensureThemes(),
]);
await Promise.race([ir.p, opts.page ?? ir.p]);
const pending = !ir.done;
// A drawer closed before the read answered asks for no repaint, and its answer is not kept for a
// later open.
if (pending) ir.p.then(() => {
if (document.querySelector(`#bus-view [data-ws-drawer="${CSS.escape(id)}"][data-drawer-pending]`)) document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));
else if (drawerInst === ir) drawerInst = null;
});
else if (drawerInst === ir) drawerInst = null;
const instRead = pending ? null : ir.value;
const o = orgsOf(body), orgName = o.name(org);
const close = `<button type="button" class="x" data-drawer-close data-focus-key="drawer-close" aria-label="Close the agent details">×</button>`;
const shell = (title, inner, attrs = '') => `<aside class="ws-drawer ws-o" style="${o.style(org)}" role="region" aria-labelledby="ws-drawer-h" data-ws-drawer="${esc(id)}"${attrs}><div class="hd"><h2 id="ws-drawer-h" tabindex="-1" data-focus-key="drawer-h">${esc(title)}</h2>${close}</div><div class="body">${inner}</div></aside>`;
const a = instRead?.inst ? agentRows([{ id: org, inst: instRead.inst, copies: instRead.copies }], body).find(r => r.key === key) : null;
if (!a) {
const why = !instRead?.inst ? `The agents of ${esc(orgName)} were not read.` : `No agent named ${esc(key)} was read in ${esc(orgName)}.`;
return shell(key, `<p class="note" data-drawer-missing>${why}</p>`);
}
// While the instances read is open, or after it failed, the agent's name is the address's and its
// role, source and state are not known: the state says so, and Stop gives that as its reason.
const unknown = pending ? 'Reading state…' : !instRead?.inst ? `State not read: ${esc(instWhy(instRead || {}))}.` : '';
const a = unknown ? { org, key, name: key, level: 'org', keep: false, item: {}, s: arr(body?.sessions).find(x => x?.kind === 'role' && x.org === org && x.role === key) || null }
: agentRows([{ id: org, inst: instRead.inst, copies: instRead.copies }], body).find(r => r.key === key);
if (!a) return shell(key, `<p class="note" data-drawer-missing>No agent named ${esc(key)} was read in ${esc(orgName)}.</p>`);
const wsRow = arr(body?.orgs).find(r => r?.org === org) ?? null;
const notRead = names => (!body ? 'the read did not answer' : wsRow?.state !== 'ok' ? busWord({ busStopped: wsRow?.state === 'stopped' }) : missOf(wsRow, names));
const field = (attr, term, value) => `<div class="ws-drawer-f"><dt>${term}</dt><dd data-drawer-${attr}>${value}</dd></div>`;
@@ -1180,7 +1221,10 @@
else if (chat && a.level === 'org' && window.MosaicAgents?.isThread?.(def)) acts.push(`<a class="ws-btn" href="#/assistant" data-drawer-message data-focus-key="drawer-message">Message<span class="ws-sr"> ${esc(a.name)}</span></a> <span class="note">Start one from New thread in ${esc(orgName)}.</span>`);
if (change && a.level === 'org' && !a.keep && running(item) > 0) acts.push(stopActions(pick, a, orgName));
// Why Stop is not offered, in words, where this Console could otherwise change the organization's agents.
else if (change) acts.push(`<p class="note" data-drawer-nostop>${a.level !== 'org' ? 'A copy is not stopped from here.'
// SP13: "not running" is a reason only once the instances read has given the agent's runs.
else if (change) acts.push(`<p class="note" data-drawer-nostop>${pending ? 'Its state is being read. Stop is offered once it is read and the agent is running.'
: unknown || !runsRead(item) ? 'Its state was not read, so Stop is not offered.'
: a.level !== 'org' ? 'A copy is not stopped from here.'
: a.keep ? `${esc(a.name)} is one of ${esc(orgName)}’s protected agents, so it is not stopped from here.`
: 'It is not running, so there is nothing to stop.'}</p>`);
const st = stopState?.id === id ? stopState : null;
@@ -1189,9 +1233,9 @@
: `<p class="note" data-drawer-readonly>This Console only reads ${esc(orgName)}. It can not message or stop its agents.</p>`;
const fields = [
field('org', 'Organization', orgChip(o, org)),
field('role', 'Role', `${esc(a.role)}`),
field('source', 'Source', source),
field('state', 'State', dot(...a.dot, true)),
unknown ? '' : field('role', 'Role', `${esc(a.role)}`),
unknown ? '' : field('source', 'Source', source),
field('state', 'State', unknown ? `<span class="muted">${unknown}</span>` : dot(...a.dot, true)),
model ? field('model', 'Model', `<span class="mono">${esc(model)}</span>`) : '',
project ? field('project', 'Project', `<a href="${esc(`${orgHref(org)}/projects/${enc(project.id)}`)}">${esc(project.name || project.key || project.id)}</a>${project.from ? ` <span class="t2">${project.from}</span>` : ''}`) : '',
task ? field('task', 'Task', `<a href="${esc(taskHref(task.ref))}">${esc(task.title || task.ref)}</a>${task.title ? ` <span class="t2 mono">${esc(task.ref)}</span>` : ''}`) : '',
@@ -1199,7 +1243,7 @@
return shell(a.name, `<dl class="ws-drawer-fields">${fields}</dl>
<section class="ws-drawer-sec" data-drawer-tasks aria-label="Open tasks assigned to ${esc(a.name)}"><h3>Open tasks</h3>${tasksBody}${tasksAll}</section>
<section class="ws-drawer-sec" data-drawer-decisions aria-label="Decisions ${esc(a.name)} raised"><h3>Decisions waiting on you</h3>${decBody}</section>
${actions}`, ` data-drawer-org-id="${esc(org)}" data-drawer-name="${esc(a.name)}" data-drawer-org-name="${esc(orgName)}"`);
${actions}`, ` data-drawer-org-id="${esc(org)}" data-drawer-name="${esc(a.name)}" data-drawer-org-name="${esc(orgName)}"${pending ? ' data-drawer-pending' : ''}`);
}
// Closing goes Back when a row link on this page pushed the current entry; otherwise it drops
// `agent` from the address in place. Either way the page repaints without it and focus returns to the row.
@@ -0,0 +1,285 @@
// 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. The Queue card says in plain words when the queue is refused because this Console runs from a
// linked worktree, and drops Open queue; any other refusal keeps its own reason.
// Plus the test SP12 left out: a repaint while Stop asks its question keeps the question on screen.
// 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 { ReadError } from '../src/reads.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({ queueError = null } = {}) {
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 () => { if (queueError) throw queueError; 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.
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, queueError = null, scripts = [] } = {}) {
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ queueError }), 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' });
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.
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);
});
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 => {
// 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 { 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('the Queue card: a linked-worktree refusal in plain words without Open queue; another refusal keeps its reason', async t => {
const linked = new ReadError('queue-refused', '/srv/checkouts/feature-x is a linked worktree; the queue runs only in the canonical checkout');
const { wait, text, has } = await setup(t, { queueError: linked });
await wait('document.querySelector("[data-org-queue-why]")');
assert.equal(await text('[data-org-queue-why]'), 'This Console does not run from the checkout that holds the queue, so the queue is not shown here.');
assert.equal(await has('[data-org-queue]'), false);
assert.doesNotMatch(await text('[data-org-dashboard]'), /linked worktree|canonical checkout/);
});
test('the Queue card: a refusal for another reason says that reason and keeps Open queue', async t => {
const other = new ReadError('queue-refused', 'HEAD is refs/heads/feature; the queue runs only on branch refactor');
const { wait, text, has } = await setup(t, { queueError: other });
await wait('document.querySelector("[data-org-queue-why]")');
assert.equal(await text('[data-org-queue-why]'), 'The queue refused the read. HEAD is refs/heads/feature; the queue runs only on branch refactor');
assert.equal(await has('[data-org-queue]'), true);
});
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'), []);
});