From 0785af8129f74595eec4386bea699ca754541116 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 00:38:03 -0600 Subject: [PATCH 1/3] feat(webui): one Inbox for the whole system, with organization filters and answers pinned to the row's organization (#1873, lane sp3) #/inbox lists every answering organization's decisions from the one GET /api/workspace read, newest first, each row with its organization chip and a project chip when it has one. Organization and type filters live in the hash query. A stopped organization is named at the top with its reason. An answer from the list reads and posts to the row's own organization (/api/orgs//...), never the one opened last, and each item can be opened in its own organization. #/orgs//inbox is the same list filtered to that organization. No server route was added. --- packages/webui/src/public/bus.js | 51 ++++- packages/webui/src/public/display.js | 10 +- packages/webui/src/public/shell.css | 8 + packages/webui/src/public/workspace.js | 103 ++++++---- packages/webui/tests/shell.test.mjs | 6 +- .../webui/tests/system-inbox-browser.test.mjs | 187 ++++++++++++++++++ .../tests/workspace-pages-browser.test.mjs | 32 +-- 7 files changed, 339 insertions(+), 58 deletions(-) create mode 100644 packages/webui/tests/system-inbox-browser.test.mjs diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index 8d9843552..1f271b643 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -583,6 +583,9 @@ }; const decideRefusal = (res, json, fallback) => res && !res.ok && res.status !== 408 && typeof json?.error === 'string' && typeof json?.message === 'string' ? { code: json.error, message: json.message } : { code: fallback, message: DECIDE_FALLBACK[fallback] }; + // SP3: an answer started from the system Inbox names its organization and goes there, whichever + // organization was opened last. One started on a decision page keeps the page's own scope. + const decideBase = a => a.org ? `/api/orgs/${enc(a.org)}` : '/api'; async function decide(step) { const a = answer; if (!a || a.pending || a.result) return; @@ -593,7 +596,7 @@ const choice = selected.key, ordinary = ['decision','hire'].includes(a.console.kind); const adjust=choice==='yes'?collectHireAdjustment(a.console):undefined; a.pending = step; a.error = null; a.confirm = null; paintDecide(); - const { res, json } = await decidePost(`/api/decisions/${enc(a.id)}/confirmation`, { choice,...(adjust===undefined?{}:{adjust}) }); + const { res, json } = await decidePost(`${decideBase(a)}/decisions/${enc(a.id)}/confirmation`, { choice,...(adjust===undefined?{}:{adjust}) }); a.pending = null; // The page moved to another decision meanwhile: its confirmation is dropped, never shown. if (answer !== a) { if (res?.type === 'opaqueredirect') signOut(); return; } @@ -607,7 +610,7 @@ if (step !== 'confirm' || !a.confirm) return; const c = a.confirm; a.pending = 'confirm'; a.error = null; paintDecide(); - const { res, json } = await decidePost(`/api/decisions/${enc(a.id)}/resolve`, { choice: c.choice, confirmation: c.confirmation }); + const { res, json } = await decidePost(`${decideBase(a)}/decisions/${enc(a.id)}/resolve`, { choice: c.choice, confirmation: c.confirmation }); // One use, whatever came back. a.pending = null; a.confirm = null; if (answer !== a) { if (res?.type === 'opaqueredirect') signOut(); else render(false); return; } @@ -617,6 +620,41 @@ paintDecide('#decide-status'); announce(['decision','hire'].includes(c.kind) ? 'Recorded' : c.action === 'approve' ? 'Approved' : 'Denied'); render(false); } else { a.error = decideRefusal(res, json, 'outcome-unknown'); paintDecide('#decide-status'); announce(`Not answered: ${a.error.message}`); } } + // SP3: the answer panel for the row selected in the Inbox, in that row's organization. That + // organization's config says whether this Console answers decisions there, and its bus inbox says + // whether this decision is eligible (its `console` field), so neither comes from the organization + // opened last. The state follows the same keep rules as the decision page's. `current` is the + // render's own check: a read that returns after the page moved on changes nothing. + async function inboxAnswer(d, current) { + if (!d || isTester || !d.console || typeof d.org !== 'string') return inboxKeep(d); + const base = `/api/orgs/${enc(d.org)}`; + let eligible = null, failed = false; + try { + const [c, i] = await Promise.all([`${base}/config`, `${base}/bus/inbox`].map(u => fetch(u, { cache: 'no-store', redirect: 'manual' }))); + if (c.type === 'opaqueredirect' || i.type === 'opaqueredirect') { if (current()) signOut(); return ''; } + if (!c.ok || !i.ok) throw new Error('not read'); + const [cfg, box] = await Promise.all([c.json(), i.json()]); + const row = (Array.isArray(box?.rows) ? box.rows : []).find(r => r?.id === d.id); + eligible = cfg?.decide === true && cfg?.tester !== true && row?.routed_to === 'human' && row.console && typeof row.console === 'object' ? row.console : null; + } catch { failed = true; } + if (!current()) return ''; + const same = answer?.org === d.org && answer.id === d.id; + if (!same) answer = eligible ? { id: d.id, org: d.org, console: eligible, pending: null, confirm: null, error: null, result: null } : null; + else if (eligible && !answer.pending) { + if (answer.console.digest !== eligible.digest) answer.confirm = null; + answer.console = eligible; + } + else if (!eligible && !failed && !answer.pending && !answer.result) answer = null; + if (answer?.org === d.org && answer.id === d.id) return decideHtml(); + return failed ? '

Whether this Console can answer it here was not read: the organization did not answer. Open it in its organization, or use a terminal.

' : ''; + } + // A row this Console cannot answer drops any answer held for another row, unless one is in flight. + function inboxKeep(d) { + if (answer?.org && !answer.pending && !(d && answer.org === d.org && answer.id === d.id && answer.result)) answer = null; + return answer?.org && d && answer.org === d.org && answer.id === d.id ? decideHtml() : ''; + } + // The receipt stays on screen after the answered row leaves the list. + const inboxAnswered = key => answer?.org && answer.result && `${answer.org}:${answer.id}` === key ? decideHtml() : ''; async function inboxView(get) { await configRead; if(!hireDraft.business && window.MosaicOrg?.id)hireDraft.business=window.MosaicOrg.id; @@ -1620,7 +1658,12 @@ if ((n === 0 || (a === 'dashboard' && n === 1)) && window.MosaicOrg) 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(); - if (a === 'inbox' && n === 1 && window.MosaicOrg) return configRead.then(() => window.MosaicWorkspace.inboxPage(q, { hire: hireStartHtml })); + // SP3: #/inbox is every organization's items; #/orgs//inbox is that list for one, with its hire form. + if (a === 'inbox' && n === 1 && window.MosaicOrg) { + const scope = /^#\/orgs\/([^/?]+)/.exec(location.hash)?.[1]; + let org = null; try { org = scope ? decodeURIComponent(scope) : null; } catch { return notFound(); } + return configRead.then(() => window.MosaicWorkspace.inboxPage(q, { scope: org, hire: org ? hireStartHtml : null, answer: d => inboxAnswer(d, current), answered: inboxAnswered })); + } if (a === 'tasks' && n === 1 && window.MosaicOrg) return window.MosaicTasksBoard.view(); if (a === 'inbox' && n === 1) return inboxView(get); if (a === 'inbox' && n === 2) return SAFE.test(b) ? decisionView(get, b, current) : notFound(`${txt(b)} is not a decision id.`); @@ -1707,7 +1750,7 @@ if (path !== '/settings/model-proxy' && proxyCleanup) {proxyCleanup();proxyCleanup=null;} if ((navigated || path !== '/settings/organizations') && orgCleanup) {orgCleanup();orgCleanup=null;} // Leaving a decision discards its confirmation immediately, including a preview in flight. - if (answer && (navigated || path !== `/inbox/${answer.id}`)) answer = null; + if (answer && (navigated || path !== (answer.org ? '/inbox' : `/inbox/${answer.id}`))) answer = null; const g = ++gen, routeVersion = routesEditVersion; const current = () => g === gen && route === routeOf() && !out(); // In organization mode #/ is the cross-organization dashboard; the processing controls stay on the board. diff --git a/packages/webui/src/public/display.js b/packages/webui/src/public/display.js index 18cbac1e5..03da72349 100644 --- a/packages/webui/src/public/display.js +++ b/packages/webui/src/public/display.js @@ -21,12 +21,14 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date( // The bare address and the old per-organization dashboard address are the system dashboard; // an organization page without the prefix gets the current organization's. #1873 SP2: an // organization's own bare address (#/orgs/, no trailing slash) is that organization's page; - // #/orgs// stays the old dashboard address. + // #/orgs// stays the old dashboard address. SP3: `#/inbox` is a system route too (every + // organization's items); `#/orgs//inbox` is that list, filtered. + const system = hash => { const bare = tail(hash).split('?')[0]; return bare === '/dashboard' || (!orgOf(hash) && bare === '/inbox'); }; const normalize = (hash, seg = current) => { const path = tail(hash), bare = path.split('?')[0], named = /^#\/orgs\/([^/?]+)$/.exec(hash.split('?')[0])?.[1]; if (named) return `#/orgs/${named}/organization${hash.slice(hash.split('?')[0].length)}`; if (bare === '/' || bare === '/dashboard') return `#/dashboard${path.slice(bare.length)}`; - return orgOf(hash) || !seg ? hash : `#/orgs/${seg}${path}`; + return orgOf(hash) || !seg || system(hash) ? hash : `#/orgs/${seg}${path}`; }; // /api/workspace reads and writes across organizations (U1, U4b), so it keeps its own path. const unscoped = /^\/api\/(?:workspace(?:\/|\?|$)|orgs(?:\/|$)|themes(?:\?|$)|org-template(?:\?|$)|org-presets(?:\?|$)|org-admin(?:\?|$)|org-validate(?:\?|$)|agents\/(?:levels|root)(?:\?|$))/; @@ -50,13 +52,13 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date( return null; }).catch(() => { listError = window.MosaicOrganizationError = 'Organization registry could not be read'; return null; }); const readList = () => readRows().then(rows => rows ?? []); - // The bus banner belongs to an organization page; the dashboard lists every bus on its own rows. + // The bus banner belongs to an organization page; the dashboard and the Inbox name every bus themselves. // #1873 SP2: the organization's own page draws its down banner with the restart action, so this one hides there. const paintBus = () => { const banner = document.getElementById('bus-down'); if (!banner) return; const org = rowsSeen.find(row => current && row.id === decodeURIComponent(current) && !row.invalid), path = route().split('?')[0]; - if (path === '/dashboard' || (path === '/organization' && org?.busHealthy === false) || (banner.dataset.orgBus && org?.busHealthy !== false)) { banner.hidden = true; delete banner.dataset.orgBus; return; } + if (system(location.hash) || (path === '/organization' && org?.busHealthy === false) || (banner.dataset.orgBus && org?.busHealthy !== false)) { banner.hidden = true; delete banner.dataset.orgBus; return; } if (org?.busHealthy === false) { banner.textContent = 'Bus unavailable'; banner.hidden = false; banner.dataset.orgBus = '1'; window.MosaicConsole?.attachBus(banner, 'Restart the bus'); } }; // Idempotent: each read replaces the banner, and removes it on recovery. diff --git a/packages/webui/src/public/shell.css b/packages/webui/src/public/shell.css index 76f58610e..2f36f5eaa 100644 --- a/packages/webui/src/public/shell.css +++ b/packages/webui/src/public/shell.css @@ -686,6 +686,14 @@ html[data-organizations] .ws-ibx .q:hover{text-decoration:underline} html[data-organizations] .ws-ibx .via{display:block;font-size:12px;color:var(--muted)} html[data-organizations] .ws-ibx tr.on{background:color-mix(in srgb,var(--action) 10%,var(--surface))} html[data-organizations] .ws-ibx tr.on td:first-child{box-shadow:inset 3px 0 0 var(--action)} +/* SP3: the system Inbox's filter rows and the organization and project chips on each item. */ +html[data-organizations] .ws-ibx-filters{flex-direction:column;align-items:flex-start;gap:8px} +html[data-organizations] .ws-fgroup{display:flex;gap:6px 8px;align-items:center;flex-wrap:wrap} +html[data-organizations] .ws-ibx-filters .ws-fchips>.ws-label,html[data-organizations] .ws-fgroup>.ws-label{min-width:36px} +html[data-organizations] .ws-ichips{display:flex;flex-wrap:wrap;align-items:center;gap:4px 6px;margin-top:3px} +html[data-organizations] .ws-ichips .via{display:inline} +html[data-organizations] .ws-ochip{background:color-mix(in srgb,var(--org,var(--muted)) 14%,var(--surface))} +html[data-organizations] .ws-ochip i{width:8px;height:8px;border-radius:999px;background:var(--org,var(--muted));flex:none} html[data-organizations] .ws-msrc{align-items:center;gap:5px;font-size:12px;color:var(--muted);margin-bottom:2px} html[data-organizations] .ws-insp{border:1px solid var(--line);border-radius:10px;background:var(--surface);display:flex;flex-direction:column;min-width:0;position:sticky;top:12px;max-height:calc(100vh - 80px);overflow:auto} html[data-organizations] .ws-insp header{display:flex;align-items:center;gap:8px;padding:10px 14px;border-bottom:1px solid var(--line);background:var(--surface);position:sticky;top:0;z-index:1;box-shadow:inset 3px 0 0 var(--org)} diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index 26a2e693a..00d1460dd 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -56,7 +56,8 @@ // A status mark: a glyph plus its word, shown or read aloud only. const dot = (cls, glyph, word, shown = false) => `${shown ? esc(word) : `${esc(word)}`}`; const orgHref = id => `#/orgs/${enc(id)}`; - const route = () => location.hash.replace(/^#\/orgs\/[^/]+/, '') || '/'; + // SP3: a system route such as #/inbox has no organization prefix; both forms give the path. + const route = () => location.hash.replace(/^#(?:\/orgs\/[^/]+)?/, '') || '/'; const ensureThemes = async () => { if (!themes) themes = await data.themes().then(b => (Array.isArray(b?.themes) ? b.themes : [])).catch(() => []); return themes; }; const instancesOf = value => { const v = value?.rows?.[0]?.instances ?? value?.instances; return v && typeof v === 'object' && !Array.isArray(v) ? v : null; }; // #1873 A3: the root and project agent copies the bus booted with, from the same instances read. @@ -639,7 +640,7 @@ const states = srows.length ? `
${srows.map(s => { const ps = [...s.g.entries()].filter(([, n]) => n > 0).map(([id, n]) => ({ id, n })); return `
${esc(s.name)}${s.n}${bar(o, ps, `width:${Math.max(4, Math.round((s.n / max) * 90))}%`)}${by(o, ps)}
`; }).join('')}
` : `

${noneRead(o, ['tasks']) ? 'Tasks not read' : 'No open tasks'}

`; const needs = inbox.slice(0, 5); - const needsList = needs.length ? `${inbox.length > needs.length ? `

${inbox.length - needs.length} more in the inbox

` : ''}` : `

${noneRead(o, ['inbox']) ? 'Decisions not read' : 'Nothing needs you'}

`; + const needsList = needs.length ? `${inbox.length > needs.length ? `

${inbox.length - needs.length} more in the inbox

` : ''}` : `

${noneRead(o, ['inbox']) ? 'Decisions not read' : 'Nothing needs you'}

`; const now = sessions.filter(s => s.kind === 'thread' || s.state === 'working' || s.state === 'waiting'); const idle = sessions.length - now.length; @@ -666,7 +667,7 @@ ${states}${missLine(o, ['tasks'], 'states')}
- ${secH('Needs you', '', `Inbox`)} + ${secH('Needs you', '', 'Inbox')} ${needsList}${missLine(o, ['inbox'], 'needs')} ${secH('Active agents', 'sessions now', `Agents`)} ${sessTable}${missLine(o, ['agents', 'claims'], 'sessions')} @@ -766,71 +767,100 @@
`; } - // ---- inbox across organizations (sample 05) ---- - // `opts.hire`: a function that returns the hire, fire and change form bus.js builds for the current - // organization (D4, P0-1). It is called after the workspace read so typing or a submit result that - // lands during the read is not painted over with an older draft. + // ---- inbox across organizations (sample 05; SP3 prototype 1114 /inbox) ---- + // SP3: #/inbox lists every organization's items from one read (GET /api/workspace), newest first, + // each row with its organization chip and a project chip when it has one. The organization and type + // filters live in the hash query and run on that read, so the chip counts, the sidebar count and the + // dashboard's Needs you agree. `opts.scope` is the organization of #/orgs//inbox: the same list, + // filtered to it. `opts.answer(d)` returns the answer panel bus.js builds in the row's own + // organization; `opts.answered(key)` keeps its receipt after the row leaves the list. `opts.hire`: a + // function that returns the hire, fire and change form bus.js builds for the current organization + // (D4, P0-1), shown on the scoped list only. It is called after the read so typing or a submit + // result that lands during the read is not painted over with an older draft. + const TYPE_WORD = { gated: 'Gated', 'within-role': 'Within role', 'cross-role': 'Cross-role', routine: 'Routine' }; + const TYPE_RE = /^[a-z][a-z-]{0,31}$/; async function inboxPage(q, opts = {}) { - const org = listParam(q, 'org', id => ORG_ID.test(id)); + const scope = typeof opts.scope === 'string' && ORG_ID.test(opts.scope) ? opts.scope : null; + const org = scope ? [scope] : listParam(q, 'org', id => ORG_ID.test(id)); + const type = TYPE_RE.test(String(q?.get?.('type') || '')) ? q.get('type') : null; const want = String(q?.get?.('d') || ''); - let body, all; - try { - [body, all] = await Promise.all([data.workspace({ sections: ['inbox'], org }), org ? data.workspace({ sections: ['inbox'] }) : null, ensureThemes()]); - took(body); - } catch (err) { return failedPage('ws-inbox', 'data-ws-inbox', 'Inbox', err); } - const every = orgsOf(all || body), o = orgsOf(body); - const everyRows = arr((all || body).inbox); + let body; + try { [body] = await Promise.all([data.workspace({ sections: ['inbox'] }), ensureThemes()]); took(body); } + catch (err) { return failedPage('ws-inbox', 'data-ws-inbox', 'Inbox', err); } + const every = orgsOf(body), everyRows = arr(body.inbox).filter(d => d && typeof d.org === 'string'); if (!noneRead(every, ['inbox'])) tellInbox(everyRows); const counts = new Map(); for (const d of everyRows) counts.set(d.org, (counts.get(d.org) || 0) + 1); - const rows = arr(body.inbox); + // The organizations in view: every one, or the chosen ones. + const o = org ? { ...every, rows: every.rows.filter(r => org.includes(r.org)), unread: every.unread.filter(r => org.includes(r.org)) } : every; + const inOrg = everyRows.filter(d => !org || org.includes(d.org)).sort((a, b) => (b.at ?? '').localeCompare(a.at ?? '')); + const rows = inOrg.filter(d => !type || d.class === type); const picked = want === 'none' ? null : rows.find(d => itemKey(d) === want) || (want ? null : rows[0] || null); - const qs = d => { const p = new URLSearchParams(); if (org) p.set('org', org.join(',')); p.set('d', d ? itemKey(d) : 'none'); return `/inbox?${p}`; }; + const here = path => (scope ? `${orgHref(scope)}${path}` : `#${path}`); + const qs = d => { const p = new URLSearchParams(); if (org && !scope) p.set('org', org.join(',')); if (type) p.set('type', type); p.set('d', d ? itemKey(d) : 'none'); return here(`/inbox?${p}`); }; + const orgChip = d => `${esc(o.name(d.org))}`; + const projChip = d => (d.project?.name ? `${esc(d.project.name)}` : ''); const row = d => { - const on = picked && itemKey(d) === itemKey(picked); + const on = picked && itemKey(d) === itemKey(picked), by = roleOf(d); return ` - ${o.ref(d.org)}${fromWords(o, d, false)} - ${av(o.meta(d.org), 'xs')}${fromWords(o, d)}${esc(d.question || `Decision ${d.id}`)}decision ${esc(d.id)}${d.taskRef ? ` · task ${esc(d.taskRef)}` : ''}${classChip(d.class)}${d.blocking ? ' blocking' : ''} + ${esc(d.question || `Decision ${d.id}`)}${orgChip(d)}${projChip(d)}${by ? `${esc(by)} · ` : ''}decision ${esc(d.id)}${d.taskRef ? ` · task ${esc(d.taskRef)}` : ''}${classChip(d.class)}${d.blocking ? ' blocking' : ''} ${classChip(d.class)}${d.blocking ? ' blocking' : ''} ${d.at ? `` : 'not reported'} `; }; - const unread = (o.unread.length ? `

