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