From bfd3ed6811508908a13a623049672d0143f09057 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 11:21:32 -0600 Subject: [PATCH 1/3] 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=: 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) --- packages/webui/src/public/bus.js | 19 +- packages/webui/src/public/shell.css | 30 ++ packages/webui/src/public/tasks-board.js | 5 + packages/webui/src/public/workspace.js | 213 +++++++++++- .../webui/tests/agent-drawer-browser.test.mjs | 317 ++++++++++++++++++ 5 files changed, 573 insertions(+), 11 deletions(-) create mode 100644 packages/webui/tests/agent-drawer-browser.test.mjs diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index dd281fb42..738c6b315 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -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//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
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(); } }); diff --git a/packages/webui/src/public/shell.css b/packages/webui/src/public/shell.css index 723bbff27..fac063015 100644 --- a/packages/webui/src/public/shell.css +++ b/packages/webui/src/public/shell.css @@ -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)} diff --git a/packages/webui/src/public/tasks-board.js b/packages/webui/src/public/tasks-board.js index 00ca60c5d..9d1dae18f 100644 --- a/packages/webui/src/public/tasks-board.js +++ b/packages/webui/src/public/tasks-board.js @@ -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. diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index 1656000c7..5d655fa02 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -253,7 +253,7 @@ const restart = window.MosaicConsole?.busButton?.('Restart the bus') || ''; return ``; } - 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 ? 'No conversation held right now' : thread ? `${esc(projectName(thread.project))}${esc(thread.name || thread.id)}${more}` : threads ? `Conversation${esc(first.thread)}${more}` : 'Conversation unavailable'; - return `${esc(shown)}${where}${modelCell(agent, model)}${dot(acls, aglyph, aword, true)}${count > 1 ? ` ${count} sessions` : ''}`; + const pick = `${id}:${agent}`, on = new URLSearchParams(opts.q?.toString?.() ?? queryOf().toString()); + on.set('agent', pick); + return `${esc(shown)}${where}${modelCell(agent, model)}${dot(acls, aglyph, aword, true)}${count > 1 ? ` ${count} sessions` : ''}`; }).join(''); const sessions = live === null ? (stopped ? '

Sessions not read: the bus is stopped.

' : '

Sessions unavailable. The bus did not answer the instance read.

') : live.length ? `
${sessionRows}
AgentProject and taskModelStatus
` : '

No active agent sessions

'; 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) ?? ''} + ${await drawerRead} `; } @@ -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 ? `${esc(a.s.task.ref)}` : ''}` : 'nothing reported'); const LEVEL_WORD = { root: 'root agent copy', project: 'project agent' }; const row = a => ` - ${esc(a.name)}${a.id ? `${esc(a.id)}` : ''}${orgChip(a)}${projChip(a)}${esc(a.role)}${a.s?.task ? `Working on ${esc(a.s.task.title || a.s.task.ref)}` : 'Working on nothing reported'} + ${scope ? esc(a.name) : drawerLink(q, a)}${a.id ? `${esc(a.id)}` : ''}${orgChip(a)}${projChip(a)}${esc(a.role)}${a.s?.task ? `Working on ${esc(a.s.task.title || a.s.task.ref)}` : 'Working on nothing reported'} ${esc(a.role)}${a.level !== 'org' ? `${esc(LEVEL_WORD[a.level])}` : ''} ${dot(...a.dot, true)} ${work(a)} @@ -1077,9 +1084,203 @@ ${table} ${scope ? '' : `
${window.MosaicDecide?.fireNote('*') ?? ''}
`} + ${await drawerRead} `; } + // ---- the agent drawer (#1873 SP12) ---- + // `?agent=:` 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) => `${esc(a.name)}`; + const stopConfirm = (name, who) => `Stop ${esc(who)}? `; + function stopActions(pick, a, orgName) { + const st = stopState?.id === pick.id ? stopState : null; + const who = `${a.name} in ${orgName}`; + return `${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 = ``; + const shell = (title, inner, attrs = '') => ``; + 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, `

${why}

`); + } + 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) => `
${term}
${value}
`; + // 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 ${esc(hire)}` : ''}${def ? `, definition ${esc(def)}` : ''}` : `Organization agent${def ? `, definition ${esc(def)}` : ''}`) + : `${a.level === 'project' ? `Project agent copy${a.project?.name ? ` for ${esc(a.project.name)}` : ''}` : 'Root agent copy'}${def ? `, definition ${esc(def)}` : ''}${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 ? `

