From 68df55130efb76ecf8cea039b570aa5d0138cd2e Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 12:54:28 -0600 Subject: [PATCH] fix(webui): sp13 review, a drawer closed before its instances read answers keeps no answer for the next open: the answer is dropped when no pending drawer is left to repaint, so a later open reads again instead of showing a state from minutes before; test closes the drawer while its read is held, lets the read say nothing runs, reopens and sees the agent working (#1873, lane sp13-org-page-fit) --- packages/webui/src/public/workspace.js | 7 ++++- .../webui/tests/org-page-fit-browser.test.mjs | 26 +++++++++++++++++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index 3b9603983..0f9374cc3 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -1167,7 +1167,12 @@ ]); await Promise.race([ir.p, opts.page ?? ir.p]); const pending = !ir.done; - 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')); }); + // A drawer closed before the read answered asks for no repaint, 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; + }); else if (drawerInst === ir) drawerInst = null; const instRead = pending ? null : ir.value; const o = orgsOf(body), orgName = o.name(org); diff --git a/packages/webui/tests/org-page-fit-browser.test.mjs b/packages/webui/tests/org-page-fit-browser.test.mjs index 744fa70fa..44acfd414 100644 --- a/packages/webui/tests/org-page-fit-browser.test.mjs +++ b/packages/webui/tests/org-page-fit-browser.test.mjs @@ -67,7 +67,8 @@ function fixture({ queueError = null } = {}) { // Runs before the page's scripts. Acme's config says this Console sends there. `window.__inst` set by // 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. +// which is the drawer's: it starts before the page's own reads. With `window.__heldIdle` the held +// answer says nothing runs. const STUB = `(() => { const real = window.fetch.bind(window); window.__posts = []; window.__answers = []; window.__instSeen = 0; @@ -77,7 +78,11 @@ const STUB = `(() => { if (/\\/api\\/orgs\\/acme\\/bus\\/instances$/.test(u) && window.__instSeen++ === 0 && window.__inst) { if (window.__inst === 'fail') return answer(502, { error: 'read-failed', message: 'the bus did not answer' }); await new Promise(done => { window.__release = done; }); - return real(url, init); + if (!window.__heldIdle) return real(url, init); + // The held answer, when a test asks, says no instance runs anything. + const v = await (await real(url, init)).json(); + for (const row of [v, ...(v.rows || [])]) for (const i of Object.values(row?.instances || {})) i.running = []; + return answer(200, v); } const cfg = u.match(/^\\/api\\/orgs\\/([^/]+)\\/config$/); if (cfg) { const c = await (await real(url, init)).json(); return answer(200, { ...c, chatSend: true, settingsSend: true, decide: true }); } @@ -201,6 +206,23 @@ test('while the drawer\'s instances read is open it says the state is being read assert.equal(await has(`${d} [data-drawer-nostop]`), 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] }); + const d = '[data-ws-drawer="acme:coder"]'; + await wait(`${settled} && document.querySelector('${d}[data-drawer-pending]')`); + await b.evaluate(`document.querySelector('${d} [data-drawer-close]').click()`); + await wait(noDrawer); + // The held read answers after the drawer closed, saying coder runs nothing. + await b.evaluate('window.__release()'); + await b.evaluate('new Promise(r => setTimeout(r, 300))'); + // Reopened from its row, the drawer reads again and shows coder working, not the closed drawer's answer. + await b.evaluate(`document.querySelector('[data-ws-drawer-open="acme:coder"]').click()`); + await wait(`${open('acme:coder')} && !document.querySelector('${d}[data-drawer-pending]') && document.querySelector('${d} [data-drawer-state]')`); + assert.match(await text(`${d} [data-drawer-state]`), /(^|\s)working$/); + assert.doesNotMatch(await text(d), /not running/); +}); + test('after the drawer\'s instances read fails it says the state was not read, and Stop says so too', async t => { const { b, wait, text, has } = await setup(t, { hash: '#/orgs/acme/organization?agent=acme%3Areviewer', inst: 'fail' }); const d = '[data-ws-drawer="acme:reviewer"]';