Not in this list: ${andList(o.unread.map(stateWords))}.

` : '') + missLine(o, ['inbox'], 'inbox'); - const table = rows.length ? `
${rows.map(row).join('')}
Items that need you
SourceNeedsClassWaiting
` : `

${noneRead(o, ['inbox']) ? 'Decisions not read' : org ? 'Nothing needs you in these organizations' : 'Nothing needs you'}

`; + // An organization that did not answer is named first, with the reason its read gave. + const why = r => `${stateWords(r).replace(/, read .*$/, '')}${r.reason ? `: ${esc(r.reason)}` : ''}${r.at ? ` (read ${esc(hm(r.at))})` : ''}`; + const unread = (o.unread.length ? `

Not read: ${andList(o.unread.map(why))}.

` : '') + missLine(o, ['inbox'], 'inbox'); + const filtered = (org && !scope) || type; + const table = rows.length ? `
${rows.map(row).join('')}
Items that need you
NeedsTypeWaiting
` : `

${noneRead(o, ['inbox']) ? 'Decisions not read' : filtered ? 'Nothing matches these filters' : 'Nothing needs you'}

`; let insp = ''; if (picked) { const d = picked, id = esc(d.id); const decideHref = `${orgHref(d.org)}/inbox/${enc(d.id)}`; - const opts = arr(d.options); - const optList = opts.length ? `` : `

