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'), []); +});