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
No files matched your search
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'), []);
|
||||
});
|
||||
Reference in new issue
Block a user