No options were given. Open the decision

`; + const answerHtml = (await opts.answer?.(d)) || ''; + const choices = arr(d.options); + const optList = choices.length ? `` : `

No options were given. Open the decision

`; const cmds = arr(d.decide).filter(c => typeof c?.command === 'string' && c.command); const term = cmds.length ? `
Answer from a terminal${cmds.map(c => `
${esc(c.command)}
`).join('')}

The inbox mixes organizations, so the copied command always names the organization's bus.

` : '

No terminal command for this item: its id or options cannot be passed to a shell safely.

'; const by = roleOf(d); insp = ``; + } else if (want && want !== 'none') { + // The answered row has left the list; its receipt stays until another row is chosen. + const kept = opts.answered?.(want) || ''; + if (kept) insp = ``; } + // Type chips: the decision classes the read carries, in the organizations in view. + const typeCounts = new Map(); + for (const d of inOrg) if (typeof d.class === 'string' && TYPE_RE.test(d.class)) typeCounts.set(d.class, (typeCounts.get(d.class) || 0) + 1); + const types = [...new Set([...typeCounts.keys(), ...(type ? [type] : [])])].sort(); + const typeChips = `
Type${types.map(c => ``).join('')}
`; const orgsWith = new Set(rows.map(d => d.org)).size; - const sub = rows.length ? `${plural(rows.length, 'item')} need${rows.length === 1 ? 's' : ''} you, from ${plural(orgsWith, 'organization')}. Each row names where it came from. An item leaves only when it is answered.` : `${noneRead(o, ['inbox']) ? 'No organization\'s inbox could be read.' : missed(o, ['inbox']).length || o.unread.length ? 'Nothing needs you in the organizations that were read.' : 'Nothing needs you right now.'} An item leaves only when it is answered.`; - return `
+ const tally = rows.length ? ` ${plural(rows.length, 'item')}${scope ? '' : ` from ${plural(orgsWith, 'organization')}`}.` : ''; + const sub = scope + ? `Items from ${esc(o.name(scope))} only.${tally} Every organization` + : `Everything waiting on you, from every organization.${tally} An item leaves only when it is answered.`; + return `

Inbox

${sub}

-
- ${orgChips(every, counts, org, everyRows.length, ['inbox'])} -
Seen and Answered are not built yet.
-
${unread} +
+ ${scope ? '' : `
Org${orgChips(every, counts, org, everyRows.length, ['inbox'])}
`} + ${typeChips} +
${table} - ${secH('Answered today', 'kept for 24 hours, then in the trail')} -

Answered and expired items are not built yet.

- ${typeof opts?.hire === 'function' ? opts.hire() : ''}
+ ${typeof opts.hire === 'function' ? opts.hire() : ''}
${insp}
`; @@ -843,7 +873,8 @@ const q = queryOf(); for (const [k, v] of Object.entries(change)) { if (v === null || v === undefined || v === '' || (Array.isArray(v) && !v.length)) q.delete(k); else q.set(k, Array.isArray(v) ? v.join(',') : String(v)); } const s = q.toString(); - history.replaceState(history.state, '', `${selHref(pathOf())}${s ? `?${s}` : ''}`); + // SP3: the address keeps its own form, an organization's or the system's (#/inbox). + history.replaceState(history.state, '', `${/^#\/orgs\/[^/?]+/.exec(location.hash)?.[0] ?? '#'}${pathOf()}${s ? `?${s}` : ''}`); if (render) document.dispatchEvent(new CustomEvent('mosaic-workspace-render')); } function setGrouping(g) { @@ -866,6 +897,8 @@ setQuery(s === f.sort ? { sort: s, order: f.order === 'desc' ? 'asc' : 'desc' } : { sort: s, order: null }); return; } + const kind = e.target.closest('[data-ws-type]'); + if (kind) { setQuery({ type: kind.dataset.wsType || null, d: null }); return; } const grp = e.target.closest('[data-ws-group]'); if (grp) { setGrouping(grp.dataset.wsGroup); return; } const pick = e.target.closest('[data-ws-select]'); diff --git a/packages/webui/tests/shell.test.mjs b/packages/webui/tests/shell.test.mjs index 249cfa72e..5a978e706 100644 --- a/packages/webui/tests/shell.test.mjs +++ b/packages/webui/tests/shell.test.mjs @@ -68,7 +68,11 @@ test('shipped strings are unchanged, and the web keeps its write boundary', () = assert.deepEqual(writes, ["fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });", "fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), cache: 'no-store', redirect: 'manual' });", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});", "fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/proxy/plugins/set',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision:harnessSettings.revision,plugins:{harness:selected}}),cache:'no-store',redirect:'manual'});", "fetch('/api/proxy/plugins/set',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision:settings.revision,plugins:{'model-provider':next}}),cache:'no-store',redirect:'manual'});", "fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/project-file', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(sent), cache: 'no-store', redirect: 'manual' });", "fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });"]); assert.equal(read('bus.js').toString().split(/method:/).length - 1, 11); // Ruling 2026-11-14 (#1873): Fire on an Agents card is a second hire.start caller, {op:'fire'}. - assert.deepEqual([...read('bus.js').toString().matchAll(/decidePost\(([^,]*),/g)].map(m => m[1]), ["`/api/decisions/hire/${templatesOnly?'templates':'start'}`", "'/api/decisions/hire/start'", 'url', '`/api/decisions/${enc(a.id)}/confirmation`', '`/api/decisions/${enc(a.id)}/resolve`']); + // SP3 (#1873): an answer from the system Inbox goes to its row's organization. decideBase is + // /api/orgs/ for such an answer and /api otherwise, so the route is still a decision's + // confirmation or resolve, only pinned to one organization. + assert.deepEqual([...read('bus.js').toString().matchAll(/decidePost\(([^,]*),/g)].map(m => m[1]), ["`/api/decisions/hire/${templatesOnly?'templates':'start'}`", "'/api/decisions/hire/start'", 'url', '`${decideBase(a)}/decisions/${enc(a.id)}/confirmation`', '`${decideBase(a)}/decisions/${enc(a.id)}/resolve`']); + assert.deepEqual([...read('bus.js').toString().matchAll(/const decideBase = [^;]*;/g)].map(m => m[0]), ["const decideBase = a => a.org ? `/api/orgs/${enc(a.org)}` : '/api';"]); // Row 155 (#1649): the one fetch(url, ...) posts only to these three routes, each a literal. // Row 176 (#1674): the tester name form adds a second /api/name caller. assert.deepEqual([...read('bus.js').toString().matchAll(/assistantPost\(([^,]*),/g)].map(m => m[1]), ['url', "'/api/settings'", "'/api/name'", "'/api/assistant-stop'", "'/api/name'"]); diff --git a/packages/webui/tests/system-inbox-browser.test.mjs b/packages/webui/tests/system-inbox-browser.test.mjs new file mode 100644 index 000000000..3a4b5991b --- /dev/null +++ b/packages/webui/tests/system-inbox-browser.test.mjs @@ -0,0 +1,187 @@ +// SP3 (#1873, single pane slice 3): one Inbox for the whole system at #/inbox. Every organization +// that answers adds its items to one list, newest first, each row with its organization chip and a +// project chip when it has one. Filters by organization and type live in the hash query. An answer +// given from the list goes to the row's own organization, never to the one opened last. A stopped +// organization is named at the top with its reason. #/orgs//inbox is the same list, filtered. +// Every bus is a fake: acme and beta answer, gamma's bus is stopped. The real GET /api/workspace +// and the real per-organization routes serve the page. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { startServer } from '../src/serve.mjs'; +import { createDevOwner } from '../src/dev-owner.mjs'; +import { browser } from './browser.mjs'; +import { close } from './fixture.mjs'; + +const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`); +const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString(); + +const ACME_DECISION = 'dec-acme-1'; +const BETA_DECISION = 'dec-beta-1'; +const DIGEST = 'd'.repeat(64); +function fixture({ empty = false } = {}) { + const LIST = [ + { id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true }, + { id: 'beta', label: 'Beta Works', theme: 'grove', busHealthy: true }, + { id: 'gamma', label: 'Gamma Home', theme: 'rosewood', busHealthy: false }, + ]; + const DATA = { + acme: { + projects: [{ id: 'p-site', repo: 'site', name: 'Website', archived: false, at: at(30) }], + tasks: [{ task_ref: 'local:1', project_key: 'site', at: at(20), fields: { title: 'Hero copy', done: false, bucket: 'Doing' } }], + agents: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'working', at: at(40), working: { since: at(40) } }], + inbox: [{ id: ACME_DECISION, question: 'Ship the hero?', class: 'gated', blocking: true, raised_by_role: 'coder', task_ref: 'local:1', at: at(10), options: [{ key: 'yes', text: 'Ship it' }, { key: 'no', text: 'Hold' }] }], + }, + beta: { + projects: [{ id: 'p-shop', repo: 'shop', name: 'Shop', archived: false, at: at(120) }], + tasks: [], agents: [{ role: 'pm', holder_run: 'run-9', harness: 'claude', state: 'waiting', at: at(15) }], + // Eligible for an answer in the Console: the bus marks it with its `console` field. + inbox: [{ id: BETA_DECISION, question: 'Pick a launch price', class: 'within-role', blocking: false, raised_by_role: 'pm', at: at(5), options: [{ key: 'low', text: 'Low' }, { key: 'high', text: 'High' }], console: { kind: 'decision', options: [{ key: 'low', text: 'Low' }, { key: 'high', text: 'High' }], digest: DIGEST } }], + }, + }; + if (empty) { DATA.acme.inbox = []; DATA.beta.inbox = []; } + 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') }, + chat: { threads: () => value(id, 'threads'), projects: () => value(id, 'projects') }, + }); + const member = org => ({ + id: org.id, doc: { appearance: { theme: org.theme } }, + status: async () => (org.busHealthy ? { healthy: true, pid: 4242, boot: null } : { healthy: false, boot: { ok: false, at: at(90), lastGood: null, reason: 'stopped by the owner' } }), + options: () => opts(org.id), + }); + return { + list: async () => LIST, + members: async () => LIST.map(member), + resolve: async id => { + const org = LIST.find(o => o.id === id); if (!org) return null; + if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } }; + 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: {} }) }, + chat: { projects: async () => DATA[id].projects, threads: async () => [] }, + }; + }, + }; +} + +// Runs before the page's scripts, so display.js wraps it and it sees each request's scoped URL. Every +// organization's config says this Console answers decisions; decision POSTs are recorded and +// answered from a queue, so no real write happens. +const STUB = `(() => { + const real = window.fetch.bind(window); + window.__posts = []; window.__answers = []; + const answer = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + window.fetch = async (url, init = {}) => { + const u = String(url); + if (/^\\/api\\/(?:orgs\\/[^/]+\\/)?config$/.test(u)) { + const c = await (await real(url, init)).json(); + return answer(200, { ...c, send: true, decide: true }); + } + if (!u.includes('/decisions/')) return real(url, init); + window.__posts.push({ url: u, method: init.method, body: init.body }); + const a = window.__answers.shift(); + if (!a) throw new TypeError('no answer queued'); + return answer(a.status ?? 200, a.body); + }; +})();`; + +async function setup(t, { viewport = [1440, 900], hash = '#/inbox', empty = false, stub = false } = {}) { + const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ empty }), log: () => {} }); + t.after(() => close(server)); + const b = await browser({ machine: false }); t.after(() => b.close()); + await b.viewport(...viewport); + if (stub) await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB }); + const base = `http://127.0.0.1:${server.address().port}`; + await b.navigate(`${base}/${hash}`); + return { b, base, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) }; +} +const items = 'JSON.stringify([...document.querySelectorAll("[data-ws-item]")].map(r => r.dataset.wsItem))'; + +test('#/inbox lists both organizations in one list, newest first, each row with its organization and project chips', async t => { + const { b, wait, text } = await setup(t); + await wait(`${items} === ${JSON.stringify(JSON.stringify([`beta:${BETA_DECISION}`, `acme:${ACME_DECISION}`]))}`); + // A system route: the address keeps no organization and the sidebar Inbox is this page. + assert.equal(await b.evaluate('location.hash.split("?")[0]'), '#/inbox'); + assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]")?.getAttribute("href")'), '#/inbox'); + assert.equal(await text('#bus-view h1'), 'Inbox'); + // Organization chips on every row; the project chip only where the item has a project. + assert.equal(await text(`[data-ws-item="acme:${ACME_DECISION}"] [data-ws-orgchip]`), 'Acme Studio'); + assert.equal(await text(`[data-ws-item="beta:${BETA_DECISION}"] [data-ws-orgchip]`), 'Beta Works'); + assert.equal(await text(`[data-ws-item="acme:${ACME_DECISION}"] [data-ws-projchip]`), 'Website'); + assert.equal(await b.evaluate(`document.querySelector('[data-ws-item="beta:${BETA_DECISION}"] [data-ws-projchip]')`), null); + // The stopped organization is named at the top, before the list, with its reason. + const note = await text('[data-ws-unread]'); + assert.match(note, /Gamma Home/); + assert.match(note, /stopped by the owner/); + assert.equal(await b.evaluate('!!(document.querySelector("[data-ws-unread]").compareDocumentPosition(document.querySelector("[data-ws-items]")) & Node.DOCUMENT_POSITION_FOLLOWING)'), true); + // The sidebar Inbox count and the dashboard read the same figure. + await wait('document.querySelector("#sections a[href=\'#/inbox\'] .ws-n")?.textContent === "2"'); + await b.evaluate('location.hash = "#/dashboard"'); + await wait('document.querySelector("[data-ws-tile=needs]")'); + assert.match(await text('[data-ws-tile=needs]'), /Needs you\s*2\s*items/i); + const needs = await b.evaluate('[...document.querySelectorAll("[data-ws-needs] a.q")].map(a => a.getAttribute("href"))'); + assert.ok(needs.includes(`#/inbox?d=${encodeURIComponent(`beta:${BETA_DECISION}`)}`), JSON.stringify(needs)); + assert.ok(await b.evaluate('[...document.querySelectorAll("[data-ws-home] a")].some(a => a.getAttribute("href") === "#/inbox")')); +}); + +test('the organization and type filters live in the hash and survive a reload', async t => { + const { b, base, wait } = await setup(t); + await wait('document.querySelectorAll("[data-ws-item]").length === 2'); + await b.evaluate('document.querySelector("[data-ws-org=beta]").click()'); + await wait(`location.hash.startsWith("#/inbox?") && new URLSearchParams(location.hash.split("?")[1]).get("org") === "beta" && ${items} === ${JSON.stringify(JSON.stringify([`beta:${BETA_DECISION}`]))}`); + const filtered = await b.evaluate('location.hash'); + await b.navigate(`${base}/${filtered}`); + await wait(`${items} === ${JSON.stringify(JSON.stringify([`beta:${BETA_DECISION}`]))} && document.querySelector("[data-ws-org=beta]")?.getAttribute("aria-pressed") === "true"`); + // Type: the classes the read carries, each a chip; choosing one narrows the list. + await b.evaluate('document.querySelector("[data-ws-org=\'\']").click()'); + await wait('document.querySelectorAll("[data-ws-item]").length === 2'); + assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-type]")].map(x => x.dataset.wsType)'), ['', 'gated', 'within-role']); + await b.evaluate('document.querySelector("[data-ws-type=gated]").click()'); + await wait(`new URLSearchParams(location.hash.split("?")[1]).get("type") === "gated" && ${items} === ${JSON.stringify(JSON.stringify([`acme:${ACME_DECISION}`]))}`); + await b.navigate(`${base}/${await b.evaluate('location.hash')}`); + await wait(`${items} === ${JSON.stringify(JSON.stringify([`acme:${ACME_DECISION}`]))} && document.querySelector("[data-ws-type=gated]")?.getAttribute("aria-pressed") === "true"`); + // Both at once with nothing left says so plainly. + await b.evaluate('location.hash = "#/inbox?org=beta&type=gated"'); + await wait('document.querySelector("[data-ws-items]")?.textContent === "Nothing matches these filters"'); +}); + +test('answering beta\'s row from the list while acme was opened last reaches beta', async t => { + const { b, wait } = await setup(t, { hash: '#/orgs/acme/organization', stub: true }); + await wait('document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"'); + await b.evaluate('location.hash = "#/inbox"'); + await wait('document.querySelectorAll("[data-ws-item]").length === 2'); + assert.equal(await b.evaluate('window.MosaicOrg.id'), 'acme'); + await b.evaluate(`document.querySelector('[data-ws-select="beta:${BETA_DECISION}"]').click()`); + await wait(`document.querySelector("[data-ws-insp]")?.dataset.wsInsp === "beta:${BETA_DECISION}" && document.querySelector("#decide-section [data-decide]")`); + // The item also opens in its own organization. + assert.equal(await b.evaluate('document.querySelector("[data-ws-insp] [data-ws-open]")?.getAttribute("href")'), `#/orgs/beta/inbox/${BETA_DECISION}`); + await b.evaluate(`window.__answers.push({ body: { id: '${BETA_DECISION}', action: 'answer', choice: 'high', text: 'High', confirmation: 'c-1', kind: 'decision' } }, { body: { id: '${BETA_DECISION}', op: 'resolved', choice: 'high' } })`); + await b.evaluate('[...document.querySelectorAll("#decide-section [data-decide]")].find(x => x.textContent === "High").click()'); + await wait('document.querySelector("#decide-section [data-decide=confirm]")'); + await b.evaluate('document.querySelector("#decide-section [data-decide=confirm]").click()'); + await wait('/Recorded/.test(document.getElementById("decide-status")?.textContent ?? "")'); + const posts = await b.evaluate('window.__posts'); + assert.deepEqual(posts.map(p => p.url), [`/api/orgs/beta/decisions/${BETA_DECISION}/confirmation`, `/api/orgs/beta/decisions/${BETA_DECISION}/resolve`]); + assert.deepEqual(JSON.parse(posts[1].body), { choice: 'high', confirmation: 'c-1' }); + // Still a system route, still scoped to acme for everything else. + assert.equal(await b.evaluate('location.hash.split("?")[0]'), '#/inbox'); + assert.equal(await b.evaluate('window.MosaicOrg.id'), 'acme'); +}); + +test('#/orgs//inbox is the same list filtered to that organization', async t => { + const { b, wait, text } = await setup(t, { hash: '#/orgs/beta/inbox' }); + await wait(`${items} === ${JSON.stringify(JSON.stringify([`beta:${BETA_DECISION}`]))}`); + assert.match(await text('[data-ws-inbox] .sub'), /Beta Works/); + assert.equal(await b.evaluate('document.querySelector("[data-ws-inbox] a[href=\'#/inbox\']") !== null'), true); + // Selecting a row keeps the organization address. + await b.evaluate(`document.querySelector('[data-ws-select="beta:${BETA_DECISION}"]').click()`); + await wait(`location.hash.startsWith("#/orgs/beta/inbox?") && document.querySelector("[data-ws-insp]")?.dataset.wsInsp === "beta:${BETA_DECISION}"`); +}); + +test('with nothing waiting the list says so and still names the stopped organization', async t => { + const { wait, text } = await setup(t, { empty: true }); + await wait('document.querySelector("[data-ws-items]")'); + assert.equal(await text('[data-ws-items]'), 'Nothing needs you'); + assert.match(await text('[data-ws-unread]'), /Gamma Home.*stopped by the owner/s); +}); diff --git a/packages/webui/tests/workspace-pages-browser.test.mjs b/packages/webui/tests/workspace-pages-browser.test.mjs index 9f2bce045..2ff4b252d 100644 --- a/packages/webui/tests/workspace-pages-browser.test.mjs +++ b/packages/webui/tests/workspace-pages-browser.test.mjs @@ -75,7 +75,8 @@ async function setup(t, { viewport = [1440, 900], hash = '/', workspaceTimeoutMs const b = await browser({ machine: false }); t.after(() => b.close()); await b.viewport(...viewport); const base = `http://127.0.0.1:${server.address().port}`; - await b.navigate(`${base}/#/orgs/acme${hash}`); + // SP3: a hash that starts with # is a whole address (#/inbox is a system route). + await b.navigate(`${base}/${hash.startsWith('#') ? hash : `#/orgs/acme${hash}`}`); return { b, base, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) }; } @@ -107,7 +108,7 @@ test('Dashboard renders every section across organizations, and a stopped one sh // Needs you lists both organizations' decisions, newest raised first as the read orders them. const needs = await b.evaluate('[...document.querySelectorAll("[data-ws-needs] a.q")].map(a => [a.textContent, a.getAttribute("href")])'); assert.equal(needs.length, 2); - assert.ok(needs.some(([q, h]) => q === 'Ship the hero?' && h === `#/orgs/acme/inbox?d=${encodeURIComponent(`acme:${ACME_DECISION}`)}`), JSON.stringify(needs)); + assert.ok(needs.some(([q, h]) => q === 'Ship the hero?' && h === `#/inbox?d=${encodeURIComponent(`acme:${ACME_DECISION}`)}`), JSON.stringify(needs)); assert.ok(needs.some(([q]) => q === 'Pick a launch price')); // Sessions now: the working and the waiting role, each with its organization. assert.match(await text('[data-ws-sessions]'), /coder/); @@ -179,7 +180,7 @@ test('Projects sorts and filters by each key, remembers the grouping, and rows o const location = hash => decodeURIComponent(hash); test('Inbox lists both organizations, copies a command with --org and the full id, and deciding needs human proof', async t => { - const { b, base, wait, text } = await setup(t, { hash: '/inbox' }); + const { b, base, wait, text } = await setup(t, { hash: '#/inbox' }); await wait('document.querySelectorAll("[data-ws-item]").length === 2 && document.querySelector("[data-ws-insp]")'); assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page] .t")?.textContent'), 'Inbox'); assert.equal(await b.evaluate('document.querySelector("[data-not-built]")'), null); @@ -189,11 +190,13 @@ test('Inbox lists both organizations, copies a command with --org and the full i const acme = items.find(([k]) => k.startsWith('acme'))[1]; assert.match(acme, /Acme Studio/); assert.match(acme, /Website/); assert.match(acme, /coder/); assert.match(acme, /gated/); const beta = items.find(([k]) => k.startsWith('beta'))[1]; - assert.match(beta, /Beta Works/); assert.match(beta, /No project/); assert.match(beta, /pm/); + assert.match(beta, /Beta Works/); assert.match(beta, /pm/); + // SP3: a project chip only where the item has a project. + assert.equal(await b.evaluate(`document.querySelector('[data-ws-item="beta:${BETA_DECISION}"] [data-ws-projchip]')`), null); assert.match(await text('[data-ws-unread]'), /Gamma Home down/); - // Tabs the data can't back say so. - assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-inbox] .ws-seg button")].map(x => [x.textContent, x.disabled])'), [['Needs you', false], ['Seen', true], ['Answered', true]]); - assert.match(await text('[data-ws-inbox]'), /not built yet/); + // SP3: the prototype's Org and Type filter rows; the Seen and Answered placeholders are gone. + assert.equal(await b.evaluate('document.querySelector("[data-ws-inbox] .ws-seg")'), null); + assert.ok(await b.evaluate('document.querySelectorAll("[data-ws-type]").length > 1')); // Select the acme decision; the inspector shows it, and the selection stays in the hash. // A pointer click focuses the link before it activates; .click() alone does not. await b.evaluate(`(() => { const a = document.querySelector('[data-ws-select="acme:${ACME_DECISION}"]'); a.focus(); a.click(); })()`); @@ -216,7 +219,7 @@ test('Inbox lists both organizations, copies a command with --org and the full i await wait('[...document.querySelectorAll("#bus-view h1")].length > 0 && !document.querySelector("#bus-view .loading")'); assert.equal(await b.evaluate('document.querySelector("#decide-section [data-decide]")'), null); // Filter to one organization. - await b.navigate(`${base}/#/orgs/acme/inbox`); + await b.navigate(`${base}/#/inbox`); await wait('document.querySelectorAll("[data-ws-item]").length === 2'); await b.evaluate('document.querySelector("[data-ws-org=beta]").click()'); await wait(`document.querySelectorAll("[data-ws-item]").length === 1 && document.querySelector("[data-ws-item]").dataset.wsItem === ${JSON.stringify(`beta:${BETA_DECISION}`)}`); @@ -270,10 +273,11 @@ test('Phone width: no horizontal scroll on any of the three pages, and the drawe await b.evaluate('document.querySelector("#sections a[href=\'#/projects\']").click()'); await wait('location.hash === "#/orgs/acme/projects" && document.querySelector("[data-ws-project]")'); assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true); - // An inbox row at phone width puts its source on its own line above the question. - await b.evaluate('location.hash = "#/orgs/acme/inbox"'); - await wait('document.querySelector("[data-ws-item] .ws-msrc")'); - assert.equal(await b.evaluate(`(() => { const r = document.querySelector('[data-ws-item]'), s = r.querySelector('.ws-msrc').getBoundingClientRect(), q = r.querySelector('a.q').getBoundingClientRect(); return s.height > 0 && s.bottom <= q.top + 1; })()`), true, 'source above question'); + // An inbox row at phone width puts its organization chip on its own line below the question. + await b.evaluate('location.hash = "#/inbox"'); + await wait('document.querySelectorAll("[data-ws-item]").length === 2'); + assert.equal(await b.evaluate(`(() => { const r = document.querySelector('[data-ws-item]'), s = r.querySelector('[data-ws-orgchip]').getBoundingClientRect(), q = r.querySelector('a.q').getBoundingClientRect(); return s.height > 0 && s.top >= q.bottom - 1; })()`), true, 'organization below question'); + assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, 'system inbox'); }); test('A read that an answering organization could not make says not read, never zero', async t => { @@ -297,7 +301,7 @@ test('A read that an answering organization could not make says not read, never await wait('document.querySelector("[data-ws-miss=projects]")'); assert.match(await text('[data-ws-miss=projects]'), /Not read in Beta Works/); // Inbox: the note names it, and the chip carries no count. - await b.evaluate('location.hash = "#/orgs/acme/inbox"'); + await b.evaluate('location.hash = "#/inbox"'); await wait('document.querySelector("[data-ws-miss=inbox]")'); assert.match(await text('[data-ws-miss=inbox]'), /Not read in Beta Works \(the chat channel is off\)/); assert.match(await text('[data-ws-org=beta] .n'), /not read/i); @@ -342,7 +346,7 @@ test('With every organization down the dashboard and inbox show no totals and no assert.equal(await text('[data-ws-states]'), 'Tasks not read'); assert.equal(await text('[data-ws-recent]'), 'Projects not read'); assert.equal(await text('[data-ws-sessions]'), 'Sessions not read'); - await b.evaluate('location.hash = "#/orgs/acme/inbox"'); + await b.evaluate('location.hash = "#/inbox"'); await wait('document.querySelector("[data-ws-items]")'); assert.equal(await text('[data-ws-items]'), 'Decisions not read'); await b.evaluate('location.hash = "#/orgs/acme/projects?grouping=flat"'); -- 2.54.0 From ba9933309b5a017a1f4ed7e8688ae458f9c5000b Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 01:09:49 -0600 Subject: [PATCH 2/3] fix(webui): sp3 review round, answers from the system Inbox read route_to and show who raised them, the hire form is linked, the sidebar count stays system-wide (#1873, lane sp3) --- packages/webui/src/public/bus.js | 16 ++++++---- packages/webui/src/public/workspace.js | 21 +++++++++---- .../webui/tests/system-inbox-browser.test.mjs | 30 +++++++++++++++++-- 3 files changed, 54 insertions(+), 13 deletions(-) diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index 1f271b643..3a2f844ee 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -628,14 +628,17 @@ async function inboxAnswer(d, current) { if (!d || isTester || !d.console || typeof d.org !== 'string') return inboxKeep(d); const base = `/api/orgs/${enc(d.org)}`; - let eligible = null, failed = false; + let eligible = null, failed = false, seen = null; try { const [c, i] = await Promise.all([`${base}/config`, `${base}/bus/inbox`].map(u => fetch(u, { cache: 'no-store', redirect: 'manual' }))); if (c.type === 'opaqueredirect' || i.type === 'opaqueredirect') { if (current()) signOut(); return ''; } if (!c.ok || !i.ok) throw new Error('not read'); const [cfg, box] = await Promise.all([c.json(), i.json()]); const row = (Array.isArray(box?.rows) ? box.rows : []).find(r => r?.id === d.id); - eligible = cfg?.decide === true && cfg?.tester !== true && row?.routed_to === 'human' && row.console && typeof row.console === 'object' ? row.console : null; + // The broker's inbox row is the decisions row (`route_to`) plus `authorization`; the confirm + // step shows who raised it and what it affects from it, as the decision page does from the trail. + eligible = cfg?.decide === true && cfg?.tester !== true && row?.route_to === 'human' && row.console && typeof row.console === 'object' ? row.console : null; + if (eligible) seen = { at: row.at, raisedBy: { role: row.raised_by_role, run: row.raised_by_run }, routeTo: row.route_to, authorization: row.authorization ?? null }; } catch { failed = true; } if (!current()) return ''; const same = answer?.org === d.org && answer.id === d.id; @@ -645,7 +648,7 @@ answer.console = eligible; } else if (!eligible && !failed && !answer.pending && !answer.result) answer = null; - if (answer?.org === d.org && answer.id === d.id) return decideHtml(); + if (answer?.org === d.org && answer.id === d.id) { if (seen) answer.decision = seen; return decideHtml(); } return failed ? '

