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)

This commit is contained in:
jason.woltje committed 2026-11-15 10:27:28 -06:00
1 parent f73c761ad3
commit 2d7d7be5ca
4 files changed
+43 -7

No files matched your search

+7 -4
View File
@@ -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 };
+3 -2
View File
@@ -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}
<div class="ws-split">
<section aria-label="Measures and sessions">
${secH('Active agent sessions', live ? (live.length ? `${live.length} running, ${liveWorking} working` : 'none running') : '', `<a href="#/agents?${oq}" data-focus-key="agents-all">This organization's agents</a>`)}
${secH('Active agent sessions', live ? (live.length ? `${live.length} running${tileAgents ? `, ${liveWorking} working` : ', working not read'}` : 'none running') : '', `<a href="#/agents?${oq}" data-focus-key="agents-all">This organization's agents</a>`)}
${sessions}
${secH('Queue', queueNote, '<a href="#/queue" data-org-queue data-focus-key="queue-open">Open queue</a>')}
${queueWhy}
@@ -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 => {
@@ -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]")');