Not read: ${esc(tasksWhy)}.

` + : open.length ? `` + : '

No open tasks assigned to it.

'; + const tasksAll = tasksWhy ? '' : `${held ? 'Its tasks on the Tasks board' : open.length ? `All ${open.length} on the Tasks board` : 'The Tasks board, filtered to it'}`; + const decWhy = notRead(['inbox']); + const raised = arr(body?.inbox).filter(d => d?.org === org && d.raisedBy === key && typeof d.id === 'string'); + const decBody = decWhy ? `

Not read: ${esc(decWhy)}.

` + : raised.length ? `${raised.length > DRAWER_LIMIT ? `${raised.length - DRAWER_LIMIT} more in the Inbox` : ''}` + : '

No decisions it raised are waiting on you.

'; + // 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(`Message ${esc(a.name)}`); + else if (chat && a.level === 'org' && window.MosaicAgents?.isThread?.(def)) acts.push(`Message ${esc(a.name)} Start one from New thread in ${esc(orgName)}.`); + 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 + ? `
${acts.join(' ')}

${esc(st?.text || '')}

` + : `

This Console only reads ${esc(orgName)}. It can not message or stop its agents.

`; + 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', `${esc(model)}`) : '', + project ? field('project', 'Project', `${esc(project.name || project.key || project.id)}${project.from ? ` ${project.from}` : ''}`) : '', + task ? field('task', 'Task', `${esc(task.title || task.ref)}${task.title ? ` ${esc(task.ref)}` : ''}`) : '', + ].join(''); + return shell(a.name, `
${fields}
+

Open tasks

${tasksBody}${tasksAll}
+

Decisions waiting on you

${decBody}
+ ${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(); })(); diff --git a/packages/webui/tests/agent-drawer-browser.test.mjs b/packages/webui/tests/agent-drawer-browser.test.mjs new file mode 100644 index 000000000..b90eaddc4 --- /dev/null +++ b/packages/webui/tests/agent-drawer-browser.test.mjs @@ -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=:), 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]}`); +}); -- 2.54.0 From 307f88be3e0d2095bdc13bd13c43185edcffba73 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 11:35:48 -0600 Subject: [PATCH 2/3] fix(webui): sp12 shots, at 1200px wide and above the page keeps clear of the agent drawer so nothing sits under it; where this Console could change the organization's agents but Stop is not offered, the drawer says why (a copy, a protected agent, or not running); drawer sections drop the page section margin (#1873, lane sp12-agent-drawer) --- packages/webui/src/public/shell.css | 5 ++++- packages/webui/src/public/workspace.js | 4 ++++ packages/webui/tests/agent-drawer-browser.test.mjs | 6 +++++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/webui/src/public/shell.css b/packages/webui/src/public/shell.css index fac063015..bca4f7427 100644 --- a/packages/webui/src/public/shell.css +++ b/packages/webui/src/public/shell.css @@ -741,7 +741,7 @@ html[data-organizations] .ws-drawer-f{display:grid;grid-template-columns:96px mi 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{display:grid;gap:6px;margin:0} html[data-organizations] .ws-drawer-sec h3{margin:0;font:700 11px/1.3 var(--font);letter-spacing:.06em;text-transform:uppercase;color:var(--muted)} html[data-organizations] .ws-drawer-list{list-style:none;margin:0;padding:0;display:grid;gap:4px;font-size:13px} html[data-organizations] .ws-drawer-list li{border:1px solid var(--line);border-radius:6px;padding:6px 9px;background:var(--canvas);overflow-wrap:anywhere} @@ -753,6 +753,9 @@ html[data-organizations] .ws-drawer-confirm{display:flex;flex-wrap:wrap;gap:8px; html[data-organizations] .ws-drawer .ws-btn.danger{color:var(--danger)} html[data-organizations] .ws-drawer-status{margin:0;flex-basis:100%;font-size:13px;color:var(--strong)} html[data-organizations] .ws-drawer-status:empty{display:none} +html[data-organizations] .ws-drawer-acts .note{flex-basis:100%} +/* Wide screens: the page keeps clear of the drawer, so nothing sits under it. */ +@media (min-width:1200px){html[data-organizations] body:has(.ws-drawer) .content{padding-right:calc(420px + 22px)}} @media (max-width:700px){ html[data-organizations] .ws-drawer{top:0;left:0;right:0;width:auto;border-left:0;box-shadow:none} } diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index 5d655fa02..5c208847b 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -1173,6 +1173,10 @@ if (chat && thread?.thread) acts.push(`Message ${esc(a.name)}`); else if (chat && a.level === 'org' && window.MosaicAgents?.isThread?.(def)) acts.push(`Message ${esc(a.name)} Start one from New thread in ${esc(orgName)}.`); 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(`