Whether this Console can answer it here was not read: the organization did not answer. Open it in its organization, or use a terminal.

' : ''; } // A row this Console cannot answer drops any answer held for another row, unless one is in flight. @@ -1662,7 +1665,7 @@ if (a === 'inbox' && n === 1 && window.MosaicOrg) { const scope = /^#\/orgs\/([^/?]+)/.exec(location.hash)?.[1]; let org = null; try { org = scope ? decodeURIComponent(scope) : null; } catch { return notFound(); } - return configRead.then(() => window.MosaicWorkspace.inboxPage(q, { scope: org, hire: org ? hireStartHtml : null, answer: d => inboxAnswer(d, current), answered: inboxAnswered })); + return configRead.then(() => window.MosaicWorkspace.inboxPage(q, { scope: org, hire: org ? hireStartHtml : null, hireIn: !org && canDecide, answer: d => inboxAnswer(d, current), answered: inboxAnswered })); } if (a === 'tasks' && n === 1 && window.MosaicOrg) return window.MosaicTasksBoard.view(); if (a === 'inbox' && n === 1) return inboxView(get); @@ -1819,7 +1822,10 @@ const refused = failed.find(refusal); if (refused) forget(); if (g !== gen) return; - if (inbox.status === 'fulfilled' && !refused) countInbox(inbox.value.rows); + // SP3 (#1873): with organizations the sidebar count is every organization's, from the same + // workspace read as #/inbox, never this one organization's inbox. + if (window.MosaicOrg) window.MosaicWorkspace?.inboxCount?.(); + else if (inbox.status === 'fulfilled' && !refused) countInbox(inbox.value.rows); if (messages.status === 'fulfilled' && !refused) messageCount = messages.value.unread; const good = result => !refused && result.status === 'fulfilled' ? result.value : null; const taskRows = good(tasks)?.rows, queueRows = good(queue)?.rows, projectRows = good(projects)?.rows; diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index 00d1460dd..a94292493 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -561,6 +561,10 @@ return `

