feat(webui): sp12 agent drawer, selecting an agent on #/agents or in an organization page's Active agent sessions opens its details at the right of the page with ?agent=<org>:<name> in the address; Back, Escape and Close close it and focus returns to the row; fields come from the workspace, instances and business reads; Message and Stop follow the organization's own config, Stop asks first and says why a refusal happened; the Tasks board takes an assignee from the address (#1873, lane sp12-agent-drawer)

This commit is contained in:
jason.woltje committed 2026-11-15 11:21:32 -06:00
1 parent 5b6b39f080
commit bfd3ed6811
5 files changed
+573 -11

No files matched your search

+14 -5
View File
@@ -1695,7 +1695,7 @@
// #/ 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 === '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];
@@ -1717,7 +1717,7 @@
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 }));
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);
@@ -1741,6 +1741,14 @@
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.
// 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.
@@ -1802,7 +1810,7 @@
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.
@@ -1943,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();
}
@@ -2152,7 +2161,7 @@
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;
@@ -2229,7 +2238,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(); } });
+30
View File
@@ -726,6 +726,36 @@ 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}
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}
@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-tiles.six{grid-template-columns:repeat(3,minmax(0,1fr))}
html[data-organizations] .ws-ibx-grid.with-insp{grid-template-columns:minmax(0,1fr)}
+5
View File
@@ -44,6 +44,7 @@
// 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.';
@@ -358,6 +359,10 @@
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') || '');
if (who !== addrAssignee) { addrAssignee = who; if (ROLE.test(who)) filter.assignee = who; }
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))]);
// Two renders at once (a first load, a poll during a filter change): the replaced one draws from the newer read.
+207 -6
View File
@@ -253,7 +253,7 @@
const restart = window.MosaicConsole?.busButton?.('Restart the bus') || '';
return `<div class="banner err" role="status" data-org-down><div><b>${stopped ? 'The bus is not running.' : 'The bus is not answering.'}</b> Nothing about ${esc(label(org))}'s agents or packs can be read until it ${stopped ? 'starts' : 'answers'}.${tail}</div>${restart ? `<div class="actions">${restart}</div>` : ''}</div>`;
}
async function dashboard(id = window.MosaicOrg.id) {
async function dashboard(id = window.MosaicOrg.id, opts = {}) {
const why = 'ws-reload-why';
// Only an organization the membership list names is read; anything else reads nothing more.
const [listRead] = await Promise.all([data.organizations().catch(() => null), ensureThemes()]);
@@ -266,6 +266,8 @@
// while the bus is down, the reason the banner above everything else says. It runs beside the
// 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(() => '');
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)])]);
const value = i => (reads[i].status === 'fulfilled' ? reads[i].value : null);
const business = value(0), settings = value(1), instances = instancesOf(value(2));
@@ -332,7 +334,9 @@
: !first ? '<span class="muted">No conversation held right now</span>'
: thread ? `${esc(projectName(thread.project))}<span class="t2">${esc(thread.name || thread.id)}${more}</span>`
: threads ? `<span class="muted">Conversation</span><span class="t2">${esc(first.thread)}${more}</span>` : '<span class="muted">Conversation unavailable</span>';
return `<tr><td>${esc(shown)}</td><td>${where}</td><td class="hide-m mono">${modelCell(agent, model)}</td><td>${dot(acls, aglyph, aword, true)}${count > 1 ? ` <span class="muted">${count} sessions</span>` : ''}</td></tr>`;
const pick = `${id}:${agent}`, on = new URLSearchParams(opts.q?.toString?.() ?? queryOf().toString());
on.set('agent', pick);
return `<tr data-ws-session-agent="${esc(pick)}"><td><a class="ws-drawer-open" href="${esc(`${orgHref(id)}/organization?${on.toString()}`)}" data-ws-drawer-open="${esc(pick)}" data-focus-key="${esc(`agent:${pick}`)}">${esc(shown)}</a></td><td>${where}</td><td class="hide-m mono">${modelCell(agent, model)}</td><td>${dot(acls, aglyph, aword, true)}${count > 1 ? ` <span class="muted">${count} sessions</span>` : ''}</td></tr>`;
}).join('');
const sessions = live === null ? (stopped ? '<p class="ws-panel tight note" data-bus-stopped>Sessions not read: the bus is stopped.</p>' : '<p class="ws-panel tight note">Sessions unavailable. The bus did not answer the instance read.</p>') : live.length ? `<div class="ws-tbl"><table><thead><tr><th scope="col">Agent</th><th scope="col">Project and task</th><th scope="col" class="hide-m">Model</th><th scope="col">Status</th></tr></thead><tbody>${sessionRows}</tbody></table></div>` : '<p class="ws-panel tight note">No active agent sessions</p>';
const open = queue ? queue.filter(r => r?.state !== 'done').length : null;
@@ -369,6 +373,7 @@
${stopped ? stoppedPanel('data-packs', 'Starter packs') : window.MosaicPacks?.section(id) ?? ''}
${stopped ? stoppedPanel('data-workflows', 'Workflows') : window.MosaicWorkflows?.section(id) ?? ''}
</div>
${await drawerRead}
</article>`;
}
@@ -993,12 +998,12 @@
const d = activity(name, item, agents);
// Never Fire the chief, the assistant or an instance the bus would refuse to end.
const keep = item?.definition === 'chief-of-staff' || name === 'chief' || item?.definition === 'assistant' || item?.endProtected === true;
all.push({ org: r.id, key: name, name, level: 'org', role: roleWords(item), id: isHired(item) ? item.definition : '', dot: d, s: session(name), fire: r.fire && !keep });
all.push({ org: r.id, key: name, name, level: 'org', role: roleWords(item), id: isHired(item) ? item.definition : '', dot: d, s: session(name), fire: r.fire && !keep, keep, item });
}
for (const [key, c] of Object.entries(r.copies || {})) {
if (!c || typeof c.name !== 'string') continue;
const d = copyDot(key, c, agents);
all.push({ org: r.id, key, name: c.name, level: c.level === 'project' ? 'project' : 'root', role: roleWords(c), id: copyBlocked(c) ? copyWhy(c) : '', dot: d, s: session(key), project: c.level === 'project' && c.project ? projectRef(r.id, c.project) : null, fire: false });
all.push({ org: r.id, key, name: c.name, level: c.level === 'project' ? 'project' : 'root', role: roleWords(c), id: copyBlocked(c) ? copyWhy(c) : '', dot: d, s: session(key), project: c.level === 'project' && c.project ? projectRef(r.id, c.project) : null, fire: false, keep: true, item: c });
}
}
for (const a of all) a.state = AGENT_STATE_KEY[a.dot[2]] || 'not-running';
@@ -1011,6 +1016,8 @@
const org = scope ? [scope] : listParam(q, 'org', id => ORG_ID.test(id));
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(() => '');
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); }
@@ -1036,7 +1043,7 @@
const work = a => (a.s?.task ? `${esc(a.s.task.title || a.s.task.ref)}${a.s.task.title && a.s.task.ref ? `<span class="t2 mono">${esc(a.s.task.ref)}</span>` : ''}` : '<span class="muted">nothing reported</span>');
const LEVEL_WORD = { root: 'root agent copy', project: 'project agent' };
const row = a => `<tr class="ws-row ws-edge ws-o${a === hit ? ' on' : ''}" style="${o.style(a.org)}" data-ws-agent="${esc(akey(a))}"${a === hit ? ' aria-current="true"' : ''}>
<td class="t1">${esc(a.name)}${a.id ? `<span class="t2${a.level === 'org' ? ' mono' : ''}">${esc(a.id)}</span>` : ''}<span class="ws-ichips">${orgChip(a)}${projChip(a)}<span class="show-m ws-arole">${esc(a.role)}</span></span><span class="t2 show-m">${a.s?.task ? `Working on ${esc(a.s.task.title || a.s.task.ref)}` : 'Working on nothing reported'}</span></td>
<td class="t1">${scope ? esc(a.name) : drawerLink(q, a)}${a.id ? `<span class="t2${a.level === 'org' ? ' mono' : ''}">${esc(a.id)}</span>` : ''}<span class="ws-ichips">${orgChip(a)}${projChip(a)}<span class="show-m ws-arole">${esc(a.role)}</span></span><span class="t2 show-m">${a.s?.task ? `Working on ${esc(a.s.task.title || a.s.task.ref)}` : 'Working on nothing reported'}</span></td>
<td class="hide-m" data-ws-role>${esc(a.role)}${a.level !== 'org' ? `<span class="t2">${esc(LEVEL_WORD[a.level])}</span>` : ''}</td>
<td>${dot(...a.dot, true)}</td>
<td class="hide-m" data-ws-work>${work(a)}</td>
@@ -1077,9 +1084,203 @@
</div>
${table}
${scope ? '' : `<div data-agents-receipt="*" role="status">${window.MosaicDecide?.fireNote('*') ?? ''}</div>`}
${await drawerRead}
</section>`;
}
// ---- the agent drawer (#1873 SP12) ----
// `?agent=<org>:<name>` on #/agents or an organization page opens one agent's details at the right of
// the page; the page stays under it. Every field comes from a read the Console already makes: the
// workspace read scoped to the agent's organization (its session, project, task, open tasks and the
// decisions it raised), that organization's instances read (role, source, state) and its business
// read (model). A field no read gives is left out. Actions follow the organization's own config:
// Message where it sends chat there, Stop (one agent, /agent/terminate) where it changes agents.
const agentPick = q => {
const v = String(q?.get?.('agent') || ''), cut = v.indexOf(':');
if (cut < 1) return null;
const org = v.slice(0, cut), key = v.slice(cut + 1);
return ORG_ID.test(org) && key && key.length <= 200 && !/\s/.test(key) ? { org, key, id: v } : null;
};
const DRAWER_LIMIT = 5, STOP_REASON = 'Stopped from the agent drawer.';
const STOP_UNKNOWN = 'Outcome unknown. Read the agents again before trying; nothing was retried.';
// The Stop state of the drawer's agent survives repaints: `confirm` while it asks, `busy` while the
// request is open, then the result in words beside the button.
let stopState = null; // { id, confirm, busy, text }
let drawerPushed = null, drawerFocus = false, drawerShown = null;
const drawerHref = (q, id) => { const n = new URLSearchParams(q?.toString?.() || ''); n.set('agent', id); return `#${pathOf()}?${n.toString()}`; };
// The address of the page under the drawer: the organization prefix and path, the query without `agent`.
const sansAgent = () => { const n = queryOf(); n.delete('agent'); const s = n.toString(); return `${/^#\/orgs\/[^/?]+/.exec(location.hash)?.[0] ?? '#'}${pathOf()}${s ? `?${s}` : ''}`; };
// The row's name as a link that opens the drawer: a native link, so Tab reaches it and Enter opens it.
const drawerLink = (q, a) => `<a class="ws-drawer-open" href="${esc(drawerHref(q, `${a.org}:${a.key}`))}" data-ws-drawer-open="${esc(`${a.org}:${a.key}`)}" data-focus-key="${esc(`agent:${a.org}:${a.key}`)}">${esc(a.name)}</a>`;
const stopConfirm = (name, who) => `<span class="ws-drawer-confirm" data-drawer-stop-confirm>Stop ${esc(who)}? <button type="button" class="ws-btn danger" data-drawer-stop-yes data-focus-key="drawer-stop-yes">Stop ${esc(name)}</button> <button type="button" class="ws-btn" data-drawer-stop-no data-focus-key="drawer-stop-no">Cancel</button></span>`;
function stopActions(pick, a, orgName) {
const st = stopState?.id === pick.id ? stopState : null;
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) : ''}`;
}
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([
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(),
]);
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>`);
}
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>`;
// Role and where it came from. A hired agent reads as hired; no read names the template it was hired from.
const item = a.item || {}, def = typeof item.definition === 'string' ? item.definition : '';
const hire = typeof item.hireDecision === 'string' ? item.hireDecision : /^org:hire-(.+)$/.exec(def)?.[1] || '';
const source = a.level === 'org'
? (isHired(item) ? `Hired${hire ? ` by hire decision <code>${esc(hire)}</code>` : ''}${def ? `, definition <code>${esc(def)}</code>` : ''}` : `Organization agent${def ? `, definition <code>${esc(def)}</code>` : ''}`)
: `${a.level === 'project' ? `Project agent copy${a.project?.name ? ` for ${esc(a.project.name)}` : ''}` : 'Root agent copy'}${def ? `, definition <code>${esc(def)}</code>` : ''}${a.id ? `. ${esc(a.id)}` : ''}`;
const model = business?.business?.instances?.find(i => i?.instance === key)?.vars?.model;
// Its project and task: the role session's (the task it holds, and that task's project), else the
// project of the conversation its live run holds.
const thread = arr(body?.sessions).find(x => x?.kind === 'thread' && x.org === org && x.instance === key) || null;
const project = a.project ? { ...a.project, from: '' } : a.s?.project?.id ? { ...a.s.project, from: a.s.projectFrom === 'task' ? 'from its task' : '' } : thread?.project?.id ? { ...thread.project, from: 'from its conversation' } : null;
const task = a.s?.task?.ref ? a.s.task : null;
const taskHref = ref => `${orgHref(org)}/tasks?task=${enc(ref)}`;
// Open tasks assigned to it, and the decisions it raised that wait on a human, from the same read.
const tasksWhy = notRead(['tasks']);
const open = arr(body?.tasks).filter(t => t?.org === org && !t.done && t.state !== 'done' && arr(t.assignees).includes(key));
const held = Number.isFinite(wsRow?.tasksHeld);
const board = `#/tasks?org=${enc(org)}&assignee=${enc(key)}`;
const tasksBody = tasksWhy ? `<p class="note">Not read: ${esc(tasksWhy)}.</p>`
: open.length ? `<ul class="ws-drawer-list">${open.slice(0, DRAWER_LIMIT).map(t => `<li><a href="${esc(taskHref(t.ref))}">${esc(t.title || t.ref)}</a>${t.state ? ` <span class="t2">${esc(t.state)}</span>` : ''}</li>`).join('')}</ul>`
: '<p class="note">No open tasks assigned to it.</p>';
const tasksAll = tasksWhy ? '' : `<a class="ws-more" href="${esc(board)}" data-drawer-tasks-all data-focus-key="drawer-tasks-all">${held ? 'Its tasks on the Tasks board' : open.length ? `All ${open.length} on the Tasks board` : 'The Tasks board, filtered to it'}</a>`;
const decWhy = notRead(['inbox']);
const raised = arr(body?.inbox).filter(d => d?.org === org && d.raisedBy === key && typeof d.id === 'string');
const decBody = decWhy ? `<p class="note">Not read: ${esc(decWhy)}.</p>`
: raised.length ? `<ul class="ws-drawer-list">${raised.slice(0, DRAWER_LIMIT).map(d => `<li><a href="${esc(`#/inbox?d=${enc(`${org}:${d.id}`)}`)}">${esc(d.question || d.id)}</a></li>`).join('')}</ul>${raised.length > DRAWER_LIMIT ? `<a class="ws-more" href="${esc(`#/inbox?org=${enc(org)}`)}">${raised.length - DRAWER_LIMIT} more in the Inbox</a>` : ''}`
: '<p class="note">No decisions it raised are waiting on you.</p>';
// Actions, only where this organization's config says this Console writes there.
const chat = config?.chatSend === true && config?.tester !== true, change = config?.settingsSend === true && config?.tester !== true;
const acts = [];
if (chat && thread?.thread) acts.push(`<a class="ws-btn" href="${esc(`#/assistant?add=${enc(`${org}:${thread.thread}`)}`)}" data-drawer-message data-focus-key="drawer-message">Message<span class="ws-sr"> ${esc(a.name)}</span></a>`);
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));
const st = stopState?.id === id ? stopState : null;
const actions = chat || change
? `<div class="ws-drawer-acts" data-drawer-actions>${acts.join(' ')}<p class="ws-drawer-status" id="ws-drawer-status" role="status" data-drawer-status>${esc(st?.text || '')}</p></div>`
: `<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)),
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>` : ''}`) : '',
].join('');
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)}"`);
}
// Closing goes Back when the drawer was opened by a row link on this page; otherwise it drops
// `agent` from the address in place. Either way the page repaints without it and focus returns to the row.
function closeDrawer() {
if (!stopState?.busy) stopState = null;
if (drawerPushed && location.hash === drawerPushed) { drawerPushed = null; history.back(); return; }
drawerPushed = null;
history.replaceState(history.state, '', sansAgent());
document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));
}
// bus.js calls this after each paint: focus moves into a drawer a row opened, and back to the row
// when the drawer has closed and focus was left nowhere.
function afterPaint() {
const view = document.getElementById('bus-view'), d = view?.querySelector('[data-ws-drawer]');
const now = d?.dataset.wsDrawer || null, was = drawerShown;
drawerShown = now;
if (d && drawerFocus) { drawerFocus = false; d.querySelector('h2')?.focus({ preventScroll: true }); return; }
drawerFocus = false;
if (!now && was) {
const a = document.activeElement;
if (!a || a === document.body || !view?.contains(a)) view?.querySelector(`[data-focus-key="${CSS.escape(`agent:${was}`)}"]`)?.focus({ preventScroll: true });
}
}
async function stopAgent(d) {
const id = d.dataset.wsDrawer, org = d.dataset.drawerOrgId, name = id.slice(org.length + 1), orgName = d.dataset.drawerOrgName;
stopState = { id, busy: true, text: `Stopping ${name}…` };
const repaint = () => document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));
const paint = () => { const now = document.querySelector(`[data-ws-drawer="${CSS.escape(id)}"]`); if (!now) return; const s = now.querySelector('[data-drawer-status]'); if (s) s.textContent = stopState.text; const b = now.querySelector('[data-drawer-stop]'); if (b) { b.disabled = !!stopState.busy; b.firstChild.textContent = stopState.busy ? 'Stopping…' : 'Stop'; } now.querySelector('[data-drawer-stop-confirm]')?.remove(); };
const finish = text => { stopState = { id, text }; paint(); const b = document.querySelector(`[data-ws-drawer="${CSS.escape(id)}"] [data-drawer-stop]`); b?.focus({ preventScroll: true }); };
paint();
let res, body = null;
try { res = await fetch(`/api/orgs/${enc(org)}/agent/terminate`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ instance: name, reason: STOP_REASON }), cache: 'no-store', redirect: 'manual' }); }
catch { return finish(`Not stopped: ${STOP_UNKNOWN}`); }
if (res.type === 'opaqueredirect' || res.status === 401) return finish('Not stopped: sign in again, then read the agents.');
try { body = await res.json(); } catch { body = null; }
if (!res.ok || body?.sent !== true && !body?.request) {
if (body?.sent === false && typeof body.message === 'string' && body.message) return finish(`Not stopped: ${body.message}`);
if (res.status === 403 && body?.error === 'read-only') return finish(`Not stopped: this Console only reads agents in ${orgName} (read-only).`);
if (body?.sent === false && typeof body.error === 'string') return finish(`Not stopped: the bus refused (${body.error}).`);
return finish(`Not stopped: ${STOP_UNKNOWN}`);
}
const request = typeof body?.request === 'string' ? body.request : '';
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(request)) return finish(STOP_UNKNOWN);
stopState = { id, busy: true, text: `Stop asked: request ${request} is open.` }; paint();
for (let tries = 0; tries < 90; tries++) {
await new Promise(r => setTimeout(r, 2000));
let r = null;
try { const got = await fetch(`/api/orgs/${enc(org)}/agent/request?id=${enc(request)}`, { cache: 'no-store', redirect: 'manual' }); if (got.type === 'opaqueredirect') return finish('Sign in again, then read the agents.'); r = got.ok ? await got.json() : null; } catch { continue; }
if (r?.request === request && r.state === 'closed') {
if (r.reason) return finish(`Not stopped: ${r.reason}.`);
stopState = { id, text: `Stopped ${name}.` }; repaint(); return;
}
}
finish(`Request ${request} is still open. Read the agents again later.`);
}
document.addEventListener('click', e => {
const link = e.target.closest?.('[data-ws-drawer-open]');
if (link) {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
drawerPushed = link.getAttribute('href'); drawerFocus = true;
return;
}
const d = e.target.closest?.('[data-ws-drawer]');
if (!d) return;
if (e.target.closest('[data-drawer-close]')) { closeDrawer(); return; }
const id = d.dataset.wsDrawer;
if (e.target.closest('[data-drawer-stop]') && !(stopState?.id === id && stopState.busy)) {
const b = d.querySelector('[data-drawer-stop]'), name = d.dataset.drawerName;
stopState = { id, confirm: true, text: '' };
d.querySelector('[data-drawer-stop-confirm]')?.remove();
b.insertAdjacentHTML('afterend', stopConfirm(name, `${name} in ${d.dataset.drawerOrgName}`));
b.disabled = true;
const s = d.querySelector('[data-drawer-status]'); if (s) s.textContent = '';
d.querySelector('[data-drawer-stop-no]')?.focus({ preventScroll: true });
return;
}
if (e.target.closest('[data-drawer-stop-no]')) {
stopState = null;
d.querySelector('[data-drawer-stop-confirm]')?.remove();
const b = d.querySelector('[data-drawer-stop]'); if (b) { b.disabled = false; b.focus({ preventScroll: true }); }
return;
}
if (e.target.closest('[data-drawer-stop-yes]')) stopAgent(d);
});
// Escape closes the drawer, unless a dialog is open or something else took the key.
document.addEventListener('keydown', e => {
if (e.key !== 'Escape' || e.defaultPrevented || document.querySelector('dialog[open]')) return;
if (!document.querySelector('#bus-view [data-ws-drawer]')) return;
e.preventDefault(); closeDrawer();
});
// ---- controls: filters write the hash query and repaint in place ----
const pathOf = () => route().split('?')[0];
const queryOf = () => new URLSearchParams(route().split('?')[1] || '');
@@ -1141,6 +1342,6 @@
// U4b: the org mark (colour and initials) for the task board (tasks-board.js).
const orgMark = Object.freeze({ ready: ensureThemes, av, colours, initials });
window.MosaicWorkspace = Object.freeze({ dashboard, holdLive, graftLive, agentGrid, icon, orgMark, refresh: () => refresh(), av, colours, label, state, dot, themes: ensureThemes, home, projectsPage, inboxPage, agentsPage, inboxCount, setGrouping, orgsOf, orgChip, projChip, unreadNote, setQuery, classWord, get pollMs() { return pollMs; } });
window.MosaicWorkspace = Object.freeze({ dashboard, afterPaint, holdLive, graftLive, agentGrid, icon, orgMark, refresh: () => refresh(), av, colours, label, state, dot, themes: ensureThemes, home, projectsPage, inboxPage, agentsPage, inboxCount, setGrouping, orgsOf, orgChip, projChip, unreadNote, setQuery, classWord, get pollMs() { return pollMs; } });
refresh();
})();
@@ -0,0 +1,317 @@
// 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]}`);
});
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 a last-activity time for an agent, so the drawer draws none.
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'`);
});
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 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\)\.$/);
// 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);
});
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]}`);
});