Organization unavailable
${list ? 'This organization is not on your list.' : 'The organization list could not be read.'}
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 = '') => `
${list ? 'This organization is not on your list.' : 'The organization list could not be read.'}
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.
' : `That is not an organization address.
That is not an organization address.
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');