${title}

${why}

`; } // The Inbox count in the navigation follows the cross-organization read wherever it was made. + // SP3: the sidebar's Inbox count on a page that reads one organization (the board) comes from the + // workspace read too, so it says what #/inbox lists. A failed read leaves the count as it was. + const inboxCount = () => data.workspace({ sections: ['inbox'] }).then(b => { if (!noneRead(orgsOf(b), ['inbox'])) tellInbox(arr(b.inbox).filter(d => d && typeof d.org === 'string')); }).catch(() => {}); + const newest = (a, b) => (b.at ?? '').localeCompare(a.at ?? ''); const tellInbox = rows => { if (Array.isArray(rows)) document.dispatchEvent(new CustomEvent('mosaic-workspace-inbox', { detail: { open: rows.length, blocking: rows.filter(d => d?.blocking).length } })); }; // A stacked bar and its per-organization figures, one segment per organization. const bar = (o, parts, style = '') => ``; @@ -639,7 +643,8 @@ const max = Math.max(1, ...srows.map(s => s.n)); const states = srows.length ? `
${srows.map(s => { const ps = [...s.g.entries()].filter(([, n]) => n > 0).map(([id, n]) => ({ id, n })); return `
${esc(s.name)}${s.n}${bar(o, ps, `width:${Math.max(4, Math.round((s.n / max) * 90))}%`)}${by(o, ps)}
`; }).join('')}
` : `

