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/chat.js b/packages/webui/src/public/chat.js
index 6f55818bd..5faf149cf 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(String(m.from_role || '?').slice(0, 1).toUpperCase())} ${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() ? `Resume assistant ` : '';
- const details = pane ? `Reading never wakes the assistant. An accepted message is not an answer.
` : redesigned ? `Conversation details Close ${sessionControls(t)}${sharing(t)}Reading never wakes the assistant. An accepted message is not an answer.
${threadWrites() ? `Resume assistant ` : ''} ` : `Conversation details ${sessionControls(t)}${sharing(t)}Reading never wakes the assistant. An accepted message is not an answer.
${threadWrites() ? `Resume assistant ` : ''}`;
+ // SP11: Resume shows unless the instances read says the agent is running.
+ const resumable = threadWrites() && current.agentIdle !== false;
+ const resume = resumable ? `Resume assistant ` : '';
+ const details = pane ? `Reading never wakes the assistant. An accepted message is not an answer.
` : redesigned ? `Conversation details Close ${sessionControls(t)}${sharing(t)}Reading never wakes the assistant. An accepted message is not an answer.
${resumable ? `Resume assistant ` : ''} ` : `Conversation details ${sessionControls(t)}${sharing(t)}Reading never wakes the assistant. An accepted message is not an answer.
${resumable ? `Resume assistant ` : ''}`;
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 ? 'Messages unavailable. ' : 'No messages yet. ')} ${current.readFailed ? '
Read again ' : s.more ? '
Showing earlier history. Read forward to reach the current conversation.
Read next messages ' : '
Caught up to the last read.
'}
New messages — go to end ${threadWrites() ? `` : ''}${txt(receipt)}
${pane ? resume : ''}${redesigned || pane ? '' : '
'} `;
}
// 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;
@@ -1274,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}$/;
@@ -1288,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/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) => `${esc(label)} ${options} `;
@@ -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 ``;
@@ -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 69c5b4212..1656000c7 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;
@@ -280,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} ` : '') : `${icon('restart')}Restart bus Stop bus `;
- const reload = `${icon('doc')}Reload organization file `;
+ // SP11 (#1873): the line beside the disabled reload says how a change to the organization file loads.
+ const reload = `${icon('doc')}Reload organization file `;
const [bcls, bglyph] = state(org);
- const meta = `Kind not recorded id ${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 recorded id ${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 ';
@@ -297,27 +304,35 @@
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) })}
+ ${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;
- 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;
+ // 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; 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
// 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);
+ 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 '
: 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 ? `Agent Project and task Model Status ${sessionRows}
` : 'No active agent sessions
';
const open = queue ? queue.filter(r => r?.state !== 'done').length : null;
@@ -338,7 +353,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${tileAgents ? `, ${liveWorking} working` : ', working not read'}` : 'none running') : '', `This organization's agents `)}
${sessions}
${secH('Queue', queueNote, 'Open queue ')}
${queueWhy}
@@ -624,7 +639,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(' · ');
@@ -669,25 +686,27 @@
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 })}
- ${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 })}
`;
// 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.
@@ -695,7 +714,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 ? `Organization Agents Projects Needs you ${orgTable}
` : 'No organizations yet
';
+ const orgList = o.rows.length ? `Organization Agents Projects Needs you ${orgTable}
` : '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' };
@@ -950,7 +969,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 +1063,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.`;
@@ -1121,6 +1141,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/assistant-threads-browser.test.mjs b/packages/webui/tests/assistant-threads-browser.test.mjs
index e265c43d6..1f854b3cd 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],
@@ -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 }) => {
@@ -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 });
@@ -331,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}`);
@@ -351,6 +374,115 @@ 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 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 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 => {
+ 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 => {
diff --git a/packages/webui/tests/kpi-tiles-browser.test.mjs b/packages/webui/tests/kpi-tiles-browser.test.mjs
index 16adcf8ad..ef50aad0e 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 () => { 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 () => [] },
@@ -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);
@@ -110,7 +112,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 +145,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]")');
@@ -165,10 +172,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()))]'), ['–']);
});
@@ -183,7 +202,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);
@@ -200,6 +219,47 @@ 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('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('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]")');
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));
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}`);
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 });
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'}});
};
})();` });
diff --git a/packages/webui/tests/workspace-pages-browser.test.mjs b/packages/webui/tests/workspace-pages-browser.test.mjs
index 8d2a4fde1..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,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: 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 () => [] },
};
},
@@ -69,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);
@@ -224,6 +227,36 @@ 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('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');