diff --git a/packages/webui/src/public/shell.css b/packages/webui/src/public/shell.css index ba6a5fb6f..723bbff27 100644 --- a/packages/webui/src/public/shell.css +++ b/packages/webui/src/public/shell.css @@ -506,9 +506,13 @@ html[data-organizations] .ws-doc:hover{background:var(--raised)} html[data-organizations] .ws-doc svg.i{width:14px;height:14px;color:var(--muted)} html[data-organizations] .ws-doc small{font-weight:500;color:var(--muted);font-size:11px} html[data-organizations] .ws-more{font-size:13px;font-weight:600;margin-left:4px;color:var(--action);text-decoration:none} -html[data-organizations] .ws-tiles{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px} -html[data-organizations] .ws-tile{background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:12px 14px;display:grid;gap:6px;align-content:start} -html[data-organizations] .ws-tile.is-empty{grid-column:1/-1;border-style:dashed;background:transparent} +/* SP8 (#1873): one row of figure tiles under the page heading, each a link to the page that lists what it counts. */ +html[data-organizations] .ws-tiles{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin:0 0 16px} +html[data-organizations] .ws-tiles.six{grid-template-columns:repeat(6,minmax(0,1fr))} +html[data-organizations] .ws-tile{background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:12px 14px;display:grid;gap:6px;align-content:start;min-width:0;color:inherit;text-decoration:none} +html[data-organizations] a.ws-tile:hover{border-color:color-mix(in srgb,var(--action) 45%,var(--line))} +html[data-organizations] a.ws-tile:focus-visible{outline:2px solid var(--action);outline-offset:2px} +html[data-organizations] .ws-tile>.ws-label{font-size:12.5px;font-weight:600;letter-spacing:0;text-transform:none} html[data-organizations] .ws-empty-t{font-size:13.5px;font-weight:600;color:var(--strong)} html[data-organizations] .ws-tbl{overflow-x:auto;border:1px solid var(--line);border-radius:8px;background:var(--surface)} html[data-organizations] .ws-tbl table{width:100%;border-collapse:collapse;font-size:13px;margin:0} @@ -580,9 +584,8 @@ html[data-organizations] .ws-tbl .ws-dot.s-working b,html[data-organizations] .w html[data-organizations] .ws-tbl .ws-dot.s-waiting b{color:var(--warning)} html[data-organizations] .ws-tbl .ws-dot.s-error b{color:var(--danger)} html[data-organizations] .ws-tbl .ws-dot.s-idle b,html[data-organizations] .ws-tbl .ws-dot.s-offline b{color:var(--muted)} -html[data-organizations] .ws-g5{display:grid;gap:10px;grid-template-columns:repeat(5,minmax(0,1fr))} html[data-organizations] .ws-tile .ws-big{font-size:24px;font-weight:800;color:var(--strong);letter-spacing:-.02em;font-variant-numeric:tabular-nums;line-height:1.1} -html[data-organizations] .ws-tile .ws-big small{font-size:13px;font-weight:600;color:var(--muted);letter-spacing:0;margin-left:4px} +html[data-organizations] .ws-tile .ws-big small{font-size:13px;font-weight:600;color:var(--muted);letter-spacing:0} html[data-organizations] .ws-tile .ws-delta{font-size:12px;color:var(--muted)} html[data-organizations] .ws-tile .ws-delta b{color:var(--success);font-weight:600} html[data-organizations] .ws-tile.quiet{border-style:dashed;background:transparent} @@ -724,7 +727,7 @@ html[data-organizations] .ws-cmd code{flex:1;min-width:0;overflow-x:auto;white-s html[data-organizations] .ws-cmd .ws-btn{padding:4px 6px} html[data-organizations] #copy-status:empty{display:none} @media (max-width:1100px){ - html[data-organizations] .ws-g5{grid-template-columns:repeat(3,minmax(0,1fr))} + 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)} html[data-organizations] .ws-insp{position:static;max-height:none} } @@ -734,8 +737,8 @@ html[data-organizations] #copy-status:empty{display:none} html[data-organizations] .content:has(.ws){padding:14px 16px 24px} html[data-organizations] .tree{padding:10px 12px 20px} html[data-organizations] .ws h1{font-size:24px} - html[data-organizations] .ws-split,html[data-organizations] .ws-tiles{grid-template-columns:minmax(0,1fr)} - html[data-organizations] .ws-tile.is-empty{grid-column:auto} + 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} @@ -751,9 +754,6 @@ html[data-organizations] #copy-status:empty{display:none} html[data-organizations] .ws-levels>.span{grid-column:auto} html[data-organizations] .ws-levels>.last.lh{border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent)} html[data-organizations] .ws-levels .mlabel{display:flex} - html[data-organizations] .ws-g5{grid-template-columns:minmax(0,1fr)} - html[data-organizations] .ws-g5.keep2{grid-template-columns:repeat(2,minmax(0,1fr))} - html[data-organizations] .ws-g5.keep2>:last-child:nth-child(odd){grid-column:1/-1} html[data-organizations] .ws-orgcards{grid-template-columns:minmax(0,1fr)} html[data-organizations] .ws-srow{grid-template-columns:92px 20px minmax(0,1fr)} html[data-organizations] .ws-srow .ws-by{grid-column:1/-1} diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index ad24e00f3..c15392a14 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -156,7 +156,7 @@ const titleCase = s => String(s || '').replace(/(^|[-_\s])(\w)/g, (_, sep, c) => `${sep ? ' ' : ''}${c.toUpperCase()}`); const clock = () => new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`; - const secH = (title, note = '', link = '') => `

${title}

${note ? `${note}` : ''}${link}
`; + const secH = (title, note = '', link = '', attrs = '') => `
${title}${note ? `${note}` : ''}${link}
`; function settingsList(org, ctx) { const { business, settings, instances, orgCount } = ctx; @@ -256,14 +256,19 @@ if (!org) return `

Organization unavailable

${list ? 'This organization is not on your list.' : 'The organization list could not be read.'}

`; // E2E6 P2-8: a stopped bus is not read for the bus-backed editors; each says the bus is stopped. const stopped = !org.busHealthy && org.busStopped === true; + // SP8 (#1873): the cross-organization read scoped to this one gives the tiles' figures and, SP1, + // 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); 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)); const threads = rowsOf(value(3)), projectRows = rowsOf(value(4)), queue = rowsOf(value(5)); const agents = rowsOf(value(6)), claims = rowsOf(value(7)); const docRead = business?.business ? await docs(id, business.business.projects).catch(() => ({ rows: [], failed: true })) : null; - // SP1: a bus that is down says so above everything else, with the reason the workspace read gives. - const downRow = org.busHealthy ? null : await data.workspace({ org: id, sections: 'projects' }).then(b => arr(b?.orgs).find(r => r?.org === id) ?? null).catch(() => null); + const ws = await wsRead; + const wsRow = arr(ws?.orgs).find(r => r?.org === id) ?? null; + const downRow = org.busHealthy ? null : wsRow; await window.MosaicConsole?.ready; const name = esc(label(org)); const tracker = business?.business?.vars?.['tracker.kind']; @@ -283,7 +288,22 @@ const docChips = !docRead ? 'Docs unavailable' : docRead.failed ? 'Docs unavailable' : docRead.rows.length ? docRead.rows.slice(0, 6).map(d => `${icon('doc')}${esc(docName(d.path))}${esc(projectName(d.project))}`).join('') : 'No docs yet'; const docrow = `
Docs${docChips}All docs
`; - const tiles = (title, note, empty) => `${secH(title, note)}
${empty}
`; + // SP8: the same tiles as the Dashboard, scoped to this organization, each a link to the system page + // filtered to it. A tile whose read gave nothing says why instead of a figure. + const mine = arr(ws?.metrics?.orgs).find(x => x?.org === id) ?? null; + const off = !ws ? 'the read did not answer' : wsRow?.state === 'ok' ? null : wsRow?.state === 'starting' ? 'the bus is starting' : busWord(org); + const whyNot = (names, metric = true) => off || missOf(wsRow, names) || (metric && !mine ? 'no figures were read' : null); + const orgAgents = instances && ws ? agentRows([{ id, inst: instances, copies: copiesOf(value(2)) }], ws) : null; + const agentsWhy = whyNot(['agents'], false) || (orgAgents ? null : 'the agent read did not answer'); + const kpi = orgsOf(ws), oq = `org=${enc(id)}`; + const orgTile = (key, title, href, why, fig) => kpiTile(kpi, { key, name: title, href, ...(why ? { none: `Not read: ${esc(why)}` } : fig) }); + const working = orgAgents?.filter(a => a.state === 'working').length ?? 0, waiting = orgAgents?.filter(a => a.state === 'waiting').length ?? 0; + const orgTiles = `
+ ${orgTile('agents', 'Agents working', `#/agents?${oq}&state=working`, agentsWhy, { big: working, small: `of ${orgAgents?.length ?? 0}`, delta: waiting ? `${waiting} waiting on you` : '' })} + ${orgTile('tasks', 'Open tasks', `#/tasks?${oq}`, whyNot(['tasks']), { big: num(mine?.openTasks) })} + ${orgTile('needs', 'Needs you', `#/inbox?${oq}`, whyNot(['inbox']), { big: num(mine?.needsYou), small: num(mine?.needsYou) === 1 ? 'item' : 'items', delta: num(mine?.needsYou) && mine?.oldestNeedsYouAt ? `oldest ${span(mine.oldestNeedsYouAt)}` : 'nothing waiting' })} + ${orgTile('done', 'Done this week', `#/tasks?${oq}`, whyNot(['tasks']), { big: num(mine?.doneThisWeek) })} +
`; const modelOf = inst => business?.business?.instances?.find(i => i?.instance === inst)?.vars?.model; const live = instances ? Object.entries(instances).filter(([, item]) => running(item)) : null; // A session's project and task is the thread its live run holds a claim on (thread.claims), not @@ -314,12 +334,11 @@
${av(org, 'lg')}

${name}

${bus}${reload}
${meta}
${org.busHealthy ? '' : downBanner(org, downRow)} + ${orgTiles} ${docrow}
- ${tiles('KPIs', 'defined in the organization file', 'No KPIs defined in the organization file')} - ${tiles('Metrics', 'measured by the bus', 'No metrics recorded yet')} - ${secH('Active agent sessions', live ? (live.length ? `${live.length} running` : 'none running') : '', 'All agents')} + ${secH('Active agent sessions', live ? (live.length ? `${live.length} running` : 'none running') : '', `This organization's agents`)} ${sessions} ${secH('Queue', queueNote, 'Open queue')} ${queueWhy} @@ -588,6 +607,19 @@ // A stacked bar and its per-organization figures, one segment per organization. const bar = (o, parts, style = '') => ``; const by = (o, parts, fmt = n => n) => `
${parts.map(p => `${av(o.meta(p.id))}${esc(o.name(p.id))}${esc(fmt(p.n))}`).join('')}
`; + // Why an organization's instances read gave nothing, in a word or two. + const READ_ERR = { 403: 'refused', 0: 'signed out' }; + const instWhy = r => READ_ERR[r.err?.status] || r.err?.code || 'no answer'; + // SP8 (#1873): one KPI tile, a link to the page that lists what it counts. A tile with no figure + // (`big` null) is drawn quiet with `none` in its place; `miss` names the organizations the figure + // leaves out, already escaped. The figure is never shown as complete while one is missing. + const kpiTile = (o, { key, name, href, big = null, small = '', parts = null, delta = '', miss = [], none = 'Not read', attrs = '' }) => { + const quiet = big == null; + const left = miss.length ? `${quiet ? '' : 'Not read in '}${andList(miss)}` : ''; + const head = `${name}`; + if (quiet) return `${head}${none}${left}`; + return `${head}${big}${small ? ` ${small}` : ''}${parts ? bar(o, parts) + by(o, parts) : ''}${delta ? `${delta}` : ''}${left}`; + }; const CLASS_CHIP = { gated: 'gated', 'within-role': 'within', routine: 'routine', 'cross-role': 'cross' }; // The one map of decision classes to words: the class chips and the Inbox Class filter both read it. const TYPE_WORD = { gated: 'Gated', 'within-role': 'Within role', 'cross-role': 'Cross-role', routine: 'Routine' }; @@ -608,33 +640,46 @@ const o = orgsOf(body), m = body.metrics, t = m?.totals, per = arr(m?.orgs); const inbox = arr(body.inbox), sessions = arr(body.sessions), projectRows = arr(body.projects), tasks = arr(body.tasks); if (!noneRead(o, ['inbox'])) tellInbox(body.inbox); - const sessN = s => num(s?.working) + num(s?.waiting) + num(s?.idle) + num(s?.live); // The reads each tile's figures come from (workspace-read.mjs orgRows). - const SRC = { needs: ['inbox'], sessions: ['agents', 'claims'], tasks: ['tasks'], projects: ['projects'] }; - // Per-organization figures name only the organizations that made the tile's reads. - const parts = (key, f) => { const ok = readers(o, SRC[key]); return per.filter(x => ok.has(x.org)).map(x => ({ id: x.org, n: num(f(x)) })); }; - const answered = o.rows.filter(r => r.state === 'ok').length; - const tile = (key, name, big, small, ps, delta) => { - const m = missed(o, SRC[key]); - if (!readers(o, SRC[key]).size) return `
${name}Not read${andList(m)}
`; - return `
${name}${big}${small ? `${small}` : ''}${ps ? bar(o, ps) + by(o, ps) : ''}${delta}${m.length ? `Not read in ${andList(m)}` : ''}
`; - }; - const tiles = t && answered ? `
- ${tile('needs', 'Needs you', num(t.needsYou), t.needsYou === 1 ? 'item' : 'items', parts('needs', x => x.needsYou), t.oldestNeedsYouAt ? `oldest ${span(t.oldestNeedsYouAt)}` : 'nothing waiting')} - ${tile('sessions', 'Sessions', sessN(t.sessions), `${num(t.sessions?.working)} working`, parts('sessions', x => sessN(x.sessions)), `${num(t.sessions?.waiting)} waiting on you · ${num(t.sessions?.idle)} idle${num(t.sessions?.live) ? ` · ${num(t.sessions.live)} live ${t.sessions.live === 1 ? 'thread' : 'threads'}` : ''}`)} - ${tile('tasks', 'Open tasks', num(t.openTasks), '', parts('tasks', x => x.openTasks), `${num(t.doneThisWeek)} done this week`)} - ${tile('projects', 'Projects', num(t.projects?.active) + num(t.projects?.archived), `${num(t.projects?.active)} active`, parts('projects', x => num(x.projects?.active) + num(x.projects?.archived)), `${num(t.projects?.archived)} archived`)} -
Model spendNo spend figures yetNo model prices are read yet, so today's use has no cost figure.
-
` : '

No organization answered, so there are no totals yet.

'; - - // SP1 (sample r2-01): one row per organization. A figure shows only when its read gave it; an - // organization that did not answer shows its state and reason instead of figures. + const SRC = { needs: ['inbox'], sessions: ['agents', 'claims'], tasks: ['tasks'], done: ['tasks'], projects: ['projects'] }; const listRow = id => (Array.isArray(listed) ? listed.find(x => x?.id === id) : null); - const projectsRead = readers(o, SRC.projects), inboxRead = readers(o, SRC.needs), sessionsRead = readers(o, SRC.sessions); - const none = '–'; // The bus state as the workspace read found it; "stopped" only when the membership list says the owner stopped it. const ROW_STATE = { ok: ['s-working', '●', 'bus running'], starting: ['s-waiting', '◐', 'bus starting'], timeout: ['s-error', '▲', 'bus not answering'], invalid: ['s-error', '▲', 'invalid'] }; const rowState = (r, fromList) => (r.state === 'down' ? ['s-offline', '○', fromList?.busStopped === true ? 'bus stopped' : 'bus down'] : ROW_STATE[r.state] || orgState(r)); + + // SP8 (#1873): one row of tiles, each a link to the page that lists what it counts. A figure adds up + // only the organizations whose reads gave it, and its tile names the rest: one that did not answer + // by its bus state, one that answered but failed a read by that read's reason. + const offWord = r => rowState(r, listRow(r.org))[2]; + const notIn = names => [...o.unread.map(r => `${esc(o.name(r.org))} (${offWord(r)})`), ...missed(o, names)]; + const parts = (key, f) => { const ok = readers(o, SRC[key]); return per.filter(x => ok.has(x.org)).map(x => ({ id: x.org, n: num(f(x)) })); }; + const sum = ps => ps.reduce((a, p) => a + p.n, 0); + const fig = (key, ps) => (m && readers(o, SRC[key]).size ? sum(ps) : null); + // A response with no metrics section leaves every figure tile quiet; it says so rather than a bare "Not read". + const noFig = m ? {} : { none: 'Not read: no figures were read' }; + const answered = o.rows.filter(r => r.state === 'ok').length; + // Agents working of all agents: the system Agents page's rows, read the way that page reads them. + // An organization whose agent read failed is named, never counted as having none. + const agentReads = await readInstances(o.rows.filter(r => r.state === 'ok' && !missOf(r, ['agents'])).map(r => r.org)); + const agentOk = agentReads.filter(r => r.inst), agentAll = agentRows(agentOk, body); + const agentParts = agentOk.map(r => ({ id: r.id, n: agentAll.filter(a => a.org === r.id && a.state === 'working').length })); + const waitingOn = agentAll.filter(a => a.state === 'waiting').length; + const ps = { needs: parts('needs', x => x.needsYou), tasks: parts('tasks', x => x.openTasks), done: parts('done', x => x.doneThisWeek), projects: parts('projects', x => x.projects?.active) }; + const needsN = fig('needs', ps.needs); + const archived = per.filter(x => readers(o, SRC.projects).has(x.org)).reduce((a, x) => a + num(x.projects?.archived), 0); + const tiles = !o.rows.length ? '

No organizations yet, so there is nothing to count.

' : `
+ ${kpiTile(o, { key: 'agents', name: 'Agents working', href: '#/agents?state=working', big: agentOk.length ? sum(agentParts) : null, small: `of ${agentAll.length}`, parts: agentParts, delta: waitingOn ? `${waitingOn} waiting on you` : '', miss: [...notIn(['agents']), ...agentReads.filter(r => !r.inst).map(r => `${esc(o.name(r.id))} (${esc(instWhy(r))})`)] })} + ${kpiTile(o, { key: 'orgs', name: 'Organizations', href: '#/dashboard', big: answered, small: `of ${o.rows.length} running`, delta: o.unread.length ? andList(o.unread.map(r => `${esc(o.name(r.org))} ${offWord(r)}`)) : 'every bus answered', attrs: ' data-ws-jump="orgs"' })} + ${kpiTile(o, { key: 'needs', name: 'Needs you', href: '#/inbox', big: needsN, small: needsN === 1 ? 'item' : 'items', parts: ps.needs, delta: needsN && t?.oldestNeedsYouAt ? `oldest ${span(t.oldestNeedsYouAt)}` : 'nothing waiting', miss: notIn(SRC.needs), ...noFig })} + ${kpiTile(o, { key: 'tasks', name: 'Open tasks', href: '#/tasks', big: fig('tasks', ps.tasks), parts: ps.tasks, miss: notIn(SRC.tasks), ...noFig })} + ${kpiTile(o, { key: 'done', name: 'Done this week', href: '#/tasks', big: fig('done', ps.done), parts: ps.done, miss: notIn(SRC.done), ...noFig })} + ${kpiTile(o, { key: 'projects', name: 'Projects', href: selHref('/projects'), big: fig('projects', ps.projects), small: 'active', parts: ps.projects, delta: `${archived} archived`, miss: notIn(SRC.projects), ...noFig })} +
`; + + // SP1 (sample r2-01): one row per organization. A figure shows only when its read gave it; an + // organization that did not answer shows its state and reason instead of figures. + const projectsRead = readers(o, SRC.projects), inboxRead = readers(o, SRC.needs), sessionsRead = readers(o, SRC.sessions); + const none = '–'; const orgTable = o.rows.map(r => { const id = r.org, fromList = listRow(id), x = per.find(p => p.org === id); const [cls, glyph, word] = rowState(r, fromList); @@ -685,11 +730,11 @@ ${tiles}
- ${secH('Organizations', `read ${esc(hm(body.readAt))}`)} + ${secH('Organizations', `read ${esc(hm(body.readAt))}`, '', 'tabindex="-1" data-ws-orgs-h data-focus-key="orgs-h"')} ${orgList} ${secH('Recent project activity', 'latest change per project; there is no activity feed yet', `All projects`)} ${trail}${missLine(o, ['projects'], 'recent')} - ${secH('Tasks by state', 'open tasks, every tracker', `Open tasks`)} + ${secH('Tasks by state', 'open tasks, every tracker', 'Open tasks')} ${states}${missLine(o, ['tasks'], 'states')}
@@ -906,26 +951,17 @@ // 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'], ['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' }; - async function agentsPage(q, opts = {}) { - // SP5 review F11: a scope that is not an organization id is refused, never widened to the system list. - if (opts.scope != null && !(typeof opts.scope === 'string' && ORG_ID.test(opts.scope))) return '

Agents

That is not an organization address.

'; - const scope = opts.scope ?? null; - 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; - 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); } - const every = orgsOf(body); - const o = org ? { ...every, rows: every.rows.filter(r => org.includes(r.org)), unread: every.unread.filter(r => org.includes(r.org)) } : every; - 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 Promise.all(askable.map(r => Promise.all([ - data.instances(r.org).then(v => ({ inst: instancesOf(v), copies: copiesOf(v) }), err => ({ inst: null, copies: {}, err })), - !scope && typeof opts.fireIn === 'function' ? opts.fireIn(r.org).then(v => v === true, () => false) : false, - ]).then(([v, fire]) => ({ id: r.org, ...v, fire })))); - const sessions = arr(body.sessions).filter(x => x?.kind === 'role' && typeof x.org === 'string' && typeof x.role === 'string'); - const projects = arr(body.projects).filter(x => typeof x?.org === 'string'); + // 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 })), + typeof fireIn === 'function' ? fireIn(id).then(v => v === true, () => false) : false, + ]).then(([v, fire]) => ({ id, ...v, fire })))); + // The agent rows of every organization whose instances read answered: its own instances and the root + // and project copies its bus booted with, each in the state the system Agents page shows. SP8: the + // Dashboard and organization tiles count these same rows, so a tile's figure is the page's row count. + function agentRows(reads, body) { + const sessions = arr(body?.sessions).filter(x => x?.kind === 'role' && typeof x.org === 'string' && typeof x.role === 'string'); + const projects = arr(body?.projects).filter(x => typeof x?.org === 'string'); const projectRef = (id, p) => { const hit = projects.find(x => x.org === id && (x.id === p || x.key === p)); return hit ? { id: hit.id, name: hit.name || hit.key || hit.id } : { id: p, name: p }; }; const all = []; for (const r of reads) { @@ -946,6 +982,24 @@ } } for (const a of all) a.state = AGENT_STATE_KEY[a.dot[2]] || 'not-running'; + return all; + } + async function agentsPage(q, opts = {}) { + // SP5 review F11: a scope that is not an organization id is refused, never widened to the system list. + if (opts.scope != null && !(typeof opts.scope === 'string' && ORG_ID.test(opts.scope))) return '

Agents

That is not an organization address.

'; + const scope = opts.scope ?? null; + 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; + 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); } + const every = orgsOf(body); + const o = org ? { ...every, rows: every.rows.filter(r => org.includes(r.org)), unread: every.unread.filter(r => org.includes(r.org)) } : every; + 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); + const all = agentRows(reads, body); const counts = new Map(); for (const a of all) counts.set(a.org, (counts.get(a.org) || 0) + 1); const inOrg = all.filter(a => !org || org.includes(a.org)).sort((x, y) => x.org.localeCompare(y.org) || x.name.localeCompare(y.name) || x.key.localeCompare(y.key)); @@ -972,9 +1026,8 @@ // answered but whose instances read failed is named too, never counted as having no agents. const why = r => `${stateWords(r).replace(/, read .*$/, '')}${r.reason ? `: ${esc(r.reason)}` : ''}${r.at ? ` (read ${esc(hm(r.at))})` : ''}`; const failed = reads.filter(r => !r.inst && (!org || org.includes(r.id))); - const READ_ERR = { 403: 'refused', 0: 'signed out' }; const unread = (o.unread.length ? `

Not read: ${andList(o.unread.map(why))}.

` : '') - + (failed.length ? `

Agents not read in ${andList(failed.map(r => `${esc(o.name(r.id))} (${esc(READ_ERR[r.err?.status] || r.err?.code || 'no answer')})`))}.

` : '') + + (failed.length ? `

Agents not read in ${andList(failed.map(r => `${esc(o.name(r.id))} (${esc(instWhy(r))})`))}.

` : '') + missLine(o, ['agents', 'tasks'], 'agents', 'What agents work on was not read in'); const anyRead = reads.some(r => r.inst && (!org || org.includes(r.id))); const filtered = (org && !scope) || stateF; @@ -1046,6 +1099,13 @@ const pick = e.target.closest('[data-ws-select]'); if (pick && !e.metaKey && !e.ctrlKey && !e.shiftKey && e.button === 0) { e.preventDefault(); setQuery({ d: pick.dataset.wsSelect }); } }); + // SP8: the Organizations tile counts the Dashboard's own table, so a plain click moves focus there. + document.addEventListener('click', e => { + const jump = e.target.closest?.('[data-ws-jump="orgs"]'); + if (!jump || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return; + const to = document.querySelector('[data-ws-orgs-h]'); + if (to) { e.preventDefault(); to.focus(); } + }); document.addEventListener('change', e => { if (e.target.matches?.('[data-ws-projects] [data-ws-status]')) setQuery({ status: e.target.value === 'active' ? null : e.target.value }); else if (e.target.matches?.('[data-ws-projects] [data-ws-state]')) setQuery({ state: e.target.value || null }); diff --git a/packages/webui/tests/kpi-tiles-browser.test.mjs b/packages/webui/tests/kpi-tiles-browser.test.mjs new file mode 100644 index 000000000..16adcf8ad --- /dev/null +++ b/packages/webui/tests/kpi-tiles-browser.test.mjs @@ -0,0 +1,221 @@ +// SP8 (#1873, single pane slice 8): the Dashboard leads with one row of KPI tiles, each built from a +// number an existing read returns and each a link to the page that lists what it counts, with the +// filter in the address where that page has one. A tile whose number some organization could not give +// names that organization; it never shows a lower number as if it were complete. An organization's +// page leads with the same tiles scoped to it, each linking to the system page filtered to it. +// Every bus is a fake: acme and beta answer, gamma's bus is stopped. The real GET /api/workspace and +// the real per-organization routes serve the pages. +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(); + +// fail: { org: [read names] } makes those reads refuse; `none` empties the organization list. +function fixture({ fail = {}, none = false } = {}) { + const LIST = none ? [] : [ + { id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true }, + { id: 'beta', label: 'Beta Works', theme: 'grove', busHealthy: true }, + { id: 'gamma', label: 'Gamma Home', theme: 'rosewood', busHealthy: false, busStopped: true }, + ]; + const decision = (id, question, role, minutes) => ({ id, question, class: 'within-role', blocking: false, raised_by_role: role, at: at(minutes), options: [{ key: 'yes', text: 'Yes' }] }); + const DATA = { + acme: { + projects: [{ id: 'p-site', repo: 'site', name: 'Website', archived: false, at: at(30) }, { id: 'p-old', repo: 'old', name: 'Old', archived: true, at: at(9000) }], + tasks: [{ task_ref: 'local:1', project_key: 'site', at: at(20), fields: { title: 'Hero copy', done: false } }], + agents: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'working', at: at(40), working: { since: at(40) } }], + inbox: [decision('dec-acme-1', 'Ship the hero?', 'coder', 10)], + instances: { coder: { definition: 'coder', running: ['run-1'] }, chief: { definition: 'chief-of-staff', running: [] } }, + }, + beta: { + projects: [{ id: 'p-shop', repo: 'shop', name: 'Shop', archived: false, at: at(120) }], + tasks: [ + { task_ref: 'local:7', project_key: 'shop', at: at(50), fields: { title: 'Price list', done: false } }, + { task_ref: 'local:8', project_key: 'shop', at: at(70), fields: { title: 'Cart copy', done: false } }, + { task_ref: 'local:9', project_key: 'shop', at: at(60 * 24), fields: { title: 'Logo', done: true } }, + ], + agents: [{ role: 'pm', holder_run: 'run-9', harness: 'claude', state: 'waiting', at: at(15) }], + inbox: [decision('dec-beta-1', 'Pick a launch price', 'pm', 5), decision('dec-beta-2', 'Name the shop', 'pm', 90)], + instances: { pm: { definition: 'pm', running: ['run-9'] }, writer: { definition: 'writer', running: [] } }, + }, + }; + const value = (id, name) => (fail[id]?.includes(name) ? Promise.reject(Object.assign(new Error('off'), { code: 'channel-disabled' })) : 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 () => (org.busHealthy ? { healthy: true, pid: 4242, boot: null } : { healthy: false, boot: { ok: false, at: at(90), lastGood: null, reason: 'stopped by the owner' } }), + options: () => opts(org.id), + }); + return { + list: async () => LIST, + members: async () => LIST.map(member), + resolve: async id => { + const org = LIST.find(o => o.id === id); if (!org) return null; + if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } }; + return { + busHealthy: true, + reads: { business: async () => ({ business: { id, human: 'jason', projects: DATA[id].projects.map(p => p.repo), appearance: { theme: org.theme } } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) }, + bus: { + messages: async () => ({ messages: [], unread: 0 }), agents: async () => DATA[id].agents, 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [], + instances: () => (fail[id]?.includes('instances') ? Promise.reject(Object.assign(new Error('off'), { code: 'bus-unavailable' })) : Promise.resolve({ revision: 'a'.repeat(64), definitions: {}, instances: DATA[id].instances })), + }, + chat: { projects: async () => DATA[id].projects, threads: async () => [] }, + }; + }, + }; +} + +async function setup(t, { viewport = [1440, 900], hash = '#/dashboard', fail, none } = {}) { + const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ fail, none }), log: () => {} }); + t.after(() => close(server)); + const b = await browser({ machine: false }); t.after(() => b.close()); + await b.viewport(...viewport); + const base = `http://127.0.0.1:${server.address().port}`; + await b.navigate(`${base}/${hash}`); + return { b, base, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) }; +} +const tileKeys = scope => `[...document.querySelectorAll(${JSON.stringify(`${scope} [data-ws-tile]`)})].map(e => e.dataset.wsTile)`; +const hrefs = scope => `Object.fromEntries([...document.querySelectorAll(${JSON.stringify(`${scope} [data-ws-tile]`)})].map(e => [e.dataset.wsTile, e.tagName === 'A' ? e.getAttribute('href') : null]))`; +const big = (scope, key) => `document.querySelector(${JSON.stringify(`${scope} [data-ws-tile=${key}] .ws-big`)})?.textContent.replace(/\\s+/g, ' ').trim() ?? null`; +const homeReady = 'document.querySelector("[data-ws-home] [data-ws-tiles] [data-ws-tile=agents] .ws-big")'; + +test('The Dashboard tiles add both answering organizations\' numbers, one row, each tile a link to the page that lists it', async t => { + const { b, wait, text } = await setup(t); + await wait(homeReady); + // One row: no second tile group anywhere on the page, and the row leads the page under its heading. + assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-home] [data-ws-tiles]").length'), 1); + assert.equal(await b.evaluate('document.querySelector("[data-ws-home] .ws-vh").nextElementSibling.hasAttribute("data-ws-tiles")'), true); + assert.deepEqual(await b.evaluate(tileKeys('[data-ws-home]')), ['agents', 'orgs', 'needs', 'tasks', 'done', 'projects']); + // Agents working of total: acme's coder works; beta's pm waits; chief and writer run nothing. + assert.equal(await b.evaluate(big('[data-ws-home]', 'agents')), '1 of 4'); + assert.equal(await b.evaluate(big('[data-ws-home]', 'orgs')), '2 of 3 running'); + assert.equal(await b.evaluate(big('[data-ws-home]', 'needs')), '3 items'); + assert.equal(await b.evaluate(big('[data-ws-home]', 'tasks')), '3'); + assert.equal(await b.evaluate(big('[data-ws-home]', 'done')), '1'); + assert.equal(await b.evaluate(big('[data-ws-home]', 'projects')), '2 active'); + // Each organization's share, named for a screen reader. + assert.match(await text('[data-ws-tile=needs] .ws-by'), /Acme Studio\s*1[\s\S]*Beta Works\s*2/); + assert.match(await text('[data-ws-tile=tasks] .ws-by'), /Acme Studio\s*1[\s\S]*Beta Works\s*2/); + assert.match(await text('[data-ws-tile=agents] .ws-by'), /Acme Studio\s*1[\s\S]*Beta Works\s*0/); + // Every tile is a link, with the filter in the address where the page has one. + const links = await b.evaluate(hrefs('[data-ws-home]')); + assert.equal(links.agents, '#/agents?state=working'); + assert.equal(links.orgs, '#/dashboard'); + assert.equal(links.needs, '#/inbox'); + assert.equal(links.tasks, '#/tasks'); + assert.equal(links.done, '#/tasks'); + assert.match(links.projects, /^#\/orgs\/[a-z0-9._-]+\/projects$/); + // No sample numbers and no trend line: no tile draws an svg sparkline. + assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-tiles] svg:not(.i)").length'), 0); + assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-tile=spend],[data-ws-tile=sessions]").length'), 0); + // The Open tasks link under Tasks by state goes to the system board too. + assert.equal(await b.evaluate('document.querySelector("[data-focus-key=home-tasks]").getAttribute("href")'), '#/tasks'); +}); + +test('A stopped organization is named on every tile its numbers would have joined, and on none it counted', async t => { + const { b, wait, text } = await setup(t); + await wait(homeReady); + for (const key of ['agents', 'needs', 'tasks', 'done', 'projects']) { + assert.match(await text(`[data-ws-tile=${key}] [data-ws-miss=${key}]`) ?? '', /Not read in Gamma Home \(bus stopped\)/, key); + } + // The Organizations tile counts it as not running and says which one. + assert.equal(await b.evaluate('document.querySelector("[data-ws-tile=orgs] [data-ws-miss]")'), null); + assert.match(await text('[data-ws-tile=orgs]'), /Gamma Home bus stopped/); +}); + +test('Following a tile lands on its page showing what the tile counted', async t => { + const { b, wait, text } = await setup(t); + await wait(homeReady); + await b.evaluate('document.querySelector("[data-ws-tile=agents]").click()'); + await wait('location.hash === "#/agents?state=working" && document.querySelector("[data-ws-agent]")'); + await wait('JSON.stringify([...document.querySelectorAll("[data-ws-agent]")].map(r => r.dataset.wsAgent)) === JSON.stringify(["acme:coder"])'); + assert.match(await text('[data-ws-astate=""] .n'), /^4$/); + await b.evaluate('location.hash = "#/dashboard"'); + await wait(homeReady); + await b.evaluate('document.querySelector("[data-ws-tile=needs]").click()'); + await wait('location.hash === "#/inbox" && document.querySelectorAll("[data-ws-item]").length === 3'); + await b.evaluate('location.hash = "#/dashboard"'); + await wait(homeReady); + // The Organizations tile's list is this page's own table: following it keeps the page and moves focus there. + await b.evaluate('document.querySelector("[data-ws-tile=orgs]").click()'); + await wait('document.activeElement?.closest?.("[data-ws-orgs-h]")'); + assert.equal(await b.evaluate('location.hash'), '#/dashboard'); +}); + +test('An answering organization whose agent read failed is named on the Agents tile, never counted as none', async t => { + const { b, wait, text } = await setup(t, { fail: { beta: ['instances'] } }); + await wait(homeReady); + assert.equal(await b.evaluate(big('[data-ws-home]', 'agents')), '1 of 2'); + assert.match(await text('[data-ws-tile=agents] [data-ws-miss=agents]'), /Beta Works/); + assert.doesNotMatch(await text('[data-ws-tile=agents] .ws-by') ?? '', /Beta Works/); +}); + +test('When no answering organization could read its agents, the Agents tile says Not read instead of a number', async t => { + const { b, wait, text } = await setup(t, { fail: { acme: ['instances'], beta: ['instances'] } }); + await wait('document.querySelector("[data-ws-tile=agents]")'); + assert.equal(await b.evaluate(big('[data-ws-home]', 'agents')), null); + assert.match(await text('[data-ws-tile=agents]'), /Not read/); + assert.equal(await b.evaluate('document.querySelector("[data-ws-tile=agents]").getAttribute("href")'), '#/agents?state=working'); +}); + +test('With no organizations there is nothing to count, so no tile shows a zero', async t => { + // With no organization the workspace pages are not drawn; the Console's own board keeps its dashes. + const { b, wait } = await setup(t, { none: true }); + await wait('document.querySelector("#dashboard-kpis .kpi strong")'); + assert.equal(await b.evaluate('document.querySelectorAll(".ws-tile, .ws-big").length'), 0); + assert.deepEqual(await b.evaluate('[...new Set([...document.querySelectorAll("#dashboard-kpis .kpi strong")].map(e => e.textContent.trim()))]'), ['–']); +}); + +test('An organization page leads with its own tiles, each linking to the system page filtered to it', async t => { + const { b, wait, text } = await setup(t, { hash: '#/orgs/beta' }); + await wait('document.querySelector("[data-org-dashboard] [data-org-tiles] [data-ws-tile=agents] .ws-big")'); + // The tiles come straight after the header, before the docs row. + assert.equal(await b.evaluate('document.querySelector("[data-org-dashboard] .ws-orghead").nextElementSibling.hasAttribute("data-org-tiles")'), true); + assert.deepEqual(await b.evaluate(tileKeys('[data-org-tiles]')), ['agents', 'tasks', 'needs', 'done']); + assert.equal(await b.evaluate(big('[data-org-tiles]', 'agents')), '0 of 2'); + assert.equal(await b.evaluate(big('[data-org-tiles]', 'tasks')), '2'); + assert.equal(await b.evaluate(big('[data-org-tiles]', 'needs')), '2 items'); + assert.equal(await b.evaluate(big('[data-org-tiles]', 'done')), '1'); + assert.deepEqual(await b.evaluate(hrefs('[data-org-tiles]')), { agents: '#/agents?org=beta&state=working', tasks: '#/tasks?org=beta', needs: '#/inbox?org=beta', done: '#/tasks?org=beta' }); + // The empty KPI and metric placeholders are gone; there is one tile row. + assert.doesNotMatch(await text('[data-org-dashboard]'), /No KPIs defined|No metrics recorded yet/); + assert.equal(await b.evaluate('document.querySelectorAll("[data-org-dashboard] .ws-tiles, [data-org-dashboard] [data-org-tiles]").length'), 1); + // Following Needs you shows beta's two items on the system Inbox. + await b.evaluate('document.querySelector("[data-org-tiles] [data-ws-tile=needs]").click()'); + await wait('location.hash === "#/inbox?org=beta" && document.querySelectorAll("[data-ws-item]").length === 2'); + // Acme's own page counts only acme. + await b.evaluate('location.hash = "#/orgs/acme"'); + await wait('document.querySelector("[data-org-dashboard] [data-org-tiles] [data-ws-tile=agents] .ws-big") && location.hash.startsWith("#/orgs/acme")'); + await wait(`${big('[data-org-tiles]', 'agents')} === "1 of 2"`); + assert.equal(await b.evaluate(big('[data-org-tiles]', 'needs')), '1 item'); + assert.equal(await b.evaluate(big('[data-org-tiles]', 'done')), '0'); + await b.evaluate('document.querySelector("[data-org-tiles] [data-ws-tile=agents]").click()'); + await wait('location.hash === "#/agents?org=acme&state=working" && JSON.stringify([...document.querySelectorAll("[data-ws-agent]")].map(r => r.dataset.wsAgent)) === JSON.stringify(["acme:coder"])'); +}); + +test('A stopped organization\'s page shows its tiles as not read, still linking to its filtered pages', async t => { + const { b, wait, text } = await setup(t, { hash: '#/orgs/gamma' }); + await wait('document.querySelector("[data-org-dashboard] [data-org-tiles]")'); + assert.equal(await b.evaluate('document.querySelectorAll("[data-org-tiles] .ws-big").length'), 0); + for (const key of ['agents', 'tasks', 'needs', 'done']) assert.match(await text(`[data-org-tiles] [data-ws-tile=${key}]`), /Not read: the bus is stopped/, key); + assert.equal(await b.evaluate('document.querySelector("[data-org-tiles] [data-ws-tile=needs]").getAttribute("href")'), '#/inbox?org=gamma'); +}); + +test('At 390 wide the tiles sit two to a row with no sideways scroll, on the Dashboard and an organization page', async t => { + const { b, wait } = await setup(t, { viewport: [390, 844] }); + await wait(homeReady); + const cols = scope => b.evaluate(`new Set([...document.querySelectorAll(${JSON.stringify(`${scope} [data-ws-tile]`)})].map(e => Math.round(e.getBoundingClientRect().left))).size`); + assert.equal(await cols('[data-ws-home]'), 2); + assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, 'dashboard'); + await b.evaluate('location.hash = "#/orgs/acme"'); + await wait('document.querySelector("[data-org-tiles] [data-ws-tile=agents] .ws-big")'); + assert.equal(await cols('[data-org-tiles]'), 2); + assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, 'organization page'); +}); diff --git a/packages/webui/tests/unified-workspace-browser.test.mjs b/packages/webui/tests/unified-workspace-browser.test.mjs index 52d6fdec1..ea01c40c9 100644 --- a/packages/webui/tests/unified-workspace-browser.test.mjs +++ b/packages/webui/tests/unified-workspace-browser.test.mjs @@ -136,9 +136,10 @@ test('organization dashboard renders every section from the per-organization rea assert.match(head.colour, /^#[0-9a-f]{6}$/i); // Docs come from the project files reads: only documents, named by project. assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".ws-doc")].map(a => a.innerText.replace(/\\s+/g, " ").trim())'), ['North star Spring launch', 'Launch plan Spring launch', 'Brand voice guide Brand refresh']); - // KPIs and metrics: one honest empty tile each, with no invented number. - const tiles = await b.evaluate('[...document.querySelectorAll(".ws-tiles")].map(t => [t.querySelectorAll(".ws-tile").length, t.querySelector(".ws-tile").classList.contains("is-empty"), t.innerText.replace(/\\s+/g, " ").trim()])'); - assert.deepEqual(tiles, [[1, true, 'No KPIs defined in the organization file'], [1, true, 'No metrics recorded yet']]); + // SP8 (#1873): one row of figure tiles. This fixture gives no cross-organization read (no members), + // so each tile says it was not read and shows no number. + const tiles = await b.evaluate('[...document.querySelectorAll(".ws-tiles")].map(t => [[...t.querySelectorAll("[data-ws-tile]")].map(e => e.dataset.wsTile), t.querySelectorAll(".ws-big").length, t.innerText.match(/Not read: the read did not answer/g)?.length ?? 0])'); + assert.deepEqual(tiles, [[['agents', 'tasks', 'needs', 'done'], 0, 4]]); // Active agent sessions: the running instances, the thread their live run holds (chief's older // Kickoff notes is not it), the model with its Change link, and the state the bus reports. With no // reported state (montage-producer) the row says only running. @@ -177,7 +178,7 @@ test('organization dashboard empty states say so in one short line', async t => await waitFor(b)('document.querySelector("[data-org-dashboard] [data-org-settings]")'); const text = await b.evaluate('document.querySelector("[data-org-dashboard]").innerText'); // Section labels are uppercased by CSS, so innerText carries the uppercase form. - for (const line of [/Docs\s+No docs yet/i, /No KPIs defined in the organization file/, /No metrics recorded yet/, /Active agent sessions\s+none running/i, /No active agent sessions/, /Queue\s+no rows/i, /Tracker: not set/]) + for (const line of [/Docs\s+No docs yet/i, /Agents working\s+Not read/, /Done this week\s+Not read/, /Active agent sessions\s+none running/i, /No active agent sessions/, /Queue\s+no rows/i, /Tracker: not set/]) assert.match(text, line); assert.equal(await b.evaluate('document.querySelectorAll("[data-org-settings] > li").length'), SETTINGS.length); }); @@ -189,9 +190,9 @@ test('organization dashboard presents sections and honest unavailable controls', await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/organization`); await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('[data-org-dashboard]'))return;await new Promise(r=>setTimeout(r,30))}throw Error('dashboard missing')})()`); const text=await b.evaluate('document.querySelector("[data-org-dashboard]").innerText'); - for(const label of ['Docs','KPIs','Metrics','Active agent sessions','Settings','Queue']) assert.match(text,new RegExp(label,'i')); + for(const label of ['Docs','Agents working','Open tasks','Needs you','Done this week','Active agent sessions','Settings','Queue']) assert.match(text,new RegExp(label,'i')); assert.match(text,/reload organization file/i); - assert.match(text,/no metrics recorded yet/i); + assert.equal(await b.evaluate('document.querySelectorAll("[data-org-tiles] .ws-big").length'),0); assert.match(text,/bus unavailable, read \d/); assert.match(text,/Sessions unavailable/); assert.equal(await b.evaluate('document.querySelector("[data-org-reload]").disabled'),true); diff --git a/packages/webui/tests/workspace-pages-browser.test.mjs b/packages/webui/tests/workspace-pages-browser.test.mjs index 97bb11903..8d2a4fde1 100644 --- a/packages/webui/tests/workspace-pages-browser.test.mjs +++ b/packages/webui/tests/workspace-pages-browser.test.mjs @@ -87,12 +87,11 @@ test('Dashboard renders every section across organizations, and a stopped one sh assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page] .t")?.textContent'), 'Dashboard'); assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]").getAttribute("href")'), '#/dashboard'); assert.equal(await text('#bus-view h1'), 'Dashboard'); - // Tiles: needs you, sessions, open tasks, projects, and an honest line for spend. - assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-tile]")].map(e => e.dataset.wsTile)'), ['needs', 'sessions', 'tasks', 'projects', 'spend']); + // SP8 (#1873): one row of figure tiles, none for spend or any figure no read returns. + assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-tile]")].map(e => e.dataset.wsTile)'), ['agents', 'orgs', 'needs', 'tasks', 'done', 'projects']); assert.match(await text('[data-ws-tile=needs]'), /Needs you\s*2\s*items/i); - assert.match(await text('[data-ws-tile=projects]'), /Projects\s*4\s*3 active/i); - assert.match(await text('[data-ws-tile=spend]'), /No spend figures yet/); - assert.doesNotMatch(await text('[data-ws-tile=spend]'), /\$|\d/); + assert.match(await text('[data-ws-tile=projects]'), /Projects\s*3 active[\s\S]*1 archived/i); + assert.doesNotMatch(await text('[data-ws-tiles]'), /spend|\$/i); // SP1: one organization row each: two running, the stopped one with its state and reason. assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-orgrow]")].map(e => [e.dataset.wsOrgrow, e.dataset.state])'), [['acme', 'ok'], ['beta', 'ok'], ['gamma', 'down']]); assert.match(await text('[data-ws-orgrow=gamma]'), /Gamma Home/); @@ -284,8 +283,9 @@ test('A read that an answering organization could not make says not read, never // beta answers, but its project and inbox reads refuse. const { b, wait, text } = await setup(t, { fail: { beta: ['projects', 'inbox'] } }); await wait('document.querySelector("[data-ws-tiles]")'); - assert.match(await text('[data-ws-tile=projects] [data-ws-miss=projects]'), /Not read in Beta Works \(the chat channel is off\)/); - assert.match(await text('[data-ws-tile=needs] [data-ws-miss=needs]'), /Not read in Beta Works/); + // The tiles also name Gamma Home, whose bus is down. + assert.match(await text('[data-ws-tile=projects] [data-ws-miss=projects]'), /Not read in Gamma Home \(bus down\) and Beta Works \(the chat channel is off\)/); + assert.match(await text('[data-ws-tile=needs] [data-ws-miss=needs]'), /Not read in .*Beta Works/); assert.match(await text('[data-ws-miss=recent]'), /Not read in Beta Works/); assert.match(await text('[data-ws-miss=needs]:not([data-ws-tile] *)'), /Not read in Beta Works/); assert.doesNotMatch(await text('.ws-home .sub'), /and \d+ projects?/, 'no project total when a project read failed'); @@ -329,10 +329,10 @@ test('Project rows and per-organization figures say not read when the reads behi // Dashboard: the per-organization figures leave Beta out instead of showing its 0. await b.evaluate('location.hash = "#/orgs/acme/"'); await wait('document.querySelector("[data-ws-tiles]")'); - for (const tile of ['needs', 'sessions', 'tasks']) { + for (const tile of ['needs', 'agents', 'tasks', 'done']) { const by = await b.evaluate(`document.querySelector('[data-ws-tile=${tile}] .ws-by')?.textContent ?? ''`); assert.doesNotMatch(by, /Beta Works/, tile); - assert.match(await text(`[data-ws-tile=${tile}] [data-ws-miss=${tile}]`), /Not read in Beta Works/, tile); + assert.match(await text(`[data-ws-tile=${tile}] [data-ws-miss=${tile}]`), /Beta Works \(the chat channel is off\)/, tile); } assert.doesNotMatch(await b.evaluate(`[...document.querySelectorAll('[data-ws-states] .ws-srow')].find(r => /Done this week/.test(r.textContent))?.querySelector('.ws-by')?.textContent ?? ''`), /Beta Works/); }); @@ -340,8 +340,10 @@ test('Project rows and per-organization figures say not read when the reads behi test('With every organization down the dashboard and inbox show no totals and no empty-list claims', async t => { const { b, wait, text } = await setup(t, { down: ['acme', 'beta'] }); await wait('document.querySelector("[data-ws-tiles]")'); - assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-home] .ws-big").length'), 0); - assert.match(await text('[data-ws-tiles]'), /No organization answered/); + // Only the Organizations tile has a figure, and it is complete: none of the three runs. + assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-home] .ws-big")].map(e => e.closest("[data-ws-tile]").dataset.wsTile)'), ['orgs']); + assert.match(await text('[data-ws-tile=orgs] .ws-big'), /^0 of 3 running$/); + for (const tile of ['agents', 'needs', 'tasks', 'done', 'projects']) assert.match(await text(`[data-ws-tile=${tile}]`), /Not read[\s\S]*Acme Studio \(bus down\), Beta Works \(bus down\) and Gamma Home \(bus /, tile); assert.equal(await text('[data-ws-needs]'), 'Decisions not read'); assert.equal(await text('[data-ws-states]'), 'Tasks not read'); assert.equal(await text('[data-ws-recent]'), 'Projects not read');