${noneRead(o, ['tasks']) ? 'Tasks not read' : 'No open tasks'}

`; - const needs = inbox.slice(0, 5); + // SP3: the same order as #/inbox, newest first, so these five are the Inbox's top five. + const needs = inbox.slice().sort(newest).slice(0, 5); const needsList = needs.length ? `${inbox.length > needs.length ? `

${inbox.length - needs.length} more in the inbox

` : ''}` : `

${noneRead(o, ['inbox']) ? 'Decisions not read' : 'Nothing needs you'}

`; const now = sessions.filter(s => s.kind === 'thread' || s.state === 'working' || s.state === 'waiting'); @@ -793,7 +798,7 @@ for (const d of everyRows) counts.set(d.org, (counts.get(d.org) || 0) + 1); // The organizations in view: every one, or the chosen ones. const o = org ? { ...every, rows: every.rows.filter(r => org.includes(r.org)), unread: every.unread.filter(r => org.includes(r.org)) } : every; - const inOrg = everyRows.filter(d => !org || org.includes(d.org)).sort((a, b) => (b.at ?? '').localeCompare(a.at ?? '')); + const inOrg = everyRows.filter(d => !org || org.includes(d.org)).sort(newest); const rows = inOrg.filter(d => !type || d.class === type); const picked = want === 'none' ? null : rows.find(d => itemKey(d) === want) || (want ? null : rows[0] || null); const here = path => (scope ? `${orgHref(scope)}${path}` : `#${path}`); @@ -812,7 +817,7 @@ const why = r => `${stateWords(r).replace(/, read .*$/, '')}${r.reason ? `: ${esc(r.reason)}` : ''}${r.at ? ` (read ${esc(hm(r.at))})` : ''}`; const unread = (o.unread.length ? `

Not read: ${andList(o.unread.map(why))}.

` : '') + missLine(o, ['inbox'], 'inbox'); const filtered = (org && !scope) || type; - const table = rows.length ? `
${rows.map(row).join('')}
Items that need you
NeedsTypeWaiting
` : `

${noneRead(o, ['inbox']) ? 'Decisions not read' : filtered ? 'Nothing matches these filters' : 'Nothing needs you'}

`; + const table = rows.length ? `
${rows.map(row).join('')}
Items that need you
NeedsClassWaiting
` : `

${noneRead(o, ['inbox']) ? 'Decisions not read' : filtered ? 'Nothing matches these filters' : 'Nothing needs you'}

`; let insp = ''; if (picked) { const d = picked, id = esc(d.id); @@ -846,7 +851,11 @@ const typeCounts = new Map(); for (const d of inOrg) if (typeof d.class === 'string' && TYPE_RE.test(d.class)) typeCounts.set(d.class, (typeCounts.get(d.class) || 0) + 1); const types = [...new Set([...typeCounts.keys(), ...(type ? [type] : [])])].sort(); - const typeChips = `
Type${types.map(c => ``).join('')}
`; + const typeChips = `
Class${types.map(c => ``).join('')}
`; + // The hire, fire and change form posts to one organization's bus, so the system list links to + // each answering organization's own Inbox, where the form is. + const hiring = opts.hireIn && !scope ? o.rows.filter(r => r.state === 'ok') : []; + const hireIn = hiring.length ? `

Hire, fire or change an agent in ${andList(hiring.map(r => `${esc(o.name(r.org))}`))}.

