From 8aefc0f7a9982a9258c6a4ab7cc7b7c6faeee40c Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 09:28:29 -0600 Subject: [PATCH] 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 ? `` + `
  • ${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');