From 13615e12b9ba87108191408070f5d1dca3191271 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 09:08:39 -0600 Subject: [PATCH 01/10] fix(webui): sp11 item 1, agent counts say running and working and count the same rows: the tiles say how many run beside how many work, the Dashboard organization rows count the tile's rows, the sessions heading lists the running copies too, and the Agents page names a running agent with no reported state (#1873, lane sp11-fixes) --- packages/webui/src/public/workspace.js | 39 ++++++++++++------- .../webui/tests/kpi-tiles-browser.test.mjs | 24 ++++++++++++ .../tests/system-agents-browser.test.mjs | 21 ++++++++-- 3 files changed, 68 insertions(+), 16 deletions(-) diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index 69c5b4212..851de9047 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -68,6 +68,12 @@ // An agent's activity is the state the bus derives for the run holding its role. A live process // alone says only running, never working or idle. const ACTIVITY = { working: ['s-working', '●', 'working'], waiting: ['s-waiting', '◐', 'waiting on you'], idle: ['s-idle', '–', 'idle'] }; + // SP11 (#1873): the agent counts share two words. Running is an agent row with a live process, + // whatever it is doing; working is a running one whose role session is working. + const LIVE_STATES = new Set(['working', 'waiting', 'idle', 'running']); + const runningOf = rows => rows.filter(a => LIVE_STATES.has(a.state)).length; + const workingOf = rows => rows.filter(a => a.state === 'working').length; + const agentNote = (run, waiting) => `${run} running${waiting ? `, ${waiting} waiting on you` : ''}`; const activity = (name, item, agents) => { const on = running(item) > 0; const held = on && agents ? agents.find(a => a?.role === name && item.running.includes(a.holder_run)) : null; @@ -297,18 +303,22 @@ const agentsWhy = whyNot(['agents'], false) || (orgAgents ? null : 'the agent read did not answer'); const kpi = orgsOf(ws), oq = `org=${enc(id)}`; const orgTile = (key, title, href, why, fig) => kpiTile(kpi, { key, name: title, href, ...(why ? { none: `Not read: ${esc(why)}` } : fig) }); - const working = orgAgents?.filter(a => a.state === 'working').length ?? 0, waiting = orgAgents?.filter(a => a.state === 'waiting').length ?? 0; + const working = orgAgents ? workingOf(orgAgents) : 0, waiting = orgAgents?.filter(a => a.state === 'waiting').length ?? 0; const orgTiles = `
- ${orgTile('agents', 'Agents working', `#/agents?${oq}&state=working`, agentsWhy, { big: working, small: `of ${orgAgents?.length ?? 0}`, delta: waiting ? `${waiting} waiting on you` : '' })} + ${orgTile('agents', 'Agents working', `#/agents?${oq}&state=working`, agentsWhy, { big: working, small: `of ${orgAgents?.length ?? 0}`, delta: orgAgents ? agentNote(runningOf(orgAgents), waiting) : '' })} ${orgTile('tasks', 'Open tasks', `#/tasks?${oq}`, whyNot(['tasks']), { big: num(mine?.openTasks) })} ${orgTile('needs', 'Needs you', `#/inbox?${oq}`, whyNot(['inbox']), { big: num(mine?.needsYou), small: num(mine?.needsYou) === 1 ? 'item' : 'items', delta: num(mine?.needsYou) && mine?.oldestNeedsYouAt ? `oldest ${span(mine.oldestNeedsYouAt)}` : 'nothing waiting' })} ${orgTile('done', 'Done this week', `#/tasks?${oq}`, whyNot(['tasks']), { big: num(mine?.doneThisWeek) })}
`; const modelOf = inst => business?.business?.instances?.find(i => i?.instance === inst)?.vars?.model; - const live = instances ? Object.entries(instances).filter(([, item]) => running(item)) : null; + // SP11: the sessions are the running rows the tile counts: the organization's instances and the + // root and project copies its bus booted with. A copy shows its own name. + 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; + const liveWorking = live ? live.filter(([key, item]) => activity(key, item, agents)[2] === 'working').length : 0; // A session's project and task is the thread its live run holds a claim on (thread.claims), not // any older conversation of the same agent. A failed read says unavailable, never "none". - const sessionRows = (live || []).map(([agent, item]) => { + const sessionRows = (live || []).map(([agent, item, shown]) => { const held = claims ? claims.filter(c => item.running.includes(c?.holder_run)) : null; const first = held?.[0], thread = first && threads ? threads.find(t => t?.id === first.thread) : null; const model = modelOf(agent), count = running(item), [acls, aglyph, aword] = activity(agent, item, agents); @@ -317,7 +327,7 @@ : !first ? 'No conversation held right now' : thread ? `${esc(projectName(thread.project))}${esc(thread.name || thread.id)}${more}` : threads ? `Conversation${esc(first.thread)}${more}` : 'Conversation unavailable'; - return `${esc(agent)}${where}${modelCell(agent, model)}${dot(acls, aglyph, aword, true)}${count > 1 ? ` ${count} sessions` : ''}`; + return `${esc(shown)}${where}${modelCell(agent, model)}${dot(acls, aglyph, aword, true)}${count > 1 ? ` ${count} sessions` : ''}`; }).join(''); const sessions = live === null ? (stopped ? '

Sessions not read: the bus is stopped.

' : '

Sessions unavailable. The bus did not answer the instance read.

') : live.length ? `
${sessionRows}
AgentProject and taskModelStatus
` : '

No active agent sessions

'; const open = queue ? queue.filter(r => r?.state !== 'done').length : null; @@ -338,7 +348,7 @@ ${docrow}
- ${secH('Active agent sessions', live ? (live.length ? `${live.length} running` : 'none running') : '', `This organization's agents`)} + ${secH('Active agent sessions', live ? (live.length ? `${live.length} running, ${liveWorking} working` : 'none running') : '', `This organization's agents`)} ${sessions} ${secH('Queue', queueNote, 'Open queue')} ${queueWhy} @@ -669,7 +679,7 @@ const needsN = fig('needs', ps.needs); const archived = per.filter(x => readers(o, SRC.projects).has(x.org)).reduce((a, x) => a + num(x.projects?.archived), 0); const tiles = !o.rows.length ? '

No organizations yet, so there is nothing to count.

' : `
- ${kpiTile(o, { key: 'agents', name: 'Agents working', href: '#/agents?state=working', big: agentOk.length ? sum(agentParts) : null, small: `of ${agentAll.length}`, parts: agentParts, delta: waitingOn ? `${waitingOn} waiting on you` : '', miss: [...notIn(['agents']), ...agentReads.filter(r => !r.inst).map(r => `${esc(o.name(r.id))} (${esc(instWhy(r))})`)] })} + ${kpiTile(o, { key: 'agents', name: 'Agents working', href: '#/agents?state=working', big: agentOk.length ? sum(agentParts) : null, small: `of ${agentAll.length}`, parts: agentParts, delta: agentOk.length ? agentNote(runningOf(agentAll), waitingOn) : '', miss: [...notIn(['agents']), ...agentReads.filter(r => !r.inst).map(r => `${esc(o.name(r.id))} (${esc(instWhy(r))})`)] })} ${kpiTile(o, { key: 'orgs', name: 'Organizations', href: '#/dashboard', big: answered, small: `of ${o.rows.length} running`, delta: o.unread.length ? andList(o.unread.map(r => `${esc(o.name(r.org))} ${offWord(r)}`)) : 'every bus answered', attrs: ' data-ws-jump="orgs"' })} ${kpiTile(o, { key: 'needs', name: 'Needs you', href: '#/inbox', big: needsN, small: needsN === 1 ? 'item' : 'items', parts: ps.needs, delta: needsN && t?.oldestNeedsYouAt ? `oldest ${span(t.oldestNeedsYouAt)}` : 'nothing waiting', miss: notIn(SRC.needs), ...noFig })} ${kpiTile(o, { key: 'tasks', name: 'Open tasks', href: '#/tasks', big: fig('tasks', ps.tasks), parts: ps.tasks, miss: notIn(SRC.tasks), ...noFig })} @@ -679,15 +689,17 @@ // SP1 (sample r2-01): one row per organization. A figure shows only when its read gave it; an // organization that did not answer shows its state and reason instead of figures. - const projectsRead = readers(o, SRC.projects), inboxRead = readers(o, SRC.needs), sessionsRead = readers(o, SRC.sessions); + const projectsRead = readers(o, SRC.projects), inboxRead = readers(o, SRC.needs); const none = '–'; const orgTable = o.rows.map(r => { const id = r.org, fromList = listRow(id), x = per.find(p => p.org === id); const [cls, glyph, word] = rowState(r, fromList); const lv = levels?.orgs?.find(v => v?.id === id); - const count = lv?.state === 'ok' && Array.isArray(lv.base?.agents) ? lv.base.agents.length : null; - const working = x && sessionsRead.has(id) ? num(x.sessions?.working) : null; - const agentsCell = count === null ? none : `${plural(count, 'agent')}${working === null ? '' : ` (${working} working)`}`; + // SP11: an organization whose agents were read counts the tile's rows, running and working; one + // that was not (a stopped bus) counts the agents in its file and says nothing of what runs. + const mineRows = agentOk.some(a => a.id === id) ? agentAll.filter(a => a.org === id) : null; + const count = mineRows ? mineRows.length : lv?.state === 'ok' && Array.isArray(lv.base?.agents) ? lv.base.agents.length : null; + const agentsCell = count === null ? none : `${plural(count, 'agent')}${mineRows ? ` (${runningOf(mineRows)} running, ${workingOf(mineRows)} working)` : ''}`; const projectsCell = x && projectsRead.has(id) ? plural(num(x.projects?.active), 'project') : none; const needsCell = x && inboxRead.has(id) ? (num(x.needsYou) ? `${num(x.needsYou)} needs you` : 'none waiting') : none; // #1873 SP2: a reason that only restates the state word ("the bus is not running" beside "bus stopped") is said once. @@ -950,7 +962,7 @@ // `opts.scope` names one organization (#/orgs//agents): the same table, that organization only. // `opts.fireIn(org)` says from that organization's own config whether this Console decides there; // Fire is drawn only on the system page and only there, and posts to the row's organization (bus.js). - const AGENT_STATE = [['working', 'Working'], ['waiting', 'Waiting on you'], ['idle', 'Idle'], ['running', 'Running'], ['not-running', 'Not running'], ['cant-launch', "Can't launch"]]; + const AGENT_STATE = [['working', 'Working'], ['waiting', 'Waiting on you'], ['idle', 'Idle'], ['running', 'Running, state unknown'], ['not-running', 'Not running'], ['cant-launch', "Can't launch"]]; const AGENT_STATE_KEY = { working: 'working', 'waiting on you': 'waiting', idle: 'idle', running: 'running', 'not running': 'not-running', "can't launch": 'cant-launch' }; // Each named organization's instances read, and whether this Console decides there when `fireIn` is given. const readInstances = (ids, fireIn) => Promise.all(ids.map(id => Promise.all([ @@ -1044,7 +1056,8 @@ const notRead = new Set(reads.filter(r => !r.inst).map(r => r.id)); const chipOrgs = { ...every, rows: every.rows.map(r => (notRead.has(r.org) ? { ...r, errors: { ...(r.errors || {}), instances: 'not read' } } : r)) }; const orgsWith = new Set(rows.map(a => a.org)).size; - const tally = rows.length ? ` ${plural(rows.length, 'agent')}${scope ? '' : ` in ${plural(orgsWith, 'organization')}`}.` : ''; + // SP11: running and working in the Dashboard tiles' words, over the rows shown. + const tally = rows.length ? ` ${plural(rows.length, 'agent')}${scope ? '' : ` in ${plural(orgsWith, 'organization')}`}: ${runningOf(rows)} running, ${workingOf(rows)} working.` : ''; const sub = scope ? `Agents in ${esc(o.name(scope))} only.${tally} Every organization` : `Every agent on the system, by organization.${tally} Open one to see it in its organization.`; diff --git a/packages/webui/tests/kpi-tiles-browser.test.mjs b/packages/webui/tests/kpi-tiles-browser.test.mjs index 16adcf8ad..63d06cac0 100644 --- a/packages/webui/tests/kpi-tiles-browser.test.mjs +++ b/packages/webui/tests/kpi-tiles-browser.test.mjs @@ -200,6 +200,30 @@ test('An organization page leads with its own tiles, each linking to the system await wait('location.hash === "#/agents?org=acme&state=working" && JSON.stringify([...document.querySelectorAll("[data-ws-agent]")].map(r => r.dataset.wsAgent)) === JSON.stringify(["acme:coder"])'); }); +// SP11 (#1873): "working" and "running" each name what they count. Running is an agent with a live +// process, whatever it is doing; working is a running agent whose role session is working. The tile, +// the Dashboard organization rows and the sessions heading count the same agent rows. +const delta = (scope, key) => `document.querySelector(${JSON.stringify(`${scope} [data-ws-tile=${key}] .ws-delta:not(.ws-miss)`)})?.textContent.trim() ?? null`; +const rowAgents = org => `document.querySelector("[data-ws-orgrow=${org}] td:nth-child(2)")?.innerText.replace(/\\s+/g, " ").trim() ?? null`; +test('Agent counts say running and working, and count the same agents on the tiles, the organization rows and the sessions heading', async t => { + const { b, wait, text } = await setup(t); + await wait(homeReady); + // acme's coder runs and works; beta's pm runs and waits on you; chief and writer run nothing. + assert.equal(await b.evaluate(big('[data-ws-home]', 'agents')), '1 of 4'); + assert.equal(await b.evaluate(delta('[data-ws-home]', 'agents')), '2 running, 1 waiting on you'); + assert.equal(await b.evaluate(rowAgents('acme')), '2 agents (1 running, 1 working)'); + assert.equal(await b.evaluate(rowAgents('beta')), '2 agents (1 running, 0 working)'); + await b.evaluate('location.hash = "#/orgs/beta"'); + await wait('document.querySelector("[data-org-dashboard] [data-org-tiles] [data-ws-tile=agents] .ws-big")'); + assert.equal(await b.evaluate(big('[data-org-tiles]', 'agents')), '0 of 2'); + assert.equal(await b.evaluate(delta('[data-org-tiles]', 'agents')), '1 running, 1 waiting on you'); + assert.match(await text('[data-org-dashboard] .ws-split'), /Active agent sessions\s*1 running, 0 working/); + await b.evaluate('location.hash = "#/orgs/acme"'); + await wait(`location.hash.startsWith("#/orgs/acme") && (${big('[data-org-tiles]', 'agents')}) === "1 of 2"`); + assert.equal(await b.evaluate(delta('[data-org-tiles]', 'agents')), '1 running'); + assert.match(await text('[data-org-dashboard] .ws-split'), /Active agent sessions\s*1 running, 1 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]")'); diff --git a/packages/webui/tests/system-agents-browser.test.mjs b/packages/webui/tests/system-agents-browser.test.mjs index 0b153b08d..914c31f3c 100644 --- a/packages/webui/tests/system-agents-browser.test.mjs +++ b/packages/webui/tests/system-agents-browser.test.mjs @@ -17,7 +17,7 @@ const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++) const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString(); const HIRE = '0f3c1a2b-4d5e-4f60-8a7b-9c0d1e2f3a4b'; -function fixture({ empty = false } = {}) { +function fixture({ empty = false, unheld = false } = {}) { const LIST = [ { id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true }, { id: 'beta', label: 'Beta Works', theme: 'grove', busHealthy: true }, @@ -38,6 +38,8 @@ function fixture({ empty = false } = {}) { }, }; if (empty) for (const d of Object.values(DATA)) { d.instances = {}; d.agents = []; } + // `unheld`: a live designer run that holds no role session, so the bus reports no state for it. + if (unheld) DATA.acme.instances.designer = { definition: 'designer', running: ['run-5'] }; const value = (id, name) => Promise.resolve(DATA[id]?.[name] ?? (['usage', 'limits'].includes(name) ? {} : [])); const opts = id => ({ bus: { inbox: () => value(id, 'inbox'), agents: () => value(id, 'agents'), 'thread.claims': () => value(id, 'claims'), tasks: () => value(id, 'tasks'), usage: () => value(id, 'usage'), limits: () => value(id, 'limits'), decisions: () => value(id, 'decisions') }, @@ -90,8 +92,8 @@ const STUB = `(() => { }; })();`; -async function setup(t, { viewport = [1440, 900], hash = '#/agents', empty = false, stub = false, noDecide = [] } = {}) { - const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ empty }), log: () => {} }); +async function setup(t, { viewport = [1440, 900], hash = '#/agents', empty = false, unheld = false, stub = false, noDecide = [] } = {}) { + const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ empty, unheld }), log: () => {} }); t.after(() => close(server)); const b = await browser({ machine: false }); t.after(() => b.close()); await b.viewport(...viewport); @@ -132,6 +134,19 @@ test('#/agents lists both organizations\' agents in one table, each row with its assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] a[data-ws-open]\')?.getAttribute("href")'), '#/orgs/beta/agents?agent=pm'); }); +// SP11 (#1873): the page says how many of its rows run and how many work, in the words the Dashboard +// tiles use. A live agent with no reported state is "Running, state unknown", not a bare "Running" +// that reads as every running agent. +test('#/agents counts its running and working agents, and names a running agent with no reported state', async t => { + const { b, wait, text } = await setup(t, { unheld: true }); + await wait(are(['acme:chief', 'acme:coder', 'acme:designer', 'beta:pm', 'beta:research-2'])); + assert.match(await text('[data-ws-agents] .sub'), /^Every agent on the system, by organization\. 5 agents in 2 organizations: 3 running, 1 working\./); + assert.equal(await b.evaluate('document.querySelector("[data-ws-astate=running]")?.innerText.replace(/\\s+/g, " ").trim()'), 'Running, state unknown 1'); + await b.evaluate('document.querySelector("[data-ws-astate=working]").click()'); + await wait(are(['acme:coder'])); + assert.match(await text('[data-ws-agents] .sub'), /1 agent in 1 organization: 1 running, 1 working\./); +}); + test('the organization and state filters live in the hash and survive a reload', async t => { const { b, base, wait } = await setup(t); await wait(are(ALL)); -- 2.54.0 From fb3bd160520dbef367d98cfa574f3b1b02e39ae5 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 09:12:45 -0600 Subject: [PATCH 02/10] fix(webui): sp11 item 2, Done this week opens the Tasks board filtered to the tasks it counted: done=week in the address keeps done tasks last changed in the past 7 days, a chip clears it, and the count line says the bus records no time a task was done (#1873, lane sp11-fixes) --- packages/webui/src/public/tasks-board.js | 16 ++++++++-- packages/webui/src/public/workspace.js | 4 +-- .../webui/tests/kpi-tiles-browser.test.mjs | 9 ++++-- .../webui/tests/system-tasks-browser.test.mjs | 32 ++++++++++++++++--- 4 files changed, 49 insertions(+), 12 deletions(-) diff --git a/packages/webui/src/public/tasks-board.js b/packages/webui/src/public/tasks-board.js index 9bdc95273..00ca60c5d 100644 --- a/packages/webui/src/public/tasks-board.js +++ b/packages/webui/src/public/tasks-board.js @@ -41,7 +41,12 @@ // The layout is remembered per viewer and per width: a phone opens on List until its viewer picks // Kanban, a wide screen on Kanban until its viewer picks List. const layouts = (() => { try { const v = JSON.parse(load(LAYOUT) || '{}'); return v && typeof v === 'object' ? v : {}; } catch { return {}; } })(); - const filter = { org: '', project: '', assignee: '', state: '', label: '', q: '' }; + // done: 'week' shows the tasks the Dashboard's Done this week tile counts. It stays in the browser: + // the rows already carry done and their last change, the same test the tile's count uses. + const filter = { org: '', project: '', assignee: '', state: '', label: '', q: '', done: '' }; + const WEEK_MS = 7 * 24 * 3600 * 1000; + const doneThisWeek = t => t.done && t.changedAt && Date.now() - Date.parse(t.changedAt) <= WEEK_MS; + const DONE_NOTE = 'Done this week counts done tasks by their last change; the bus records no time a task was done.'; // The board style in force: the filtered project's when it has one, else the one organization's the // view is narrowed to (by the org chip or by a project of that org), else none. function activeBoard() { @@ -167,6 +172,7 @@ && (!filter.assignee || (filter.assignee === '-' ? !t.assignees.length : t.assignees.includes(filter.assignee))) && (!filter.state || stateOf(t) === filter.state) && (!filter.label || t.labels.map(String).includes(filter.label)) + && (!filter.done || doneThisWeek(t)) && (!q || !needle || `${t.title ?? ''} ${shortId(t.ref)} ${projectName(t) ?? ''}`.toLowerCase().includes(needle))); } const select = (name, label, options, focus) => ``; @@ -189,6 +195,7 @@ const labelSel = labels.length ? select('label', 'Label', opt('', 'Any', filter.label) + labels.map(l => opt(l, l, filter.label)).join(''), 'tb-label') : ''; return `
${scope ? '' : `
${chips}
`} + ${filter.done ? '' : ''} ${select('project', 'Project', projectOpts, 'tb-project')}${select('assignee', 'Assignee', assigneeOpts, 'tb-assignee')}${select('state', 'State', stateOpts, 'tb-state')}${labelSel}
`; @@ -244,8 +251,9 @@ // total; with one of those on, the line says how many match rather than inventing an "of N". // The scope's org= is where the board reads, not a filter. const onServer = new URLSearchParams(readQuery()).size > (scope ? 2 : 1), n = rows.length; - const line = onServer ? `

