From c26879637a6819888f1db20542af5c0917e2ae4f Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 13:52:25 -0600 Subject: [PATCH] fix(webui): sp14 review, every drawer opened while its instances read is open repaints when that read answers: the pending drawer names the read it waits on, and each open repaints its own drawer if it still waits on that read, so a second agent opened before the answer no longer says Reading state until the next poll; the Settings card's Agents and roles row uses the same running-not-read check as the Active agent sessions heading, copies included; test opens chief while coder's read is held and sees chief's state at once (#1873, lane sp14-org-page-truth) --- packages/webui/src/public/workspace.js | 24 +++++++++---------- .../webui/tests/org-page-fit-browser.test.mjs | 16 +++++++++++++ 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index 257b7684e..44fac0663 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -179,7 +179,7 @@ const secH = (title, note = '', link = '', attrs = '') => `
${title}${note ? `${note}` : ''}${link}
`; function settingsList(org, ctx) { - const { business, settings, instances, orgCount } = ctx; + const { business, settings, instances, orgCount, noRuns } = ctx; const b = business?.business, s = settings; const unread = 'Could not be read'; const names = instances ? Object.keys(instances) : null; @@ -192,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')} · ${runsUnread(instances) ? 'running not read' : `${Object.values(instances).filter(running).length} running`}` : 'No agents') : unread, 'org file', 'agents'], + ['people', 'Agents and roles', names ? (names.length ? `${plural(names.length, 'agent')} · ${noRuns ? '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'], @@ -374,7 +374,7 @@
${window.MosaicWorkflows?.card?.(id, { stopped, label: label(org) }) ?? ''} ${secH('Settings', 'every setting, with where its value comes from')} - ${settingsList(org, { business, settings, instances, orgCount: list ? list.length : null })} + ${settingsList(org, { business, settings, instances, noRuns, orgCount: list ? list.length : null })}
@@ -1143,10 +1143,10 @@ // page for it: once the page's own reads have answered (`opts.page`), a drawer whose instances read // has not says its state is being read, and repaints when the read answers. That answer is kept for // the one repaint it asks for; a repaint while the read is still open waits on the same read. - let drawerInst = null; // { org, done, value, p } + let drawerInst = null, drawerReads = 0; // { org, n, done, value, p } const drawerInstRead = org => { if (drawerInst?.org === org) return drawerInst; - const r = { org, done: false, value: null }; + const r = { org, n: ++drawerReads, done: false, value: null }; r.p = data.instances(org).then(v => ({ inst: instancesOf(v), copies: copiesOf(v) }), err => ({ inst: null, copies: {}, err })) .then(v => { r.done = true; r.value = v; return v; }); drawerInst = r; @@ -1166,12 +1166,12 @@ await Promise.race([ir.p, opts.page ?? ir.p]); const pending = !ir.done; // 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. + // page's own reads are not made again. Each open repaints its own drawer if it is still on screen and + // still waits on this read (data-drawer-pending names the read), so a second agent opened while the + // read was open repaints too. A closed drawer repaints nothing, and the answer is not kept for a later open. if (pending) ir.p.then(() => { - 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 (drawerInst === ir) drawerInst = null; + const node = document.querySelector(`#bus-view [data-ws-drawer="${CSS.escape(id)}"][data-drawer-pending="${ir.n}"]`); if (!node) return; const key = focusKey(node), tpl = document.createElement('template'); tpl.innerHTML = drawerHtml(pick, { body, business, config, pending: false, instRead: ir.value }); @@ -1181,7 +1181,7 @@ refocus(fresh, key); }); else if (drawerInst === ir) drawerInst = null; - return drawerHtml(pick, { body, business, config, pending, instRead: pending ? null : ir.value }); + return drawerHtml(pick, { body, business, config, pending: pending && ir.n, instRead: pending ? null : ir.value }); } function drawerHtml(pick, { body, business, config, pending, instRead }) { const { org, key, id } = pick; @@ -1253,7 +1253,7 @@ return shell(a.name, `
${fields}

Open tasks

${tasksBody}${tasksAll}

Decisions waiting on you

${decBody}
- ${actions}`, ` data-drawer-org-id="${esc(org)}" data-drawer-name="${esc(a.name)}" data-drawer-org-name="${esc(orgName)}"${pending ? ' data-drawer-pending' : ''}`); + ${actions}`, ` data-drawer-org-id="${esc(org)}" data-drawer-name="${esc(a.name)}" data-drawer-org-name="${esc(orgName)}"${pending ? ` data-drawer-pending="${pending}"` : ''}`); } // Closing goes Back when a row link on this page pushed the current entry; otherwise it drops // `agent` from the address in place. Either way the page repaints without it and focus returns to the row. diff --git a/packages/webui/tests/org-page-fit-browser.test.mjs b/packages/webui/tests/org-page-fit-browser.test.mjs index 712bfb651..c8c9fdb7b 100644 --- a/packages/webui/tests/org-page-fit-browser.test.mjs +++ b/packages/webui/tests/org-page-fit-browser.test.mjs @@ -218,6 +218,22 @@ test('while the drawer\'s instances read is open it says the state is being read assert.equal(await b.evaluate('document.activeElement?.dataset?.focusKey'), 'drawer-close'); }); +test('SP14 review: another agent opened while the drawer\'s instances read is open repaints when that read answers', async t => { + const { b, wait, text, has } = await setup(t, { hash: '#/orgs/acme/organization?agent=acme%3Acoder', inst: 'hold' }); + await wait(`${settled} && document.querySelector('[data-ws-drawer="acme:coder"][data-drawer-pending]')`); + // Chief opens before the held read answers; its drawer waits on the same read. + await b.evaluate('location.hash = "#/orgs/acme/organization?agent=acme%3Achief"'); + const d = '[data-ws-drawer="acme:chief"]'; + await wait(`document.querySelector('${d}[data-drawer-pending]')`); + assert.equal(await text(`${d} [data-drawer-state]`), 'Reading state…'); + // The read answers. Chief's drawer repaints from it at once, not at the next poll. + await b.evaluate('window.__release()'); + await b.evaluate('new Promise(r => setTimeout(r, 1000))'); + assert.equal(await has(`${d}[data-drawer-pending]`), false, 'chief\'s drawer still says Reading state… after its read answered'); + assert.match(await text(`${d} [data-drawer-state]`), /not running/); + assert.equal(await has('[data-ws-drawer="acme:coder"]'), false); +}); + test('a drawer closed before its instances read answers keeps no answer for the next open', async t => { const idle = 'window.__heldIdle = true;'; const { b, wait, text } = await setup(t, { hash: '#/orgs/acme/organization?agent=acme%3Acoder', inst: 'hold', scripts: [idle] });