From 5fba4726dc88f1eecbcfdb9d3ee02c93e657e078 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 12:34:30 -0600 Subject: [PATCH] fix(webui): sp13 the organization page fits its width: its two columns wrap to one once the content is narrower than both need, with or without the agent drawer, and the Settings card puts a value on its own line when the card is narrow, so no value or Open link is cut; #/orgs/?query is the organization page with the query kept and ?agent= opens the drawer; the drawer says its state is being read while the instances read is open and that it was not read when that read fails, never not running, and its Stop note says the same; an instance whose runs were not read shows state not read on the Agents table; the Queue card says in plain words when this Console does not run from the checkout that holds the queue and drops Open queue there (#1873, lane sp13-org-page-fit) --- packages/webui/src/public/display.js | 5 +- packages/webui/src/public/shell.css | 16 +- packages/webui/src/public/workspace.js | 77 ++++-- .../webui/tests/org-page-fit-browser.test.mjs | 256 ++++++++++++++++++ 4 files changed, 329 insertions(+), 25 deletions(-) create mode 100644 packages/webui/tests/org-page-fit-browser.test.mjs diff --git a/packages/webui/src/public/display.js b/packages/webui/src/public/display.js index df2ed3ed9..0613263b4 100644 --- a/packages/webui/src/public/display.js +++ b/packages/webui/src/public/display.js @@ -5,7 +5,8 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date( // Requests already sent keep the organization they were sent with. (() => { const nativeFetch = window.fetch.bind(window); - const orgOf = hash => /^#\/orgs\/([^/]+)/.exec(hash)?.[1] ?? null; + // SP13: the organization segment stops at `/` or `?`, so `#/orgs/?agent=…` names . + const orgOf = hash => /^#\/orgs\/([^/?]+)/.exec(hash)?.[1] ?? null; const atLoad = orgOf(location.hash); if (!atLoad && !document.documentElement.hasAttribute('data-organizations')) return; // current: the scoping organization as its hash segment. settled: false only while the first @@ -16,7 +17,7 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date( const settle = seg => { current = seg; settled = true; markReady(seg ? decodeURIComponent(seg) : null); }; // An address that names its organization is settled at load: anything waiting on ready goes on. if (atLoad) markReady(decodeURIComponent(atLoad)); - const tail = hash => { const m = /^#\/orgs\/[^/]+(.*)$/.exec(hash); return m ? (m[1] || '/') : hash.startsWith('#/') ? hash.slice(1) : '/'; }; + const tail = hash => { const m = /^#\/orgs\/[^/?]+(.*)$/.exec(hash); return m ? (!m[1] ? '/' : m[1][0] === '?' ? `/${m[1]}` : m[1]) : hash.startsWith('#/') ? hash.slice(1) : '/'; }; const route = () => tail(location.hash); // The bare address and the old per-organization dashboard address are the system dashboard; // an organization page without the prefix gets the current organization's. #1873 SP2: an diff --git a/packages/webui/src/public/shell.css b/packages/webui/src/public/shell.css index bca4f7427..4899f45cc 100644 --- a/packages/webui/src/public/shell.css +++ b/packages/webui/src/public/shell.css @@ -485,6 +485,11 @@ html[data-organizations] .ws-panel.tight{padding:12px 14px} html[data-organizations] .ws-split{display:grid;grid-template-columns:minmax(0,1.45fr) minmax(0,1fr);gap:16px;align-items:start} html[data-organizations] .ws-split{margin-top:22px} html[data-organizations] .ws-split>section>.ws-sec-h:first-child{margin-top:0} +/* SP13 (#1873): the organization page's two columns stack once its content is narrower than both need, + with or without the agent drawer beside it: a wrap, not a viewport breakpoint. */ +html[data-organizations] .ws-dash>.ws-split{display:flex;flex-wrap:wrap;align-items:flex-start} +html[data-organizations] .ws-dash>.ws-split>section{flex:1.45 1 480px;min-width:0} +html[data-organizations] .ws-dash>.ws-split>section+section{flex:1 1 360px} /* E2E6 P1-3: the long editors (Autonomy, Packs, Workflows) span the page below the split, so neither column runs screens past the other at 1440. */ html[data-organizations] .ws-wide{display:grid;grid-template-columns:minmax(0,1fr);gap:16px;margin-top:22px} @@ -522,15 +527,20 @@ html[data-organizations] .ws-tbl tr:last-child td{border-bottom:0} html[data-organizations] .ws-tbl .t1{color:var(--strong)} html[data-organizations] .ws-tbl .t2{display:block;font-size:12px;color:var(--muted)} html[data-organizations] .ws-tbl td.mono{font-family:var(--mono);font-size:12.5px;white-space:nowrap} -html[data-organizations] .ws-cfg{display:grid;list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--surface)} +html[data-organizations] .ws-cfg{container-type:inline-size;display:grid;list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--surface)} html[data-organizations] .ws-cfg li{display:grid;align-items:center;grid-template-columns:18px 132px minmax(0,1fr) auto auto;gap:0 10px;padding:7px 12px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent)} html[data-organizations] .ws-cfg li:last-child{border-bottom:0} html[data-organizations] .ws-cfg svg.i{width:16px;height:16px;color:var(--muted)} html[data-organizations] .ws-cfg .k{font-size:13px;font-weight:600;color:var(--strong)} -html[data-organizations] .ws-cfg .v{font-size:12px;color:var(--muted);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +html[data-organizations] .ws-cfg .v{font-size:12px;color:var(--muted);min-width:0;overflow-wrap:anywhere} html[data-organizations] .ws-cfg .src-tag{font:500 11px var(--mono);color:var(--accent);white-space:nowrap} html[data-organizations] .ws-cfg .go{font-size:12.5px;font-weight:600;color:var(--action);text-decoration:none;min-width:32px;text-align:right} html[data-organizations] .ws-cfg a.go:hover{text-decoration:underline} +/* SP13: a Settings card narrower than its five columns need puts each value on its own line. */ +@container (max-width:560px){ + html[data-organizations] .ws-cfg li{grid-template-columns:18px minmax(0,1fr) auto auto} + html[data-organizations] .ws-cfg .v{grid-column:2/-1;grid-row:2} +} html[data-organizations] .ws-cfg li.danger .k,html[data-organizations] .ws-cfg li.danger svg.i,html[data-organizations] .ws-cfg li.danger .go,html[data-organizations] .ws-cfg li.danger .src-tag{color:var(--danger)} /* Agents (sample 08). */ @@ -773,8 +783,6 @@ html[data-organizations] .ws-drawer-acts .note{flex-basis:100%} html[data-organizations] .ws-split{grid-template-columns:minmax(0,1fr)} html[data-organizations] .ws-tiles,html[data-organizations] .ws-tiles.six{grid-template-columns:repeat(2,minmax(0,1fr))} html[data-organizations] .ws-sec-h .note{flex-basis:100%;order:3} - html[data-organizations] .ws-cfg li{grid-template-columns:18px minmax(0,1fr) auto auto} - html[data-organizations] .ws-cfg .v{grid-column:2/-1;grid-row:2} html[data-organizations] .ws-meta{padding-left:0;margin-top:0} html[data-organizations] .ws-orghead .ws-right{margin-left:0} html[data-organizations] .ws-busctl,html[data-organizations] .ws-busctl .bus-restart{align-items:flex-start} diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index f72a8673f..3b9603983 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -57,7 +57,7 @@ const dot = (cls, glyph, word, shown = false) => `${shown ? esc(word) : `${esc(word)}`}`; const orgHref = id => `#/orgs/${enc(id)}`; // SP3: a system route such as #/inbox has no organization prefix; both forms give the path. - const route = () => location.hash.replace(/^#(?:\/orgs\/[^/]+)?/, '') || '/'; + const route = () => location.hash.replace(/^#(?:\/orgs\/[^/?]+)?/, '') || '/'; const ensureThemes = async () => { if (!themes) themes = await data.themes().then(b => (Array.isArray(b?.themes) ? b.themes : [])).catch(() => []); return themes; }; const instancesOf = value => { const v = value?.rows?.[0]?.instances ?? value?.instances; return v && typeof v === 'object' && !Array.isArray(v) ? v : null; }; // #1873 A3: the root and project agent copies the bus booted with, from the same instances read. @@ -74,7 +74,13 @@ const runningOf = rows => rows.filter(a => LIVE_STATES.has(a.state)).length; const workingOf = rows => rows.filter(a => a.state === 'working').length; const agentNote = (run, waiting) => `${run} running${waiting ? `, ${waiting} waiting on you` : ''}`; + // SP13 (#1873): an instances answer that carries no runs for an agent (the bus gives `running` on + // every answer it enriches) has not said whether the agent runs, so its state reads as not read, + // never as not running. + const STATE_UNREAD = ['s-unknown', '?', 'state not read']; + const runsRead = item => Array.isArray(item?.running); const activity = (name, item, agents) => { + if (!runsRead(item)) return STATE_UNREAD; const on = running(item) > 0; const held = on && agents ? agents.find(a => a?.role === name && item.running.includes(a.holder_run)) : null; return ACTIVITY[held?.state] || (on ? ['s-working', '●', 'running'] : ['s-idle', '–', 'not running']); @@ -267,8 +273,11 @@ // organization's own reads, not after them. const wsRead = data.workspace({ org: id, sections: ['sessions', 'projects', 'metrics'] }).catch(() => null); // SP12: an agent row opens its drawer over this page; only an agent of this organization. - const drawerRead = agentDrawer(opts.q ?? queryOf(), { only: id, configIn: opts.configIn }).catch(() => ''); + // SP13: it waits on its own instances read only until this page's reads have answered. + let pageRead; const page = new Promise(done => { pageRead = done; }); + const drawerRead = agentDrawer(opts.q ?? queryOf(), { only: id, configIn: opts.configIn, page }).catch(() => ''); const reads = await Promise.allSettled([data.business(id), data.settings(id), data.instances(id), data.threads(id), data.projects(id), data.queue(id), data.agents(id), data.claims(id), ...(stopped ? [] : [window.MosaicAutonomy?.load(id), window.MosaicPacks?.load(id), window.MosaicWorkflows?.load(id)])]); + pageRead(); const value = i => (reads[i].status === 'fulfilled' ? reads[i].value : null); const business = value(0), settings = value(1), instances = instancesOf(value(2)); const threads = rowsOf(value(3)), projectRows = rowsOf(value(4)), queue = rowsOf(value(5)); @@ -343,7 +352,12 @@ const queueNote = queue === null ? 'unavailable' : queue.length ? `${plural(queue.length, 'row')}, ${open} not done` : 'no rows'; // E2E6 P2-6: a failed queue read says why, in the words the Queue page uses (server-redacted). const qerr = queue === null && reads[5].status === 'rejected' ? reads[5].reason : null; - const queueWhy = queue !== null ? '' : `