` : ''; const orgsWith = new Set(rows.map(d => d.org)).size; const tally = rows.length ? ` ${plural(rows.length, 'item')}${scope ? '' : ` from ${plural(orgsWith, 'organization')}`}.` : ''; const sub = scope @@ -860,7 +869,7 @@ ${typeChips}
${table} - ${typeof opts.hire === 'function' ? opts.hire() : ''}
+ ${typeof opts.hire === 'function' ? opts.hire() : hireIn}
${insp}
`; @@ -918,6 +927,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, setGrouping, 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, inboxCount, setGrouping, get pollMs() { return pollMs; } }); refresh(); })(); diff --git a/packages/webui/tests/system-inbox-browser.test.mjs b/packages/webui/tests/system-inbox-browser.test.mjs index 3a4b5991b..6e512b239 100644 --- a/packages/webui/tests/system-inbox-browser.test.mjs +++ b/packages/webui/tests/system-inbox-browser.test.mjs @@ -58,7 +58,8 @@ function fixture({ empty = false } = {}) { 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: {} }) }, + // The broker's inbox row: the decisions row, so `route_to`, plus `authorization`. + 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', route_to: 'human', authorization: null })), tasks: async () => [], instances: async () => ({ business: id, instances: {} }) }, chat: { projects: async () => DATA[id].projects, threads: async () => [] }, }; }, @@ -147,7 +148,7 @@ test('the organization and type filters live in the hash and survive a reload', }); test('answering beta\'s row from the list while acme was opened last reaches beta', async t => { - const { b, wait } = await setup(t, { hash: '#/orgs/acme/organization', stub: true }); + const { b, wait, text } = await setup(t, { hash: '#/orgs/acme/organization', stub: true }); await wait('document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"'); await b.evaluate('location.hash = "#/inbox"'); await wait('document.querySelectorAll("[data-ws-item]").length === 2'); @@ -159,6 +160,11 @@ test('answering beta\'s row from the list while acme was opened last reaches bet await b.evaluate(`window.__answers.push({ body: { id: '${BETA_DECISION}', action: 'answer', choice: 'high', text: 'High', confirmation: 'c-1', kind: 'decision' } }, { body: { id: '${BETA_DECISION}', op: 'resolved', choice: 'high' } })`); await b.evaluate('[...document.querySelectorAll("#decide-section [data-decide]")].find(x => x.textContent === "High").click()'); await wait('document.querySelector("#decide-section [data-decide=confirm]")'); + // The confirm step names who raised the item, from beta's bus row, as the decision page does. + const confirm = await text('#decide-section'); + assert.match(confirm, /Raised by pm · run run/); + assert.match(confirm, /Decision routed to: human/); + assert.doesNotMatch(confirm, /date unknown/); await b.evaluate('document.querySelector("#decide-section [data-decide=confirm]").click()'); await wait('/Recorded/.test(document.getElementById("decide-status")?.textContent ?? "")'); const posts = await b.evaluate('window.__posts'); @@ -185,3 +191,23 @@ test('with nothing waiting the list says so and still names the stopped organiza assert.equal(await text('[data-ws-items]'), 'Nothing needs you'); assert.match(await text('[data-ws-unread]'), /Gamma Home.*stopped by the owner/s); }); + +test('the hire, fire and change form is reached by clicks from #/inbox, one link per answering organization', async t => { + const { b, wait } = await setup(t, { stub: true }); + await wait('document.querySelector("[data-ws-hire-in]")'); + assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-hire-in] a")].map(a => [a.textContent, a.getAttribute("href")])'), [['Acme Studio', '#/orgs/acme/inbox'], ['Beta Works', '#/orgs/beta/inbox']]); + // The system list has no form of its own: a hire goes to one organization's bus. + assert.equal(await b.evaluate('document.getElementById("hire-start")'), null); + await b.evaluate('[...document.querySelectorAll("[data-ws-hire-in] a")].find(a => a.textContent === "Beta Works").click()'); + await wait('location.hash === "#/orgs/beta/inbox" && document.getElementById("hire-start")'); + assert.equal(await b.evaluate('window.MosaicOrg.id'), 'beta'); + assert.equal(await b.evaluate('document.querySelector("#hire-start input[readonly]")?.value'), 'beta'); +}); + +test('the sidebar Inbox count on an organization\'s board is every organization\'s, as #/inbox lists', async t => { + const { b, wait } = await setup(t, { hash: '#/orgs/acme/board' }); + await wait('document.querySelector("#sections a[href$=\'/inbox\'] .ws-n")?.textContent === "2"'); + // Held across the board's next refresh, which reads only acme's inbox. + await new Promise(r => setTimeout(r, 300)); + assert.equal(await b.evaluate('document.querySelector("#sections a[href$=\'/inbox\'] .ws-n")?.textContent'), '2'); +}); -- 2.54.0 From 3885f4628b03d1eea527caa2eac9a7e513b50193 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 02:09:04 -0600 Subject: [PATCH 3/3] fix(webui): sp3 second review round, #/inbox keeps the system theme, hire links follow each organization's own decide (#1873, lane sp3) --- packages/webui/src/public/app.js | 3 +- packages/webui/src/public/bus.js | 6 +- packages/webui/src/public/display.js | 2 + packages/webui/src/public/workspace.js | 10 ++- .../webui/tests/system-inbox-browser.test.mjs | 64 ++++++++++++++++--- 5 files changed, 70 insertions(+), 15 deletions(-) diff --git a/packages/webui/src/public/app.js b/packages/webui/src/public/app.js index 80d74a12b..8b492746e 100644 --- a/packages/webui/src/public/app.js +++ b/packages/webui/src/public/app.js @@ -63,7 +63,8 @@ // #1873 SP2: the system dashboard is no organization's page. It shows the system theme: this browser's // own choice made there, else the default, never the theme of the organization opened last. const SYSTEM_KEY = 'mosaic-console-appearance-system'; - const onSystem = () => !!window.MosaicOrg && !/^#\/orgs\//.test(location.hash) && ['', '/', '/dashboard'].includes(location.hash.replace(/^#/, '').split('?')[0]); + // SP3: display.js owns which addresses are system pages (the dashboard and #/inbox); the bare address is one before it normalizes. + const onSystem = () => !!window.MosaicOrg && ((!/^#\/orgs\//.test(location.hash) && ['', '/'].includes(location.hash.replace(/^#/, '').split('?')[0])) || window.MosaicOrg.system?.() === true); const store = { read(k) { try { const v = JSON.parse(localStorage.getItem(k)); return v && typeof v === 'object' && !Array.isArray(v) ? v : null; } catch { return null; } }, write(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); return true; } catch { return false; } }, diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index 3a2f844ee..ba1796e11 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -620,6 +620,10 @@ paintDecide('#decide-status'); announce(['decision','hire'].includes(c.kind) ? 'Recorded' : c.action === 'approve' ? 'Approved' : 'Denied'); render(false); } else { a.error = decideRefusal(res, json, 'outcome-unknown'); paintDecide('#decide-status'); announce(`Not answered: ${a.error.message}`); } } + // Whether this Console decides in one organization, from that organization's own config: the system + // Inbox's hire links must not follow the organization opened last, whose bus may be the stopped one. + const decidesIn = id => (isTester ? Promise.resolve(false) : fetch(`/api/orgs/${enc(id)}/config`, { cache: 'no-store', redirect: 'manual' }) + .then(r => (r.ok ? r.json() : null)).then(c => c?.decide === true && c?.tester !== true).catch(() => false)); // SP3: the answer panel for the row selected in the Inbox, in that row's organization. That // organization's config says whether this Console answers decisions there, and its bus inbox says // whether this decision is eligible (its `console` field), so neither comes from the organization @@ -1665,7 +1669,7 @@ if (a === 'inbox' && n === 1 && window.MosaicOrg) { const scope = /^#\/orgs\/([^/?]+)/.exec(location.hash)?.[1]; let org = null; try { org = scope ? decodeURIComponent(scope) : null; } catch { return notFound(); } - return configRead.then(() => window.MosaicWorkspace.inboxPage(q, { scope: org, hire: org ? hireStartHtml : null, hireIn: !org && canDecide, answer: d => inboxAnswer(d, current), answered: inboxAnswered })); + return configRead.then(() => window.MosaicWorkspace.inboxPage(q, { scope: org, hire: org ? hireStartHtml : null, hireIn: org ? null : decidesIn, answer: d => inboxAnswer(d, current), answered: inboxAnswered })); } if (a === 'tasks' && n === 1 && window.MosaicOrg) return window.MosaicTasksBoard.view(); if (a === 'inbox' && n === 1) return inboxView(get); diff --git a/packages/webui/src/public/display.js b/packages/webui/src/public/display.js index 03da72349..87d6125fb 100644 --- a/packages/webui/src/public/display.js +++ b/packages/webui/src/public/display.js @@ -162,6 +162,8 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date( // The open organization's name as the last list read gave it, for the breadcrumb. get label() { const id = current ? decodeURIComponent(current) : null, row = id && rowsSeen.find(r => r.id === id); return id ? (typeof row?.label === 'string' && row.label ? row.label : id) : null; }, ready, route, get list() { return list; }, readList, refreshList, + // SP3: whether this address is a system page (no organization's), for the shell's theme scope. + system: () => system(location.hash), switchTo: next => { const path = route().split('?')[0]; location.hash = `#/orgs/${encodeURIComponent(next)}${path === '/dashboard' || path === '/' ? '/organization' : route()}`; }, }); Object.defineProperty(window, 'MosaicOrg', { configurable: true, enumerable: true, get: () => (current || !settled ? api : undefined) }); diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index a94292493..aa2cc5c3d 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -818,6 +818,13 @@ const unread = (o.unread.length ? `

Not read: ${andList(o.unread.map(why))}.

` : '') + missLine(o, ['inbox'], 'inbox'); const filtered = (org && !scope) || type; const table = rows.length ? `
${rows.map(row).join('')}
Items that need you
NeedsClassWaiting
` : `

${noneRead(o, ['inbox']) ? 'Decisions not read' : filtered ? 'Nothing matches these filters' : 'Nothing needs you'}

