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/<org>/...), never the one opened last, and each item can be opened in its own organization. #/orgs/<id>/inbox is the same list filtered to that organization. No server route was added.
This commit is contained in:
1 parent
d1b01329bc
commit
0785af8129
7 files changed
+339
-58
No files matched your search
@@ -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 ? '<p class="note" data-ws-answer-why>Whether this Console can answer it here was not read: the organization did not answer. Open it in its organization, or use a terminal.</p>' : '';
|
||||
}
|
||||
// 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/<id>/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.
|
||||
|
||||
@@ -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/<id>, no trailing slash) is that organization's page;
|
||||
// #/orgs/<id>/ stays the old dashboard address.
|
||||
// #/orgs/<id>/ stays the old dashboard address. SP3: `#/inbox` is a system route too (every
|
||||
// organization's items); `#/orgs/<id>/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.
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -56,7 +56,8 @@
|
||||
// A status mark: a glyph plus its word, shown or read aloud only.
|
||||
const dot = (cls, glyph, word, shown = false) => `<span class="ws-dot ${cls}" title="${esc(word)}"><b aria-hidden="true">${glyph}</b>${shown ? esc(word) : `<span class="ws-sr">${esc(word)}</span>`}</span>`;
|
||||
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 ? `<div class="ws-panel tight ws-states" data-ws-states>${srows.map(s => { const ps = [...s.g.entries()].filter(([, n]) => n > 0).map(([id, n]) => ({ id, n })); return `<div class="ws-srow"><span class="sl">${esc(s.name)}</span><span class="sn">${s.n}</span>${bar(o, ps, `width:${Math.max(4, Math.round((s.n / max) * 90))}%`)}${by(o, ps)}</div>`; }).join('')}</div>` : `<p class="ws-panel tight note" data-ws-states>${noneRead(o, ['tasks']) ? 'Tasks not read' : 'No open tasks'}</p>`;
|
||||
|
||||
const needs = inbox.slice(0, 5);
|
||||
const needsList = needs.length ? `<ul class="ws-needs" data-ws-needs>${needs.map(d => `<li class="ws-edge ws-o" style="${o.style(d.org)}">${av(o.meta(d.org), 'md')}<a class="q" href="${esc(selHref(`/inbox?d=${enc(itemKey(d))}`))}" data-focus-key="${esc(`need:${itemKey(d)}`)}">${esc(d.question || `Decision ${d.id}`)}</a><span class="src">${fromWords(o, d)}</span>${classChip(d.class)}</li>`).join('')}</ul>${inbox.length > needs.length ? `<p class="note ws-more-line">${inbox.length - needs.length} more in the inbox</p>` : ''}` : `<p class="ws-panel tight note" data-ws-needs>${noneRead(o, ['inbox']) ? 'Decisions not read' : 'Nothing needs you'}</p>`;
|
||||
const needsList = needs.length ? `<ul class="ws-needs" data-ws-needs>${needs.map(d => `<li class="ws-edge ws-o" style="${o.style(d.org)}">${av(o.meta(d.org), 'md')}<a class="q" href="${esc(`#/inbox?d=${enc(itemKey(d))}`)}" data-focus-key="${esc(`need:${itemKey(d)}`)}">${esc(d.question || `Decision ${d.id}`)}</a><span class="src">${fromWords(o, d)}</span>${classChip(d.class)}</li>`).join('')}</ul>${inbox.length > needs.length ? `<p class="note ws-more-line">${inbox.length - needs.length} more in the inbox</p>` : ''}` : `<p class="ws-panel tight note" data-ws-needs>${noneRead(o, ['inbox']) ? 'Decisions not read' : 'Nothing needs you'}</p>`;
|
||||
|
||||
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')}
|
||||
</section>
|
||||
<section aria-label="Needs you and sessions">
|
||||
${secH('Needs you', '', `<a href="${esc(selHref('/inbox'))}" data-focus-key="home-inbox">Inbox</a>`)}
|
||||
${secH('Needs you', '', '<a href="#/inbox" data-focus-key="home-inbox">Inbox</a>')}
|
||||
${needsList}${missLine(o, ['inbox'], 'needs')}
|
||||
${secH('Active agents', 'sessions now', `<a href="${esc(selHref('/agents'))}" data-focus-key="home-agents">Agents</a>`)}
|
||||
${sessTable}${missLine(o, ['agents', 'claims'], 'sessions')}
|
||||
@@ -766,71 +767,100 @@
|
||||
</section>`;
|
||||
}
|
||||
|
||||
// ---- 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/<id>/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 => `<span class="ws-chip ws-o ws-ochip" style="${o.style(d.org)}" data-ws-orgchip><i aria-hidden="true"></i>${esc(o.name(d.org))}</span>`;
|
||||
const projChip = d => (d.project?.name ? `<span class="ws-chip outline ws-pchip" data-ws-projchip>${esc(d.project.name)}</span>` : '');
|
||||
const row = d => {
|
||||
const on = picked && itemKey(d) === itemKey(picked);
|
||||
const on = picked && itemKey(d) === itemKey(picked), by = roleOf(d);
|
||||
return `<tr class="ws-row ws-edge ws-o${on ? ' on' : ''}" style="${o.style(d.org)}" data-ws-item="${esc(itemKey(d))}">
|
||||
<td class="src hide-m">${o.ref(d.org)}<span class="via">${fromWords(o, d, false)}</span></td>
|
||||
<td><span class="ws-msrc show-m">${av(o.meta(d.org), 'xs')}${fromWords(o, d)}</span><a class="q" href="${esc(selHref(qs(d)))}" data-ws-select="${esc(itemKey(d))}" data-focus-key="${esc(`item:${itemKey(d)}`)}"${on ? ' aria-current="true"' : ''}>${esc(d.question || `Decision ${d.id}`)}</a><span class="via">decision <span class="mono">${esc(d.id)}</span>${d.taskRef ? ` · task ${esc(d.taskRef)}` : ''}</span><span class="show-m">${classChip(d.class)}${d.blocking ? ' <span class="ws-chip block">blocking</span>' : ''}</span></td>
|
||||
<td><a class="q" href="${esc(qs(d))}" data-ws-select="${esc(itemKey(d))}" data-focus-key="${esc(`item:${itemKey(d)}`)}"${on ? ' aria-current="true"' : ''}>${esc(d.question || `Decision ${d.id}`)}</a><span class="ws-ichips">${orgChip(d)}${projChip(d)}<span class="via">${by ? `${esc(by)} · ` : ''}decision <span class="mono">${esc(d.id)}</span>${d.taskRef ? ` · task ${esc(d.taskRef)}` : ''}</span><span class="show-m">${classChip(d.class)}${d.blocking ? ' <span class="ws-chip block">blocking</span>' : ''}</span></span></td>
|
||||
<td class="hide-m">${classChip(d.class)}${d.blocking ? ' <span class="ws-chip block">blocking</span>' : ''}</td>
|
||||
<td class="num">${d.at ? `<time datetime="${esc(d.at)}">${esc(span(d.at))}</time>` : '<span class="muted">not reported</span>'}</td>
|
||||
</tr>`;
|
||||
};
|
||||
const unread = (o.unread.length ? `<p class="note ws-unread-note" data-ws-unread>Not in this list: ${andList(o.unread.map(stateWords))}.</p>` : '') + missLine(o, ['inbox'], 'inbox');
|
||||
const table = rows.length ? `<div class="ws-tbl"><table class="ws-ibx" data-ws-items><caption class="ws-sr">Items that need you</caption><thead><tr><th scope="col" class="hide-m">Source</th><th scope="col">Needs</th><th scope="col" class="hide-m">Class</th><th scope="col" class="num">Waiting</th></tr></thead><tbody>${rows.map(row).join('')}</tbody></table></div>` : `<p class="ws-panel tight note" data-ws-items>${noneRead(o, ['inbox']) ? 'Decisions not read' : org ? 'Nothing needs you in these organizations' : 'Nothing needs you'}</p>`;
|
||||
// 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 ? `<p class="note ws-unread-note" data-ws-unread>Not read: ${andList(o.unread.map(why))}.</p>` : '') + missLine(o, ['inbox'], 'inbox');
|
||||
const filtered = (org && !scope) || type;
|
||||
const table = rows.length ? `<div class="ws-tbl"><table class="ws-ibx" data-ws-items><caption class="ws-sr">Items that need you</caption><thead><tr><th scope="col">Needs</th><th scope="col" class="hide-m">Type</th><th scope="col" class="num">Waiting</th></tr></thead><tbody>${rows.map(row).join('')}</tbody></table></div>` : `<p class="ws-panel tight note" data-ws-items>${noneRead(o, ['inbox']) ? 'Decisions not read' : filtered ? 'Nothing matches these filters' : 'Nothing needs you'}</p>`;
|
||||
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 ? `<ul class="ws-opts">${opts.map(x => `<li><div class="row"><span class="k">${esc(x.key)}</span><b>${esc(x.text || '')}</b></div><div class="row"><a class="ws-btn" href="${esc(decideHref)}" data-focus-key="${esc(`answer:${itemKey(d)}:${x.key}`)}">${d.console ? `Answer ${esc(x.key)}…` : 'Open the decision'}<span class="ws-sr"> on ${esc(o.name(d.org))}'s bus</span></a></div></li>`).join('')}</ul>` : `<p class="note">No options were given. <a href="${esc(decideHref)}" data-focus-key="${esc(`answer:${itemKey(d)}`)}">Open the decision</a></p>`;
|
||||
const answerHtml = (await opts.answer?.(d)) || '';
|
||||
const choices = arr(d.options);
|
||||
const optList = choices.length ? `<ul class="ws-opts">${choices.map(x => `<li><div class="row"><span class="k">${esc(x.key)}</span><b>${esc(x.text || '')}</b></div>${answerHtml.includes('data-decide') ? '' : `<div class="row"><a class="ws-btn" href="${esc(decideHref)}" data-focus-key="${esc(`answer:${itemKey(d)}:${x.key}`)}">${d.console ? `Answer ${esc(x.key)}…` : 'Open the decision'}<span class="ws-sr"> on ${esc(o.name(d.org))}'s bus</span></a></div>`}</li>`).join('')}</ul>` : `<p class="note">No options were given. <a href="${esc(decideHref)}" data-focus-key="${esc(`answer:${itemKey(d)}`)}">Open the decision</a></p>`;
|
||||
const cmds = arr(d.decide).filter(c => typeof c?.command === 'string' && c.command);
|
||||
const term = cmds.length ? `<div data-ws-cmds><span class="ws-label">Answer from a terminal</span>${cmds.map(c => `<div class="ws-cmd"><code>${esc(c.command)}</code><button type="button" class="ws-btn" data-copy="${esc(c.command)}" data-focus-key="${esc(`copy:${itemKey(d)}:${c.key}`)}" aria-label="Copy the command for option ${esc(c.key)}" title="Copy">${icon('copy')}</button></div>`).join('')}<p class="note">The inbox mixes organizations, so the copied command always names the organization's bus.</p><p class="small muted" id="copy-status" role="status"></p></div>` : '<p class="note" data-ws-cmds>No terminal command for this item: its id or options cannot be passed to a shell safely.</p>';
|
||||
const by = roleOf(d);
|
||||
insp = `<aside class="ws-insp ws-o" style="${o.style(d.org)}" aria-label="Decision ${id}" data-ws-insp="${esc(itemKey(d))}">
|
||||
<header>${av(o.meta(d.org))}<span class="ws-label">Decision <span class="mono">${id}</span></span>${classChip(d.class)}<a class="x" href="${esc(selHref(qs(null)))}" data-ws-select="none" data-focus-key="insp-close" aria-label="Close the decision">${icon('close')}</a></header>
|
||||
<header>${av(o.meta(d.org))}<span class="ws-label">Decision <span class="mono">${id}</span></span>${classChip(d.class)}<a class="x" href="${esc(qs(null))}" data-ws-select="none" data-focus-key="insp-close" aria-label="Close the decision">${icon('close')}</a></header>
|
||||
<div class="body">
|
||||
<div class="ws-ichips">${orgChip(d)}${projChip(d)}</div>
|
||||
<h3>${esc(d.question || `Decision ${d.id}`)}</h3>
|
||||
<div class="ws-auth">${esc(o.name(d.org))} · ${esc(d.project?.name || 'No project')}${by ? ` · raised by <b>${esc(by)}</b>` : ''}${d.at ? ` at ${esc(hm(d.at))}` : ''}.${d.class === 'gated' ? ' Only you can answer a gated decision.' : ''}</div>
|
||||
${d.task?.title || d.taskRef ? `<p class="note">Task ${esc(d.taskRef || '')}${d.task?.title ? ` · ${esc(d.task.title)}` : ''}</p>` : ''}
|
||||
${optList}
|
||||
${answerHtml}
|
||||
<p><a class="ws-btn" href="${esc(decideHref)}" data-ws-open data-focus-key="${esc(`open:${itemKey(d)}`)}">Open in ${esc(o.name(d.org))}</a></p>
|
||||
${term}
|
||||
<div><span class="ws-label">History</span><p class="note">Not built yet.</p></div>
|
||||
</div>
|
||||
</aside>`;
|
||||
} 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 = `<aside class="ws-insp" aria-label="Answered" data-ws-insp="${esc(want)}"><header><span class="ws-label">Answered</span><a class="x" href="${esc(qs(null))}" data-ws-select="none" data-focus-key="insp-close" aria-label="Close">${icon('close')}</a></header><div class="body">${kept}</div></aside>`;
|
||||
}
|
||||
// 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 = `<div class="ws-fchips" role="group" aria-label="Type"><span class="ws-label">Type</span><button type="button" class="f plain" data-ws-type="" data-focus-key="type:all" aria-pressed="${!type}">All <span class="n">${inOrg.length}</span></button>${types.map(c => `<button type="button" class="f plain" data-ws-type="${esc(c)}" data-focus-key="${esc(`type:${c}`)}" aria-pressed="${type === c}">${esc(TYPE_WORD[c] || c)} <span class="n">${typeCounts.get(c) || 0}</span></button>`).join('')}</div>`;
|
||||
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 `<section class="ws ws-inbox" data-ws-inbox>
|
||||
const tally = rows.length ? ` ${plural(rows.length, 'item')}${scope ? '' : ` from ${plural(orgsWith, 'organization')}`}.` : '';
|
||||
const sub = scope
|
||||
? `Items from ${esc(o.name(scope))} only.${tally} <a href="#/inbox" data-focus-key="inbox-all">Every organization</a>`
|
||||
: `Everything waiting on you, from every organization.${tally} An item leaves only when it is answered.`;
|
||||
return `<section class="ws ws-inbox" data-ws-inbox${scope ? ` data-ws-scope="${esc(scope)}"` : ''}>
|
||||
<div class="ws-vh"><h1 tabindex="-1">Inbox</h1><p class="sub">${sub}</p></div>
|
||||
<div class="ws-toolbar">
|
||||
${orgChips(every, counts, org, everyRows.length, ['inbox'])}
|
||||
<div class="ws-right"><span class="ws-seg" role="group" aria-label="State" aria-describedby="ws-tabs-why"><button type="button" aria-pressed="true" data-focus-key="tab-needs">Needs you</button><button type="button" aria-pressed="false" disabled title="Not built yet">Seen</button><button type="button" aria-pressed="false" disabled title="Not built yet">Answered</button></span><span class="note" id="ws-tabs-why">Seen and Answered are not built yet.</span></div>
|
||||
</div>
|
||||
${unread}
|
||||
<div class="ws-toolbar ws-ibx-filters">
|
||||
${scope ? '' : `<div class="ws-fgroup"><span class="ws-label">Org</span>${orgChips(every, counts, org, everyRows.length, ['inbox'])}</div>`}
|
||||
${typeChips}
|
||||
</div>
|
||||
<div class="ws-ibx-grid${insp ? ' with-insp' : ''}"><div class="ws-ibx-list">${table}
|
||||
${secH('Answered today', 'kept for 24 hours, then in the trail')}
|
||||
<p class="ws-panel tight note" data-ws-answered>Answered and expired items are not built yet.</p>
|
||||
${typeof opts?.hire === 'function' ? opts.hire() : ''}</div>
|
||||
${typeof opts.hire === 'function' ? opts.hire() : ''}</div>
|
||||
${insp}
|
||||
</div>
|
||||
</section>`;
|
||||
@@ -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]');
|
||||
|
||||
@@ -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/<org> 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'"]);
|
||||
|
||||
@@ -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/<id>/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/<id>/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);
|
||||
});
|
||||
@@ -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"');
|
||||
|
||||
Reference in new issue
Block a user