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');