`; + // The hire, fire and change form posts to one organization's bus, so the system list links to + // each answering organization's own Inbox, where the form is: only those whose own config says this + // Console decides there, never judged by the organization opened last (its bus may be the stopped one). + // Read before the answer panel: its HTML must be the last thing this render waits for, so a + // confirmation shown while another read was in flight is never painted over with an older panel. + const asks = typeof opts.hireIn === 'function' && !scope ? o.rows.filter(r => r.state === 'ok') : []; + const hiring = (await Promise.all(asks.map(async r => ((await opts.hireIn(r.org)) === true ? r : null)))).filter(Boolean); let insp = ''; if (picked) { const d = picked, id = esc(d.id); @@ -852,9 +859,6 @@ for (const d of inOrg) if (typeof d.class === 'string' && TYPE_RE.test(d.class)) typeCounts.set(d.class, (typeCounts.get(d.class) || 0) + 1); const types = [...new Set([...typeCounts.keys(), ...(type ? [type] : [])])].sort(); const typeChips = `
Class${types.map(c => ``).join('')}
`; - // The hire, fire and change form posts to one organization's bus, so the system list links to - // each answering organization's own Inbox, where the form is. - const hiring = opts.hireIn && !scope ? o.rows.filter(r => r.state === 'ok') : []; const hireIn = hiring.length ? `

Hire, fire or change an agent in ${andList(hiring.map(r => `${esc(o.name(r.org))}`))}.

` : ''; const orgsWith = new Set(rows.map(d => d.org)).size; const tally = rows.length ? ` ${plural(rows.length, 'item')}${scope ? '' : ` from ${plural(orgsWith, 'organization')}`}.` : ''; diff --git a/packages/webui/tests/system-inbox-browser.test.mjs b/packages/webui/tests/system-inbox-browser.test.mjs index 6e512b239..f2125a5b9 100644 --- a/packages/webui/tests/system-inbox-browser.test.mjs +++ b/packages/webui/tests/system-inbox-browser.test.mjs @@ -35,7 +35,7 @@ function fixture({ empty = false } = {}) { projects: [{ id: 'p-shop', repo: 'shop', name: 'Shop', archived: false, at: at(120) }], tasks: [], agents: [{ role: 'pm', holder_run: 'run-9', harness: 'claude', state: 'waiting', at: at(15) }], // Eligible for an answer in the Console: the bus marks it with its `console` field. - inbox: [{ id: BETA_DECISION, question: 'Pick a launch price', class: 'within-role', blocking: false, raised_by_role: 'pm', at: at(5), options: [{ key: 'low', text: 'Low' }, { key: 'high', text: 'High' }], console: { kind: 'decision', options: [{ key: 'low', text: 'Low' }, { key: 'high', text: 'High' }], digest: DIGEST } }], + inbox: [{ id: BETA_DECISION, authorization: { action: 'x.y', target: 'shop', approvalChoice: 'high' }, question: 'Pick a launch price', class: 'within-role', blocking: false, raised_by_role: 'pm', at: at(5), options: [{ key: 'low', text: 'Low' }, { key: 'high', text: 'High' }], console: { kind: 'decision', options: [{ key: 'low', text: 'Low' }, { key: 'high', text: 'High' }], digest: DIGEST } }], }, }; if (empty) { DATA.acme.inbox = []; DATA.beta.inbox = []; } @@ -57,9 +57,9 @@ function fixture({ empty = false } = {}) { if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } }; 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 () => ({}) }, + 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 () => ({}) }, // The broker's inbox row: the decisions row, so `route_to`, plus `authorization`. - 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', route_to: 'human', authorization: null })), 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', route_to: 'human', authorization: d.authorization ?? null })), tasks: async () => [], instances: async () => ({ business: id, instances: {} }) }, chat: { projects: async () => DATA[id].projects, threads: async () => [] }, }; }, @@ -67,8 +67,9 @@ function fixture({ empty = false } = {}) { } // Runs before the page's scripts, so display.js wraps it and it sees each request's scoped URL. Every -// organization's config says this Console answers decisions; decision POSTs are recorded and -// answered from a queue, so no real write happens. +// organization's config says this Console answers decisions, except those named in `noDecide`; +// decision POSTs are recorded and answered from a queue, so no real write happens. +const stubFor = (noDecide = []) => `window.__noDecide = ${JSON.stringify(noDecide)};\n${STUB}`; const STUB = `(() => { const real = window.fetch.bind(window); window.__posts = []; window.__answers = []; @@ -77,7 +78,8 @@ const STUB = `(() => { const u = String(url); if (/^\\/api\\/(?:orgs\\/[^/]+\\/)?config$/.test(u)) { const c = await (await real(url, init)).json(); - return answer(200, { ...c, send: true, decide: true }); + const org = (u.match(/^\\/api\\/orgs\\/([^/]+)\\//) || [])[1]; + return answer(200, { ...c, send: true, decide: !window.__noDecide.includes(org) }); } if (!u.includes('/decisions/')) return real(url, init); window.__posts.push({ url: u, method: init.method, body: init.body }); @@ -87,12 +89,12 @@ const STUB = `(() => { }; })();`; -async function setup(t, { viewport = [1440, 900], hash = '#/inbox', empty = false, stub = false } = {}) { +async function setup(t, { viewport = [1440, 900], hash = '#/inbox', empty = false, stub = false, noDecide = [] } = {}) { const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ empty }), log: () => {} }); t.after(() => close(server)); const b = await browser({ machine: false }); t.after(() => b.close()); await b.viewport(...viewport); - if (stub) await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB }); + if (stub) await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stubFor(noDecide) }); const base = `http://127.0.0.1:${server.address().port}`; await b.navigate(`${base}/${hash}`); return { b, base, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) }; @@ -165,6 +167,7 @@ test('answering beta\'s row from the list while acme was opened last reaches bet assert.match(confirm, /Raised by pm · run run/); assert.match(confirm, /Decision routed to: human/); assert.doesNotMatch(confirm, /date unknown/); + assert.match(confirm, /Affected object: shop/); await b.evaluate('document.querySelector("#decide-section [data-decide=confirm]").click()'); await wait('/Recorded/.test(document.getElementById("decide-status")?.textContent ?? "")'); const posts = await b.evaluate('window.__posts'); @@ -204,10 +207,51 @@ test('the hire, fire and change form is reached by clicks from #/inbox, one link assert.equal(await b.evaluate('document.querySelector("#hire-start input[readonly]")?.value'), 'beta'); }); +test('the hire links follow each organization\'s own config, not the organization opened last', async t => { + // Acme is opened last and this Console does not decide there; beta's config still says it does. + const { b, wait } = await setup(t, { hash: '#/orgs/acme/organization', stub: true, noDecide: ['acme'] }); + await wait('document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"'); + await b.evaluate('location.hash = "#/inbox"'); + await wait('document.querySelector("[data-ws-hire-in]")'); + assert.equal(await b.evaluate('window.MosaicOrg.id'), 'acme'); + assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-hire-in] a")].map(a => a.getAttribute("href"))'), ['#/orgs/beta/inbox']); +}); + +test('with no organization deciding here, #/inbox shows no hire line', async t => { + const { b, wait } = await setup(t, { stub: true, noDecide: ['acme', 'beta'] }); + await wait('document.querySelectorAll("[data-ws-item]").length === 2'); + assert.equal(await b.evaluate('document.querySelector("[data-ws-hire-in]")'), null); +}); + test('the sidebar Inbox count on an organization\'s board is every organization\'s, as #/inbox lists', async t => { const { b, wait } = await setup(t, { hash: '#/orgs/acme/board' }); await wait('document.querySelector("#sections a[href$=\'/inbox\'] .ws-n")?.textContent === "2"'); - // Held across the board's next refresh, which reads only acme's inbox. - await new Promise(r => setTimeout(r, 300)); + // Held across a second board render, which reads only acme's inbox. + const reads = 'performance.getEntriesByType("resource").filter(e => new URL(e.name).pathname === "/api/orgs/acme/bus/inbox").length'; + const before = await b.evaluate(reads); + assert.ok(before >= 1, String(before)); + await b.evaluate('window.dispatchEvent(new HashChangeEvent("hashchange"))'); + await wait(`${reads} > ${before}`); + await new Promise(r => setTimeout(r, 200)); assert.equal(await b.evaluate('document.querySelector("#sections a[href$=\'/inbox\'] .ws-n")?.textContent'), '2'); }); + +// SP2 gave the system dashboard the system theme. #/inbox is a system page too: it shows the system +// theme, and a choice made there is the system's own, never written to the organization opened last. +test('#/inbox keeps the system theme, and a choice made there stays off the organization opened last', async t => { + const { b, wait } = await setup(t, { hash: '#/orgs/acme/organization' }); + await wait('document.documentElement.dataset.palette === "cobalt"'); + await b.evaluate('location.hash = "#/inbox"'); + await wait(`${items} !== "[]" && document.documentElement.dataset.palette === "harbor"`); + assert.equal(await b.evaluate('window.MosaicAppearance.state().organization'), null); + assert.equal(await b.evaluate('document.getElementById("org-cue").hidden'), true); + await b.evaluate('window.MosaicAppearance.set({ mode: "light" })'); + assert.equal(await b.evaluate('document.documentElement.dataset.mode'), 'light'); + assert.equal(await b.evaluate('localStorage.getItem("mosaic-console-appearance:acme")'), null); + // The organization's own Inbox is its page: its theme, and not the system's choice. + await b.evaluate('location.hash = "#/orgs/acme/inbox"'); + await wait('document.documentElement.dataset.palette === "cobalt"'); + assert.notEqual(await b.evaluate('document.documentElement.dataset.mode'), 'light'); + await b.evaluate('location.hash = "#/inbox"'); + await wait('document.documentElement.dataset.palette === "harbor" && document.documentElement.dataset.mode === "light"'); +}); -- 2.54.0