Sessions unavailable. The bus did not answer the instance read.
') : live.length ? `
Agent
Project and task
Model
Status
${sessionRows}
` : '
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 ? `
Agent
Project and task
Model
Status
${sessionRows}
` : '
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');