${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.'}

`); const st = stopState?.id === id ? stopState : null; const actions = chat || change ? `
${acts.join(' ')}

${esc(st?.text || '')}

` diff --git a/packages/webui/tests/agent-drawer-browser.test.mjs b/packages/webui/tests/agent-drawer-browser.test.mjs index b90eaddc4..4cff4dcf8 100644 --- a/packages/webui/tests/agent-drawer-browser.test.mjs +++ b/packages/webui/tests/agent-drawer-browser.test.mjs @@ -140,7 +140,9 @@ test('a row on #/agents opens the drawer: a region with a heading, the page stay // 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]}`); + assert.ok(box[1] >= box[2] - 20, `drawer right edge ${box[1]} should reach the right of ${box[2]}`); // At 1440 wide nothing on the page sits under it: the agents table ends left of the drawer. + const table = await b.evaluate(`Math.round(document.querySelector('[data-ws-agent-list]').getBoundingClientRect().right)`); + assert.ok(table <= box[0], `the agents table right edge ${table} should be left of the drawer at ${box[0]}`); }); test('the drawer shows each field from its read: name, organization, role and source, state, model, project, task, open tasks and decisions', async t => { @@ -297,6 +299,8 @@ test('Message opens the agent\'s thread in an Assistant pane; Stop asks first an await b.evaluate('location.hash = "#/agents?agent=acme%3Achief"'); await wait(open('acme:chief')); assert.equal(await has('[data-ws-drawer="acme:chief"] [data-drawer-stop]'), false); + // Where Stop is not offered, the drawer says why beside the actions. + assert.match(await text('[data-ws-drawer="acme:chief"] [data-drawer-actions] [data-drawer-nostop]'), /^chief is one of Acme Studio’s protected agents, so it is not stopped from here\.$/); }); test('no action is shown where the Console cannot write for the agent\'s organization', async t => { -- 2.54.0 From 69ce460f91a96ed99adfef3b198f8b5768a99c06 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 11:46:19 -0600 Subject: [PATCH 3/3] fix(webui): sp12 review, the agent drawer holds at most one history entry: a row opened from the page pushes one marked entry, another row replaces it, the open row changes nothing, and Close goes Back only from that marked entry, so a shared link never leaves the Console; Escape while Stop asks cancels only the question; the Tasks board drops the assignee filter the address set when the address drops it; tests cover a Stop the bus takes (#1873, lane sp12-agent-drawer) --- packages/webui/src/public/tasks-board.js | 3 +- packages/webui/src/public/workspace.js | 49 ++++++++++------ .../webui/tests/agent-drawer-browser.test.mjs | 56 ++++++++++++++++++- 3 files changed, 89 insertions(+), 19 deletions(-) diff --git a/packages/webui/src/public/tasks-board.js b/packages/webui/src/public/tasks-board.js index 9d1dae18f..9293d3fb8 100644 --- a/packages/webui/src/public/tasks-board.js +++ b/packages/webui/src/public/tasks-board.js @@ -362,7 +362,8 @@ // 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; } + // When the address drops it, the filter it set goes too, unless the select has changed it since. + if (who !== addrAssignee) { const was = addrAssignee; addrAssignee = who; if (ROLE.test(who)) filter.assignee = who; else if (!who && was && filter.assignee === was) filter.assignee = ''; } if (filter.org && filter.project && filter.project !== '-' && !filter.project.startsWith(`${filter.org}:`)) filter.project = ''; if (dragging) await Promise.race([dragEnded, new Promise(r => setTimeout(r, 30000))]); // Two renders at once (a first load, a poll during a filter change): the replaced one draws from the newer read. diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index 5c208847b..f72a8673f 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -1106,7 +1106,13 @@ // 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; + let drawerFocus = false, drawerShown = null; + // The drawer holds at most one history entry. A row opened from the page without a drawer pushes one + // entry marked in history.state; switching rows replaces it. Close goes Back only from that marked + // entry, so a shared link or a reload of an unmarked one never leaves the Console. + const DRAWER_MARK = 'mosaicAgentDrawer'; + const stateOf = () => (history.state && typeof history.state === 'object' ? history.state : null); + const unmarked = () => { const st = stateOf(); if (!st || !(DRAWER_MARK in st)) return history.state; const { [DRAWER_MARK]: _, ...rest } = st; return rest; }; 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}` : ''}`; }; @@ -1195,13 +1201,12 @@