Showing ${n} ${n === 1 ? 'task that matches' : 'tasks that match'} the filters.

` - : n !== all ? `

Showing ${n} of ${all} tasks.

` : ''; + const note = filter.done ? ` ${DONE_NOTE}` : ''; + const line = onServer ? `

Showing ${n} ${n === 1 ? 'task that matches' : 'tasks that match'} the filters.${note}

` + : n !== all ? `

Showing ${n} of ${all} tasks.${note}

` : note ? `

${note.trim()}

` : ''; // An empty result is only exhaustive when every organization's tasks were read. const unread = snap.orgs.filter(o => !tasksRead(o)); const none = what => (unread.length ? `${what} in the organizations that answered. Tasks from ${unread.map(o => o.label).join(', ')} could not be read.` : `${what}.`); @@ -349,6 +357,7 @@ const org = String(q.get('org') || ''), project = String(q.get('project') || ''); filter.org = scope || (ORG_ID.test(org) ? org : ''); filter.project = PROJECT_SELECTOR.test(project) || project === '-' ? project : ''; + filter.done = q.get('done') === 'week' ? 'week' : ''; if (filter.org && filter.project && filter.project !== '-' && !filter.project.startsWith(`${filter.org}:`)) filter.project = ''; if (dragging) await Promise.race([dragEnded, new Promise(r => setTimeout(r, 30000))]); // Two renders at once (a first load, a poll during a filter change): the replaced one draws from the newer read. @@ -592,6 +601,7 @@ ws()?.setQuery?.({ org: org || null, ...(clear ? { project: null } : {}) }, true); return; } + if (e.target.closest('[data-tb-done]')) { filter.done = ''; paint(); ws()?.setQuery?.({ done: null }, true); return; } const open = e.target.closest('[data-tb-open]'); if (open) { const t = find(open.dataset.tbOpen); if (t) openDialog(editForm(t), open.dataset.focusKey, t); return; } const add = e.target.closest('[data-tb-new]'); diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index 851de9047..9ce8dd145 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -308,7 +308,7 @@ ${orgTile('agents', 'Agents working', `#/agents?${oq}&state=working`, agentsWhy, { big: working, small: `of ${orgAgents?.length ?? 0}`, delta: orgAgents ? agentNote(runningOf(orgAgents), waiting) : '' })} ${orgTile('tasks', 'Open tasks', `#/tasks?${oq}`, whyNot(['tasks']), { big: num(mine?.openTasks) })} ${orgTile('needs', 'Needs you', `#/inbox?${oq}`, whyNot(['inbox']), { big: num(mine?.needsYou), small: num(mine?.needsYou) === 1 ? 'item' : 'items', delta: num(mine?.needsYou) && mine?.oldestNeedsYouAt ? `oldest ${span(mine.oldestNeedsYouAt)}` : 'nothing waiting' })} - ${orgTile('done', 'Done this week', `#/tasks?${oq}`, whyNot(['tasks']), { big: num(mine?.doneThisWeek) })} + ${orgTile('done', 'Done this week', `#/tasks?${oq}&done=week`, whyNot(['tasks']), { big: num(mine?.doneThisWeek) })}
`; const modelOf = inst => business?.business?.instances?.find(i => i?.instance === inst)?.vars?.model; // SP11: the sessions are the running rows the tile counts: the organization's instances and the @@ -683,7 +683,7 @@ ${kpiTile(o, { key: 'orgs', name: 'Organizations', href: '#/dashboard', big: answered, small: `of ${o.rows.length} running`, delta: o.unread.length ? andList(o.unread.map(r => `${esc(o.name(r.org))} ${offWord(r)}`)) : 'every bus answered', attrs: ' data-ws-jump="orgs"' })} ${kpiTile(o, { key: 'needs', name: 'Needs you', href: '#/inbox', big: needsN, small: needsN === 1 ? 'item' : 'items', parts: ps.needs, delta: needsN && t?.oldestNeedsYouAt ? `oldest ${span(t.oldestNeedsYouAt)}` : 'nothing waiting', miss: notIn(SRC.needs), ...noFig })} ${kpiTile(o, { key: 'tasks', name: 'Open tasks', href: '#/tasks', big: fig('tasks', ps.tasks), parts: ps.tasks, miss: notIn(SRC.tasks), ...noFig })} - ${kpiTile(o, { key: 'done', name: 'Done this week', href: '#/tasks', big: fig('done', ps.done), parts: ps.done, miss: notIn(SRC.done), ...noFig })} + ${kpiTile(o, { key: 'done', name: 'Done this week', href: '#/tasks?done=week', big: fig('done', ps.done), parts: ps.done, miss: notIn(SRC.done), ...noFig })} ${kpiTile(o, { key: 'projects', name: 'Projects', href: selHref('/projects'), big: fig('projects', ps.projects), small: 'active', parts: ps.projects, delta: `${archived} archived`, miss: notIn(SRC.projects), ...noFig })}
`; diff --git a/packages/webui/tests/kpi-tiles-browser.test.mjs b/packages/webui/tests/kpi-tiles-browser.test.mjs index 63d06cac0..c65988ac3 100644 --- a/packages/webui/tests/kpi-tiles-browser.test.mjs +++ b/packages/webui/tests/kpi-tiles-browser.test.mjs @@ -110,7 +110,7 @@ test('The Dashboard tiles add both answering organizations\' numbers, one row, e assert.equal(links.orgs, '#/dashboard'); assert.equal(links.needs, '#/inbox'); assert.equal(links.tasks, '#/tasks'); - assert.equal(links.done, '#/tasks'); + assert.equal(links.done, '#/tasks?done=week'); assert.match(links.projects, /^#\/orgs\/[a-z0-9._-]+\/projects$/); // No sample numbers and no trend line: no tile draws an svg sparkline. assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-tiles] svg:not(.i)").length'), 0); @@ -143,6 +143,11 @@ test('Following a tile lands on its page showing what the tile counted', async t await wait('location.hash === "#/inbox" && document.querySelectorAll("[data-ws-item]").length === 3'); await b.evaluate('location.hash = "#/dashboard"'); await wait(homeReady); + // SP11: Done this week lands on the board showing the one task it counted, beta's Logo. + await b.evaluate('document.querySelector("[data-ws-tile=done]").click()'); + await wait('location.hash === "#/tasks?done=week" && JSON.stringify([...document.querySelectorAll("[data-tasks-board] [data-tb-task]")].map(e => e.dataset.tbTask)) === JSON.stringify(["beta|local:9"])'); + await b.evaluate('location.hash = "#/dashboard"'); + await wait(homeReady); // The Organizations tile's list is this page's own table: following it keeps the page and moves focus there. await b.evaluate('document.querySelector("[data-ws-tile=orgs]").click()'); await wait('document.activeElement?.closest?.("[data-ws-orgs-h]")'); @@ -183,7 +188,7 @@ test('An organization page leads with its own tiles, each linking to the system assert.equal(await b.evaluate(big('[data-org-tiles]', 'tasks')), '2'); assert.equal(await b.evaluate(big('[data-org-tiles]', 'needs')), '2 items'); assert.equal(await b.evaluate(big('[data-org-tiles]', 'done')), '1'); - assert.deepEqual(await b.evaluate(hrefs('[data-org-tiles]')), { agents: '#/agents?org=beta&state=working', tasks: '#/tasks?org=beta', needs: '#/inbox?org=beta', done: '#/tasks?org=beta' }); + assert.deepEqual(await b.evaluate(hrefs('[data-org-tiles]')), { agents: '#/agents?org=beta&state=working', tasks: '#/tasks?org=beta', needs: '#/inbox?org=beta', done: '#/tasks?org=beta&done=week' }); // The empty KPI and metric placeholders are gone; there is one tile row. assert.doesNotMatch(await text('[data-org-dashboard]'), /No KPIs defined|No metrics recorded yet/); assert.equal(await b.evaluate('document.querySelectorAll("[data-org-dashboard] .ws-tiles, [data-org-dashboard] [data-org-tiles]").length'), 1); diff --git a/packages/webui/tests/system-tasks-browser.test.mjs b/packages/webui/tests/system-tasks-browser.test.mjs index 70082c723..9ddc5712b 100644 --- a/packages/webui/tests/system-tasks-browser.test.mjs +++ b/packages/webui/tests/system-tasks-browser.test.mjs @@ -18,7 +18,7 @@ const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++) const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString(); const D = n => String(n).repeat(64); -function fixture({ empty = false, archived = [] } = {}) { +function fixture({ empty = false, archived = [], done = false } = {}) { const LIST = [ { id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true }, { id: 'beta', label: 'Beta Works', theme: 'grove', busHealthy: true }, @@ -33,9 +33,11 @@ function fixture({ empty = false, archived = [] } = {}) { beta: [{ n: 2, title: 'Price page', state: 'in-progress', group: 'shop', assignees: [] }, { n: 3, title: 'Spring banner', state: 'todo', group: 'ads', assignees: [] }], }; if (empty) { STORE.acme = []; STORE.beta = []; } + // `done`: one task finished two days ago and one ten days ago; only the first is done this week. + if (done) { STORE.acme.push({ n: 4, title: 'Old logo', state: 'done', group: 'site', assignees: [], ago: 60 * 24 * 10 }); STORE.beta.push({ n: 5, title: 'Launch email', state: 'done', group: 'shop', assignees: [], ago: 60 * 24 * 2 }); } for (const [org, list] of Object.entries(PROJECTS)) for (const p of list) p.archived = archived.includes(`${org}:${p.id}`); - const tasks = id => (STORE[id] || []).map(t => ({ source: 'store', task_ref: `task:${t.n}`, at: at(10), priority: 0, digest: D(t.n), requirement: 'REQ-WEB-1', - fields: { title: t.title, state: t.state, group: t.group, assignees: t.assignees, due_date: null } })); + const tasks = id => (STORE[id] || []).map(t => ({ source: 'store', task_ref: `task:${t.n}`, at: at(t.ago ?? 10), priority: 0, digest: D(t.n), requirement: 'REQ-WEB-1', + fields: { title: t.title, state: t.state, group: t.group, assignees: t.assignees, due_date: null, ...(t.state === 'done' ? { done: true } : {}) } })); const writes = []; const taskWrite = org => async (verb, args) => { writes.push([org, verb, args]); return { task_ref: args.task_ref, digest: D(9) }; }; const opts = id => ({ @@ -81,8 +83,8 @@ const STUB = noWrite => `window.__noWrite = ${JSON.stringify(noWrite)}; }; })();`; -async function setup(t, { viewport = [1440, 900], hash = '#/tasks', empty = false, noWrite = [], archived = [] } = {}) { - const orgs = fixture({ empty, archived }); +async function setup(t, { viewport = [1440, 900], hash = '#/tasks', empty = false, noWrite = [], archived = [], done = false } = {}) { + const orgs = fixture({ empty, archived, done }); const server = await startServer({ port: 0, sso: createDevOwner(), organizations: orgs, send: async () => ({}), log: () => {} }); t.after(() => close(server)); const b = await browser({ machine: false, args: ['--disable-extensions'] }); t.after(() => b.close()); @@ -118,6 +120,26 @@ test('#/tasks shows both organizations\' tasks on one board, each card with its assert.equal(await b.evaluate('document.querySelector("#bus-view .s1-stale")'), null); }); +// SP11 (#1873): the Dashboard's Done this week tile opens the board on the tasks it counted: done, and +// changed in the last seven days. The bus keeps no time a task was done, so the filter says it reads +// the last change, the time the tile counts by. The filter lives in the address and clears. +test('#/tasks?done=week shows only the tasks Done this week counted, says what it counts, and clears', async t => { + const { b, base, wait, text } = await setup(t, { hash: '#/tasks?done=week', done: true }); + await wait(`${cards} === ${JSON.stringify(JSON.stringify(['beta|task:5']))}`); + assert.match(await text('[data-tb-done]'), /^Done this week\s+×$/); + assert.match(await b.evaluate('document.querySelector("[data-tb-done]").getAttribute("aria-label")'), /^Clear Done this week: done tasks last changed in the past 7 days$/); + assert.match(await text('[data-tasks-board] .tb-count'), /^Showing 1 of 5 tasks\. Done this week counts done tasks by their last change; the bus records no time a task was done\.$/); + // It survives a reload and joins the organization filter. + await b.navigate(`${base}/#/tasks?org=acme&done=week`); + await wait('document.querySelector("[data-tb-done]") && document.querySelector("[data-tasks-board] .ws-panel")?.textContent.includes("No tasks match these filters")'); + await b.evaluate('location.hash = "#/tasks?done=week"'); + await wait(`${cards} === ${JSON.stringify(JSON.stringify(['beta|task:5']))}`); + // Clearing it drops it from the address and shows every task again. + await b.evaluate('document.querySelector("[data-tb-done]").click()'); + await wait(`location.hash === "#/tasks" && ${cards} === ${JSON.stringify(JSON.stringify(['acme|task:1', 'acme|task:4', 'beta|task:2', 'beta|task:3', 'beta|task:5']))}`); + assert.equal(await b.evaluate('document.querySelector("[data-tb-done]")'), null); +}); + test('the organization and project filters live in the address and survive a reload', async t => { const { b, base, wait } = await setup(t); await wait(`${cards} === ${ALL}`); -- 2.54.0 From d50d79cdddb655969f4e2f3a8825f7aec08d3147 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 09:19:14 -0600 Subject: [PATCH 03/10] fix(webui): sp11 item 3, Resume assistant shows only when the bus instances read says the thread's agent runs no process; a failed read or an unlisted agent keeps the control (#1873, lane sp11-fixes) --- packages/webui/src/public/chat.js | 22 ++++++++++++++++--- .../tests/assistant-threads-browser.test.mjs | 22 ++++++++++++++++++- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/packages/webui/src/public/chat.js b/packages/webui/src/public/chat.js index 6f55818bd..e5d0c284a 100644 --- a/packages/webui/src/public/chat.js +++ b/packages/webui/src/public/chat.js @@ -90,6 +90,16 @@ signedOut = true; forget(); document.dispatchEvent(new Event('mosaic-signed-out')); } + // SP11: whether the thread's agent runs no process, from the bus instances read the Agents page uses. + // true: paused or stopped; false: running; null: the read failed or does not list the agent. + async function agentIdle(instance, signal) { + try { + const res = await fetch(pane ? `/api/orgs/${encodeURIComponent(env.org)}/bus/instances` : '/api/bus/instances', { cache: 'no-store', redirect: 'manual', signal }); + if (!res.ok || res.type === 'opaqueredirect') return null; + const item = (await res.json())?.rows?.[0]?.instances?.[instance]; + return item && typeof item === 'object' && Array.isArray(item.running) ? item.running.length === 0 : null; + } catch (err) { if (err.name === 'AbortError') throw err; return null; } + } async function request(path, value, signal) { if (out()) throw new ChatError('signed-out'); let res; @@ -317,8 +327,10 @@ const messages = s.rows.map((m, i) => `
  • ${txt(m.from_role === 'human' ? 'You' : m.from_role)} ${stamp(m.at)}
    ${messageBody(m)}${!redesigned && withRequest.has(m.id) ? mount(m) : ''}
  • ${redesigned ? (mountsAfter.get(i) || []).map(origin => `
  • ${mount(origin)}
  • `).join('') : ''}`).join(''); const frame = pane ? '' : redesigned ? window.MosaicProjects.header(project, 'conversations', current.threads, t.name, true) : heading(t.name); // A pane leaves out the details: sessions and sharing are unavailable on an organization Console. - const resume = threadWrites() ? `` : ''; - const details = pane ? `

    Reading never wakes the assistant. An accepted message is not an answer.

    ` : redesigned ? `

    Conversation details

    ${sessionControls(t)}${sharing(t)}

    Reading never wakes the assistant. An accepted message is not an answer.

    ${threadWrites() ? `` : ''}
    ` : `
    Conversation details${sessionControls(t)}${sharing(t)}

    Reading never wakes the assistant. An accepted message is not an answer.

    ${threadWrites() ? `` : ''}`; + // SP11: Resume shows unless the instances read says the agent is running. + const resumable = threadWrites() && current.agentIdle !== false; + const resume = resumable ? `` : ''; + const details = pane ? `

    Reading never wakes the assistant. An accepted message is not an answer.

    ` : redesigned ? `

    Conversation details

    ${sessionControls(t)}${sharing(t)}

    Reading never wakes the assistant. An accepted message is not an answer.

    ${resumable ? `` : ''}
    ` : `
    Conversation details${sessionControls(t)}${sharing(t)}

    Reading never wakes the assistant. An accepted message is not an answer.

    ${resumable ? `` : ''}`; return `
    ${pane ? '' : `

    ${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}

    `}${frame}${libraryLine(libraryOf(current, t))}${details}${redesigned ? '

    ' : ''}
      ${messages || (current.readFailed ? '
    1. Messages unavailable.
    2. ' : '
    3. No messages yet.
    4. ')}
    ${current.readFailed ? '' : s.more ? '

    Showing earlier history. Read forward to reach the current conversation.

    ' : '

    Caught up to the last read.

    '}
    ${threadWrites() ? `

    To: ${redesigned ? `${txt(t.instance)}` : `${txt(t.instance)} · Private conversation`}

    ${redesigned ? '

    Enter adds a new line.

    ' : ''}
    ` : ''}

    ${txt(receipt)}

    ${pane ? resume : ''}${redesigned || pane ? '' : '

    Files

    Files unavailable.
    '}
    `; } // C2-10c: rooms reuse the same state, renderer and generation fences as threads. @@ -789,7 +801,7 @@ paint(); return; } const old = current; - current = { container, kind, id, orgArchived: old?.orgArchived ?? false, loading: true, archiveUnknown: kind === 'thread' && !user && !tester, settings: !!settings, section, path: originalPath, canSend, user, tester, chatGrants: [], thread: old && old.id === id ? old.thread : null, shared: old && old.id === id ? old.shared : false, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', readFailed: false, channelDisabled: old?.channelDisabled ?? false, refused: false, legacyDefault: old && old.id === id ? old.legacyDefault : false, sessions: old && old.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old && old.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old && old.id === id ? old.key : id }; + current = { container, kind, id, orgArchived: old?.orgArchived ?? false, loading: true, archiveUnknown: kind === 'thread' && !user && !tester, settings: !!settings, section, path: originalPath, canSend, user, tester, chatGrants: [], thread: old && old.id === id ? old.thread : null, shared: old && old.id === id ? old.shared : false, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', readFailed: false, channelDisabled: old?.channelDisabled ?? false, refused: false, legacyDefault: old && old.id === id ? old.legacyDefault : false, sessions: old && old.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old && old.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old && old.id === id ? old.key : id, agentIdle: old && old.id === id ? old.agentIdle : null }; current.projectArchived = current.projects.some(p => p.id === (kind === 'project' ? id : current.thread?.project) && p.archived); if (kind === 'thread' && old?.kind === 'thread' && old.id === id && !navigated && old.archiveUnknown === false && old.projects.some(p => p.id === old.thread?.project)) current.archiveUnknown = false; if (current.projectArchived) current.archiveUnknown = false; @@ -873,6 +885,10 @@ if (g !== generation || out()) return; current.shares = shares.rows; } + if (kind === 'thread' && current.thread && threadWrites()) { + current.agentIdle = await agentIdle(current.thread.instance, signal); + if (g !== generation || out()) return; + } let s = null, page = null, after = null; if (kind === 'thread' && catalog === null) { current.legacyDefault = true; current.sessions = []; current.session = null; diff --git a/packages/webui/tests/assistant-threads-browser.test.mjs b/packages/webui/tests/assistant-threads-browser.test.mjs index e265c43d6..096634248 100644 --- a/packages/webui/tests/assistant-threads-browser.test.mjs +++ b/packages/webui/tests/assistant-threads-browser.test.mjs @@ -42,7 +42,7 @@ function orgs(state) { reads: { business: async () => ({ business: { id } }), settings: async () => ({}), busStatus: async () => ({ live: true, down: false }), queue: async () => ({ rows: [] }) }, bus: { inbox: async () => [], agents: async () => [], tasks: async () => [], 'thread.claims': async () => [], messages: async () => ({ messages: [], unread: 0 }), - instances: async () => ({ business: id, revision: 'a'.repeat(64), definitions: {}, instances: { assistant: { definition: 'assistant', vars: {} } } }), + instances: async () => ({ business: id, revision: 'a'.repeat(64), definitions: {}, instances: { assistant: { definition: 'assistant', vars: {}, ...(state.run?.[id] ? { running: state.run[id] } : {}) } } }), }, chat: { projects: async () => PROJECTS[id], @@ -304,6 +304,26 @@ test('SP7: a thread link on a project page opens the thread as a pane on the Ass assert.equal(await b.evaluate('location.hash'), `#/orgs/beta/projects/threads/${T.b1}`); }); +// SP11 (#1873): Resume assistant shows only when the organization's instances read says the thread's +// agent runs no process (paused or stopped). An agent the read does not say about keeps the control. +test('Resume assistant shows only in a pane whose agent the instances read says is not running', async t => { + const state = { down: new Set(), sent: [], created: [], run: { alpha: ['run-1'], beta: [] } }; + const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1},home:${T.h1}`, state }); + const resume = sel => `!!${PANE(sel)}.querySelector('button.pane-resume')`; + await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)} && ${ready(`home:${T.h1}`)}`); + await wait(resume(`beta:${T.b1}`)); + assert.equal(await b.evaluate(resume(`alpha:${T.a1}`)), false, 'a running assistant offers no resume'); + assert.equal(await b.evaluate(resume(`home:${T.h1}`)), true, 'a read that does not say keeps the control'); + // Alpha's assistant stops: the next read shows the control. + state.run.alpha = []; + await b.evaluate('window.MosaicAssistant.refresh()'); + await wait(resume(`alpha:${T.a1}`)); + // Beta's starts again: its control goes. + state.run.beta = ['run-2']; + await b.evaluate('window.MosaicAssistant.refresh()'); + await wait(`!${resume(`beta:${T.b1}`)}`); +}); + test('SP7: with three open, a fourth thread replaces the first pane with no unsent message; when all three hold one, nothing is replaced and the page says why', async t => { const state = { down: new Set(), sent: [], created: [] }; const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}`, state }); -- 2.54.0 From 9cff3255e6586973734330339991ef01b9976ec2 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 09:23:20 -0600 Subject: [PATCH 04/10] fix(webui): sp11 items 4 and 5, a pane's unsent message is kept per thread for the tab's life and comes back when the pane opens again, Full view included; closing a pane with one asks first; a pane whose send waits for its receipt is held when a fourth thread opens (#1873, lane sp11-fixes) --- packages/webui/src/public/assistant.js | 32 +++++++-- packages/webui/src/public/chat.js | 8 ++- packages/webui/src/public/display.js | 3 +- .../tests/assistant-threads-browser.test.mjs | 68 ++++++++++++++++++- 4 files changed, 100 insertions(+), 11 deletions(-) diff --git a/packages/webui/src/public/assistant.js b/packages/webui/src/public/assistant.js index 27abd59de..aa9786502 100644 --- a/packages/webui/src/public/assistant.js +++ b/packages/webui/src/public/assistant.js @@ -299,9 +299,24 @@ } // ---------- panes ---------- + // SP11: a pane's unsent message is kept per thread for the life of the browser tab, so leaving the + // Assistant page (a Full view link included) and coming back puts it in the composer again. + // sessionStorage can be missing or refuse a write; the Map still keeps it while the page lives. + const DRAFTS = 'mosaic-assistant-drafts'; + const kept = (() => { + try { + const v = JSON.parse(sessionStorage.getItem(DRAFTS) || 'null'); + return new Map(v && typeof v === 'object' && !Array.isArray(v) ? Object.entries(v).filter(([k, t]) => typeof t === 'string' && t.trim() && parse(`open=${k}`).keys[0] === k) : []); + } catch { return new Map(); } + })(); + 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 || ''; + function keep(p) { 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); }); function makePane(key) { const { org, id } = split(key); - const p = { key, org, id, n: ++paneSeq, el: null, chat: null, first: true, title: null, draft: '', held: false, seq: 0 }; + const p = { key, org, id, n: ++paneSeq, el: null, chat: null, first: true, title: null, draft: kept.get(key) || '', held: false, seq: 0 }; const box = mounted.root.querySelector('[data-assist-panes]'); box.insertAdjacentHTML('beforeend', paneShell(p)); p.el = box.lastElementChild; @@ -320,7 +335,9 @@ } // SP7: a thread opened from a link takes a free slot or, with all three taken, the place of the // first pane that holds no unsent message. When every pane holds one, nothing is replaced. - const holds = p => !!p.draft.trim() || !!p.chat?.hasDraft(); + // SP11: a pane whose send is in flight, or whose sent message the conversation does not show yet, + // is held too, until its receipt is in. + const holds = p => !!p.draft.trim() || !!p.chat?.hasDraft() || !!p.chat?.busy?.(); function place(key) { const had = mounted.panes.find(p => p.key === key); if (had) return had; @@ -328,11 +345,11 @@ const old = mounted.panes.find(p => !holds(p)); if (!old) return null; const p = makePane(key); - old.chat?.dispose(); old.chat = null; old.el.replaceWith(p.el); + drop(old); old.chat?.dispose(); old.chat = null; old.el.replaceWith(p.el); mounted.panes[mounted.panes.indexOf(old)] = p; return p; } - const REFUSED = `That thread was not opened: all ${MAX} panes hold an unsent message. Send or clear one, or close a pane, then open the thread again.`; + const REFUSED = `That thread was not opened: all ${MAX} panes hold an unsent message or a message waiting to show. Send or clear one, wait for a sent message to show, or close a pane, then open the thread again.`; function add(key) { if (!mounted) return false; const p = place(key); @@ -360,10 +377,13 @@ else location.hash = `#/assistant?add=${key}`; }); + // SP11: closing a pane that holds unsent text asks first; a close drops the kept text. function closePane(p) { if (!mounted || !p) return; const at = mounted.panes.indexOf(p); if (at < 0) return; + if (typedIn(p).trim() && !window.confirm(`Close ${p.title || 'this thread'}? Its unsent message is lost.`)) return; + drop(p); mounted.notice = ''; p.chat?.dispose(); p.chat = null; p.el.remove(); mounted.panes.splice(at, 1); @@ -484,7 +504,7 @@ function unmount() { if (!mounted) return; clearTimeout(mounted.timer); - for (const p of mounted.panes) p.chat?.dispose(); + for (const p of mounted.panes) { keep(p); p.chat?.dispose(); } window.removeEventListener('resize', fit); mounted = null; mountGen++; } @@ -495,7 +515,7 @@ if (typeof query === 'string') { const want = parse(query); if (want.named && want.keys.join(',') !== mounted.panes.map(p => p.key).join(',')) { - for (const p of [...mounted.panes]) if (!want.keys.includes(p.key)) { p.chat?.dispose(); p.el.remove(); } + for (const p of [...mounted.panes]) if (!want.keys.includes(p.key)) { keep(p); p.chat?.dispose(); p.el.remove(); } const kept = new Map(mounted.panes.map(p => [p.key, p])); mounted.panes = want.keys.map(k => kept.get(k) || makePane(k)); const box = mounted.root.querySelector('[data-assist-panes]'); diff --git a/packages/webui/src/public/chat.js b/packages/webui/src/public/chat.js index e5d0c284a..5faf149cf 100644 --- a/packages/webui/src/public/chat.js +++ b/packages/webui/src/public/chat.js @@ -1290,12 +1290,16 @@ } on('mosaic-signed-out', () => { signedOut = true; forget(); }); const hasDraft = () => [...states.values()].some(s => Boolean(s.draft?.trim())) || [...forms.values()].some(f => Boolean(f.name?.trim() || f.repo || f.instanceChosen)); + // SP11: the unsent text of a pane's thread, and whether a write is in flight or a sent message + // still waits for the conversation to show it. + const draft = () => [...states.values()].find(s => s.draft?.trim())?.draft || ''; + const busy = () => [...states.values()].some(s => s.pending || !!s.receiptId); // Ends a pane instance: its listeners, reads and observers stop and its container is left as is. function dispose() { life.abort(); generation++; controller?.abort(); transcriptObserver?.disconnect(); transcriptResize?.disconnect(); current = null; lastPaint = null; } - return { render, leave, forget, refresh, projectPath, hasDraft, dispose }; + return { render, leave, forget, refresh, projectPath, hasDraft, draft, busy, dispose }; } const main = chatInstance(); const ORG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; @@ -1304,7 +1308,7 @@ function pane(org) { if (typeof org !== 'string' || !ORG.test(org)) throw new TypeError('pane needs an organization id'); const chat = chatInstance({ org, prefix: `chat-p${++panes}-` }); - return Object.freeze({ render: chat.render, refresh: chat.refresh, dispose: chat.dispose, hasDraft: chat.hasDraft }); + return Object.freeze({ render: chat.render, refresh: chat.refresh, dispose: chat.dispose, hasDraft: chat.hasDraft, draft: chat.draft, busy: chat.busy }); } window.MosaicChat = Object.freeze({ render: main.render, leave: main.leave, forget: main.forget, projectPath: main.projectPath, hasDraft: main.hasDraft, pane }); })(); diff --git a/packages/webui/src/public/display.js b/packages/webui/src/public/display.js index 7a9978fae..df2ed3ed9 100644 --- a/packages/webui/src/public/display.js +++ b/packages/webui/src/public/display.js @@ -118,7 +118,8 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date( return read; }; let previous = location.hash, skipGuard = false; - const dirty = () => window.MosaicChat?.hasDraft?.() || window.MosaicAssistant?.hasDraft?.() || window.MosaicOrgCreate?.hasDraft?.() || window.MosaicWorkflows?.hasDraft?.() || [...document.querySelectorAll('#reply, #conv-reply, #doc-source')].some(el => !!el.value?.trim()); + // SP11: the Assistant keeps its panes' unsent text per thread, so it is not asked about here. + const dirty = () => window.MosaicChat?.hasDraft?.() || window.MosaicOrgCreate?.hasDraft?.() || window.MosaicWorkflows?.hasDraft?.() || [...document.querySelectorAll('#reply, #conv-reply, #doc-source')].some(el => !!el.value?.trim()); // A switch scopes later reads to the new organization; listeners drop what they held for the old one. function rescope(seg) { const from = current; diff --git a/packages/webui/tests/assistant-threads-browser.test.mjs b/packages/webui/tests/assistant-threads-browser.test.mjs index 096634248..d2ac8924a 100644 --- a/packages/webui/tests/assistant-threads-browser.test.mjs +++ b/packages/webui/tests/assistant-threads-browser.test.mjs @@ -51,7 +51,7 @@ function orgs(state) { shares: async () => [], messages: async ({ thread }) => [ ...(MESSAGES[thread] ? [{ id: thread.replace(/^2/, '3'), from_role: 'assistant', body: MESSAGES[thread], at: '2026-11-15T10:00:00.000Z' }] : []), - ...state.sent.filter(s => s.org === id && s.thread === thread).map(s => ({ id: s.id, from_role: 'human', body: s.body, at: '2026-11-15T10:01:00.000Z' })), + ...(state.hideSent ? [] : state.sent).filter(s => s.org === id && s.thread === thread).map(s => ({ id: s.id, from_role: 'human', body: s.body, at: '2026-11-15T10:01:00.000Z' })), ...(state.replies || []).filter(r => r.org === id && r.thread === thread).map((r, n) => ({ id: `50000000-0000-4000-8000-${String(n + 1).padStart(12, '0')}`, from_role: 'assistant', body: r.body, at: '2026-11-15T10:02:00.000Z' })), ], send: async ({ thread, body }) => { @@ -351,8 +351,11 @@ test('SP7: with three open, a fourth thread replaces the first pane with no unse assert.deepEqual(await b.evaluate(panes), [`alpha:${T.a1}`, `home:${T.h1}`, `alpha:${T.a2}`]); assert.deepEqual(await b.evaluate(`[${field(`alpha:${T.a1}`)}.value, ${field(`home:${T.h1}`)}.value, ${field(`alpha:${T.a2}`)}.value]`), ['alpha draft', 'home draft', 'kickoff draft']); assert.equal(await b.evaluate(query), `alpha:${T.a1},home:${T.h1},alpha:${T.a2}`); - // Closing a pane clears the reason; the thread then opens. + // Closing a pane clears the reason; the thread then opens. SP11: the pane holds a message, so the + // close asks first. + await b.evaluate(`window.__asked = []; window.confirm = m => (window.__asked.push(m), true)`); await b.evaluate(`${PANE(`home:${T.h1}`)}.querySelector('button[data-assist-close]').click()`); + assert.equal(await b.evaluate('window.__asked.length'), 1); await wait(`!/not opened/.test(document.querySelector('[data-assist-note]').textContent)`); assert.equal(await b.evaluate(`document.querySelector('[data-assist-note]').hasAttribute('data-refused')`), false); await follow(b, `#/orgs/beta/projects/threads/${T.b1}`); @@ -371,6 +374,67 @@ test('SP7: each pane names its own organization\'s Messages page, and the bar no assert.doesNotMatch(await b.evaluate(`document.querySelector('.assist-bar').innerText`), /Messages/); }); +// SP11 (#1873): a pane's unsent message is kept per thread while the tab is open. Leaving the +// Assistant page, by the sidebar or by a pane's Full view into another organization, keeps it, and the +// pane shows it again when it opens. Closing a pane that holds one asks first. +test('SP11: an unsent pane message survives leaving the Assistant page, through Full view too, and closing a pane with one asks first', async t => { + const state = { down: new Set(), sent: [], created: [] }; + const a1 = `alpha:${T.a1}`, b1 = `beta:${T.b1}`, both = `#/assistant?open=${a1},${b1}`; + const { b, wait } = await open(t, { hash: both, state }); + await wait(`${ready(a1)} && ${ready(b1)} && !${field(a1)}.disabled && !${field(b1)}.disabled`); + await type(b, a1, 'alpha keep'); + await type(b, b1, 'beta keep'); + // Away to Tasks and back. + await b.evaluate(`location.hash = '#/tasks'`); + await wait(`!document.querySelector('[data-assist-pane]') && !!document.querySelector('[data-tasks-board]')`); + await b.evaluate(`location.hash = ${JSON.stringify(both)}`); + await wait(`${field(a1)}?.value === 'alpha keep' && ${field(b1)}?.value === 'beta keep'`); + // Away through Beta's Full view: no discard question, and the message is still there after. + await b.evaluate(`${PANE(b1)}.querySelector('a[data-assist-pane-full]').click()`); + await wait(`location.hash.startsWith('#/orgs/beta/') && !document.querySelector('[data-assist-pane]')`); + assert.equal(await b.evaluate(`!!document.querySelector('dialog[open]')`), false, 'leaving asks nothing: the message is kept'); + await b.evaluate(`location.hash = ${JSON.stringify(both)}`); + await wait(`${field(a1)}?.value === 'alpha keep' && ${field(b1)}?.value === 'beta keep'`); + // Closing a pane with an unsent message asks; saying no keeps the pane. + await b.evaluate(`window.__asked = []; window.confirm = m => (window.__asked.push(m), false)`); + await b.evaluate(`${PANE(a1)}.querySelector('button[data-assist-close]').click()`); + assert.deepEqual(await b.evaluate('window.__asked'), ['Close Launch email? Its unsent message is lost.']); + assert.deepEqual(await b.evaluate(panes), [a1, b1]); + // Saying yes closes it and drops the message: the thread opens again empty. + await b.evaluate(`window.confirm = m => (window.__asked.push(m), true)`); + await b.evaluate(`${PANE(a1)}.querySelector('button[data-assist-close]').click()`); + await wait(`document.querySelectorAll('[data-assist-pane]').length === 1`); + await follow(b, `#/orgs/alpha/projects/threads/${T.a1}`); + await wait(`${ready(a1)} && !!${field(a1)} && !${field(a1)}.disabled`); + assert.equal(await b.evaluate(`${field(a1)}.value`), ''); + // A pane with nothing typed closes with no question. + await b.evaluate(`${PANE(a1)}.querySelector('button[data-assist-close]').click()`); + await wait(`document.querySelectorAll('[data-assist-pane]').length === 1`); + assert.equal(await b.evaluate('window.__asked.length'), 2); + 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 => { + const state = { down: new Set(), sent: [], created: [], hideSent: true }; + const a1 = `alpha:${T.a1}`, b1 = `beta:${T.b1}`, a2 = `alpha:${T.a2}`, h1 = `home:${T.h1}`; + const { b, wait } = await open(t, { hash: `#/assistant?open=${a1},${b1},${a2}`, state }); + await wait(`${ready(a1)} && ${ready(b1)} && ${ready(a2)} && !${field(a1)}.disabled`); + await b.evaluate(`(() => { const el = ${field(a1)}; el.value = 'in flight'; el.dispatchEvent(new Event('input', { bubbles: true })); el.form.requestSubmit(); })()`); + await wait(`/Accepted message/.test(${PANE(a1)}.innerText) && ${field(a1)}.value === ''`); + await follow(b, `#/orgs/home/projects/threads/${T.h1}`); + await wait(ready(h1)); + assert.deepEqual(await b.evaluate(panes), [a1, h1, a2], 'the waiting pane stays; the first free one gives way'); + // The message shows: the receipt is in and the pane is free again. + state.hideSent = false; + await b.evaluate('window.MosaicAssistant.refresh()'); + await wait(`/Message shown in this conversation/.test(${PANE(a1)}.innerText)`); + await follow(b, `#/orgs/beta/projects/threads/${T.b1}`); + await wait(ready(b1)); + assert.deepEqual(await b.evaluate(panes), [b1, h1, a2]); +}); + // SP7 review: a pane has no Request details, sessions or sharing, so each pane links its thread's full // view, and a link marked data-full-view is not turned into a pane. test('SP7: each pane links its thread\'s full view, and one plain click reaches it', async t => { -- 2.54.0 From 8aefc0f7a9982a9258c6a4ab7cc7b7c6faeee40c Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 09:28:29 -0600 Subject: [PATCH 05/10] fix(webui): sp11 item 6, an organization's decision page, decision list, replies and messages name a decision class in the Inbox's words from the same map; message classes the map does not know show as they are (#1873, lane sp11-fixes) --- packages/webui/src/public/bus.js | 10 ++++++---- packages/webui/src/public/workspace.js | 6 ++++-- .../tests/workspace-pages-browser.test.mjs | 19 ++++++++++++++++++- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index c9cc4ad79..49ee77e42 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -208,7 +208,7 @@ out.groupAs = m?.from_role === 'human' ? 'request' : m?.decision ? 'decision' : 'other'; } else if (r.table === 'decisions') { out.kind = 'decision.raised'; out.decision = r.id; out.actor = { role: r.raised_by_role, run: r.raised_by_run }; - out.text = `Raised a ${r.class} decision for ${r.route_to}: ${String(r.question).split('\n')[0]}`; + out.text = `Raised a ${String(classWord(r.class)).toLowerCase()} decision for ${r.route_to}: ${String(r.question).split('\n')[0]}`; } else if (r.table === 'decision_events') { // The human answers from the CLI (via cli) or, since row 252 (#1753), a grant request from the // signed-in Console (via console); every other row is the broker's. @@ -243,7 +243,9 @@ const badge = (text, kind = 'muted') => `${txt(text)}`; // Class chip: the decision class, the action in mono. BLOCKING is its own chip (CSS capitalises it; the text stays lowercase). const CLASSES = ['routine', 'within-role', 'cross-role', 'gated']; - const chip = (cls, action) => `${txt(cls)} · ${txt(action)}`; + // SP11: the class in the Inbox's words, from workspace.js's map. + const classWord = cls => window.MosaicWorkspace?.classWord?.(cls) ?? cls; + const chip = (cls, action) => `${txt(classWord(cls))} · ${txt(action)}`; const blockChip = text => `${txt(text)}`; // An empty state says what would appear and where it comes from. const empty = (what, from) => `
    ${what}${from ? `

    ${from}

    ` : ''}
    `; @@ -354,7 +356,7 @@ function repliesHtml() { if (replies === null) return '

    The replies to it could not be read. Read again to retry; mosaic inbox in a terminal lists them.

    '; if (!replies.length) return '

    No reply yet. One shows here when the role answers, as the page refreshes.

    '; - return `

    Replies ${replies.length}

      ${replies.map(m => `
    1. ${txt(m.from_role)} ${when(m.at)} · ${txt(m.class)} · trail

      ${txt(m.body)}

    2. `).join('')}
    ${replies.length >= REPLIES ? `

    The first ${REPLIES} replies show here.

    ` : ''}`; + return `

    Replies ${replies.length}

      ${replies.map(m => `
    1. ${txt(m.from_role)} ${when(m.at)} · ${txt(classWord(m.class))} · trail

      ${txt(m.body)}

    2. `).join('')}
    ${replies.length >= REPLIES ? `

    The first ${REPLIES} replies show here.

    ` : ''}`; } const draft = { to: '', body: '' }; function composerHtml() { @@ -707,7 +709,7 @@ if (after !== null && !SAFE.test(after)) return notFound('The message cursor is not a message id.'); const page = await get('messages', after === null ? undefined : after); const body = page.rows.length ? `
      ${page.rows.map(m => - `
    • ${txt(m.from_role)} · ${txt(m.class)} · ${when(m.at)} · ${m.read ? 'Read' : 'Unread'} · trail

      ${messageText(m)}

    • `).join('')}
    ` + `
  • ${txt(m.from_role)} · ${txt(classWord(m.class))} · ${when(m.at)} · ${m.read ? 'Read' : 'Unread'} · trail

    ${messageText(m)}

  • `).join('')}` : empty('No messages to you.', 'Messages appear here as the bus is read.'); return `${head('Messages', `${readNote(get)} Every message to you, newest first. Reading here marks nothing read.`)}

    ${page.total} messages · ${page.unread} unread

    ${body} diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index 9ce8dd145..35cef6cfe 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -634,7 +634,9 @@ const CLASS_CHIP = { gated: 'gated', 'within-role': 'within', routine: 'routine', 'cross-role': 'cross' }; // The one map of decision classes to words: the class chips and the Inbox Class filter both read it. const TYPE_WORD = { gated: 'Gated', 'within-role': 'Within role', 'cross-role': 'Cross-role', routine: 'Routine' }; - const classChip = c => (c ? `${esc(TYPE_WORD[c] || c)}` : ''); + // SP11: the Inbox's word for a decision class; a class the map does not know shows as it is. + const classWord = c => (Object.hasOwn(TYPE_WORD, c) ? TYPE_WORD[c] : c); + const classChip = c => (c ? `${esc(classWord(c))}` : ''); const itemKey = d => `${d.org}:${d.id}`; const roleOf = d => d.session?.role || d.raisedBy || ''; const fromWords = (o, d, withOrg = true) => [withOrg ? o.name(d.org) : '', d.project?.name || 'No project', roleOf(d)].filter(Boolean).map(esc).join(' · '); @@ -1134,6 +1136,6 @@ // U4b: the org mark (colour and initials) for the task board (tasks-board.js). const orgMark = Object.freeze({ ready: ensureThemes, av, colours, initials }); - window.MosaicWorkspace = Object.freeze({ dashboard, holdLive, graftLive, agentGrid, icon, orgMark, refresh: () => refresh(), av, colours, label, state, dot, themes: ensureThemes, home, projectsPage, inboxPage, agentsPage, inboxCount, setGrouping, orgsOf, orgChip, projChip, unreadNote, setQuery, get pollMs() { return pollMs; } }); + window.MosaicWorkspace = Object.freeze({ dashboard, holdLive, graftLive, agentGrid, icon, orgMark, refresh: () => refresh(), av, colours, label, state, dot, themes: ensureThemes, home, projectsPage, inboxPage, agentsPage, inboxCount, setGrouping, orgsOf, orgChip, projChip, unreadNote, setQuery, classWord, get pollMs() { return pollMs; } }); refresh(); })(); diff --git a/packages/webui/tests/workspace-pages-browser.test.mjs b/packages/webui/tests/workspace-pages-browser.test.mjs index 8d2a4fde1..bc66a7d76 100644 --- a/packages/webui/tests/workspace-pages-browser.test.mjs +++ b/packages/webui/tests/workspace-pages-browser.test.mjs @@ -61,7 +61,9 @@ function fixture({ pollMs, fail = {}, down = [] } = {}) { return { busHealthy: true, reads: { business: async () => ({ business: { id, human: 'jason', projects: DATA[id].projects.map(p => p.repo) } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) }, - bus: { messages: async () => ({ messages: [], unread: 0 }), agents: async () => [], 'thread.claims': async () => [], inbox: async () => DATA[id].inbox.map(d => ({ ...d, business: id, raised_by_run: 'run', routed_to: 'human' })), tasks: async () => [], instances: async () => ({ business: id, instances: {} }) }, + bus: { messages: async () => ({ messages: [], unread: 0 }), agents: async () => [], 'thread.claims': async () => [], inbox: async () => DATA[id].inbox.map(d => ({ ...d, business: id, raised_by_run: 'run', routed_to: 'human' })), tasks: async () => [], instances: async () => ({ business: id, instances: {} }), + // SP11: a decision's own page reads its trail. + trail: async subject => DATA[id].inbox.filter(d => d.id === subject).map((d, i) => ({ table: 'decisions', seq: i + 1, ...d, business: id, raised_by_run: 'run', route_to: 'human', action: 'price.set', recommendation: null })) }, chat: { projects: async () => DATA[id].projects, threads: async () => [] }, }; }, @@ -224,6 +226,21 @@ test('Inbox lists both organizations, copies a command with --org and the full i await wait(`document.querySelectorAll("[data-ws-item]").length === 1 && document.querySelector("[data-ws-item]").dataset.wsItem === ${JSON.stringify(`beta:${BETA_DECISION}`)}`); }); +// SP11 (#1873): an organization's own decision page names the class in the words the Inbox uses, +// from the same map, not the raw class. +test('SP11: the per-organization decision page names the class as the Inbox does', async t => { + const { b, base, wait } = await setup(t, { hash: '#/inbox' }); + const item = `[data-ws-item="beta:${BETA_DECISION}"]`; + await wait(`!!document.querySelector(${JSON.stringify(`${item} .ws-chip.within`)})`); + const word = await b.evaluate(`document.querySelector(${JSON.stringify(`${item} .ws-chip.within`)}).textContent`); + assert.equal(word, 'Within role'); + await b.navigate(`${base}/#/orgs/beta/inbox/${BETA_DECISION}`); + await wait('!!document.querySelector("#bus-view .tag.tag-within-role")'); + const chip = await b.evaluate('document.querySelector("#bus-view .tag.tag-within-role").textContent'); + assert.match(chip, new RegExp(`^${word} · `), chip); + assert.doesNotMatch(await b.evaluate('document.getElementById("bus-view").innerText'), /within-role/); +}); + test('A poll repaints in place: focus, caret and scroll stay where they were', async t => { const { b, wait } = await setup(t, { hash: '/projects', viewport: [1000, 420] }); await wait('document.querySelector("[data-ws-table]") && document.querySelectorAll("[data-ws-project]").length === 3'); -- 2.54.0 From 11d2b43b3a28605081a35025a794e84fcae555ac Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 09:29:05 -0600 Subject: [PATCH 06/10] fix(webui): sp11 item 7, the organization header line beside the disabled reload says in plain words that the bus reads the organization file when it starts and a change loads on restart (#1873, lane sp11-fixes) --- packages/webui/src/public/workspace.js | 5 +++-- packages/webui/tests/unified-workspace-browser.test.mjs | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index 35cef6cfe..309da2af2 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -286,9 +286,10 @@ const offTitle = "Restarting the bus is the owner's trailed action, on a Console started with send and the action trail"; const stopTitle = "Stopping the bus is the owner's trailed action, on a Console started with send and the action trail"; const bus = restart ? (org.busHealthy ? `${restart}` : '') : ` `; - const reload = ``; + // SP11 (#1873): the line beside the disabled reload says how a change to the organization file loads. + const reload = ``; const [bcls, bglyph] = state(org); - const meta = `

    Kind not recordedid ${esc(id)}Tracker: ${tracker ? esc(titleCase(tracker)) : 'not set'}${dot(bcls, bglyph, org.busHealthy ? `bus running, read ${clock()}` : org.busStopped ? `bus stopped, read ${clock()}` : `bus unavailable, read ${clock()}`, true)}Restart is a trailed human action; reload arrives with the cross-org update.

    `; + const meta = `

    Kind not recordedid ${esc(id)}Tracker: ${tracker ? esc(titleCase(tracker)) : 'not set'}${dot(bcls, bglyph, org.busHealthy ? `bus running, read ${clock()}` : org.busStopped ? `bus stopped, read ${clock()}` : `bus unavailable, read ${clock()}`, true)}The bus reads the organization file when it starts. To load a change to the file, restart the bus.

    `; const projectName = pid => projectRows?.find(p => p.id === pid)?.name || pid; const docChips = !docRead ? 'Docs unavailable' : docRead.failed ? 'Docs unavailable' : docRead.rows.length ? docRead.rows.slice(0, 6).map(d => `${icon('doc')}${esc(docName(d.path))}${esc(projectName(d.project))}`).join('') : 'No docs yet'; diff --git a/packages/webui/tests/unified-workspace-browser.test.mjs b/packages/webui/tests/unified-workspace-browser.test.mjs index 9987bc80a..6fd418757 100644 --- a/packages/webui/tests/unified-workspace-browser.test.mjs +++ b/packages/webui/tests/unified-workspace-browser.test.mjs @@ -211,7 +211,8 @@ test('each header control is present, reload is disabled, and restart and stop a stop: stop ? [stop.textContent, stop.disabled, stop.closest('.bus-restart') === live.closest('.bus-restart')] : null, stopOff: stopOff ? [stopOff.textContent, stopOff.disabled, !!stopOff.title] : null, reload: [reload.textContent, reload.disabled, reload.title, document.getElementById(reload.getAttribute('aria-describedby'))?.textContent] }; })()`)); - const RELOAD = ['Reload organization file', true, 'Reload arrives with the cross-org update', 'Restart is a trailed human action; reload arrives with the cross-org update.']; + // SP11 (#1873): the line beside the disabled reload says in plain words how a change to the file loads. + const RELOAD = ['Reload organization file', true, 'Reloading without a restart is not available yet', 'The bus reads the organization file when it starts. To load a change to the file, restart the bus.']; // The owner on a Console started with --send gets console-restart.js's restart button. // Stop bus sits beside it, in the same control, and is one press like Restart bus. assert.deepEqual(await header(owner), { live: ['Restart bus', false], off: null, stop: ['Stop bus', false, true], stopOff: null, reload: RELOAD }); -- 2.54.0 From 8b4a26dd2bccd84de3ceb76d6e2d8644c090f127 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 09:32:01 -0600 Subject: [PATCH 07/10] fix(webui): sp11 item 8, with no organizations #/dashboard is the Dashboard: it says there is nothing to count and offers New organization, which opens the Organizations form at its ID field; #/ stays the Console's own board (#1873, lane sp11-fixes) --- packages/webui/src/public/bus.js | 6 ++++-- packages/webui/src/public/workspace.js | 2 +- .../webui/tests/kpi-tiles-browser.test.mjs | 20 +++++++++++++++---- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index 49ee77e42..dd281fb42 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -1691,7 +1691,9 @@ if (parts.includes(null)) return notFound(); const [a, b, c] = parts, n = parts.length; // #1873 SP1: `#/dashboard` is the system dashboard, every organization on one page. - if ((n === 0 || (a === 'dashboard' && n === 1)) && window.MosaicOrg) return window.MosaicWorkspace.home(); + // SP11: with no organization it is still the Dashboard (it says so and offers New organization); + // #/ is then the Console's own board. + if ((n === 0 && window.MosaicOrg) || (a === 'dashboard' && n === 1 && window.MosaicWorkspace)) return window.MosaicWorkspace.home(); if (a === 'projects' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.projectsPage(q); if (a === 'organization' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.dashboard(); // SP3: #/inbox is every organization's items; #/orgs//inbox is that list for one, with its hire form. @@ -1739,7 +1741,7 @@ const paused = () => $('pause')?.getAttribute('aria-pressed') === 'true'; // A file open in the editor is never refreshed from under its draft; Reload is the person's to press. // The cross-organization pages (U3) poll at the workspace read's pollMs. - const wsPage = path => !!window.MosaicOrg && (path === '/' || path === '' || path === '/dashboard' || path === '/projects' || path === '/inbox' || path === '/agents' || path === '/tasks' || /^\/workflows(\/[^/]+)?$/.test(path)); + const wsPage = path => (path === '/dashboard' && !!window.MosaicWorkspace) || !!window.MosaicOrg && (path === '/' || path === '' || path === '/dashboard' || path === '/projects' || path === '/inbox' || path === '/agents' || path === '/tasks' || /^\/workflows(\/[^/]+)?$/.test(path)); function schedule() { clearTimeout(timer); if (!paused() && !out() && !routeOf().startsWith('/docs/')) timer = setTimeout(() => render(false), wsPage(routeOf().split('?')[0]) ? window.MosaicWorkspace?.pollMs || 10000 : 10000); } // Keep focus, open
    and the copy status across a refresh of the same page. // In the send form, the focused field and its caret too (its values come from `draft`). diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index 309da2af2..05e7777da 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -710,7 +710,7 @@ const why = r.state === 'ok' || !(reason || r.boot?.at) ? '' : `${esc(reason)}${r.boot?.at ? `${reason ? ' · ' : ''}last start ${esc(when(r.boot.at))}` : ''}`; return `${av(o.meta(id))}${esc(o.name(id))}${dot(cls, glyph, word, true)}${why}${agentsCell}${projectsCell}${needsCell}`; }).join(''); - const orgList = o.rows.length ? `
    ${orgTable}
    OrganizationAgentsProjectsNeeds you
    ` : '

    No organizations yet

    '; + const orgList = o.rows.length ? `
    ${orgTable}
    OrganizationAgentsProjectsNeeds you
    ` : '

    No organizations yet. New organization

    '; const recent = projectRows.filter(p => p.lastActivity).slice(0, 6); const STATE_WORD = { working: 'has a session working', waiting: 'is waiting on you', idle: 'has an idle session', none: 'no live session' }; diff --git a/packages/webui/tests/kpi-tiles-browser.test.mjs b/packages/webui/tests/kpi-tiles-browser.test.mjs index c65988ac3..fde76f62c 100644 --- a/packages/webui/tests/kpi-tiles-browser.test.mjs +++ b/packages/webui/tests/kpi-tiles-browser.test.mjs @@ -170,10 +170,22 @@ test('When no answering organization could read its agents, the Agents tile says assert.equal(await b.evaluate('document.querySelector("[data-ws-tile=agents]").getAttribute("href")'), '#/agents?state=working'); }); -test('With no organizations there is nothing to count, so no tile shows a zero', async t => { - // With no organization the workspace pages are not drawn; the Console's own board keeps its dashes. - const { b, wait } = await setup(t, { none: true }); - await wait('document.querySelector("#dashboard-kpis .kpi strong")'); +// SP11 (#1873): with no organizations, #/dashboard is the Dashboard, not Not found. It says there is +// nothing to count and offers New organization, which opens the Organizations form at its ID field. +// #/ stays the Console's own board, with its dashes. +test('With no organizations the Dashboard says so, shows no tile, and offers New organization', async t => { + const { b, wait, text } = await setup(t, { none: true }); + await wait('document.querySelector("[data-ws-home] h1")'); + assert.equal(await text('#bus-view h1'), 'Dashboard'); + assert.doesNotMatch(await text('#bus-view'), /Not found/); + assert.equal(await b.evaluate('document.querySelectorAll(".ws-tile, .ws-big").length'), 0); + assert.match(await text('[data-ws-home] [data-ws-tiles]'), /^No organizations yet, so there is nothing to count\./); + assert.equal(await b.evaluate('document.querySelector("[data-ws-home] [data-ws-orgs] a")?.getAttribute("href") ?? null'), '#/settings/organizations?new=1'); + assert.equal(await text('[data-ws-home] [data-ws-orgs] a'), 'New organization'); + await b.evaluate('document.querySelector("[data-ws-home] [data-ws-orgs] a").click()'); + await wait('document.activeElement?.id === "org-new-id"'); + await b.evaluate('location.hash = "#/"'); + await wait('document.querySelector("#dashboard-kpis .kpi strong") && !document.querySelector("[data-ws-home]")'); assert.equal(await b.evaluate('document.querySelectorAll(".ws-tile, .ws-big").length'), 0); assert.deepEqual(await b.evaluate('[...new Set([...document.querySelectorAll("#dashboard-kpis .kpi strong")].map(e => e.textContent.trim()))]'), ['–']); }); -- 2.54.0 From f73c761ad390d62ac50e1d93f973061a27b6912e Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 09:57:03 -0600 Subject: [PATCH 08/10] fix(webui): sp11 review, signing out drops kept pane messages and stops keeping them; an organization page's sessions heading and rows take activity from the session list the Agents working tile counts, so the two agree; the Messages page test covers class words (#1873, lane sp11-fixes) --- packages/webui/src/public/assistant.js | 9 ++++++- packages/webui/src/public/workspace.js | 7 ++++-- .../tests/assistant-threads-browser.test.mjs | 24 +++++++++++++++++++ .../webui/tests/kpi-tiles-browser.test.mjs | 19 +++++++++++---- .../tests/workspace-pages-browser.test.mjs | 24 +++++++++++++++---- 5 files changed, 72 insertions(+), 11 deletions(-) diff --git a/packages/webui/src/public/assistant.js b/packages/webui/src/public/assistant.js index aa9786502..73894cc73 100644 --- a/packages/webui/src/public/assistant.js +++ b/packages/webui/src/public/assistant.js @@ -311,9 +311,16 @@ })(); 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 || ''; - function keep(p) { const text = typedIn(p); if (text.trim()) kept.set(p.key, text); else kept.delete(p.key); saveKept(); } + // 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. + 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', () => { + 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 = ''; } + }); 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 05e7777da..7252089aa 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -316,13 +316,16 @@ // root and project copies its bus booted with. A copy shows its own name. 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; - const liveWorking = live ? live.filter(([key, item]) => activity(key, item, agents)[2] === 'working').length : 0; + // 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. + 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 // any older conversation of the same agent. A failed read says unavailable, never "none". const sessionRows = (live || []).map(([agent, item, shown]) => { const held = claims ? claims.filter(c => item.running.includes(c?.holder_run)) : null; const first = held?.[0], thread = first && threads ? threads.find(t => t?.id === first.thread) : null; - const model = modelOf(agent), count = running(item), [acls, aglyph, aword] = activity(agent, item, agents); + const model = modelOf(agent), count = running(item), [acls, aglyph, aword] = activity(agent, item, tileAgents); const more = held && held.length > 1 ? ` · ${held.length - 1} more` : ''; const where = held === null ? 'Conversation unavailable' : !first ? 'No conversation held right now' diff --git a/packages/webui/tests/assistant-threads-browser.test.mjs b/packages/webui/tests/assistant-threads-browser.test.mjs index d2ac8924a..1b7efc671 100644 --- a/packages/webui/tests/assistant-threads-browser.test.mjs +++ b/packages/webui/tests/assistant-threads-browser.test.mjs @@ -414,6 +414,30 @@ test('SP11: an unsent pane message survives leaving the Assistant page, through assert.deepEqual(state.sent, []); }); +// SP11 review: signing out drops every kept pane message, so the next person to sign in on this tab +// never sees text typed before, after a reload too. +test('SP11: signing out drops kept pane messages, and a reload after it opens the pane empty', 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]')`); + assert.match(await b.evaluate(`sessionStorage.getItem('mosaic-assistant-drafts') || ''`), /private note/); + await b.evaluate(`location.hash = ${JSON.stringify(hash)}`); + await wait(`${field(a1)}?.value === 'private note'`); + await b.evaluate(`document.dispatchEvent(new Event('mosaic-signed-out')), true`); + await wait(`document.body.classList.contains('signed-out')`); + assert.equal(await b.evaluate(`sessionStorage.getItem('mosaic-assistant-drafts')`), null); + // A query string makes it a real load; the same address with only a hash would not reload. + await b.navigate(base + '?again=1' + hash); + await wait(`!document.body.classList.contains('signed-out') && ${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 fde76f62c..69b671387 100644 --- a/packages/webui/tests/kpi-tiles-browser.test.mjs +++ b/packages/webui/tests/kpi-tiles-browser.test.mjs @@ -16,7 +16,9 @@ const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++) const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString(); // fail: { org: [read names] } makes those reads refuse; `none` empties the organization list. -function fixture({ fail = {}, none = false } = {}) { +// split: { org: agents } is what that organization's own bus agents read answers, while the +// cross-organization sessions read still answers the fixture's agents. +function fixture({ fail = {}, none = false, split = {} } = {}) { const LIST = none ? [] : [ { id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true }, { id: 'beta', label: 'Beta Works', theme: 'grove', busHealthy: true }, @@ -63,7 +65,7 @@ function fixture({ fail = {}, none = false } = {}) { 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 () => DATA[id].agents, 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [], + messages: async () => ({ messages: [], unread: 0 }), agents: async () => 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 () => [] }, @@ -72,8 +74,8 @@ function fixture({ fail = {}, none = false } = {}) { }; } -async function setup(t, { viewport = [1440, 900], hash = '#/dashboard', fail, none } = {}) { - const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ fail, none }), log: () => {} }); +async function setup(t, { viewport = [1440, 900], hash = '#/dashboard', fail, none, split } = {}) { + const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ fail, none, split }), log: () => {} }); t.after(() => close(server)); const b = await browser({ machine: false }); t.after(() => b.close()); await b.viewport(...viewport); @@ -241,6 +243,15 @@ test('Agent counts say running and working, and count the same agents on the til assert.match(await text('[data-org-dashboard] .ws-split'), /Active agent sessions\s*1 running, 1 working/); }); +test('An organization page counts working from one read: the sessions heading and rows follow the tile when the two bus reads disagree', async t => { + // acme's own agents read says the coder is idle; the sessions read the tile counts says working. + const split = { acme: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'idle', at: at(40) }] }; + const { b, wait, text } = await setup(t, { hash: '#/orgs/acme', split }); + await wait(`document.querySelector("[data-org-dashboard]") && (${big('[data-org-tiles]', 'agents')}) === "1 of 2"`); + assert.match(await text('[data-org-dashboard] .ws-split'), /Active agent sessions\s*1 running, 1 working/); + assert.match(await text('[data-org-dashboard] .ws-split tbody'), /coder[\s\S]*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]")'); diff --git a/packages/webui/tests/workspace-pages-browser.test.mjs b/packages/webui/tests/workspace-pages-browser.test.mjs index bc66a7d76..c782fd0e9 100644 --- a/packages/webui/tests/workspace-pages-browser.test.mjs +++ b/packages/webui/tests/workspace-pages-browser.test.mjs @@ -14,7 +14,8 @@ const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString() // acme and beta answer; gamma's bus is stopped. Decision ids are long so a shortened id would show. const ACME_DECISION = 'dec-acme-7f3c9a21-5b8e-4d1f-9c2a-0e6b7d8f9a10'; const BETA_DECISION = 'dec-beta-1a2b3c4d-5e6f-4a1b-8c9d-0e1f2a3b4c5d'; -function fixture({ pollMs, fail = {}, down = [] } = {}) { +// messages: { org: rows } is what that organization's bus messages read answers. +function fixture({ pollMs, fail = {}, down = [], messages = {} } = {}) { const LIST = [ { id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true }, { id: 'beta', label: 'Beta Works', theme: 'grove', busHealthy: true }, @@ -61,7 +62,7 @@ function fixture({ pollMs, fail = {}, down = [] } = {}) { return { busHealthy: true, reads: { business: async () => ({ business: { id, human: 'jason', projects: DATA[id].projects.map(p => p.repo) } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) }, - bus: { messages: async () => ({ messages: [], unread: 0 }), agents: async () => [], 'thread.claims': async () => [], inbox: async () => DATA[id].inbox.map(d => ({ ...d, business: id, raised_by_run: 'run', routed_to: 'human' })), tasks: async () => [], instances: async () => ({ business: id, instances: {} }), + bus: { messages: async () => ({ messages: messages[id] ?? [], unread: 0 }), agents: async () => [], 'thread.claims': async () => [], inbox: async () => DATA[id].inbox.map(d => ({ ...d, business: id, raised_by_run: 'run', routed_to: 'human' })), tasks: async () => [], instances: async () => ({ business: id, instances: {} }), // SP11: a decision's own page reads its trail. trail: async subject => DATA[id].inbox.filter(d => d.id === subject).map((d, i) => ({ table: 'decisions', seq: i + 1, ...d, business: id, raised_by_run: 'run', route_to: 'human', action: 'price.set', recommendation: null })) }, chat: { projects: async () => DATA[id].projects, threads: async () => [] }, @@ -71,8 +72,8 @@ function fixture({ pollMs, fail = {}, down = [] } = {}) { }; } -async function setup(t, { viewport = [1440, 900], hash = '/', workspaceTimeoutMs, fail, down } = {}) { - const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ fail, down }), log: () => {}, ...(workspaceTimeoutMs ? { workspaceTimeoutMs } : {}) }); +async function setup(t, { viewport = [1440, 900], hash = '/', workspaceTimeoutMs, fail, down, messages } = {}) { + const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ fail, down, messages }), log: () => {}, ...(workspaceTimeoutMs ? { workspaceTimeoutMs } : {}) }); t.after(() => close(server)); const b = await browser({ machine: false }); t.after(() => b.close()); await b.viewport(...viewport); @@ -241,6 +242,21 @@ test('SP11: the per-organization decision page names the class as the Inbox does assert.doesNotMatch(await b.evaluate('document.getElementById("bus-view").innerText'), /within-role/); }); +test('SP11: the per-organization Messages page names a known class as the Inbox does and shows an unknown one as it is', async t => { + const at = '2026-11-15T10:00:00.000Z'; + const messages = { beta: [ + { id: 'msg-beta-1a2b3c4d-0001', from_role: 'pm', class: 'within-role', body: 'Price list is ready.', at, read: false }, + { id: 'msg-beta-1a2b3c4d-0002', from_role: 'pm', class: 'NOTE', body: 'Logo is in.', at, read: true }, + ] }; + const { b, base, wait } = await setup(t, { hash: '#/inbox', messages }); + await b.navigate(`${base}/#/orgs/beta/messages`); + await wait('document.querySelectorAll("#message-list li").length === 2'); + const lines = await b.evaluate('JSON.stringify([...document.querySelectorAll("#message-list li p.small")].map(p => p.textContent))'); + const [known, unknown] = JSON.parse(lines); + assert.match(known, /^pm · Within role · /, known); + assert.match(unknown, /^pm · NOTE · /, unknown); +}); + test('A poll repaints in place: focus, caret and scroll stay where they were', async t => { const { b, wait } = await setup(t, { hash: '/projects', viewport: [1000, 420] }); await wait('document.querySelector("[data-ws-table]") && document.querySelectorAll("[data-ws-project]").length === 3'); -- 2.54.0 From 2d7d7be5cac7851ed12b87038f45e7efb246f11e Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 10:04:06 -0600 Subject: [PATCH 09/10] 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]")'); -- 2.54.0 From 5b6b39f080d0ca8beaa1e2644b84fc15a1893a86 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 10:27:07 -0600 Subject: [PATCH 10/10] test(webui): sp11 item 3, the w15 fixture answers the bus instances read the conversation page now makes, with no rows, so Resume shows as before and the read is not counted as unexpected (#1873, lane sp11-fixes) --- packages/webui/tests/w15-fixture.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webui/tests/w15-fixture.mjs b/packages/webui/tests/w15-fixture.mjs index 84b6b044e..61fdb957f 100644 --- a/packages/webui/tests/w15-fixture.mjs +++ b/packages/webui/tests/w15-fixture.mjs @@ -45,7 +45,7 @@ export async function w15Fixture(t, { scenario = {}, slice = 'shell' } = {}) { if(/^\\/api\\/chat\\/threads\\/[^/]+\\/sessions$/.test(path)) return data({rows:[{id:ids.session,name:'Default',default:true}]}); if(/^\\/api\\/chat\\/threads\\/[^/]+\\/messages(?:\\?.*)?$/.test(path)) return data({rows:[],after:null,hasMore:false}); if(path==='/api/chat/threads' || /^\\/api\\/chat\\/threads\\?(shared=true|project=)/.test(path)) return data({rows:[{id:ids.thread,project:ids.project,name:'Project conversation',instance:'assistant'}]}); - if(/^\\/api\\/bus\\/(inbox|messages|agents|tasks|queue|trail)(\\?|$)/.test(path)) return data({rows:[],unread:0}); + if(/^\\/api\\/bus\\/(inbox|messages|agents|instances|tasks|queue|trail)(\\?|$)/.test(path)) return data({rows:[],unread:0}); __unexpected.push(method+' '+path); return new Response(JSON.stringify({error:'fixture-unexpected-api'}),{status:403,headers:{'content-type':'application/json'}}); }; })();` }); -- 2.54.0