From 0785af8129f74595eec4386bea699ca754541116 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 00:38:03 -0600 Subject: [PATCH] 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"');