${qerr?.code === 'queue-refused' ? `The queue refused the read.${qerr.detail ? ` ${esc(qerr.detail.replace(//g, 'This checkout'))}` : ''}` + // SP13 (#1873): the store refuses any checkout that is a linked worktree. Said plainly: this Console + // runs from another checkout than the one holding the queue. The Queue page reads the same queue + // and would only refuse again, so the card drops its link. Any other refusal keeps its own words. + const queueElsewhere = qerr?.code === 'queue-refused' && /is a linked worktree; the queue runs only in the canonical checkout/.test(qerr.detail || ''); + const queueWhy = queue !== null ? '' : `

${queueElsewhere ? 'This Console does not run from the checkout that holds the queue, so the queue is not shown here.' + : qerr?.code === 'queue-refused' ? `The queue refused the read.${qerr.detail ? ` ${esc(qerr.detail.replace(//g, 'This checkout'))}` : ''}` : qerr?.status === 0 ? 'You are signed out. Reload the page to sign in again.' : qerr?.code === 'not-configured' ? 'This Console has no queue to read for this organization.' : `The queue read did not answer${qerr?.code ? ` (${esc(qerr.code)})` : ''}.`}

`; @@ -359,7 +373,7 @@
${secH('Active agent sessions', live ? (live.length ? `${live.length} running${tileAgents ? `, ${liveWorking} working` : ', working not read'}` : 'none running') : '', `This organization's agents`)} ${sessions} - ${secH('Queue', queueNote, 'Open queue')} + ${secH('Queue', queueNote, queueElsewhere ? '' : 'Open queue')} ${queueWhy}
@@ -974,8 +988,8 @@ // `opts.scope` names one organization (#/orgs//agents): the same table, that organization only. // `opts.fireIn(org)` says from that organization's own config whether this Console decides there; // Fire is drawn only on the system page and only there, and posts to the row's organization (bus.js). - const AGENT_STATE = [['working', 'Working'], ['waiting', 'Waiting on you'], ['idle', 'Idle'], ['running', 'Running, state unknown'], ['not-running', 'Not running'], ['cant-launch', "Can't launch"]]; - const AGENT_STATE_KEY = { working: 'working', 'waiting on you': 'waiting', idle: 'idle', running: 'running', 'not running': 'not-running', "can't launch": 'cant-launch' }; + const AGENT_STATE = [['working', 'Working'], ['waiting', 'Waiting on you'], ['idle', 'Idle'], ['running', 'Running, state unknown'], ['not-running', 'Not running'], ['not-read', 'State not read'], ['cant-launch', "Can't launch"]]; + const AGENT_STATE_KEY = { working: 'working', 'waiting on you': 'waiting', idle: 'idle', running: 'running', 'not running': 'not-running', 'state not read': 'not-read', "can't launch": 'cant-launch' }; // Each named organization's instances read, and whether this Console decides there when `fireIn` is given. const readInstances = (ids, fireIn) => Promise.all(ids.map(id => Promise.all([ data.instances(id).then(v => ({ inst: instancesOf(v), copies: copiesOf(v) }), err => ({ inst: null, copies: {}, err })), @@ -1017,7 +1031,9 @@ const want = String(q?.get?.('state') || ''); const stateF = AGENT_STATE.some(([k]) => k === want) ? want : null; // SP12: the system page's agent drawer reads beside the page. - const drawerRead = scope ? Promise.resolve('') : agentDrawer(q, { configIn: opts.configIn }).catch(() => ''); + // SP13: it waits on its own instances read only until this page's reads have answered. + let pageRead = () => {}; const page = new Promise(done => { pageRead = done; }); + const drawerRead = scope ? Promise.resolve('') : agentDrawer(q, { configIn: opts.configIn, page }).catch(() => ''); let body; try { [body] = await Promise.all([data.workspace({ sections: ['sessions', 'projects'] }), ensureThemes()]); took(body); } catch (err) { return failedPage('ws-agents-all', 'data-ws-agents', 'Agents', err); } @@ -1026,6 +1042,7 @@ const askable = (scope ? o.rows : every.rows).filter(r => r.state === 'ok'); // Each answering organization's instances, and (system page only) whether this Console decides there. const reads = await readInstances(askable.map(r => r.org), scope ? null : opts.fireIn); + pageRead(); const all = agentRows(reads, body); const counts = new Map(); for (const a of all) counts.set(a.org, (counts.get(a.org) || 0) + 1); @@ -1124,25 +1141,44 @@ const who = `${a.name} in ${orgName}`; return `${st?.confirm ? stopConfirm(a.name, who) : ''}`; } + // SP13 (#1873): the drawer's instances read gives the agent's state. The drawer does not hold the + // page for it: once the page's own reads have answered (`opts.page`), a drawer whose instances read + // has not says its state is being read, and repaints when the read answers. That answer is kept for + // the one repaint it asks for; a repaint while the read is still open waits on the same read. + let drawerInst = null; // { org, done, value, p } + const drawerInstRead = org => { + if (drawerInst?.org === org) return drawerInst; + const r = { org, done: false, value: null }; + r.p = data.instances(org).then(v => ({ inst: instancesOf(v), copies: copiesOf(v) }), err => ({ inst: null, copies: {}, err })) + .then(v => { r.done = true; r.value = v; return v; }); + drawerInst = r; + return r; + }; async function agentDrawer(q, opts = {}) { const pick = agentPick(q); if (!pick || (opts.only && pick.org !== opts.only)) return ''; const { org, key, id } = pick; - const [body, instRead, business, config] = await Promise.all([ + const ir = drawerInstRead(org); + const [body, business, config] = await Promise.all([ data.workspace({ org, sections: ['sessions', 'projects', 'inbox', 'tasks'] }).catch(() => null), - data.instances(org).then(v => ({ inst: instancesOf(v), copies: copiesOf(v) }), () => null), data.business(org).catch(() => null), typeof opts.configIn === 'function' ? Promise.resolve(opts.configIn(org)).catch(() => null) : null, ensureThemes(), ]); + await Promise.race([ir.p, opts.page ?? ir.p]); + const pending = !ir.done; + if (pending) ir.p.then(() => { if (document.querySelector(`#bus-view [data-ws-drawer="${CSS.escape(id)}"][data-drawer-pending]`)) document.dispatchEvent(new CustomEvent('mosaic-workspace-render')); }); + else if (drawerInst === ir) drawerInst = null; + const instRead = pending ? null : ir.value; 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}

`); - } + // While the instances read is open, or after it failed, the agent's name is the address's and its + // role, source and state are not known: the state says so, and Stop gives that as its reason. + const unknown = pending ? 'Reading state…' : !instRead?.inst ? `State not read: ${esc(instWhy(instRead || {}))}.` : ''; + const a = unknown ? { org, key, name: key, level: 'org', keep: false, item: {}, s: arr(body?.sessions).find(x => x?.kind === 'role' && x.org === org && x.role === key) || null } + : agentRows([{ id: org, inst: instRead.inst, copies: instRead.copies }], body).find(r => r.key === key); + if (!a) return shell(key, `

No agent named ${esc(key)} was read in ${esc(orgName)}.

`); 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}
`; @@ -1180,7 +1216,10 @@ 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.' + // SP13: "not running" is a reason only once the instances read has given the agent's runs. + else if (change) acts.push(`

${pending ? 'Its state is being read. Stop is offered once it is read and the agent is running.' + : unknown || !runsRead(item) ? 'Its state was not read, so Stop is not offered.' + : a.level !== 'org' ? 'A copy is not stopped from here.' : a.keep ? `${esc(a.name)} is one of ${esc(orgName)}’s protected agents, so it is not stopped from here.` : 'It is not running, so there is nothing to stop.'}

`); const st = stopState?.id === id ? stopState : null; @@ -1189,9 +1228,9 @@ : `

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)), + unknown ? '' : field('role', 'Role', `${esc(a.role)}`), + unknown ? '' : field('source', 'Source', source), + field('state', 'State', unknown ? `${unknown}` : 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)}` : ''}`) : '', @@ -1199,7 +1238,7 @@ 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)}"`); + ${actions}`, ` data-drawer-org-id="${esc(org)}" data-drawer-name="${esc(a.name)}" data-drawer-org-name="${esc(orgName)}"${pending ? ' data-drawer-pending' : ''}`); } // Closing goes Back when a row link on this page pushed the current entry; otherwise it drops // `agent` from the address in place. Either way the page repaints without it and focus returns to the row. diff --git a/packages/webui/tests/org-page-fit-browser.test.mjs b/packages/webui/tests/org-page-fit-browser.test.mjs new file mode 100644 index 000000000..13a80886e --- /dev/null +++ b/packages/webui/tests/org-page-fit-browser.test.mjs @@ -0,0 +1,256 @@ +// SP13 (#1873): the organization page fits its width, and four things seen live. +// 1. At every content width from 360 px up, with and without the agent drawer, nothing in the Settings +// card is cut off by its card and the page does not scroll sideways. +// 2. `#/orgs/?query` is the organization page with the query kept; `?agent=` opens the drawer. +// 3. The drawer never says "not running" before its instances read has answered: while it is open the +// state is being read, after it fails the state was not read, and the Stop note says the same. +// 4. The Queue card says in plain words when the queue is refused because this Console runs from a +// linked worktree, and drops Open queue; any other refusal keeps its own reason. +// Plus the test SP12 left out: a repaint while Stop asks its question keeps the question on screen. +// Every bus is a fake. The real GET /api/workspace and per-organization routes serve the page; only the +// per-organization config, the agent writes and (when a test asks) the drawer's instances read are +// stubbed in the page. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { startServer } from '../src/serve.mjs'; +import { createDevOwner } from '../src/dev-owner.mjs'; +import { ReadError } from '../src/reads.mjs'; +import { browser } from './browser.mjs'; +import { close } from './fixture.mjs'; + +const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`); +const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString(); + +function fixture({ queueError = null } = {}) { + const LIST = [{ id: 'acme', label: 'Acme Studio', theme: 'cobalt' }]; + const d = { + projects: [{ id: 'p-site', repo: 'site', name: 'Website', archived: false, at: at(30) }], + tasks: [], inbox: [], threads: [], + agents: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'working', at: at(40), working: { since: at(40), messages: 2 } }], + claims: [], + instances: { + coder: { definition: 'coder', running: ['run-1'] }, + chief: { definition: 'chief-of-staff', running: [] }, + reviewer: { definition: 'reviewer', running: [] }, + }, + // Long values, so a narrow Settings card has something to cut. + model: { coder: 'claude-opus-5-5', chief: 'claude-sonnet-5-5', reviewer: 'gpt-5.5-codex-high-reasoning' }, + }; + const value = name => Promise.resolve(d[name] ?? (['usage', 'limits'].includes(name) ? {} : [])); + const opts = () => ({ + bus: { inbox: () => value('inbox'), agents: () => value('agents'), 'thread.claims': () => value('claims'), tasks: () => value('tasks'), usage: () => value('usage'), limits: () => value('limits'), decisions: () => value('decisions') }, + chat: { threads: () => value('threads'), projects: () => value('projects') }, + }); + const member = org => ({ id: org.id, doc: { appearance: { theme: org.theme } }, status: async () => ({ healthy: true, pid: 4242, boot: null }), options: opts }); + return { + list: async () => LIST.map(o => ({ ...o, busHealthy: true })), + members: async () => LIST.map(member), + resolve: async id => { + const org = LIST.find(o => o.id === id); if (!org) return null; + return { + busHealthy: true, + reads: { + business: async () => ({ business: { id, human: 'jason', projects: d.projects.map(p => p.repo), appearance: { theme: org.theme }, vars: { 'tracker.kind': 'gitea-issues-and-milestones' }, arbiters: { decisions: 'chief-of-staff', reviews: 'reviewer', merges: 'human-owner-of-the-organization' }, instances: Object.entries(d.model).map(([instance, model]) => ({ instance, vars: { model } })) } }), + settings: async () => ({}), + queue: async () => { if (queueError) throw queueError; return { rows: [] }; }, + busStatus: async () => ({}), + }, + bus: { + messages: async () => ({ messages: [], unread: 0 }), agents: async () => d.agents, 'thread.claims': async () => d.claims, inbox: async () => d.inbox, tasks: async () => d.tasks, + instances: async () => ({ revision: 'a'.repeat(64), definitions: {}, instances: structuredClone(d.instances) }), + }, + chat: { projects: async () => d.projects, threads: async () => d.threads }, + }; + }, + }; +} + +// Runs before the page's scripts. Acme's config says this Console sends there. `window.__inst` set by +// a test holds ('hold', released by window.__release()) or fails ('fail') the first bus instances read, +// which is the drawer's: it starts before the page's own reads. +const STUB = `(() => { + const real = window.fetch.bind(window); + window.__posts = []; window.__answers = []; window.__instSeen = 0; + const answer = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + window.fetch = async (url, init = {}) => { + const u = String(url); + if (/\\/api\\/orgs\\/acme\\/bus\\/instances$/.test(u) && window.__instSeen++ === 0 && window.__inst) { + if (window.__inst === 'fail') return answer(502, { error: 'read-failed', message: 'the bus did not answer' }); + await new Promise(done => { window.__release = done; }); + return real(url, init); + } + const cfg = u.match(/^\\/api\\/orgs\\/([^/]+)\\/config$/); + if (cfg) { const c = await (await real(url, init)).json(); return answer(200, { ...c, chatSend: true, settingsSend: true, decide: true }); } + if (!/\\/agent\\//.test(u) || (init.method || 'GET') === 'GET' && !u.includes('/agent/request')) return real(url, init); + window.__posts.push({ url: u, method: init.method || 'GET', body: init.body ?? null }); + const a = window.__answers.shift(); + if (!a) throw new TypeError('no answer queued'); + return answer(a.status ?? 200, a.body); + }; +})();`; + +async function setup(t, { viewport = [1440, 900], hash = '#/orgs/acme/organization', inst = null, queueError = null, scripts = [] } = {}) { + const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ queueError }), log: () => {} }); + t.after(() => close(server)); + const b = await browser({ machine: false }); t.after(() => b.close()); + await b.viewport(...viewport); + if (inst) await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `window.__inst = ${JSON.stringify(inst)};` }); + await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB }); + for (const source of scripts) await b.call('Page.addScriptToEvaluateOnNewDocument', { source }); + const base = `http://127.0.0.1:${server.address().port}`; + await b.navigate(`${base}/${hash}`); + const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText.replace(/\\s+/g, " ").trim() ?? null`); + const has = sel => b.evaluate(`!!document.querySelector(${JSON.stringify(sel)})`); + return { b, base, wait: waitFor(b), text, has }; +} +const drawer = key => `document.querySelector('[data-ws-drawer="${key}"]')`; +const open = key => `!!${drawer(key)} && ${drawer(key)}.querySelector("h2")?.textContent.length > 0`; +const noDrawer = '!document.querySelector("[data-ws-drawer]")'; +const settled = 'document.querySelectorAll("[data-org-settings] [data-org-setting]").length > 10'; + +// Everything in the Settings card that is cut: a cell whose content is wider than its box, a cell that +// reaches past the card's edge or, from 1200 px, under the drawer, and a page that scrolls sideways. +const CLIPPED = `(() => { + const card = document.querySelector('[data-org-settings]'), r = card.getBoundingClientRect(); + // From 1200 px the page keeps clear of the drawer (SP12); below that the drawer covers the page by design. + const d = innerWidth >= 1200 ? document.querySelector('[data-ws-drawer]')?.getBoundingClientRect() : null; + const edge = Math.min(r.right, d && d.left > 0 ? d.left : Infinity); + const out = []; + for (const li of card.querySelectorAll('[data-org-setting]')) { + for (const el of li.querySelectorAll('.k,.v,.src-tag,.go')) { + if (!el.textContent.trim()) continue; + const b = el.getBoundingClientRect(); + if (el.scrollWidth > el.clientWidth + 1 || b.right > edge + 1 || b.left < r.left - 1) out.push(li.dataset.orgSetting + ' ' + el.className + ' ' + el.scrollWidth + '/' + el.clientWidth + ' right ' + Math.round(b.right) + '/' + Math.round(edge)); + } + } + // The sessions table beside it scrolls only as a last resort; with these rows the columns stack first. + for (const tb of document.querySelectorAll('[data-org-dashboard] .ws-split .ws-tbl')) if (tb.scrollWidth > tb.clientWidth + 1) out.push('sessions table ' + tb.scrollWidth + '/' + tb.clientWidth); + const page = document.documentElement; + if (page.scrollWidth > page.clientWidth) out.push('page ' + page.scrollWidth + '/' + page.clientWidth); + return out; +})()`; + +test('the Settings card fits at 1440 with the drawer open, at 735 and 390 without it, and at every width between', async t => { + const { b, base, wait } = await setup(t, { hash: '#/orgs/acme/organization?agent=acme%3Acoder' }); + await wait(`${settled} && ${open('acme:coder')}`); + assert.deepEqual(await b.evaluate(CLIPPED), [], 'at 1440 with the drawer open'); + // Each Open link keeps its whole word. + assert.ok(await b.evaluate(`[...document.querySelectorAll('[data-org-settings] a.go')].every(a => a.innerText.trim() === 'Open' && a.scrollWidth <= a.clientWidth + 1)`)); + // Without the drawer: 735 and 390 wide, and a content width of about 735 (1023 with the sidebar). + for (const [w, h] of [[735, 900], [390, 844], [1023, 900]]) { + await b.viewport(w, h); + await b.navigate(`${base}/#/orgs/acme/organization`); + await wait(`${settled} && ${noDrawer}`); + assert.deepEqual(await b.evaluate(CLIPPED), [], `at ${w} without the drawer`); + } + // Every 40 px from 360 to 1440, with and without the drawer. + for (const hash of ['#/orgs/acme/organization', '#/orgs/acme/organization?agent=acme%3Acoder']) { + await b.navigate(`${base}/${hash}`); + await wait(`${settled} && ${hash.includes('agent') ? open('acme:coder') : noDrawer}`); + for (let w = 360; w <= 1440; w += 40) { + await b.viewport(w, 900); + await b.evaluate('new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))'); + assert.deepEqual(await b.evaluate(CLIPPED), [], `at ${w}${hash.includes('agent') ? ' with the drawer' : ''}`); + } + } +}); + +test('the bare organization address with a query is the organization page, query kept; ?agent= opens the drawer', async t => { + const { b, base, wait, text } = await setup(t, { hash: '#/orgs/acme?agent=acme%3Acoder' }); + await wait(`document.querySelector('[data-org-dashboard] h1') && ${open('acme:coder')}`); + assert.equal(await text('[data-org-dashboard] h1'), 'Acme Studio'); + assert.equal(await b.evaluate('!!document.querySelector("[data-org-unavailable]")'), false); + assert.equal(await b.evaluate('new URLSearchParams(location.hash.split("?")[1] || "").get("agent")'), 'acme:coder'); + assert.match(await b.evaluate('location.hash'), /^#\/orgs\/acme\/organization\?agent=acme%3Acoder$/); + // The reads are scoped to acme, never to "acme?agent=…". + assert.equal(await b.evaluate('window.MosaicOrg?.id'), 'acme'); + // Another query, no drawer: the same page with the query kept. + await b.navigate(`${base}/#/orgs/acme?tab=settings`); + await wait(`document.querySelector('[data-org-dashboard] h1')?.innerText === 'Acme Studio'`); + assert.equal(await b.evaluate('location.hash'), '#/orgs/acme/organization?tab=settings'); + assert.equal(await b.evaluate('!!document.querySelector("[data-ws-drawer]")'), false); + // Typed into the address bar of a page already open. + await b.evaluate('location.hash = "#/orgs/acme?agent=acme%3Achief"'); + await wait(open('acme:chief')); + assert.equal(await text('[data-org-dashboard] h1'), 'Acme Studio'); +}); + +test('while the drawer\'s instances read is open it says the state is being read, never not running, then shows it', async t => { + const { b, wait, text, has } = await setup(t, { hash: '#/orgs/acme/organization?agent=acme%3Acoder', inst: 'hold' }); + const d = '[data-ws-drawer="acme:coder"]'; + await wait(`${settled} && ${open('acme:coder')}`); + assert.equal(await has(`${d}[data-drawer-pending]`), true); + assert.equal(await text(`${d} [data-drawer-state]`), 'Reading state…'); + assert.doesNotMatch(await text(d), /not running/); + // Stop is not offered yet, and its note says why without saying not running. + assert.equal(await has(`${d} [data-drawer-stop]`), false); + assert.match(await text(`${d} [data-drawer-nostop]`), /^Its state is being read\./); + // The page under it painted from its own reads: coder is a running session there. + assert.match(await text('[data-ws-session-agent="acme:coder"]'), /working/); + // When the read answers, the drawer repaints with the state, and Stop. + await b.evaluate('window.__release()'); + await wait(`!document.querySelector('${d}[data-drawer-pending]') && document.querySelector('${d} [data-drawer-stop]')`); + assert.match(await text(`${d} [data-drawer-state]`), /(^|\s)working$/); + assert.equal(await has(`${d} [data-drawer-nostop]`), false); +}); + +test('after the drawer\'s instances read fails it says the state was not read, and Stop says so too', async t => { + const { b, wait, text, has } = await setup(t, { hash: '#/orgs/acme/organization?agent=acme%3Areviewer', inst: 'fail' }); + const d = '[data-ws-drawer="acme:reviewer"]'; + await wait(`${settled} && ${open('acme:reviewer')}`); + assert.equal(await has(`${d}[data-drawer-pending]`), false); + assert.match(await text(`${d} [data-drawer-state]`), /^State not read: /); + assert.doesNotMatch(await text(d), /not running/); + assert.equal(await has(`${d} [data-drawer-stop]`), false); + assert.equal(await text(`${d} [data-drawer-nostop]`), 'Its state was not read, so Stop is not offered.'); + // A later repaint reads again: reviewer runs nothing, so now it says not running, with that reason. + await b.evaluate('document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))'); + await wait(`/not running/.test(document.querySelector('${d} [data-drawer-state]')?.innerText ?? '')`); + assert.equal(await text(`${d} [data-drawer-nostop]`), 'It is not running, so there is nothing to stop.'); +}); + +test('an instances answer that carries no runs says state not read, in the drawer and on the Agents table', async t => { + // A bus answer the bus did not add runs to (its async read path): no `running` key on any instance. + const strip = `(() => { const real = window.fetch; window.fetch = async (url, init) => { const r = await real(url, init); if (!/\\/bus\\/instances$/.test(String(url)) || !r.ok) return r; const v = await r.json(); for (const row of [v, ...(v.rows || [])]) for (const i of Object.values(row?.instances || {})) delete i.running; return new Response(JSON.stringify(v), { status: 200, headers: { 'content-type': 'application/json' } }); }; })();`; + const { wait, text, has } = await setup(t, { hash: '#/agents?agent=acme%3Acoder', scripts: [strip] }); + const d = '[data-ws-drawer="acme:coder"]'; + await wait(`${open('acme:coder')} && document.querySelector('[data-ws-agent="acme:coder"]')`); + assert.match(await text(`${d} [data-drawer-state]`), /state not read$/); + assert.equal(await text(`${d} [data-drawer-nostop]`), 'Its state was not read, so Stop is not offered.'); + assert.match(await text('[data-ws-agent="acme:coder"]'), /state not read/); + assert.doesNotMatch(await text('[data-ws-agent-list]'), /not running/); + assert.equal(await has('[data-ws-astate="not-read"]'), true); +}); + +test('the Queue card: a linked-worktree refusal in plain words without Open queue; another refusal keeps its reason', async t => { + const linked = new ReadError('queue-refused', '/srv/checkouts/feature-x is a linked worktree; the queue runs only in the canonical checkout'); + const { wait, text, has } = await setup(t, { queueError: linked }); + await wait('document.querySelector("[data-org-queue-why]")'); + assert.equal(await text('[data-org-queue-why]'), 'This Console does not run from the checkout that holds the queue, so the queue is not shown here.'); + assert.equal(await has('[data-org-queue]'), false); + assert.doesNotMatch(await text('[data-org-dashboard]'), /linked worktree|canonical checkout/); +}); + +test('the Queue card: a refusal for another reason says that reason and keeps Open queue', async t => { + const other = new ReadError('queue-refused', 'HEAD is refs/heads/feature; the queue runs only on branch refactor'); + const { wait, text, has } = await setup(t, { queueError: other }); + await wait('document.querySelector("[data-org-queue-why]")'); + assert.equal(await text('[data-org-queue-why]'), 'The queue refused the read. HEAD is refs/heads/feature; the queue runs only on branch refactor'); + assert.equal(await has('[data-org-queue]'), true); +}); + +test('a repaint while Stop asks its question keeps the question on screen and Stop disabled', async t => { + const { b, wait, has } = await setup(t, { hash: '#/orgs/acme/organization?agent=acme%3Acoder' }); + const d = '[data-ws-drawer="acme:coder"]'; + await wait(`${open('acme:coder')} && document.querySelector('${d} [data-drawer-stop]')`); + await b.evaluate(`document.querySelector('${d} [data-drawer-stop]').click()`); + await wait(`document.querySelector('${d} [data-drawer-stop-yes]')`); + // Mark the drawer, so the test knows the repaint replaced it. + await b.evaluate(`document.querySelector('${d}').dataset.before = '1'`); + await b.evaluate('document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))'); + await wait(`document.querySelector('${d}') && !document.querySelector('${d}').dataset.before`); + assert.equal(await has(`${d} [data-drawer-stop-confirm]`), true); + assert.equal(await has(`${d} [data-drawer-stop-yes]`), true); + assert.equal(await b.evaluate(`document.querySelector('${d} [data-drawer-stop]').disabled`), true); + assert.deepEqual(await b.evaluate('window.__posts'), []); +});