Decisions waiting on you

${decBody}
${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 + // 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. function closeDrawer() { if (!stopState?.busy) stopState = null; - if (drawerPushed && location.hash === drawerPushed) { drawerPushed = null; history.back(); return; } - drawerPushed = null; - history.replaceState(history.state, '', sansAgent()); + if (stateOf()?.[DRAWER_MARK] && queryOf().has('agent')) { history.back(); return; } + history.replaceState(unmarked(), '', 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 @@ -1253,7 +1258,14 @@ 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; + e.preventDefault(); + const to = new URL(link.href).hash; + // The open row again: the address stays, focus goes to the drawer's heading. + if (to === location.hash) { document.querySelector('#bus-view [data-ws-drawer] h2')?.focus({ preventScroll: true }); return; } + drawerFocus = true; + if (queryOf().has('agent')) history.replaceState(history.state, '', to); + else history.pushState({ ...(stateOf() || {}), [DRAWER_MARK]: true }, '', to); + document.dispatchEvent(new CustomEvent('mosaic-workspace-render')); return; } const d = e.target.closest?.('[data-ws-drawer]'); @@ -1270,19 +1282,23 @@ 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-no]')) { cancelStop(d); 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. + // Cancel the Stop question: nothing sent, Stop enabled again and focused. + function cancelStop(d) { + 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 }); } + } + // Escape cancels the Stop question while it asks, else 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(); + const d = document.querySelector('#bus-view [data-ws-drawer]'); + if (!d) return; + e.preventDefault(); + if (d.querySelector('[data-drawer-stop-confirm]')) cancelStop(d); else closeDrawer(); }); // ---- controls: filters write the hash query and repaint in place ---- @@ -1293,7 +1309,8 @@ for (const [k, v] of Object.entries(change)) { if (v === null || v === undefined || v === '' || (Array.isArray(v) && !v.length)) q.delete(k); else q.set(k, Array.isArray(v) ? v.join(',') : String(v)); } const s = q.toString(); // SP3: the address keeps its own form, an organization's or the system's (#/inbox). - history.replaceState(history.state, '', `${/^#\/orgs\/[^/?]+/.exec(location.hash)?.[0] ?? '#'}${pathOf()}${s ? `?${s}` : ''}`); + // A filter change makes the entry differ from the one before the drawer, so Close then stays in place. + history.replaceState(unmarked(), '', `${/^#\/orgs\/[^/?]+/.exec(location.hash)?.[0] ?? '#'}${pathOf()}${s ? `?${s}` : ''}`); if (render) document.dispatchEvent(new CustomEvent('mosaic-workspace-render')); } function setGrouping(g) { diff --git a/packages/webui/tests/agent-drawer-browser.test.mjs b/packages/webui/tests/agent-drawer-browser.test.mjs index 4cff4dcf8..6e2a8cb08 100644 --- a/packages/webui/tests/agent-drawer-browser.test.mjs +++ b/packages/webui/tests/agent-drawer-browser.test.mjs @@ -140,7 +140,8 @@ test('a row on #/agents opens the drawer: a region with a heading, the page stay // At the right of the page. const box = await b.evaluate(`(() => { const r = document.querySelector('${d}').getBoundingClientRect(); return [Math.round(r.left), Math.round(r.right), innerWidth]; })()`); assert.ok(box[0] > box[2] / 2, `drawer left edge ${box[0]} should be in the right half of ${box[2]}`); - assert.ok(box[1] >= box[2] - 20, `drawer right edge ${box[1]} should reach the right of ${box[2]}`); // At 1440 wide nothing on the page sits under it: the agents table ends left of the drawer. + assert.ok(box[1] >= box[2] - 20, `drawer right edge ${box[1]} should reach the right of ${box[2]}`); + // At 1440 wide nothing on the page sits under it: the agents table ends left of the drawer. const table = await b.evaluate(`Math.round(document.querySelector('[data-ws-agent-list]').getBoundingClientRect().right)`); assert.ok(table <= box[0], `the agents table right edge ${table} should be left of the drawer at ${box[0]}`); }); @@ -170,11 +171,15 @@ test('the drawer shows each field from its read: name, organization, role and so // 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. + // No read gives the time of an agent's last activity. The sessions read's `since` is when its + // current state began, which is not the same thing, so the drawer draws no time. assert.equal(await has(`${d} [data-drawer-activity]`), false); // The Tasks board link opens the board filtered to the agent. await b.evaluate(`document.querySelector('${d} [data-drawer-tasks-all]').click()`); await wait(`document.querySelector('[data-tasks-board] select[data-tb-filter="assignee"]')?.value === 'coder'`); + // When the address drops the assignee, the board drops the filter it took from the address. + await b.evaluate('location.hash = "#/tasks"'); + await wait(`document.querySelector('[data-tasks-board] select[data-tb-filter="assignee"]')?.value === ''`); }); test('a hired agent shows where its role came from; a not-running agent says not running', async t => { @@ -222,6 +227,37 @@ test('the address round trip: Back closes the drawer, Forward and a reload open await wait(`document.querySelector("[data-ws-agent-list]") && ${noDrawer}`); }); +test('the drawer holds at most one history entry: Close after switching agents, or after a shared link, stays in the Console', async t => { + const { b, base, wait } = await setup(t); + await wait(`document.querySelector(${JSON.stringify(rowLink('acme:coder'))})`); + const before = await b.evaluate('history.length'); + await b.evaluate(`document.querySelector(${JSON.stringify(rowLink('acme:coder'))}).click()`); + await wait(open('acme:coder')); + // Another row while the drawer is open switches it in place: no second entry. + await b.evaluate(`document.querySelector(${JSON.stringify(rowLink('beta:pm'))}).click()`); + await wait(`${open('beta:pm')} && !${drawer('acme:coder')}`); + assert.equal(await b.evaluate(agentParam), 'beta:pm'); + assert.equal(await b.evaluate('history.length'), before + 1); + // The open row again does nothing to the address. + await b.evaluate(`document.querySelector(${JSON.stringify(rowLink('beta:pm'))}).click()`); + assert.equal(await b.evaluate('history.length'), before + 1); + // One Close goes back to the page without the drawer. + await b.evaluate(`${drawer('beta:pm')}.querySelector("[data-drawer-close]").click()`); + await wait(`${noDrawer} && location.hash === "#/agents"`); + // A shared link opened after another site: clicking its row and then Close stays here. + await b.navigate('about:blank'); + await b.navigate(`${base}/#/agents?agent=acme%3Acoder`); + await wait(open('acme:coder')); + await b.evaluate(`document.querySelector(${JSON.stringify(rowLink('acme:coder'))}).click()`); + await b.evaluate(`${drawer('acme:coder')}.querySelector("[data-drawer-close]").click()`); + await wait(`location.href.startsWith(${JSON.stringify(base)}) && ${noDrawer} && location.hash === "#/agents"`); + // Escape on a shared link does the same. + await b.navigate(`${base}/#/agents?agent=beta%3Apm`); + await wait(open('beta:pm')); + await b.key('Escape'); + await wait(`location.href.startsWith(${JSON.stringify(base)}) && ${noDrawer} && location.hash === "#/agents"`); +}); + test('the keyboard path: Tab reaches the row, Enter opens, Escape closes and focus returns to the row', async t => { const { b, wait } = await setup(t); const link = JSON.stringify(rowLink('acme:coder')); @@ -295,6 +331,22 @@ test('Message opens the agent\'s thread in an Assistant pane; Stop asks first an await b.evaluate(`document.querySelector('${d} [data-drawer-stop-yes]').click()`); await wait(`/read-only|only reads/.test(document.querySelector('${d} [data-drawer-status]')?.textContent ?? "")`); assert.match(await text(`${d} [data-drawer-status]`), /^Not stopped: this Console only reads agents in Acme Studio \(read-only\)\.$/); + // Escape while it asks cancels only the question: the drawer stays and focus is back on Stop. + await b.evaluate(`document.querySelector('${d} [data-drawer-stop]').click()`); + await wait(`document.querySelector('${d} [data-drawer-stop-no]') === document.activeElement`); + await b.key('Escape'); + await wait(`!document.querySelector('${d} [data-drawer-stop-confirm]') && document.activeElement === document.querySelector('${d} [data-drawer-stop]')`); + assert.equal(await b.evaluate(`document.querySelector('${d} [data-drawer-stop]').disabled`), false); + assert.equal(await b.evaluate(agentParam), 'acme:coder'); + assert.equal(await b.evaluate('window.__posts').then(p => p.length), 2); + // A Stop the bus takes: the request is followed until it closes, then the drawer says it stopped. + const REQ = '0f0e0d0c-0b0a-4908-8706-050403020100'; + await b.evaluate(`window.__answers.push({ status: 200, body: { sent: true, request: '${REQ}' } }, { status: 200, body: { request: '${REQ}', state: 'closed' } })`); + await b.evaluate(`document.querySelector('${d} [data-drawer-stop]').click()`); + await wait(`document.querySelector('${d} [data-drawer-stop-yes]')`); + await b.evaluate(`document.querySelector('${d} [data-drawer-stop-yes]').click()`); + await wait(`/^Stopped coder\.$/.test(document.querySelector('${d} [data-drawer-status]')?.textContent ?? "")`); + assert.deepEqual((await b.evaluate('window.__posts')).slice(2).map(p => [p.url, p.method]), [['/api/orgs/acme/agent/terminate', 'POST'], [`/api/orgs/acme/agent/request?id=${REQ}`, 'GET']]); // A not-running agent has nothing to stop. await b.evaluate('location.hash = "#/agents?agent=acme%3Achief"'); await wait(open('acme:chief')); -- 2.54.0