From 2d7d7be5cac7851ed12b87038f45e7efb246f11e Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 10:04:06 -0600 Subject: [PATCH] fix(webui): sp11 review 2, the Sign out form drops kept pane messages as an expired session does, and the page it unloads keeps none; when neither bus read gives agent activity, an organization page's sessions heading says working was not read instead of a figure (#1873, lane sp11-fixes) --- packages/webui/src/public/assistant.js | 11 +++++---- packages/webui/src/public/workspace.js | 5 ++-- .../tests/assistant-threads-browser.test.mjs | 24 +++++++++++++++++++ .../webui/tests/kpi-tiles-browser.test.mjs | 10 +++++++- 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/packages/webui/src/public/assistant.js b/packages/webui/src/public/assistant.js index 73894cc73..bea6bad49 100644 --- a/packages/webui/src/public/assistant.js +++ b/packages/webui/src/public/assistant.js @@ -311,16 +311,19 @@ })(); 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 typedIn = p => p.chat?.draft?.() || p.draft || p.el?.querySelector('form textarea')?.value || ''; - // Signed out: the kept drafts go and nothing is kept again, so the next person to sign in on this - // tab never sees text typed before. + // 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 + // this tab never sees text typed before. let draftsOff = false; function keep(p) { if (draftsOff) return; const text = typedIn(p); if (text.trim()) kept.set(p.key, text); else kept.delete(p.key); saveKept(); } function drop(p) { if (kept.delete(p.key)) saveKept(); } window.addEventListener('pagehide', () => { if (mounted) for (const p of mounted.panes) keep(p); }); - document.addEventListener('mosaic-signed-out', () => { + function forgetDrafts() { draftsOff = true; kept.clear(); saveKept(); 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); + document.addEventListener('submit', e => { if (e.target?.id === 'sign-out') forgetDrafts(); }, true); function makePane(key) { const { org, id } = split(key); const p = { key, org, id, n: ++paneSeq, el: null, chat: null, first: true, title: null, draft: kept.get(key) || '', held: false, seq: 0 }; diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index 7252089aa..1656000c7 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -317,7 +317,8 @@ const copyLive = Object.entries(copiesOf(value(2))).filter(([, c]) => c && typeof c.name === 'string' && !copyBlocked(c) && running(c)).map(([key, c]) => [key, c, c.name]); const live = instances ? [...Object.entries(instances).filter(([, item]) => running(item)).map(([key, item]) => [key, item, key]), ...copyLive] : null; // The sessions heading and rows take their activity from the session list the tile counts, so the - // two never disagree. When the tile shows no figure they use the bus agents read instead. + // two never disagree. When the tile shows no figure they use the bus agents read instead; when + // that failed too, the heading says working was not read rather than a figure. const tileAgents = orgAgents && !agentsWhy ? arr(ws.sessions).filter(x => x?.kind === 'role' && x.org === id && typeof x.role === 'string').map(x => ({ role: x.role, holder_run: x.run, state: x.state })) : agents; const liveWorking = live ? live.filter(([key, item]) => activity(key, item, tileAgents)[2] === 'working').length : 0; // A session's project and task is the thread its live run holds a claim on (thread.claims), not @@ -352,7 +353,7 @@ ${docrow}
- ${secH('Active agent sessions', live ? (live.length ? `${live.length} running, ${liveWorking} working` : 'none running') : '', `This organization's agents`)} + ${secH('Active agent sessions', live ? (live.length ? `${live.length} running${tileAgents ? `, ${liveWorking} working` : ', working not read'}` : 'none running') : '', `This organization's agents`)} ${sessions} ${secH('Queue', queueNote, 'Open queue')} ${queueWhy} diff --git a/packages/webui/tests/assistant-threads-browser.test.mjs b/packages/webui/tests/assistant-threads-browser.test.mjs index 1b7efc671..1f854b3cd 100644 --- a/packages/webui/tests/assistant-threads-browser.test.mjs +++ b/packages/webui/tests/assistant-threads-browser.test.mjs @@ -438,6 +438,30 @@ test('SP11: signing out drops kept pane messages, and a reload after it opens th assert.deepEqual(state.sent, []); }); +// SP11 review: the Sign out form is a plain POST that unloads the page; its submit drops the kept +// messages and the pagehide it causes keeps nothing, so a sign-in after it opens the pane empty. +test('SP11: the Sign out form drops kept pane messages, and the page it unloads keeps none', async t => { + const state = { down: new Set(), sent: [], created: [] }; + const a1 = `alpha:${T.a1}`, hash = `#/assistant?open=${a1}`; + const { b, base, wait } = await open(t, { hash, state }); + await wait(`${ready(a1)} && !${field(a1)}.disabled`); + await type(b, a1, 'private note'); + await b.evaluate(`location.hash = '#/tasks'`); + await wait(`!document.querySelector('[data-assist-pane]')`); + await b.evaluate(`location.hash = ${JSON.stringify(hash)}`); + await wait(`${field(a1)}?.value === 'private note'`); + await b.evaluate(`document.getElementById('sign-out').requestSubmit(), true`); + // The POST unloads the page; poll from here, since the old page's context goes away under it. + let left = false; + for (let i = 0; i < 200 && !left; i++) { try { left = await b.evaluate(`location.pathname === '/auth/logout'`); } catch { /* navigating */ } if (!left) await new Promise(r => setTimeout(r, 30)); } + assert.equal(left, true, 'the Sign out form posted'); + await b.navigate(base + '?again=1' + hash); + await wait(`${ready(a1)} && !!${field(a1)} && !${field(a1)}.disabled`); + assert.equal(await b.evaluate(`${field(a1)}.value`), ''); + assert.equal(await b.evaluate(`sessionStorage.getItem('mosaic-assistant-drafts')`), null); + 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/kpi-tiles-browser.test.mjs b/packages/webui/tests/kpi-tiles-browser.test.mjs index 69b671387..ef50aad0e 100644 --- a/packages/webui/tests/kpi-tiles-browser.test.mjs +++ b/packages/webui/tests/kpi-tiles-browser.test.mjs @@ -65,7 +65,7 @@ function fixture({ fail = {}, none = false, split = {} } = {}) { busHealthy: true, reads: { business: async () => ({ business: { id, human: 'jason', projects: DATA[id].projects.map(p => p.repo), appearance: { theme: org.theme } } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) }, bus: { - messages: async () => ({ messages: [], unread: 0 }), agents: async () => split[id] ?? DATA[id].agents, 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [], + messages: async () => ({ messages: [], unread: 0 }), agents: async () => { if (fail[id]?.includes('agents')) throw Object.assign(new Error('off'), { code: 'channel-disabled' }); return split[id] ?? DATA[id].agents; }, 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [], instances: () => (fail[id]?.includes('instances') ? Promise.reject(Object.assign(new Error('off'), { code: 'bus-unavailable' })) : Promise.resolve({ revision: 'a'.repeat(64), definitions: {}, instances: DATA[id].instances })), }, chat: { projects: async () => DATA[id].projects, threads: async () => [] }, @@ -252,6 +252,14 @@ test('An organization page counts working from one read: the sessions heading an assert.match(await text('[data-org-dashboard] .ws-split tbody'), /coder[\s\S]*working/); }); +test('When neither bus read gives agent activity, the sessions heading says working was not read, never a figure', async t => { + const { b, wait, text } = await setup(t, { hash: '#/orgs/acme', fail: { acme: ['agents'] } }); + await wait('document.querySelector("[data-org-dashboard] [data-org-tiles] [data-ws-tile=agents]")'); + assert.match(await text('[data-org-tiles] [data-ws-tile=agents]'), /Not read/); + assert.match(await text('[data-org-dashboard] .ws-split'), /Active agent sessions\s*1 running, working not read/); + assert.doesNotMatch(await text('[data-org-dashboard] .ws-split'), /\d+ working/); +}); + test('A stopped organization\'s page shows its tiles as not read, still linking to its filtered pages', async t => { const { b, wait, text } = await setup(t, { hash: '#/orgs/gamma' }); await wait('document.querySelector("[data-org-dashboard] [data-org-tiles]")');