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) => `${glyph}${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 `
${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 `
${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 ? `
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 `