From 2fc97a3edbc3d1a4df60c4255277cdd950d263c2 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 13:38:09 -0600 Subject: [PATCH] fix(webui): sp14 the organization page tells the truth: the Queue card and its queue read are gone (the Queue page stays at #/queue); an instances answer in which no agent carries runs says running was not read on the Active agent sessions heading and body, the Agents working tile and Settings' Agents and roles row, and on the Dashboard the Agents working tile leaves that organization out and names it while its row says running not read, never 0; the Assistant's kept pane messages are stored with an opaque viewer the /api/config read now names and are read back only under the same viewer, so a second person on the same tab, or a store from before, opens empty; the drawer's instances answer repaints only the drawer from the reads it already has, so no page read is made again (15 requests before, 0 now); a full Assistant set disables its picker at mount (#1873, lane sp14-org-page-truth) --- packages/webui/src/public/assistant.js | 39 +++++++--- packages/webui/src/public/bus.js | 5 +- packages/webui/src/public/workspace.js | 74 ++++++++++-------- packages/webui/src/serve.mjs | 8 +- .../tests/assistant-threads-browser.test.mjs | 50 +++++++++++- .../webui/tests/org-page-fit-browser.test.mjs | 76 +++++++++++++------ .../tests/unified-workspace-browser.test.mjs | 18 ++--- 7 files changed, 191 insertions(+), 79 deletions(-) diff --git a/packages/webui/src/public/assistant.js b/packages/webui/src/public/assistant.js index bea6bad49..45fc33f5f 100644 --- a/packages/webui/src/public/assistant.js +++ b/packages/webui/src/public/assistant.js @@ -302,14 +302,29 @@ // SP11: a pane's unsent message is kept per thread for the life of the browser tab, so leaving the // Assistant page (a Full view link included) and coming back puts it in the composer again. // sessionStorage can be missing or refuse a write; the Map still keeps it while the page lives. + // SP14: the kept messages belong to the person who typed them. They are stored with the viewer the + // page's /api/config read names (bus.js, window.MosaicViewer), and read back only by a page that + // read the same viewer. An entry stored under another viewer, or under none (an older entry, a + // failed read), is removed unread, so a tab that never saw a sign-out (another tab signed out, the + // session expired) does not hand one person's text to the next. A Console with no sign-in has one + // fixed viewer. Until the viewer is read nothing is written; with none, messages live only in the page. const DRAFTS = 'mosaic-assistant-drafts'; - const kept = (() => { - try { - const v = JSON.parse(sessionStorage.getItem(DRAFTS) || 'null'); - return new Map(v && typeof v === 'object' && !Array.isArray(v) ? Object.entries(v).filter(([k, t]) => typeof t === 'string' && t.trim() && parse(`open=${k}`).keys[0] === k) : []); - } catch { return new Map(); } - })(); - const saveKept = () => { try { if (kept.size) sessionStorage.setItem(DRAFTS, JSON.stringify(Object.fromEntries(kept))); else sessionStorage.removeItem(DRAFTS); } catch { /* the Map still holds it */ } }; + const kept = new Map(); + let viewer = null, known = false, viewerRead = null; + const saveKept = () => { + if (!known) return; + try { if (kept.size && viewer) sessionStorage.setItem(DRAFTS, JSON.stringify({ viewer, drafts: Object.fromEntries(kept) })); else sessionStorage.removeItem(DRAFTS); } catch { /* the Map still holds it */ } + }; + const ready = () => (viewerRead ??= (async () => { + let v = null, stored = null; + try { v = await window.MosaicViewer; } catch { /* no viewer */ } + try { stored = JSON.parse(sessionStorage.getItem(DRAFTS) || 'null'); } catch { /* none kept */ } + viewer = typeof v === 'string' && v ? v : null; + known = true; + const own = !draftsOff && viewer && stored && typeof stored === 'object' && stored.viewer === viewer && stored.drafts && typeof stored.drafts === 'object' && !Array.isArray(stored.drafts); + if (own) for (const [k, t] of Object.entries(stored.drafts)) if (!kept.has(k) && typeof t === 'string' && t.trim() && parse(`open=${k}`).keys[0] === k) kept.set(k, t); + saveKept(); + })()); const typedIn = p => p.chat?.draft?.() || p.draft || p.el?.querySelector('form textarea')?.value || ''; // Signed out, by an expired session or by the Sign out form: the kept drafts go and nothing is kept // again, not even by the pagehide the form's own submit causes, so the next person to sign in on @@ -320,6 +335,7 @@ window.addEventListener('pagehide', () => { if (mounted) for (const p of mounted.panes) keep(p); }); function forgetDrafts() { draftsOff = true; kept.clear(); saveKept(); + try { sessionStorage.removeItem(DRAFTS); } catch { /* nothing to remove */ } if (mounted) for (const p of mounted.panes) { p.draft = ''; const t = p.el?.querySelector('form textarea'); if (t) t.value = ''; } } document.addEventListener('mosaic-signed-out', forgetDrafts); @@ -491,7 +507,10 @@ // ---------- public ---------- async function mount(container, query, { navigated = false } = {}) { unmount(); - ++mountGen; + const gen = ++mountGen; + // SP14: the kept messages are known only once the viewer is; a page left meanwhile mounts nothing. + await ready(); + if (gen !== mountGen) return; container.innerHTML = frame(); const root = container.querySelector('[data-assistant]'); mounted = { root, panes: [], show: null, turn: 0, timer: null, list: null, got: null, o: null, form: null, formTurn: 0, notice: '' }; @@ -509,10 +528,12 @@ else if (navigated) root.querySelector('h1').focus({ preventScroll: true }); writeUrl(); paintTabs(); layout(); root.querySelector('[data-assist-empty]').hidden = mounted.panes.length > 0; + // SP14: a full set offers no fourth from the start, not only once the organization reads answer. + root.querySelector('[data-assist-open]').disabled = mounted.panes.length >= MAX; await draw({ navigated: true }); } function unmount() { - if (!mounted) return; + if (!mounted) { mountGen++; return; } clearTimeout(mounted.timer); for (const p of mounted.panes) { keep(p); p.chat?.dispose(); } window.removeEventListener('resize', fit); diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index 738c6b315..9b50d15d8 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -2125,7 +2125,10 @@ const path = routeOf().split('?')[0]; if (path.startsWith('/chat')||path.startsWith('/projects')||path.startsWith('/inbox')||path.startsWith('/settings')||path.startsWith('/docs/')||wsPage(path)) render(false); else if (window.MosaicOrg ? path === '/board' : path === '/' || path === '') paintHeld(heldErr); }; - configRead = fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(applyConfig).catch(() => {}); + configRead = fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(c => { applyConfig(c); return c; }).catch(() => null); + // SP14 (#1873): who is signed in, from this same read, for what the page keeps per person + // (assistant.js's kept pane messages). null when the read failed or named nobody. + window.MosaicViewer = configRead.then(c => (typeof c?.viewer === 'string' && c.viewer ? c.viewer : null)); // E1 (P1-5): after Restart bus the organization dashboard draws its bus line now, not at the next poll. // E2E6 P2-2: a write on the organization page that changes other sections (a pack apply) asks for a // fresh read of the page; editors in progress are kept by holdLive. diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index 0f9374cc3..257b7684e 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -79,6 +79,14 @@ // never as not running. const STATE_UNREAD = ['s-unknown', '?', 'state not read']; const runsRead = item => Array.isArray(item?.running); + // SP14 (#1873): an instances answer in which no agent carries runs has not said what runs at all, so + // a count of running or working agents taken from it says not read, never 0. A copy that cannot + // launch has no runs to give and is left out. + const RUNS_WHY = 'the instance read did not say which agents run'; + const runsUnread = (inst, copies = {}) => { + const all = [...Object.values(inst || {}), ...Object.values(copies || {}).filter(c => c && typeof c.name === 'string' && !copyBlocked(c))]; + return all.length > 0 && !all.some(runsRead); + }; const activity = (name, item, agents) => { if (!runsRead(item)) return STATE_UNREAD; const on = running(item) > 0; @@ -184,7 +192,7 @@ ['business', 'Identity', b ? `${label(org)} · ${org.id} · kind not recorded` : unread, 'org file', 'business'], ['flag', 'Appearance', `${titleCase(theme)} theme · ${initials(org)} avatar`, b?.appearance?.theme || org.theme ? 'org file' : 'system', 'appearance'], ['folder', 'Projects', b ? (Array.isArray(b.projects) && b.projects.length ? plural(b.projects.length, 'project') : 'No projects') : unread, 'org file', '#/projects'], - ['people', 'Agents and roles', names ? (names.length ? `${plural(names.length, 'agent')} · ${Object.values(instances).filter(running).length} running` : 'No agents') : unread, 'org file', 'agents'], + ['people', 'Agents and roles', names ? (names.length ? `${plural(names.length, 'agent')} · ${runsUnread(instances) ? 'running not read' : `${Object.values(instances).filter(running).length} running`}` : 'No agents') : unread, 'org file', 'agents'], ['term', 'Models and routing', models ? (models.length ? models.join(' · ') : 'No models set') : unread, 'org file', 'routing'], ['queue', 'Arbiters', b ? arbiters || 'None set' : unread, 'org file', 'business'], ['tasks', 'Task tracker', b ? (b.vars?.['tracker.kind'] ? titleCase(b.vars['tracker.kind']) : 'Not set') : unread, 'org file', 'business'], @@ -276,12 +284,12 @@ // 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)])]); + const reads = await Promise.allSettled([data.business(id), data.settings(id), data.instances(id), data.threads(id), data.projects(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)); - const agents = rowsOf(value(6)), claims = rowsOf(value(7)); + const threads = rowsOf(value(3)), projectRows = rowsOf(value(4)); + const agents = rowsOf(value(5)), claims = rowsOf(value(6)); const docRead = business?.business ? await docs(id, business.business.projects).catch(() => ({ rows: [], failed: true })) : null; const ws = await wsRead; const wsRow = arr(ws?.orgs).find(r => r?.org === id) ?? null; @@ -312,7 +320,9 @@ 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'); + // SP14: no agent in the instances answer carries runs, so what runs, and so what works, was not read. + const noRuns = !!instances && runsUnread(instances, copiesOf(value(2))); + const agentsWhy = whyNot(['agents'], false) || (orgAgents ? null : 'the agent read did not answer') || (noRuns ? RUNS_WHY : null); 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 ? workingOf(orgAgents) : 0, waiting = orgAgents?.filter(a => a.state === 'waiting').length ?? 0; @@ -347,20 +357,7 @@ on.set('agent', pick); return `${esc(shown)}${where}${modelCell(agent, model)}${dot(acls, aglyph, aword, true)}${count > 1 ? ` ${count} sessions` : ''}`; }).join(''); - const sessions = live === null ? (stopped ? '

Sessions not read: the bus is stopped.

' : '

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

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

No active agent sessions

'; - const open = queue ? queue.filter(r => r?.state !== 'done').length : null; - 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; - // 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)})` : ''}.`}

`; + const sessions = live !== null && noRuns ? `

Not read: ${RUNS_WHY}.

` : live === null ? (stopped ? '

Sessions not read: the bus is stopped.

' : '

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

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

No active agent sessions

'; const stoppedPanel = (attr, title) => `

${title}

Not read: the bus is stopped.

`; return `
@@ -371,10 +368,8 @@ ${docrow}
- ${secH('Active agent sessions', live ? (live.length ? `${live.length} running${tileAgents ? `, ${liveWorking} working` : ', working not read'}` : 'none running') : '', `This organization's agents`)} + ${secH('Active agent sessions', live ? (noRuns ? 'running not read' : live.length ? `${live.length} running${tileAgents ? `, ${liveWorking} working` : ', working not read'}` : 'none running') : '', `This organization's agents`)} ${sessions} - ${secH('Queue', queueNote, queueElsewhere ? '' : 'Open queue')} - ${queueWhy}
${window.MosaicWorkflows?.card?.(id, { stopped, label: label(org) }) ?? ''} @@ -699,13 +694,16 @@ // 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; + // SP14 (#1873): an organization whose instances answer carries no runs adds nothing to the running + // and working figures; the tile names it, and its row says running was not read. + const runOk = agentOk.filter(r => !runsUnread(r.inst, r.copies)), runRows = agentAll.filter(a => runOk.some(r => r.id === a.org)); + const agentParts = runOk.map(r => ({ id: r.id, n: runRows.filter(a => a.org === r.id && a.state === 'working').length })); + const waitingOn = runRows.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: agentOk.length ? agentNote(runningOf(agentAll), waitingOn) : '', miss: [...notIn(['agents']), ...agentReads.filter(r => !r.inst).map(r => `${esc(o.name(r.id))} (${esc(instWhy(r))})`)] })} + ${kpiTile(o, { key: 'agents', name: 'Agents working', href: '#/agents?state=working', big: runOk.length ? sum(agentParts) : null, small: `of ${runRows.length}`, parts: agentParts, delta: runOk.length ? agentNote(runningOf(runRows), waitingOn) : '', miss: [...notIn(['agents']), ...agentReads.filter(r => !r.inst).map(r => `${esc(o.name(r.id))} (${esc(instWhy(r))})`), ...agentOk.filter(r => !runOk.includes(r)).map(r => `${esc(o.name(r.id))} (running not read)`)] })} ${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 })} @@ -725,7 +723,7 @@ // that was not (a stopped bus) counts the agents in its file and says nothing of what runs. const mineRows = agentOk.some(a => a.id === id) ? agentAll.filter(a => a.org === id) : null; const count = mineRows ? mineRows.length : lv?.state === 'ok' && Array.isArray(lv.base?.agents) ? lv.base.agents.length : null; - const agentsCell = count === null ? none : `${plural(count, 'agent')}${mineRows ? ` (${runningOf(mineRows)} running, ${workingOf(mineRows)} working)` : ''}`; + const agentsCell = count === null ? none : `${plural(count, 'agent')}${mineRows ? ` (${runOk.some(a => a.id === id) ? `${runningOf(mineRows)} running, ${workingOf(mineRows)} working` : 'running not read'})` : ''}`; const projectsCell = x && projectsRead.has(id) ? plural(num(x.projects?.active), 'project') : none; const needsCell = x && inboxRead.has(id) ? (num(x.needsYou) ? `${num(x.needsYou)} needs you` : 'none waiting') : none; // #1873 SP2: a reason that only restates the state word ("the bus is not running" beside "bus stopped") is said once. @@ -1157,7 +1155,7 @@ async function agentDrawer(q, opts = {}) { const pick = agentPick(q); if (!pick || (opts.only && pick.org !== opts.only)) return ''; - const { org, key, id } = pick; + const { org, id } = pick; const ir = drawerInstRead(org); const [body, business, config] = await Promise.all([ data.workspace({ org, sections: ['sessions', 'projects', 'inbox', 'tasks'] }).catch(() => null), @@ -1167,14 +1165,26 @@ ]); await Promise.race([ir.p, opts.page ?? ir.p]); const pending = !ir.done; - // A drawer closed before the read answered asks for no repaint, and its answer is not kept for a - // later open. + // SP14 (#1873): when the read answers, only the drawer repaints, from the reads it already has; the + // page's own reads are not made again. A drawer closed before the read answered, or one whose read + // a later open replaced, repaints nothing, and its answer is not kept for a later open. 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 mine = drawerInst === ir; + if (mine) drawerInst = null; + const node = mine ? document.querySelector(`#bus-view [data-ws-drawer="${CSS.escape(id)}"][data-drawer-pending]`) : null; + if (!node) return; + const key = focusKey(node), tpl = document.createElement('template'); + tpl.innerHTML = drawerHtml(pick, { body, business, config, pending: false, instRead: ir.value }); + const fresh = tpl.content.firstElementChild; + if (!fresh) return; + node.replaceWith(fresh); + refocus(fresh, key); }); else if (drawerInst === ir) drawerInst = null; - const instRead = pending ? null : ir.value; + return drawerHtml(pick, { body, business, config, pending, instRead: pending ? null : ir.value }); + } + function drawerHtml(pick, { body, business, config, pending, instRead }) { + const { org, key, id } = pick; const o = orgsOf(body), orgName = o.name(org); const close = ``; const shell = (title, inner, attrs = '') => ``; diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index 14c058fc2..73390ed37 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -4,7 +4,7 @@ import { proxyAdminHTTP } from './proxy-admin.mjs'; import { createServer } from 'node:http'; import { bindTesterSocket, TESTER_ORIGIN } from './tester-socket.mjs'; import { readFileSync, openSync, fstatSync, closeSync, constants } from 'node:fs'; -import { timingSafeEqual, randomUUID } from 'node:crypto'; +import { timingSafeEqual, randomUUID, createHash } from 'node:crypto'; import { resolve } from 'node:path'; import { isIP } from 'node:net'; import { BusReadError, busStatus as coreBusStatus, subjectOk, sendOutcome } from './bus.mjs'; @@ -251,6 +251,10 @@ const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.header // Q175 phase 1 (#1690): under --sso the writer comes from a session the handler already checked, // so it carries `sso: true` and the public origin. The header-preview signer is read-only. const signedIn = (req, p) => p.sso === true || one(req, p.header) === p.user; +// SP14 (#1873): who is signed in, as an opaque stable name the page keys what it stores per person by +// (the Assistant's kept pane messages). A hash of the session's subject, never the subject itself. A +// Console with no sign-in has one fixed local viewer. +const viewerOf = session => (typeof session?.sub === 'string' && session.sub ? createHash('sha256').update(JSON.stringify([String(session.provider ?? ''), String(session.iss ?? ''), session.sub])).digest('hex').slice(0, 32) : 'local'); const ownOrigin = p => p.origin ?? `https://${p.host}`; async function busSend(req, res, { send, preview, log }) { if (!signedIn(req, preview)) return json(res, 401, { error: 'sign-in required' }); @@ -1235,7 +1239,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF // `send`: the page shows the composer only where a send can pass (the public name, --send on). // `edit`: the editor shows Save only where a save can pass, the same test as the route's. // `user` and `federation` (FED R6) only when there is one, so the owner's config is unchanged. - if (path === '/api/config') return json(res, 200, { board: upstream, ...(organizations && preselect ? { preselect } : {}), ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(sso?.devOwner ? { devOwner: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite), workspaceFiles: true, projectRemotes: true, projectRemoteWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), ...(organizations ? { orgSetup: !tester && !granted && named && sso !== null && owner.send !== null } : {}), ...(proxyAdmin && !tester && !granted && named && sso !== null && currentSession?.rootOwner === true ? { proxyAdmin: true, proxyAdminSend: owner.send !== null } : {}), send: !organizations && !tester && Boolean(send) && named, ...(organizations && orgId ? { chatSend: !tester && Boolean(send) && named && orgBusHealthy, settingsSend: !tester && !granted && Boolean(send) && named && orgBusHealthy } : {}), boot, consoleRestart: !tester && named && currentSession?.instanceRole === 'operator' && owner.send !== null && restart !== null && trail !== null, decide: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !organizations && !tester && !granted && Boolean(send) && Boolean(projectSave) && named, northStarApprove: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(projectSave) && Boolean(projectFile) && named }); + if (path === '/api/config') return json(res, 200, { board: upstream, viewer: viewerOf(currentSession), ...(organizations && preselect ? { preselect } : {}), ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(sso?.devOwner ? { devOwner: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite), workspaceFiles: true, projectRemotes: true, projectRemoteWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), ...(organizations ? { orgSetup: !tester && !granted && named && sso !== null && owner.send !== null } : {}), ...(proxyAdmin && !tester && !granted && named && sso !== null && currentSession?.rootOwner === true ? { proxyAdmin: true, proxyAdminSend: owner.send !== null } : {}), send: !organizations && !tester && Boolean(send) && named, ...(organizations && orgId ? { chatSend: !tester && Boolean(send) && named && orgBusHealthy, settingsSend: !tester && !granted && Boolean(send) && named && orgBusHealthy } : {}), boot, consoleRestart: !tester && named && currentSession?.instanceRole === 'operator' && owner.send !== null && restart !== null && trail !== null, decide: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !organizations && !tester && !granted && Boolean(send) && Boolean(projectSave) && named, northStarApprove: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(projectSave) && Boolean(projectFile) && named }); if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) }); if (path === '/favicon.ico') { res.writeHead(204); return res.end(); } const file = files.get(path); diff --git a/packages/webui/tests/assistant-threads-browser.test.mjs b/packages/webui/tests/assistant-threads-browser.test.mjs index 1f854b3cd..7a885944d 100644 --- a/packages/webui/tests/assistant-threads-browser.test.mjs +++ b/packages/webui/tests/assistant-threads-browser.test.mjs @@ -69,9 +69,9 @@ function orgs(state) { }; } -async function open(t, { width = 1440, height = 900, hash, state = { down: new Set(['home']), sent: [], created: [] } } = {}) { +async function open(t, { width = 1440, height = 900, hash, state = { down: new Set(['home']), sent: [], created: [] }, sso = createDevOwner() } = {}) { const organizations = orgs(state); - const server = await startServer({ port: 0, sso: createDevOwner(), organizations, send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {} }); + const server = await startServer({ port: 0, sso, organizations, send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {} }); t.after(() => close(server)); const b = await browser({ machine: false }); t.after(() => b.close()); await b.viewport(width, height); @@ -462,6 +462,52 @@ test('SP11: the Sign out form drops kept pane messages, and the page it unloads assert.deepEqual(state.sent, []); }); +// SP14 (#1873): kept pane messages belong to the person who typed them. They are stored with who is +// signed in, and a load under anyone else, or a store that names no one (the SP11 form), opens empty. +// Two people sign in, one after the other, on one tab; the same person keeps theirs across a reload. +test('SP14: a kept pane message is shown again only to the person who typed it, on the same tab', async t => { + const state = { down: new Set(), sent: [], created: [] }; + const a1 = `alpha:${T.a1}`, hash = `#/assistant?open=${a1}`; + const dev = createDevOwner(); + const person = sub => ({ ...dev.session(), provider: 'sso', iss: 'https://id.example.test', sub, name: sub }); + let who = person('person-a'); + const { b, base, wait } = await open(t, { hash, state, sso: { ...dev, session: () => who } }); + const stored = () => b.evaluate(`sessionStorage.getItem('mosaic-assistant-drafts') || ''`); + const leave = async () => { await b.evaluate(`location.hash = '#/tasks'`); await wait(`!document.querySelector('[data-assist-pane]') && !!document.querySelector('[data-tasks-board]')`); }; + const load = async () => { await b.navigate(`${base}?n=${Math.random().toString(36).slice(2)}${hash}`); await wait(`${ready(a1)} && !!${field(a1)} && !${field(a1)}.disabled`); }; + await wait(`${ready(a1)} && !${field(a1)}.disabled`); + await type(b, a1, 'note from a'); + await leave(); + assert.match(await stored(), /note from a/); + // Stored with an opaque name for the person, never their subject. + assert.doesNotMatch(await stored(), /person-a/); + // Person B signs in on the same tab: the pane opens empty and A's message is gone from the store. + who = person('person-b'); + await load(); + assert.equal(await b.evaluate(`${field(a1)}.value`), ''); + assert.doesNotMatch(await stored(), /note from a/); + await type(b, a1, 'note from b'); + await leave(); + assert.match(await stored(), /note from b/); + // A again: B's message is not shown to A either. + who = person('person-a'); + await load(); + assert.equal(await b.evaluate(`${field(a1)}.value`), ''); + assert.doesNotMatch(await stored(), /note from b/); + // The same person keeps their own message across a load. + await type(b, a1, 'a again'); + await leave(); + await load(); + assert.equal(await b.evaluate(`${field(a1)}.value`), 'a again'); + // A store in the form kept before SP14, which names no one, is dropped. + await leave(); + await b.evaluate(`sessionStorage.setItem('mosaic-assistant-drafts', JSON.stringify({ ${JSON.stringify(a1)}: 'unowned note' })), true`); + await load(); + assert.equal(await b.evaluate(`${field(a1)}.value`), ''); + assert.doesNotMatch(await stored(), /unowned note/); + assert.deepEqual(state.sent, []); +}); + // SP11 (#1873): a pane whose message was sent but not yet shown in the conversation is not free: a // fourth thread replaces the first pane that holds nothing, and that pane only once its receipt is in. test('SP11: a pane whose send still waits for its receipt is not replaced by a fourth thread', async t => { diff --git a/packages/webui/tests/org-page-fit-browser.test.mjs b/packages/webui/tests/org-page-fit-browser.test.mjs index 44acfd414..712bfb651 100644 --- a/packages/webui/tests/org-page-fit-browser.test.mjs +++ b/packages/webui/tests/org-page-fit-browser.test.mjs @@ -4,9 +4,11 @@ // 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. +// 4. (SP14 removed the Queue card; the organization page now makes no queue read.) // Plus the test SP12 left out: a repaint while Stop asks its question keeps the question on screen. +// SP14 (#1873): the page has no Queue card and makes no queue read; an instances answer with no runs says +// running was not read wherever a running or working count would show; and the drawer's instances answer +// repaints only the drawer, with no page read made again. // 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. @@ -14,14 +16,13 @@ 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 } = {}) { +function fixture({ counts = {} } = {}) { const LIST = [{ id: 'acme', label: 'Acme Studio', theme: 'cobalt' }]; const d = { projects: [{ id: 'p-site', repo: 'site', name: 'Website', archived: false, at: at(30) }], @@ -52,7 +53,7 @@ function fixture({ queueError = null } = {}) { 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: [] }; }, + queue: async () => { counts.queue = (counts.queue ?? 0) + 1; return { rows: [] }; }, busStatus: async () => ({}), }, bus: { @@ -69,6 +70,11 @@ function fixture({ queueError = null } = {}) { // 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. With `window.__heldIdle` the held // answer says nothing runs. +// Every URL the page fetches, in order, for the tests that count reads. +const URLS = `(() => { const real = window.fetch; window.__urls = []; window.fetch = (url, init) => { window.__urls.push(String(url)); return real(url, init); }; })();`; +// 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 STUB = `(() => { const real = window.fetch.bind(window); window.__posts = []; window.__answers = []; window.__instSeen = 0; @@ -94,8 +100,8 @@ const STUB = `(() => { }; })();`; -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: () => {} }); +async function setup(t, { viewport = [1440, 900], hash = '#/orgs/acme/organization', inst = null, counts = {}, scripts = [] } = {}) { + const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ counts }), log: () => {} }); t.after(() => close(server)); const b = await browser({ machine: false }); t.after(() => b.close()); await b.viewport(...viewport); @@ -188,7 +194,7 @@ test('the bare organization address with a query is the organization page, query }); 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 { b, wait, text, has } = await setup(t, { hash: '#/orgs/acme/organization?agent=acme%3Acoder', inst: 'hold', scripts: [URLS] }); const d = '[data-ws-drawer="acme:coder"]'; await wait(`${settled} && ${open('acme:coder')}`); assert.equal(await has(`${d}[data-drawer-pending]`), true); @@ -199,11 +205,17 @@ test('while the drawer\'s instances read is open it says the state is being read 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. + // When the read answers, the drawer repaints with the state, and Stop. SP14: only the drawer repaints, + // from the reads it already has: the page and its reads are left alone, and focus stays where it was. + await b.evaluate(`document.querySelector('[data-org-dashboard]').dataset.before = '1'; document.querySelector('${d} [data-drawer-close]').focus(); window.__urls.length = 0`); 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); + await b.evaluate('new Promise(r => setTimeout(r, 400))'); + assert.deepEqual(await b.evaluate(`window.__urls.filter(u => /^\\/api\\/(workspace|orgs\\/)/.test(u))`), [], 'no read is made again after the drawer\'s read answers'); + assert.equal(await b.evaluate(`document.querySelector('[data-org-dashboard]').dataset.before`), '1', 'the page under the drawer was not repainted'); + assert.equal(await b.evaluate('document.activeElement?.dataset?.focusKey'), 'drawer-close'); }); test('a drawer closed before its instances read answers keeps no answer for the next open', async t => { @@ -239,9 +251,7 @@ test('after the drawer\'s instances read fails it says the state was not read, a }); 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 { 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$/); @@ -251,21 +261,39 @@ test('an instances answer that carries no runs says state not read, in the drawe 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.'); +test('SP14: the organization page has no Queue card and makes no queue read', async t => { + const counts = {}; + const { b, wait, has } = await setup(t, { counts, scripts: [URLS] }); + await wait(`${settled} && document.querySelector('[data-ws-session-agent="acme:coder"]')`); + await b.evaluate('new Promise(r => setTimeout(r, 400))'); assert.equal(await has('[data-org-queue]'), false); - assert.doesNotMatch(await text('[data-org-dashboard]'), /linked worktree|canonical checkout/); + assert.equal(await has('[data-org-queue-why]'), false); + const heads = await b.evaluate(`[...document.querySelectorAll('[data-org-dashboard] h2, [data-org-dashboard] h3')].map(h => h.textContent.trim())`); + assert.ok(heads.includes('Active agent sessions'), heads.join(' | ')); + assert.ok(!heads.some(h => /^Queue\b/.test(h)), heads.join(' | ')); + assert.deepEqual(await b.evaluate('window.__urls.filter(u => /queue/i.test(u))'), []); + assert.equal(counts.queue ?? 0, 0, 'the server read no queue'); }); -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('SP14: an instances answer with no runs says running was not read on the organization page and the Dashboard, never 0', async t => { + const { b, base, wait, text, has } = await setup(t, { scripts: [STRIP] }); + await wait(`${settled} && document.querySelector('[data-org-dashboard] [data-ws-tile="agents"]')`); + const sec = await b.evaluate(`[...document.querySelectorAll('[data-org-dashboard] .ws-sec-h')].find(h => h.querySelector('h2')?.textContent === 'Active agent sessions')?.innerText.replace(/\\s+/g, ' ').trim()`); + assert.match(sec, /^Active agent sessions running not read /); + assert.doesNotMatch(sec, /none running|\b0 running/); + assert.equal(await text('[data-runs-unread]'), 'Not read: the instance read did not say which agents run.'); + assert.equal(await text('[data-org-dashboard] [data-ws-tile="agents"]'), 'Agents working Not read: the instance read did not say which agents run'); + assert.equal(await has('[data-org-dashboard] [data-ws-tile="agents"] .ws-big'), false); + assert.equal(await text('[data-org-setting="Agents and roles"] .v'), '3 agents · running not read'); + // The Dashboard: the tile counts no organization whose runs were not read and names it; its row says so. + await b.navigate(`${base}/#/dashboard`); + await wait(`document.querySelector('[data-ws-tile="agents"]') && document.querySelector('[data-ws-orgrow="acme"]')`); + const tile = await text('[data-ws-tile="agents"]'); + assert.match(tile, /^Agents working Not read Acme Studio \(running not read\)$/); + assert.equal(await has('[data-ws-tile="agents"] .ws-big'), false); + const row = await text('[data-ws-orgrow="acme"]'); + assert.match(row, /3 agents \(running not read\)/); + assert.doesNotMatch(row, /\b0 running|0 working/); }); test('a repaint while Stop asks its question keeps the question on screen and Stop disabled', async t => { diff --git a/packages/webui/tests/unified-workspace-browser.test.mjs b/packages/webui/tests/unified-workspace-browser.test.mjs index 6fd418757..01911e0b5 100644 --- a/packages/webui/tests/unified-workspace-browser.test.mjs +++ b/packages/webui/tests/unified-workspace-browser.test.mjs @@ -90,7 +90,7 @@ test('workspace navigation exposes all organizations and remembers project group assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'),true); }); -test('sidebar follows sample 01 and Queue opens from the organization dashboard, not the sidebar', async t => { +test('sidebar follows sample 01; the Queue is in neither the sidebar nor the organization dashboard, and #/queue still opens', async t => { const server = await startServer({ port: 0, sso: createDevOwner(), organizations: workspaceOrgs(), log: () => {} }); t.after(() => close(server)); const b = await browser({ machine: false }); t.after(() => b.close()); const wait = waitFor(b); @@ -114,9 +114,9 @@ test('sidebar follows sample 01 and Queue opens from the organization dashboard, assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#console-nav a[href]")].filter(a => /\\/(queue|docs)$/.test(a.getAttribute("href"))).length'), 0); // Settings stays pinned to the foot of the sidebar. assert.equal(await b.evaluate('(() => { const nav = document.getElementById("console-nav").getBoundingClientRect(), foot = document.getElementById("sections-foot").getBoundingClientRect(); return foot.bottom >= nav.bottom - 24; })()'), true); - const queue = await b.evaluate('document.querySelector("[data-org-dashboard] [data-org-queue]").getAttribute("href")'); - assert.equal(queue, '#/queue'); - await b.evaluate('document.querySelector("[data-org-queue]").click()'); + // SP14 (#1873): the organization dashboard has no Queue card; the Queue page stays at its address. + assert.equal(await b.evaluate('document.querySelector("[data-org-dashboard] a[href=\'#/queue\'], [data-org-queue]")'), null); + await b.evaluate('location.hash = "#/orgs/ddigital/queue"'); await wait('location.hash === "#/orgs/ddigital/queue" && [...document.querySelectorAll("#bus-view h1")].some(h => h.textContent.trim() === "Queue")'); // Every sidebar view is built: Inbox (U3, workspace-pages-browser.test.mjs), Tasks (U4b, // tasks-board-browser.test.mjs) and Assistant (SP6, assistant-threads-browser.test.mjs), which @@ -152,7 +152,7 @@ test('organization dashboard renders every section from the per-organization rea assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".ws-tbl a.ws-change")].map(a => [a.getAttribute("href"), a.getAttribute("aria-label")])'), ['chief', 'copywriter', 'montage-producer'].map(n => ['#/settings/routing', `Change the model for ${n}`])); assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".ws-tbl thead th")].map(th => th.textContent)'), ['Agent', 'Project and task', 'Model', 'Status']); - assert.match(await b.evaluate('document.querySelector("[data-org-dashboard]").innerText'), /Queue\s+3 rows, 2 not done\s+Open queue/); + assert.doesNotMatch(await b.evaluate('document.querySelector("[data-org-dashboard]").innerText'), /Open queue|\d+ rows?, \d+ not done/); // Settings: one row per setting, each with an icon, a value, a source tag and, where a page exists, Open. const settings = await b.evaluate(`[...document.querySelectorAll('[data-org-settings] > li')].map(li => ({ name: li.dataset.orgSetting, icon: !!li.querySelector('svg'), value: li.querySelector('.v').textContent, tag: li.querySelector('.src-tag').textContent, href: li.querySelector('a.go')?.getAttribute('href') ?? null, danger: li.classList.contains('danger') }))`); assert.deepEqual(settings.map(s => s.name), SETTINGS); @@ -178,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, /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/]) + 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/, /Tracker: not set/]) assert.match(text, line); assert.equal(await b.evaluate('document.querySelectorAll("[data-org-settings] > li").length'), SETTINGS.length); }); @@ -190,7 +190,7 @@ 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','Agents working','Open tasks','Needs you','Done this week','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']) assert.match(text,new RegExp(label,'i')); assert.match(text,/reload organization file/i); assert.equal(await b.evaluate('document.querySelectorAll("[data-org-tiles] .ws-big").length'),0); assert.match(text,/bus unavailable, read \d/); @@ -307,7 +307,7 @@ test('keyboard focus follows visual order and survives a data refresh', async t assert.deepEqual([...new Set(wide.map(s => s.region))].filter(r => r !== 'other'), regions); assertVisualOrder(wide.filter(s => s.region !== 'other'), regions, '1440'); const keys = wide.map(s => s.key); - for (const key of ['org:forge', 'proj:ddigital:spring-launch', 'doc:spring-launch:docs/north-star.md', 'docs-all', 'agents-all', 'queue-open', 'wfg-all', 'set:Identity', 'set:Archive or delete']) assert.ok(keys.includes(key), `Tab never reaches ${key}`); + for (const key of ['org:forge', 'proj:ddigital:spring-launch', 'doc:spring-launch:docs/north-star.md', 'docs-all', 'agents-all', 'wfg-all', 'set:Identity', 'set:Archive or delete']) assert.ok(keys.includes(key), `Tab never reaches ${key}`); // The disabled reload is not a tab stop; the restart button is, and Stop bus follows it. assert.ok(!keys.includes('org-reload')); assert.ok(keys.includes('Restart bus')); assert.equal(keys.indexOf('Stop bus'), keys.indexOf('Restart bus') + 1); @@ -319,7 +319,7 @@ test('keyboard focus follows visual order and survives a data refresh', async t await b.viewport(1440, 900); // A refresh repaints the dashboard, the sidebar and the restart button; focus stays on the same control. - for (const key of ['set:Projects', 'queue-open', 'doc:spring-launch:docs/launch-plan.md']) { + for (const key of ['set:Projects', 'agents-all', 'doc:spring-launch:docs/launch-plan.md']) { await wait(`document.querySelector('[data-focus-key="${key}"]')`); await b.evaluate(`window.oldDashboard = document.querySelector('[data-org-dashboard]'); document.querySelector('[data-focus-key="${key}"]').focus(); document.getElementById('refresh').click()`); await wait('document.querySelector("[data-org-dashboard]") && document.querySelector("[data-org-dashboard]") !== window.oldDashboard');