Compare commits

...
8 Commits
Author SHA1 Message Date
jason.woltje ed4f45ed23 fix(webui): sp4 third review round, a move still writing never paints the board over the not-on-your-list page, an archived project keeps its filter while another filter matches none of its tasks (#1873, lane sp4) 2026-11-15 03:54:28 -06:00
jason.woltje 79df01f1de fix(webui): sp4 second review round, a project filter stays while its organization does not answer, a gone filter is cleaned only from the address its read started on, a read in flight never paints over the not-on-your-list page (#1873, lane sp4) 2026-11-15 03:54:28 -06:00
jason.woltje d7b98b4698 fix(webui): sp4, an organization's own task board with no filter on no longer says its tasks match the filters (#1873, lane sp4)
The scope's org= in the workspace read is where the board reads, not a filter, so the
count line and the empty state no longer treat it as one. Found in the retaken shots.
2026-11-15 03:54:28 -06:00
jason.woltje 0f29fe0d92 fix(webui): sp4 review round, one organization chip per card with its avatar, a filter that is gone leaves the address, an impossible organization address is not the system board (#1873, lane sp4)
- The card's organization chip carries the avatar in place of its dot; the bare avatar
  before it is gone (prototype shot 13).
- A filter naming an organization or project that is not on the list is dropped from the
  address after the render, and a dropped project reads again without its server filter.
- #/orgs/<id>/tasks with an id no organization can have says "This organization is not
  on your list." instead of showing every organization's tasks.
- The write check's comment and the test's fake say orgSetup is Console-wide today.
2026-11-15 03:54:28 -06:00
jason.woltje 32be3a01bc feat(webui): one task board for every organization, with organization and project filters in the address (#1873, lane sp4)
#/tasks is a system route that shows every organization's tasks on one board,
with the per-organization columns, organization and project chips on each card,
and the organization and project filters kept in the address. An organization
that did not answer is named first with its reason. A move or edit goes to the
row's own organization and is offered only where that organization's own config
takes task changes. #/orgs/<id>/tasks is the same board scoped to one
organization, and a card opened from the system board links to its task there.
A render replaced by a newer one now draws from that newer read instead of
throwing on an empty board.
2026-11-15 03:54:28 -06:00
jason.woltje f97e0d3aeb fix(webui): sp5 re-check notes, a late named Fire leaves a newer Fire's busy state alone, the picked agent row gets the left bar, tests for the picked agent with the state filter and a refused scope (#1873, lane sp5) 2026-11-15 03:32:32 -06:00
jason.woltje eebdb2f0ce fix(webui): sp5 review round, Open addresses the agent and the scoped page marks it, Fire words name the organization, a named Fire keeps its outcome, Cancel restores focus (#1873, lane sp5)
- Open on #/agents goes to #/orgs/<id>/agents?agent=<key>; the scoped table puts that agent first,
  marks it (aria-current) and draws no Open or Actions column (review F1, F2).
- Fire's aria-label, confirm group and receipt use the organization's label (F5).
- A Fire sent to a named organization keeps its receipt after the opened organization changes (F7).
- Cancel moves focus back to the row's Fire (F10).
- agentsPage refuses a scope that is not an organization id instead of listing every organization (F11).
2026-11-15 03:26:18 -06:00
jason.woltje 5373b110ac feat(webui): one Agents page for the whole system, with organization and state filters, Fire sent to the row's organization (#1873, lane sp5)
#/agents lists every organization's agents in one table: name, role, state,
what each works on, organization and project chips. Org and state filters
live in the hash query. A stopped organization is named at the top. A row
opens the agent in its organization; Fire is drawn only where that
organization's own config lets this Console decide, and posts to that
organization's hire route. #/orgs/<id>/agents leads with the same table
scoped to one organization, above the levels grid and held roles. A hired
agent reads as Hired (or Hired from pack X) with its org:hire id as
secondary text. The sidebar and dashboard Agents links open the system page.
2026-11-15 03:13:54 -06:00
12 changed files with 933 additions and 77 deletions

No files matched your search

+69 -29
View File
@@ -488,46 +488,65 @@
const FIRE_REASON='Fired from the Agents page.';
let fireAsk=null,fireBusy=false,fireReceipt=null;
const fireKey=(org,name)=>`${org}\u0000${name}`;
function fireNote(){const org=hireOrg();const r=fireReceipt&&fireReceipt.org===org?fireReceipt:null;
// #1873 SP5: the system Agents page lists every organization's agents, so its controls carry their
// organization (data-agent-fire-org) and post to that organization's own bus, never to the one
// opened last. Its receipt box is `data-agents-receipt="*"` and shows a receipt from any of them.
// The agent cards keep the old shape: no attribute, the opened organization, canDecide.
function fireNote(scope=hireOrg()){const r=fireReceipt&&(scope==='*'?fireReceipt.system:fireReceipt.org===scope)?fireReceipt:null;
return r?`<p class="receipt${r.failed?' failed':''}" data-agents-receipt-text>${txt(r.text)}${r.code?` (<code>${txt(r.code)}</code>)`:''}</p>`:'';}
function paintFireNote(){for(const e of document.querySelectorAll('[data-agents-receipt]'))e.innerHTML=fireNote();}
async function fireAgent(name){
const org=hireOrg();
if(fireBusy||!org||out()||!canDecide)return;
function paintFireNote(){for(const e of document.querySelectorAll('[data-agents-receipt]'))e.innerHTML=fireNote(e.dataset.agentsReceipt||hireOrg());}
const fireSel=(attr,name,org)=>`[${attr}="${CSS.escape(name)}"]${org?`[data-agent-fire-org="${CSS.escape(org)}"]`:':not([data-agent-fire-org])'}`;
async function fireAgent(name,named=null,orgName=named){
const org=named??hireOrg();
// A named organization was checked against its own config when the control was drawn (decidesIn).
if(fireBusy||!org||out()||(!named&&!canDecide)||isTester)return;
fireBusy=true;
for(const b of document.querySelectorAll('[data-agent-fire-yes],[data-agent-fire-no]'))b.disabled=true;
const epoch=orgEpoch;
const {res,json}=await decidePost('/api/decisions/hire/start',{business:org,hire:{op:'fire',level:'org',name,reason:FIRE_REASON}});
if(epoch!==orgEpoch){if(res?.type==='opaqueredirect')signOut();return;}
fireBusy=false;fireAsk=null;
const {res,json}=await decidePost(`${decideBase({ org: named })}/decisions/hire/start`,{business:org,hire:{op:'fire',level:'org',name,reason:FIRE_REASON}});
// SP5 review F7: a named organization's Fire went to that organization, so its outcome still
// stands after the opened organization changes.
// After a switch, busy and confirm state belong to whatever Fire started since; leave them alone.
const stale=epoch!==orgEpoch;
if(stale&&!named){if(res?.type==='opaqueredirect')signOut();return;}
if(!stale){fireBusy=false;fireAsk=null;}
if(res?.type==='opaqueredirect'){signOut();return;}
const where=named?` in ${orgName}`:'';
if(res?.ok && json?.op==='resolved'){
for(const c of document.querySelectorAll(`[data-org="${CSS.escape(org)}"] [data-agent-card="${CSS.escape(name)}"]`))c.remove();
for(const r of document.querySelectorAll(`[data-agents-org="${CSS.escape(org)}"] tr[data-agent="${CSS.escape(name)}"]`))r.remove();
fireReceipt={org,text:`Fired ${name}. Recorded decision ${json.id}.`};
for(const r of document.querySelectorAll(`[data-ws-agent="${CSS.escape(`${org}:${name}`)}"]`))r.remove();
fireReceipt={org,system:!!named,text:`Fired ${name}${where}. Recorded decision ${json.id}.`};
paintFireNote();announce(fireReceipt.text);
document.dispatchEvent(new CustomEvent('mosaic-org-changed',{detail:{org}}));
} else {
const r=decideRefusal(res,json,'outcome-unknown');
fireReceipt={org,failed:true,text:`${name} was not fired: ${r.message}`,code:r.code};
fireReceipt={org,system:!!named,failed:true,text:`${name}${where} was not fired: ${r.message}`,code:r.code};
paintFireNote();
for(const c of document.querySelectorAll(`[data-agent-fire-confirm="${CSS.escape(name)}"]`))c.outerHTML=fireButton(name);
for(const c of document.querySelectorAll(fireSel('data-agent-fire-confirm',name,named)))c.outerHTML=fireButton(name,named,orgName);
}
}
const fireButton=name=>`<button type="button" class="btn small" data-agent-fire="${esc(name)}" data-focus-key="${esc(`fire:${name}`)}" aria-label="Fire ${esc(name)}">Fire</button>`;
const fireConfirm=name=>`<span class="ws-fire-confirm" role="group" aria-label="Fire ${esc(name)}?" data-agent-fire-confirm="${esc(name)}">Fire ${txt(name)}? <button type="button" class="btn small primary" data-agent-fire-yes="${esc(name)}" data-focus-key="${esc(`fire-yes:${name}`)}"${fireBusy?' disabled':''}>Confirm fire</button><button type="button" class="btn small" data-agent-fire-no="${esc(name)}"${fireBusy?' disabled':''}>Cancel</button></span>`;
// What the agent card shows for one agent: nothing without the decide right or outside the page's own organization.
function fireControl(org,name){
if(!canDecide||isTester||org!==hireOrg())return '';
return fireAsk===fireKey(org,name)?fireConfirm(name):fireButton(name);
const fireOrgAttr=org=>org?` data-agent-fire-org="${esc(org)}"`:'';
const fireFocus=(kind,name,org)=>esc(org?`${kind}:${org}:${name}`:`${kind}:${name}`);
// SP5 review F5: the words name the organization by its label (`orgName`); its id addresses the control.
const fireNameAttr=(org,orgName)=>org&&orgName&&orgName!==org?` data-agent-fire-name="${esc(orgName)}"`:'';
const fireButton=(name,org=null,orgName=org)=>`<button type="button" class="btn small" data-agent-fire="${esc(name)}"${fireOrgAttr(org)}${fireNameAttr(org,orgName)} data-focus-key="${fireFocus('fire',name,org)}" aria-label="Fire ${esc(name)}${org?` in ${esc(orgName)}`:''}">Fire</button>`;
const fireConfirm=(name,org=null,orgName=org)=>`<span class="ws-fire-confirm" role="group" aria-label="Fire ${esc(name)}${org?` in ${esc(orgName)}`:''}?" data-agent-fire-confirm="${esc(name)}"${fireOrgAttr(org)}${fireNameAttr(org,orgName)}>Fire ${txt(name)}? <button type="button" class="btn small primary" data-agent-fire-yes="${esc(name)}"${fireOrgAttr(org)} data-focus-key="${fireFocus('fire-yes',name,org)}"${fireBusy?' disabled':''}>Confirm fire</button><button type="button" class="btn small" data-agent-fire-no="${esc(name)}"${fireOrgAttr(org)}${fireBusy?' disabled':''}>Cancel</button></span>`;
// What the agent card shows for one agent: nothing without the decide right or outside the page's own
// organization. SP5: `allowed` given means the caller read that organization's own config (decidesIn);
// the control is then drawn for that organization, whichever one was opened last.
function fireControl(org,name,allowed,orgName=org){
const named=typeof allowed==='boolean';
if(isTester||(named?!allowed:(!canDecide||org!==hireOrg())))return '';
return fireAsk===fireKey(org,name)?fireConfirm(name,named?org:null,named?orgName:null):fireButton(name,named?org:null,named?orgName:null);
}
document.addEventListener('click',e=>{
const t=e.target.closest?.('[data-agent-fire],[data-agent-fire-yes],[data-agent-fire-no]');
if(!t)return;
const org=hireOrg();
if(t.hasAttribute('data-agent-fire')){const name=t.dataset.agentFire;fireAsk=fireKey(org,name);t.outerHTML=fireConfirm(name);document.querySelector(`[data-agent-fire-yes="${CSS.escape(name)}"]`)?.focus();}
else if(t.hasAttribute('data-agent-fire-no')){const name=t.dataset.agentFireNo;fireAsk=null;t.closest('[data-agent-fire-confirm]').outerHTML=fireButton(name);announce('Cancelled. Nothing was fired.');}
else void fireAgent(t.dataset.agentFireYes);
const named=t.dataset.agentFireOrg??null,org=named??hireOrg(),orgName=t.closest('[data-agent-fire-name]')?.dataset.agentFireName??named;
if(t.hasAttribute('data-agent-fire')){const name=t.dataset.agentFire;fireAsk=fireKey(org,name);t.outerHTML=fireConfirm(name,named,orgName);document.querySelector(fireSel('data-agent-fire-yes',name,named))?.focus();}
else if(t.hasAttribute('data-agent-fire-no')){const name=t.dataset.agentFireNo;fireAsk=null;t.closest('[data-agent-fire-confirm]').outerHTML=fireButton(name,named,orgName);document.querySelector(fireSel('data-agent-fire',name,named))?.focus();announce('Cancelled. Nothing was fired.');}
else void fireAgent(t.dataset.agentFireYes,named,orgName);
});
window.MosaicDecide=Object.freeze({fireControl,fireNote});
document.addEventListener('click',e=>{if(e.target.closest?.('[data-hire-load]'))hireSubmit(true);});
@@ -622,8 +641,13 @@
}
// Whether this Console decides in one organization, from that organization's own config: the system
// Inbox's hire links must not follow the organization opened last, whose bus may be the stopped one.
const decidesIn = id => (isTester ? Promise.resolve(false) : fetch(`/api/orgs/${enc(id)}/config`, { cache: 'no-store', redirect: 'manual' })
.then(r => (r.ok ? r.json() : null)).then(c => c?.decide === true && c?.tester !== true).catch(() => false));
const orgConfig = id => (isTester ? Promise.resolve(null) : fetch(`/api/orgs/${enc(id)}/config`, { cache: 'no-store', redirect: 'manual' })
.then(r => (r.ok ? r.json() : null)).catch(() => null));
const decidesIn = id => orgConfig(id).then(c => c?.decide === true && c?.tester !== true);
// SP4: whether this Console takes task changes in one organization, from that organization's own
// config (orgSetup, as the per-organization board read it), so the system task board offers a move
// only where the row's organization takes it.
const tasksIn = id => orgConfig(id).then(c => c?.orgSetup === true && c?.tester !== true);
// 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
@@ -876,12 +900,15 @@
return `<section aria-labelledby="b-thread-claims">${title}<div class="banner err" role="alert"><p>${why(err)}</p>${retry}</div></section>`;
}
}
async function agentsView(get) {
// SP5: an organization's scoped page leads with its rows of the system Agents table (which owns the
// heading), then the levels grid under its own h2, then held roles and threads.
const scopedTop = async (q, org, note) => `${await window.MosaicWorkspace.agentsPage(q, { scope: org })}${await window.MosaicWorkspace.agentGrid(note, undefined, { under: true })}`;
async function agentsView(get, q = new URLSearchParams(), org = null) {
let claims;
try { claims = await get('agents'); } catch (err) {
if (!window.MosaicOrg || err.code === 'signed-out') throw err;
if (refusal(err)) forget();
return `${await window.MosaicWorkspace.agentGrid()}<div class="banner err" role="alert">The selected organization's held roles could not be read. ${why(err)}</div>`;
return `${org ? await scopedTop(q, org) : await window.MosaicWorkspace.agentGrid()}<div class="banner err" role="alert">The selected organization's held roles could not be read. ${why(err)}</div>`;
}
let instances = null, threads = [];
if (!isTester) {
@@ -898,7 +925,7 @@
const threadSection = await threadClaimsSection(get, holders, threads, !!instances, running);
const rows = claims.map(c => `<tr><th scope="row">${txt(c.role)}</th><td>${stateMark(c.state)}${c.working ? `<span class="source">${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}</span>` : ''}</td><td>${attention(c)}</td><td>${txt(holders.get(c.holder_run) || c.role)}<span class="source">run ${txt(c.holder_run)}${!instances ? ', running status unavailable' : running.has(c.holder_run) ? '' : ', not running'}</span></td><td>${txt(c.harness)}${c.address ? `<span class="source">${txt(c.address)}</span>` : ''}</td><td>${when(c.at)}</td></tr>`).join('');
// Organization mode (sample 08): the levels grid carries the page heading; held roles follow.
const top = window.MosaicOrg ? await window.MosaicWorkspace.agentGrid(readNote(get)) : head('Agents', readNote(get));
const top = !window.MosaicOrg ? head('Agents', readNote(get)) : org ? await scopedTop(q, org, readNote(get)) : await window.MosaicWorkspace.agentGrid(readNote(get));
return `${top}
<section aria-labelledby="b-claims"><h2 id="b-claims">Held roles <span class="count">${claims.length}</span></h2><p class="small muted">The current holder of each claimed role. A role nobody holds does not appear. Working: the holder has messages it received and hasn't answered yet. Waiting: a decision the role raised is open in your inbox. Otherwise idle.</p>
${claims.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Held roles, scroll for all columns"><table class="s1-table s1-agents"><thead><tr>${['Role', 'State', 'Attention', 'Holder', 'Harness', 'Held since'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div>` : empty('No role is held right now.', 'A role shows here while a run holds its claim on the bus.')}</section>
@@ -1671,12 +1698,24 @@
let org = null; try { org = scope ? decodeURIComponent(scope) : null; } catch { return notFound(); }
return configRead.then(() => window.MosaicWorkspace.inboxPage(q, { scope: org, hire: org ? hireStartHtml : null, hireIn: org ? null : decidesIn, answer: d => inboxAnswer(d, current), answered: inboxAnswered }));
}
if (a === 'tasks' && n === 1 && window.MosaicOrg) return window.MosaicTasksBoard.view();
// SP4: #/tasks is every organization's tasks on one board; #/orgs/<id>/tasks is that board for one.
if (a === 'tasks' && 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.MosaicTasksBoard.view(q, { scope: org, writesIn: tasksIn }));
}
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.`);
if (a === 'messages' && n === 1) return messagesView(get, q);
if (a === 'tasks' && n === 1) return tasksView(get);
if (a === 'tasks' && n === 2) return TASK.test(b) ? taskView(get, b) : notFound(`${txt(b)} is not a task reference.`);
// SP5: #/agents is every organization's agents; #/orgs/<id>/agents is that table for one, above its levels grid and held roles.
if (a === 'agents' && n === 1 && window.MosaicOrg) {
const scope = /^#\/orgs\/([^/?]+)/.exec(location.hash)?.[1];
let org = null; try { org = scope ? decodeURIComponent(scope) : null; } catch { return notFound(); }
if (!org) return configRead.then(() => window.MosaicWorkspace.agentsPage(q, { scope: null, fireIn: decidesIn }));
return agentsView(get, q, org);
}
if (a === 'agents' && n === 1) return agentsView(get);
if (a === 'queue' && n === 1) return queueView(get, q);
if (a === 'queue' && n === 2) return /^[1-9]\d{0,5}$/.test(b) ? queueRowView(get, b) : notFound(`${txt(b)} is not a queue row id.`);
@@ -1692,7 +1731,7 @@
const paused = () => $('pause')?.getAttribute('aria-pressed') === 'true';
// A file open in the editor is never refreshed from under its draft; Reload is the person's to press.
// The cross-organization pages (U3) poll at the workspace read's pollMs.
const wsPage = path => !!window.MosaicOrg && (path === '/' || path === '' || path === '/dashboard' || path === '/projects' || path === '/inbox');
const wsPage = path => !!window.MosaicOrg && (path === '/' || path === '' || path === '/dashboard' || path === '/projects' || path === '/inbox' || path === '/agents' || path === '/tasks');
function schedule() { clearTimeout(timer); if (!paused() && !out() && !routeOf().startsWith('/docs/')) timer = setTimeout(() => render(false), wsPage(routeOf().split('?')[0]) ? window.MosaicWorkspace?.pollMs || 10000 : 10000); }
// Keep focus, open <details> and the copy status across a refresh of the same page.
// In the send form, the focused field and its caret too (its values come from `draft`).
@@ -2068,7 +2107,8 @@
// E2E6 P2-2: a write on the organization page that changes other sections (a pack apply) asks for a
// fresh read of the page; editors in progress are kept by holdLive.
// Ruling 2026-11-14: an apply, hire or fire redraws the Agents page in the same response cycle, not at the next poll.
document.addEventListener('mosaic-org-changed', e => { if (window.MosaicOrg && e.detail?.org === window.MosaicOrg.id && ['/organization', '/agents'].includes(routeOf().split('?')[0])) render(false); });
// SP5: the system Agents page shows every organization, so a change in any of them redraws it.
document.addEventListener('mosaic-org-changed', e => { if (!window.MosaicOrg) return; const path = routeOf().split('?')[0]; if ((path === '/agents' && window.MosaicOrg.system()) || (e.detail?.org === window.MosaicOrg.id && ['/organization', '/agents'].includes(path))) render(false); });
// #1873 SP1: another organization opened in place (display.js, before this file's hashchange
// render). Nothing read, drafted or confirmed for the old one carries over; the new one's config
// is read before the next page draws. A write already sent finishes against the old organization,
+4 -2
View File
@@ -22,8 +22,10 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
// 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. 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'); };
// organization's items); `#/orgs/<id>/inbox` is that list, filtered. SP5: `#/agents` is every
// organization's agents; `#/orgs/<id>/agents` is that table for one organization. SP4: `#/tasks` is
// one task board for every organization; `#/orgs/<id>/tasks` is that board, scoped to one.
const system = hash => { const bare = tail(hash).split('?')[0]; return bare === '/dashboard' || (!orgOf(hash) && (bare === '/inbox' || bare === '/agents' || bare === '/tasks')); };
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)}`;
+10
View File
@@ -692,6 +692,16 @@ html[data-organizations] .ws-fgroup{display:flex;gap:6px 8px;align-items:center;
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}
/* #1873 SP5: the system Agents table. Open and Fire sit side by side at one size; a confirm wraps in its cell. */
html[data-organizations] .ws-agt .ws-arole{font-size:12px;color:var(--muted)}
html[data-organizations] .ws-agt td.ws-act{white-space:nowrap;text-align:right;width:1%}
html[data-organizations] .ws-agt td.ws-act>*+*{margin-left:6px}
html[data-organizations] .ws-agt td.ws-act .btn.small{min-height:0;padding:4px 10px;font:600 13px/1.4 var(--font);border-radius:var(--r)}
html[data-organizations] .ws-agt .ws-fire-confirm{display:inline-flex;flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:6px;white-space:normal}
html[data-organizations] .ws-agt .t2.mono{overflow-wrap:anywhere}
html[data-organizations] .ws-agt tr.on{background:color-mix(in srgb,var(--action) 10%,var(--surface))}
html[data-organizations] .ws-agt tr.on td:first-child{box-shadow:inset 3px 0 0 var(--action)}
@media (forced-colors:active){html[data-organizations] .ws-agt tr.on td:first-child{border-left:3px solid Highlight}}
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}
+136 -29
View File
@@ -10,6 +10,12 @@
// shown as the bus answered it. Vikunja tasks are read only here: the bus refuses them
// (tracker-not-store), so the board offers no control for them.
// bus.js calls view() for #/tasks in organization mode and repaints it on its own 10 s poll.
// SP4 (#1873): #/tasks is a system page, every organization on one board; #/orgs/<id>/tasks is the
// same board scoped to that organization (bus.js passes the scope). The org and project filters live
// in the address query (?org=, ?project=), so a reload keeps them. A move or edit goes to the row's own
// organization and is offered only where that organization's own config takes task changes (bus.js
// passes writesIn), never judged by the organization opened last. A card opened on the system board
// links to the task in its organization (#/orgs/<id>/tasks?task=<ref>), which opens it there.
(() => {
'use strict';
if (!document.documentElement.hasAttribute('data-organizations')) return;
@@ -56,6 +62,11 @@
// `opened` is the task as the edit dialog showed it: Save diffs against it and sends its digest, so a
// poll that reads a newer task while the dialog is open turns a stale save into a task-conflict.
let snap = null, canWrite = false, notice = null, dragging = null, dragEnded = null, opened = null;
// `scope` is the one organization of #/orgs/<id>/tasks, or null on the system board. `allowed` holds
// the organizations whose own config takes task changes from this Console, asked on every read.
let scope = null, writesIn = null, allowed = new Set(), autoOpened = null;
const ORG_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/, TASK_REF = /^(?:task:\d{1,15}|vikunja:\d{1,15}\/\d{1,15})$/;
const ws = () => window.MosaicWorkspace;
const mark = () => window.MosaicWorkspace?.orgMark;
const orgOf = id => snap?.orgs.find(o => o.id === id) || { id, label: id, theme: null };
@@ -69,7 +80,7 @@
const stateOf = t => (STATE_LABEL[t.state] ? t.state : UNREAD);
// An org can answer while its tasks read fails (errors.tasks): its tasks are unavailable, not zero.
const tasksRead = o => o?.state === 'ok' && !o.errors?.tasks;
const orgWritable = o => canWrite && tasksRead(o) && o.tracker === 'built-in';
const orgWritable = o => canWrite && tasksRead(o) && o.tracker === 'built-in' && allowed.has(o.id);
const taskWritable = t => t.writable === true && typeof t.digest === 'string' && orgWritable(orgOf(t.org));
const projectName = t => t.project?.name || t.project?.key || null;
const dueText = iso => {
@@ -84,6 +95,7 @@
const PROJECT_SELECTOR = /^[a-z0-9][a-z0-9._-]{0,63}:[A-Za-z0-9._-]{1,128}$/, ROLE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
function readQuery() {
const q = new URLSearchParams({ sections: 'tasks,projects' });
if (scope) q.set('org', scope);
if (PROJECT_SELECTOR.test(filter.project)) q.set('project', filter.project);
if (filter.state) q.set('task-state', filter.state === UNREAD ? 'open' : filter.state);
if (ROLE.test(filter.assignee)) q.set('assignee', filter.assignee);
@@ -97,7 +109,6 @@
mark()?.ready().catch(() => null),
window.MosaicConsole?.ready,
]);
canWrite = window.MosaicConsole?.config?.orgSetup === true;
if (response.type === 'opaqueredirect' || !response.ok) {
const body = await response.json().catch(() => null);
throw Object.assign(new Error('read failed'), { status: response.status, message: typeof body?.message === 'string' ? body.message : null });
@@ -106,16 +117,46 @@
if (!Array.isArray(body?.orgs) || !Array.isArray(body.tasks) || !Array.isArray(body.projects)) throw new Error('read failed');
const named = new Map((list || []).map(o => [o.id, o]));
const orgs = body.orgs.map(o => ({ ...o, id: o.org, label: named.get(o.org)?.label || o.org, theme: named.get(o.org)?.theme ?? o.theme ?? null, roles: Array.isArray(o.roles) ? o.roles : [] }));
return { orgs, tasks: body.tasks, projects: body.projects, summary: body.summary, readAt: body.readAt };
// Each organization that could take a change is asked through its own config. The server sets
// orgSetup for the whole Console today, so the answer only differs where an organization's config
// read fails or 404s. With no way to ask, none is asked and the board is read only.
const asked = orgs.filter(o => tasksRead(o) && o.tracker === 'built-in');
const ask = typeof writesIn === 'function' ? writesIn : null;
const yes = ask ? await Promise.all(asked.map(o => Promise.resolve(ask(o.id)).then(v => v === true, () => false))) : [];
const takes = new Set(asked.filter((o, i) => yes[i]).map(o => o.id));
// The Console-wide word, for the head's reason: no organization that could take a change takes one.
// With none to ask, the open organization's config still says whether this Console writes at all.
const writes = asked.length ? takes.size > 0 : window.MosaicConsole?.config?.orgSetup === true;
return { orgs, tasks: body.tasks, projects: body.projects, summary: body.summary, readAt: body.readAt, takes, asked: asked.map(o => o.id), writes, view: ws()?.orgsOf?.(body) ?? null };
}
// Only the latest read lands: a filter change and the poll can both be reading.
let readSeq = 0;
const reload = async () => { const n = ++readSeq, next = await read(); if (n !== readSeq) return false; snap = next; prune(); return true; };
// A read that a newer one replaced keeps nothing; `newest` lets a view wait for the read that does.
let readSeq = 0, newest = null;
// The address the read starts from goes to prune, so a cleanup never touches a page opened since.
const reload = () => { const n = ++readSeq, at = location.hash; return (newest = read().then(next => { if (n !== readSeq) return false; snap = next; allowed = next.takes; canWrite = next.writes; return prune(at) ? reload() : true; })); };
async function refilter() { paint(); try { if (await reload()) paint(); } catch { /* the next poll shows the failed read */ } }
// A filter naming an org or project that is gone falls back to all.
function prune() {
if (filter.org && !snap.orgs.some(o => o.id === filter.org)) filter.org = '';
if (filter.project && filter.project !== '-' && !snap.projects.some(p => `${p.org}:${p.id}` === filter.project)) filter.project = '';
// The address drops it too, so it never names a filter the board is not using. A dropped project
// was filtered on the server, so it returns true and the read runs again without it.
// A project is gone only when its organization is off the list, or answered with its projects and
// the project is not among them and no task came back for it (with no other filter on). An organization that is down,
// starting or timed out, or whose projects read failed, lists no projects, so its filter stays.
// `at` is the address the read started from.
function prune(at) {
const gone = {};
if (!scope && filter.org && !snap.orgs.some(o => o.id === filter.org)) { filter.org = ''; gone.org = null; }
if (filter.project && filter.project !== '-') {
const po = snap.orgs.find(o => o.id === filter.project.split(':')[0]);
const answered = !po || (po.state === 'ok' && !po.errors?.projects);
// No task back is proof only when no other filter narrowed the read on the server.
const narrowed = filter.state || filter.assignee || filter.label;
if (answered && !narrowed && !snap.tasks.length && !snap.projects.some(p => `${p.org}:${p.id}` === filter.project)) { filter.project = ''; gone.project = null; }
}
// After this render: bus.js drops a render whose address changed while it ran. Only while the
// address is still the one the read started from, and with a repaint in case the render in
// flight was the one dropped.
if (Object.keys(gone).length) setTimeout(() => { if (location.hash === at) ws()?.setQuery?.(gone, true); }, 0);
return 'project' in gone;
}
// ---------- filters ----------
@@ -137,17 +178,17 @@
const chips = [chip('', `All <span class="n">${snap.tasks.length}</span>`)].concat(snap.orgs.map(o => chip(o.id,
`${av(o.id)}${esc(o.label)}<span class="n">${tasksRead(o) ? counts.get(o.id) : 'unavailable'}</span>`,
`${o.label}, ${tasksRead(o) ? `${counts.get(o.id)} tasks` : 'tasks unavailable'}`))).join('');
const scope = snap.orgs.filter(o => !filter.org || o.id === filter.org);
const shown = snap.orgs.filter(o => !filter.org || o.id === filter.org);
const projectOpts = opt('', `All ${snap.projects.filter(p => !filter.org || p.org === filter.org).length}`, filter.project) + opt('-', 'No project', filter.project)
+ scope.map(o => { const ps = snap.projects.filter(p => p.org === o.id); return ps.length ? `<optgroup label="${esc(o.label)}">${ps.map(p => opt(`${p.org}:${p.id}`, p.name || p.key || p.id, filter.project)).join('')}</optgroup>` : ''; }).join('');
const roles = [...new Set(scope.flatMap(o => o.roles).concat(snap.tasks.filter(t => !filter.org || t.org === filter.org).flatMap(t => t.assignees)))].sort();
+ shown.map(o => { const ps = snap.projects.filter(p => p.org === o.id); return ps.length ? `<optgroup label="${esc(o.label)}">${ps.map(p => opt(`${p.org}:${p.id}`, p.name || p.key || p.id, filter.project)).join('')}</optgroup>` : ''; }).join('');
const roles = [...new Set(shown.flatMap(o => o.roles).concat(snap.tasks.filter(t => !filter.org || t.org === filter.org).flatMap(t => t.assignees)))].sort();
if (filter.assignee && filter.assignee !== '-' && !roles.includes(filter.assignee)) roles.push(filter.assignee);
const assigneeOpts = opt('', 'Anyone', filter.assignee) + opt('-', 'Nobody', filter.assignee) + roles.map(r => opt(r, r, filter.assignee)).join('');
const stateOpts = opt('', 'Any', filter.state) + states().map(([v, l]) => opt(v, l, filter.state)).join('') + (snap.tasks.some(t => stateOf(t) === UNREAD) ? opt(UNREAD, 'State not read', filter.state) : '');
const labels = [...new Set(snap.tasks.flatMap(t => t.labels.map(String)))].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
const labelSel = labels.length ? select('label', 'Label', opt('', 'Any', filter.label) + labels.map(l => opt(l, l, filter.label)).join(''), 'tb-label') : '';
return `<div class="tb-toolbar">
<div class="tb-chips" role="group" aria-label="Organization">${chips}</div>
${scope ? '' : `<div class="tb-chips" role="group" aria-label="Organization">${chips}</div>`}
${select('project', 'Project', projectOpts, 'tb-project')}${select('assignee', 'Assignee', assigneeOpts, 'tb-assignee')}${select('state', 'State', stateOpts, 'tb-state')}${labelSel}
<div class="tb-right"><label class="tb-search">${icon('search')}<input type="search" placeholder="Find a task" aria-label="Find a task" data-tb-q data-focus-key="tb-q" value="${esc(filter.q)}" autocomplete="off"></label></div>
</div>`;
@@ -158,12 +199,15 @@
const prio = t => (Number.isSafeInteger(t.priority) && t.priority > 0 ? `<span class="tb-chip${t.priority >= 4 ? ' warn' : ''}" title="Priority ${t.priority} of 5">P${t.priority}</span>` : '');
const due = t => { const d = t.dueDate && dueText(t.dueDate); return d ? `<span class="tb-chip${overdue(t) ? ' warn' : ''}" title="Due ${esc(t.dueDate.slice(0, 10))} (UTC)">${overdue(t) ? 'overdue' : 'due'} ${esc(d)}</span>` : ''; };
const title = t => esc(t.title || 'Untitled');
// The shared chips (workspace.js), so a card reads as the Inbox's rows do.
const orgChip = t => (snap.view && ws()?.orgChip ? ws().orgChip(snap.view, t.org, av(t.org, 'xs') || undefined) : `${av(t.org, 'xs')}<span class="ws-sr">${esc(orgOf(t.org).label)}</span>`);
const projChip = t => (projectName(t) ? (ws()?.projChip?.(projectName(t)) ?? `<span>${esc(projectName(t))}</span>`) : '');
function card(t) {
const w = taskWritable(t), k = esc(key(t));
return `<article class="tb-card ws-o${t.state === 'done' ? ' dim' : ''}" style="${colours(t.org)}" data-tb-task="${k}" data-org="${esc(t.org)}"${w ? ' draggable="true"' : ''}>
<div class="top">${av(t.org, 'xs')}<span class="ws-sr">${esc(orgOf(t.org).label)}</span><span class="id">${esc(shortId(t.ref))}</span>${trk(t.tracker)}</div>
<div class="top">${orgChip(t)}<span class="id">${esc(shortId(t.ref))}</span>${trk(t.tracker)}</div>
<div class="tt"><button type="button" class="tb-open" data-tb-open="${k}" data-focus-key="open:${k}">${title(t)}</button></div>
<div class="bot"><span>${projectName(t) ? esc(projectName(t)) : '<span class="muted">No project</span>'}</span>${who(t)}${prio(t)}${due(t)}</div>
<div class="bot">${projChip(t) || '<span><span class="muted">No project</span></span>'}${who(t)}${prio(t)}${due(t)}</div>
</article>`;
}
function kanban(rows) {
@@ -184,10 +228,10 @@
};
return `<div class="ws-tbl tb-list" data-tb-list><table><thead><tr><th scope="col">Org</th><th scope="col">Id</th><th scope="col">Task</th><th scope="col" class="hide-m">Project</th><th scope="col">State</th><th scope="col" class="hide-m">Assignee</th><th scope="col" class="hide-m">Priority</th><th scope="col" class="hide-m">Due</th></tr></thead><tbody>
${sorted.map(t => `<tr class="tb-row ws-o${t.state === 'done' ? ' dim' : ''}" style="${colours(t.org)}" data-tb-task="${esc(key(t))}" data-org="${esc(t.org)}">
<td>${av(t.org)}<span class="ws-sr">${esc(orgOf(t.org).label)}</span></td>
<td>${orgChip(t)}</td>
<td class="mono">${esc(shortId(t.ref))}<span class="t2">${t.tracker === 'vikunja' ? 'Vikunja' : 'built-in'}</span></td>
<td><button type="button" class="tb-open t1" data-tb-open="${esc(key(t))}" data-focus-key="lopen:${esc(key(t))}">${title(t)}</button><span class="t2 show-m">${esc(projectName(t) || 'No project')}</span></td>
<td class="hide-m">${projectName(t) ? esc(projectName(t)) : '<span class="muted">none</span>'}</td>
<td class="hide-m">${projChip(t) || '<span class="muted">none</span>'}</td>
<td>${stateCell(t)}</td>
<td class="hide-m">${t.assignees.length ? esc(t.assignees.join(', ')) : '<span class="muted">nobody</span>'}</td>
<td class="hide-m">${prio(t) || '<span class="muted">none</span>'}</td>
@@ -198,13 +242,14 @@
const rows = filtered(), all = snap.tasks.length;
// Project, state, assignee and label filter on the server, so the board never holds the unfiltered
// total; with one of those on, the line says how many match rather than inventing an "of N".
const onServer = new URLSearchParams(readQuery()).size > 1, n = rows.length;
// The scope's org= is where the board reads, not a filter.
const onServer = new URLSearchParams(readQuery()).size > (scope ? 2 : 1), n = rows.length;
const line = onServer ? `<p class="tb-count">Showing ${n} ${n === 1 ? 'task that matches' : 'tasks that match'} the filters.</p>`
: n !== all ? `<p class="tb-count">Showing ${n} of ${all} tasks.</p>` : '';
// An empty result is only exhaustive when every organization's tasks were read.
const unread = snap.orgs.filter(o => !tasksRead(o));
const none = what => (unread.length ? `${what} in the organizations that answered. Tasks from ${unread.map(o => o.label).join(', ')} could not be read.` : `${what}.`);
const body = !snap.orgs.length ? '<p class="ws-panel tight note">No organizations yet.</p>'
const body = !snap.orgs.length ? `<p class="ws-panel tight note">${scope ? 'This organization is not on your list.' : 'No organizations yet.'}</p>`
: !snap.orgs.some(tasksRead) ? '<p class="ws-panel tight note">No organization answered with its tasks, so no tasks could be read.</p>'
: !all && !onServer ? `<p class="ws-panel tight note">${esc(none('No tasks yet'))}</p>`
: !rows.length ? `<p class="ws-panel tight note">${esc(none('No tasks match these filters'))}</p>`
@@ -230,17 +275,33 @@
const seg = `<span class="ws-seg" role="group" aria-label="Layout"><button type="button" data-tb-layout="kanban" aria-pressed="${lay === 'kanban'}" data-focus-key="tb-layout-kanban">Kanban</button><button type="button" data-tb-layout="list" aria-pressed="${lay === 'list'}" data-focus-key="tb-layout-list">List</button></span>`;
const add = `<button type="button" class="ws-btn" data-tb-new data-focus-key="tb-new"${why ? ` disabled title="${esc(why)}"` : ''}>${icon('plus')}New task</button>`;
let sub = '';
if (snap) {
if (snap && scope) {
const n = snap.tasks.length, name = snap.orgs.find(o => o.id === scope)?.label || scope;
sub = `Tasks from ${esc(name)} only. ${n} ${n === 1 ? 'task' : 'tasks'}. <a href="#/tasks" data-focus-key="tasks-all">Every organization</a>`;
if (snap.orgs.some(o => o.tracker === 'vikunja')) sub += ' Vikunja tasks are read only here.';
if (!canWrite) sub += ' This Console takes no task changes, so the board is read only.';
} else if (snap) {
const n = snap.tasks.length, m = snap.orgs.filter(tasksRead).length, of = snap.summary?.of ?? snap.orgs.length;
sub = `${n} ${n === 1 ? 'task' : 'tasks'} from ${m} of ${of} ${of === 1 ? 'organization' : 'organizations'}. Each organization keeps its own tasks; a change made here goes only to that organization's bus.`;
if (snap.orgs.some(o => o.tracker === 'vikunja')) sub += ' Vikunja tasks are read only here.';
if (!canWrite) sub += ' This Console takes no task changes, so the board is read only.';
else {
const no = snap.asked.filter(id => !allowed.has(id)).map(id => esc(orgOf(id).label));
if (no.length) sub += ` This Console takes no task changes in ${no.length < 2 ? no[0] : `${no.slice(0, -1).join(', ')} and ${no.at(-1)}`}, so ${no.length < 2 ? 'its' : 'their'} tasks are read only here.`;
}
}
return `<div class="ws-vh"><h1 tabindex="-1">Tasks</h1><div class="ws-right">${seg}${add}</div>${sub ? `<p class="sub">${sub}</p>` : ''}</div>`;
}
const noticeLine = () => (notice ? `<p class="tb-notice${notice.error ? ' is-error' : ''}" role="${notice.error ? 'alert' : 'status'}" data-tb-notice>${esc(notice.text)}</p>` : '<p class="tb-notice" role="status" data-tb-notice></p>');
// An organization that did not answer is named first, with its reason, as on the Inbox.
function unreadLine() {
const v = snap?.view;
if (!v || !ws()?.unreadNote) return '';
const o = filter.org && !scope ? { ...v, rows: v.rows.filter(r => r.org === filter.org), unread: v.unread.filter(r => r.org === filter.org) } : v;
return ws().unreadNote(o, ['tasks'], 'tasks');
}
function markup() {
return `<section class="ws tb" data-tasks-board data-layout="${layoutNow()}">${head()}${noticeLine()}${trackers()}${toolbar()}${results()}</section>`;
return `<section class="ws tb" data-tasks-board data-layout="${layoutNow()}"${scope ? ` data-tb-scope="${esc(scope)}"` : ''}>${head()}${noticeLine()}${unreadLine()}${trackers()}${toolbar()}${results()}</section>`;
}
const failed = err => `<section class="ws tb" data-tasks-board><div class="ws-vh"><h1 tabindex="-1">Tasks</h1><p class="sub">The task board could not be read${err?.message && err.message !== 'read failed' ? `: ${esc(err.message)}` : ''}. It tries again on the next refresh.</p></div></section>`;
@@ -266,7 +327,8 @@
// Repaint in place, for the board's own changes (filters, layout, a write).
function paint(part = 'all') {
const root = document.querySelector('[data-tasks-board]');
if (!root || !snap) return;
// Only over a board drawn from a read: never over the not-on-your-list page or a failed read.
if (!root || !snap || !root.querySelector('[data-tb-results]')) return;
const kept = snapshotView(root);
if (part === 'results') root.querySelector('[data-tb-results]').outerHTML = results();
else root.outerHTML = markup();
@@ -274,9 +336,38 @@
}
// bus.js's view for #/tasks. A drag in progress holds the poll's repaint until it ends.
async function view() {
// q is the address query; opts.scope the organization of #/orgs/<id>/tasks; opts.writesIn asks one
// organization's own config whether it takes task changes.
async function view(q = new URLSearchParams(), opts = {}) {
// An address that names an organization no id can match is not the system board.
// A read still in flight from the board left behind lands nowhere and paints nothing.
if (typeof opts.scope === 'string' && opts.scope && !ORG_ID.test(opts.scope)) { ++readSeq; snap = null; return `<section class="ws tb" data-tasks-board><div class="ws-vh"><h1 tabindex="-1">Tasks</h1></div><p class="ws-panel tight note">This organization is not on your list.</p></section>`; }
const s = typeof opts.scope === 'string' && ORG_ID.test(opts.scope) ? opts.scope : null;
if (s !== scope) snap = null;
scope = s; writesIn = typeof opts.writesIn === 'function' ? opts.writesIn : null;
// The org and project filters come from the address on every render, checked; anything else is all.
const org = String(q.get('org') || ''), project = String(q.get('project') || '');
filter.org = scope || (ORG_ID.test(org) ? org : '');
filter.project = PROJECT_SELECTOR.test(project) || project === '-' ? project : '';
if (filter.org && filter.project && filter.project !== '-' && !filter.project.startsWith(`${filter.org}:`)) filter.project = '';
if (dragging) await Promise.race([dragEnded, new Promise(r => setTimeout(r, 30000))]);
try { await reload(); } catch (err) { snap = null; return failed(err); }
// Two renders at once (a first load, a poll during a filter change): the replaced one draws from the newer read.
try { let p = reload(); while (!(await p) && newest !== p) p = newest; } catch (err) { snap = null; return failed(err); }
// A not-on-your-list page opened meanwhile dropped every read; bus.js drops this render as not current.
if (!snap) return '';
// #/orgs/<id>/tasks?task=<ref> opens that task once, after bus.js has drawn the board.
const want = scope ? String(q.get('task') || '') : '';
if (!want) autoOpened = null;
else if (autoOpened !== `${scope}|${want}`) {
autoOpened = `${scope}|${want}`;
const k = `${scope}|${want}`;
setTimeout(() => {
if (!document.querySelector(`[data-tasks-board][data-tb-scope="${CSS.escape(scope || '')}"]`) || autoOpened !== k) return;
const t = TASK_REF.test(want) ? find(k) : null;
if (t) { openDialog(editForm(t), `open:${key(t)}`, t); dialogEl().dataset.fromAddress = '1'; }
else { notice = { error: true, text: `Task ${shortId(want)} is not on this board.` }; paint(); }
}, 0);
}
// Taken after the read, just before bus.js replaces the view, so focus, caret or scroll moved while
// the read was in flight is what is kept. bus.js puts focus back by key; this puts back the rest.
const kept = snapshotView(document.querySelector('[data-tasks-board]'));
@@ -317,7 +408,13 @@
d = document.createElement('dialog');
d.id = 'tb-dialog'; d.className = 'tb-dialog'; d.setAttribute('aria-labelledby', 'tb-dialog-h');
document.body.append(d);
d.addEventListener('close', () => { const back = d.dataset.return; d.innerHTML = ''; d.removeAttribute('aria-describedby'); opened = null; if (back) document.querySelector(`[data-focus-key="${CSS.escape(back)}"]`)?.focus(); });
d.addEventListener('close', () => {
const back = d.dataset.return, fromAddress = d.dataset.fromAddress === '1';
d.innerHTML = ''; d.removeAttribute('aria-describedby'); delete d.dataset.fromAddress; opened = null;
if (back) document.querySelector(`[data-focus-key="${CSS.escape(back)}"]`)?.focus();
// A task the address opened leaves the address when it closes, so a reload does not reopen it.
if (fromAddress && /^#\/orgs\/[^/?]+\/tasks\?(?:.*&)?task=/.test(location.hash)) ws()?.setQuery?.({ task: null }, true);
});
}
return d;
}
@@ -348,14 +445,17 @@
function editForm(t, refusal = null) {
const o = orgOf(t.org), w = taskWritable(t);
const why = t.tracker === 'vikunja' ? 'This task is in Vikunja. The board reads it but does not change it; change it in Vikunja.'
: !canWrite ? 'This Console takes no task changes.' : o.state !== 'ok' ? 'This organization\'s bus did not answer.' : !w ? 'The bus did not give this task a version to check a change against.' : '';
: !canWrite ? 'This Console takes no task changes.' : o.state !== 'ok' ? 'This organization\'s bus did not answer.'
: !allowed.has(t.org) ? `This Console takes no task changes in ${o.label}.` : !w ? 'The bus did not give this task a version to check a change against.' : '';
// On the system board the task also opens in its own organization.
const goto = scope ? '' : `<a class="ws-btn" href="${esc(`#/orgs/${encodeURIComponent(t.org)}/tasks?${new URLSearchParams({ task: t.ref })}`)}" data-tb-goto>Open in ${esc(o.label)}</a>`;
const facts = `<p class="tb-facts">${av(t.org, 'xs')}${esc(o.label)} · <span class="mono">${esc(shortId(t.ref))}</span> ${trk(t.tracker)}${t.requirement ? ` · <span class="mono">${esc(t.requirement)}</span>` : ''}${projectName(t) ? ` · ${esc(projectName(t))}` : ''}</p>`;
if (!w) {
return `<div class="tb-form" data-tb-form="view"><h2 id="tb-dialog-h">${title(t)}</h2>${facts}
<dl class="tb-dl"><dt>State</dt><dd>${esc(stateLabel(t.state) || 'Not read')}</dd><dt>Assignees</dt><dd>${t.assignees.length ? esc(t.assignees.join(', ')) : 'nobody'}</dd><dt>Priority</dt><dd>${prio(t) || 'none'}</dd><dt>Due</dt><dd>${due(t) || 'none'}</dd></dl>
<p class="tb-field-note">${esc(why)}</p>
${refusal ? `<p id="tb-dialog-status" class="tb-form-status" role="status" data-tb-form-status>${esc(refusal)}</p>` : ''}
<div class="tb-actions"><button type="button" class="ws-btn" data-tb-cancel>Close</button></div></div>`;
<div class="tb-actions">${goto}<span class="tb-gap"></span><button type="button" class="ws-btn" data-tb-cancel>Close</button></div></div>`;
}
return `<form method="dialog" class="tb-form" data-tb-form="edit" data-tb-key="${esc(key(t))}">
<h2 id="tb-dialog-h">Edit ${esc(shortId(t.ref))}</h2>${facts}
@@ -363,11 +463,12 @@
<div class="tb-row2">${stateSelect(t.state)}${prioSelect(t.priority)}<label>Due date<input type="date" name="due_date" value="${dateValue(t.dueDate)}"></label></div>
${roleBoxes([...new Set([...o.roles, ...t.assignees])].sort(), t.assignees)}
<p class="tb-form-status" role="alert" data-tb-form-status></p>
<div class="tb-actions"><button type="button" class="ws-btn danger" data-tb-archive>Archive</button><span class="tb-gap"></span><button type="button" class="ws-btn" data-tb-cancel>Cancel</button><button type="submit" class="ws-btn primary" data-tb-submit>Save</button></div>
<div class="tb-actions"><button type="button" class="ws-btn danger" data-tb-archive>Archive</button>${goto}<span class="tb-gap"></span><button type="button" class="ws-btn" data-tb-cancel>Cancel</button><button type="submit" class="ws-btn primary" data-tb-submit>Save</button></div>
</form>`;
}
function openDialog(html, back, task = null) {
const d = dialogEl();
delete d.dataset.fromAddress;
d.innerHTML = html; d.dataset.return = back || ''; opened = task ? structuredClone(task) : null;
if (d.querySelector('#tb-dialog-status')) d.setAttribute('aria-describedby', 'tb-dialog-status'); else d.removeAttribute('aria-describedby');
if (!d.open) d.showModal();
@@ -474,6 +575,8 @@
const d = e.target.closest?.('#tb-dialog');
if (d) {
if (e.target.closest('[data-tb-cancel]')) { e.preventDefault(); d.close(); }
// The link navigates; the dialog closes so the organization's board opens the task itself.
else if (e.target.closest('[data-tb-goto]')) { d.dataset.return = ''; d.close(); }
else if (e.target.closest('[data-tb-archive]')) { e.preventDefault(); archive(e.target.closest('form')); }
return;
}
@@ -482,8 +585,11 @@
if (lay) { layouts[phone() ? 'phone' : 'wide'] = lay.dataset.tbLayout; save(LAYOUT, JSON.stringify(layouts)); paint(); return; }
const chip = e.target.closest('[data-tb-org]');
if (chip) {
filter.org = chip.dataset.tbOrg;
if (filter.project && filter.project !== '-' && filter.org && !filter.project.startsWith(`${filter.org}:`)) { filter.project = ''; refilter(); } else paint();
// The org filter lives in the address: paint now, then bus.js re-renders from the new query.
const org = chip.dataset.tbOrg, clear = filter.project && filter.project !== '-' && org && !filter.project.startsWith(`${org}:`);
filter.org = org; if (clear) filter.project = '';
paint();
ws()?.setQuery?.({ org: org || null, ...(clear ? { project: null } : {}) }, true);
return;
}
const open = e.target.closest('[data-tb-open]');
@@ -500,6 +606,7 @@
}
if (!inBoard(e) || !snap) return;
const f = e.target.dataset?.tbFilter;
if (f === 'project') { filter.project = e.target.value; paint(); ws()?.setQuery?.({ project: e.target.value || null }, true); return; }
if (f) { filter[f] = e.target.value; refilter(); return; }
if (e.target.matches('[data-tb-move]')) move(e.target.dataset.tbMove, e.target.value);
});
+141 -12
View File
@@ -363,6 +363,17 @@
return hit ? { key: hit[0], copy: hit[1] } : { key: null, copy: null };
};
const LEVEL_CLS = { root: 'root', org: 'org', project: 'proj' };
// SP5: what an agent is, in words. A hired agent's definition is `org:hire-<decision>`. No read names
// the template it was hired from, so it reads as hired, and from which pack when a pack hired it;
// the definition id stays as secondary text.
const isHired = item => typeof item?.hireDecision === 'string' || /^org:hire-/.test(String(item?.definition ?? ''));
const packOf = item => (typeof item?.pack?.label === 'string' && item.pack.label ? item.pack.label : typeof item?.pack?.id === 'string' ? item.pack.id : '');
const ROLE_WORD = { 'chief-of-staff': 'Chief of staff', assistant: 'Assistant' };
const roleWords = item => {
const d = typeof item?.definition === 'string' ? item.definition : '';
if (isHired(item)) return packOf(item) ? `Hired from pack ${packOf(item)}` : 'Hired';
return ROLE_WORD[d] || d.replace(/^org:/, '') || 'not reported';
};
const projectName = (rows, id) => { const p = Array.isArray(rows) ? rows.find(r => r?.id === id) : null; return p?.name || id; };
let agentsPick = null; // the project chosen in "Available in a project", kept across repaints
// Repaints that section alone when the choice changes, one renderer per organization: a side-by-side
@@ -392,7 +403,8 @@
if (!set && inst === null) return `<p class="ws-panel tight note" data-agents-why>${selOrg?.busHealthy === false ? `Agents not read: ${busWord(selOrg)}.` : 'Agents unavailable. The bus did not answer the instance read.'}</p>`;
if (!rows.length) return '<p class="ws-panel tight note">No agents configured</p>';
const where = a => (a.level === 'root' ? 'Your root configuration' : a.level === 'org' ? `${label(selOrg)} file` : `${names(a.project)} project`);
const sub = a => [a.definition && a.definition !== a.name ? a.definition : '', a.level === 'org' && typeof inst?.[a.name]?.pack?.id === 'string' ? `from pack ${inst[a.name].pack.id}` : '', a.overrides?.length ? `replaces the ${a.overrides.map(o => (o.level === 'org' ? 'organization' : o.level)).join(' and ')} ${a.overrides.length > 1 ? 'definitions' : 'definition'}` : '', a.narrowed?.length ? `narrowed here: ${a.narrowed.join(', ')}` : ''].filter(Boolean).join(' · ');
const hired = a => a.level === 'org' && isHired({ ...inst?.[a.name], definition: a.definition });
const sub = a => [hired(a) ? 'hired' : a.definition && a.definition !== a.name ? a.definition : '', a.level === 'org' && typeof inst?.[a.name]?.pack?.id === 'string' ? `from pack ${inst[a.name].pack.id}` : '', hired(a) ? a.definition : '', a.overrides?.length ? `replaces the ${a.overrides.map(o => (o.level === 'org' ? 'organization' : o.level)).join(' and ')} ${a.overrides.length > 1 ? 'definitions' : 'definition'}` : '', a.narrowed?.length ? `narrowed here: ${a.narrowed.join(', ')}` : ''].filter(Boolean).join(' · ');
const status = a => {
if (a.assistant) return dot('s-unknown', '?', 'status not reported', true);
if (a.level !== 'org') {
@@ -425,9 +437,10 @@
return `<div class="ws-tbl"><table><thead><tr><th scope="col">Agent</th><th scope="col">Level</th><th scope="col" class="hide-m">Defined in</th><th scope="col" class="hide-m">Memory</th><th scope="col">Status</th><th scope="col" class="hide-m">Model</th></tr></thead><tbody>${body}</tbody></table></div>${note}`;
}
async function agentGrid(note = '', selected = window.MosaicOrg?.id) {
// SP5: `opts.under` draws it below the scoped Agents table (#/orgs/<id>/agents), which owns the page heading.
async function agentGrid(note = '', selected = window.MosaicOrg?.id, opts = {}) {
const [list, levels] = await Promise.all([data.organizations().catch(() => null), data.agentLevels().catch(() => null), ensureThemes()]);
const head = `<div class="ws-vh"><h1 tabindex="-1">Agents</h1><div class="ws-right"><a class="ws-btn" href="#/settings/agents" data-focus-key="agents-edit">${icon('settings')}Edit agents</a></div><p class="sub">Agents are defined at three levels: root, organization and project. Each organization runs its own copy of a root agent, and a project's agents run only in that project. The copies are made when the organization's bus starts.${note ? ` ${note}` : ''}</p></div>`;
const head = `<div class="ws-vh">${opts.under ? '<h2>Agents by level</h2>' : '<h1 tabindex="-1">Agents</h1>'}<div class="ws-right"><a class="ws-btn" href="#/settings/agents" data-focus-key="agents-edit">${icon('settings')}Edit agents</a></div><p class="sub">Agents are defined at three levels: root, organization and project. Each organization runs its own copy of a root agent, and a project's agents run only in that project. The copies are made when the organization's bus starts.${note ? ` ${note}` : ''}</p></div>`;
if (!Array.isArray(list)) return `<section class="ws ws-agents" data-org-agents>${head}<p class="ws-panel tight note">Organizations unavailable.</p></section>`;
const valid = list.filter(org => !org.invalid);
const admitted = valid.some(org => org.id === selected);
@@ -551,6 +564,13 @@
const missOf = (r, names) => { const n = r?.state === 'ok' && r.errors ? names.find(x => r.errors[x]) : null; return n ? READ_WHY[r.errors[n]] || String(r.errors[n]) : null; };
const missed = (o, names) => o.rows.filter(r => missOf(r, names)).map(r => `${esc(o.name(r.org))} (${esc(missOf(r, names))})`);
const missLine = (o, names, key, lead = 'Not read in') => { const m = missed(o, names); return m.length ? `<p class="note ws-miss" data-ws-miss="${key}">${lead} ${andList(m)}.</p>` : ''; };
// SP3/SP4: a row's organization and project as chips, and the organizations that did not answer
// named first with the reason their read gave. The Inbox and the system task board share these.
// `mark`, already-escaped markup such as the organization's avatar, takes the dot's place.
const orgChip = (o, id, mark = '<i aria-hidden="true"></i>') => `<span class="ws-chip ws-o ws-ochip" style="${o.style(id)}" data-ws-orgchip>${mark}${esc(o.name(id))}</span>`;
const projChip = name => (name ? `<span class="ws-chip outline ws-pchip" data-ws-projchip>${esc(name)}</span>` : '');
const unreadWhy = r => `${stateWords(r).replace(/, read .*$/, '')}${r.reason ? `: ${esc(r.reason)}` : ''}${r.at ? ` (read ${esc(hm(r.at))})` : ''}`;
const unreadNote = (o, names, key) => (o.unread.length ? `<p class="note ws-unread-note" data-ws-unread>Not read: ${andList(o.unread.map(unreadWhy))}.</p>` : '') + missLine(o, names, key);
// The organizations that answered and made every read a figure is built from. Only these count
// toward it: a failed read reaches the merge as an empty list, so its zero is not a figure.
const readers = (o, names) => new Set(o.rows.filter(r => r.state === 'ok' && !missOf(r, names)).map(r => r.org));
@@ -674,7 +694,7 @@
<section aria-label="Needs you and sessions">
${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>`)}
${secH('Active agents', 'sessions now', '<a href="#/agents" data-focus-key="home-agents">Agents</a>')}
${sessTable}${missLine(o, ['agents', 'claims'], 'sessions')}
</section>
</div>
@@ -803,19 +823,17 @@
const picked = want === 'none' ? null : rows.find(d => itemKey(d) === want) || (want ? null : rows[0] || null);
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 chips = d => `${orgChip(o, d.org)}${projChip(d.project?.name)}`;
const row = d => {
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><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><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">${chips(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>`;
};
// 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 unread = unreadNote(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">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' : filtered ? 'Nothing matches these filters' : 'Nothing needs you'}</p>`;
// The hire, fire and change form posts to one organization's bus, so the system list links to
@@ -838,7 +856,7 @@
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(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>
<div class="ws-ichips">${chips(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>` : ''}
@@ -879,6 +897,115 @@
</section>`;
}
// ---- agents across organizations (#1873 SP5, prototype shot 14) ----
// One table of every organization's agents: the organization's own instances and the root and
// project copies its bus booted with, from each organization's instances read; what each one works
// on from the workspace read's role sessions. Filters by organization and state live in the hash.
// `opts.scope` names one organization (#/orgs/<id>/agents): the same table, that organization only.
// `opts.fireIn(org)` says from that organization's own config whether this Console decides there;
// Fire is drawn only on the system page and only there, and posts to the row's organization (bus.js).
const AGENT_STATE = [['working', 'Working'], ['waiting', 'Waiting on you'], ['idle', 'Idle'], ['running', 'Running'], ['not-running', 'Not running'], ['cant-launch', "Can't launch"]];
const AGENT_STATE_KEY = { working: 'working', 'waiting on you': 'waiting', idle: 'idle', running: 'running', 'not running': 'not-running', "can't launch": 'cant-launch' };
async function agentsPage(q, opts = {}) {
// SP5 review F11: a scope that is not an organization id is refused, never widened to the system list.
if (opts.scope != null && !(typeof opts.scope === 'string' && ORG_ID.test(opts.scope))) return '<section class="ws ws-agents-all" data-ws-agents data-ws-failed><div class="ws-vh"><h1 tabindex="-1">Agents</h1><p class="sub">That is not an organization address.</p></div></section>';
const scope = opts.scope ?? null;
const org = scope ? [scope] : listParam(q, 'org', id => ORG_ID.test(id));
const want = String(q?.get?.('state') || '');
const stateF = AGENT_STATE.some(([k]) => k === want) ? want : null;
let body;
try { [body] = await Promise.all([data.workspace({ sections: ['sessions', 'projects'] }), ensureThemes()]); took(body); }
catch (err) { return failedPage('ws-agents-all', 'data-ws-agents', 'Agents', err); }
const every = orgsOf(body);
const o = org ? { ...every, rows: every.rows.filter(r => org.includes(r.org)), unread: every.unread.filter(r => org.includes(r.org)) } : every;
const askable = (scope ? o.rows : every.rows).filter(r => r.state === 'ok');
// Each answering organization's instances, and (system page only) whether this Console decides there.
const reads = await Promise.all(askable.map(r => Promise.all([
data.instances(r.org).then(v => ({ inst: instancesOf(v), copies: copiesOf(v) }), err => ({ inst: null, copies: {}, err })),
!scope && typeof opts.fireIn === 'function' ? opts.fireIn(r.org).then(v => v === true, () => false) : false,
]).then(([v, fire]) => ({ id: r.org, ...v, fire }))));
const sessions = arr(body.sessions).filter(x => x?.kind === 'role' && typeof x.org === 'string' && typeof x.role === 'string');
const projects = arr(body.projects).filter(x => typeof x?.org === 'string');
const projectRef = (id, p) => { const hit = projects.find(x => x.org === id && (x.id === p || x.key === p)); return hit ? { id: hit.id, name: hit.name || hit.key || hit.id } : { id: p, name: p }; };
const all = [];
for (const r of reads) {
if (!r.inst) continue;
const mine = sessions.filter(x => x.org === r.id);
const agents = mine.map(x => ({ role: x.role, holder_run: x.run, state: x.state }));
const session = name => mine.find(x => x.role === name) || null;
for (const [name, item] of Object.entries(r.inst)) {
const d = activity(name, item, agents);
// Never Fire the chief, the assistant or an instance the bus would refuse to end.
const keep = item?.definition === 'chief-of-staff' || name === 'chief' || item?.definition === 'assistant' || item?.endProtected === true;
all.push({ org: r.id, key: name, name, level: 'org', role: roleWords(item), id: isHired(item) ? item.definition : '', dot: d, s: session(name), fire: r.fire && !keep });
}
for (const [key, c] of Object.entries(r.copies || {})) {
if (!c || typeof c.name !== 'string') continue;
const d = copyDot(key, c, agents);
all.push({ org: r.id, key, name: c.name, level: c.level === 'project' ? 'project' : 'root', role: roleWords(c), id: copyBlocked(c) ? copyWhy(c) : '', dot: d, s: session(key), project: c.level === 'project' && c.project ? projectRef(r.id, c.project) : null, fire: false });
}
}
for (const a of all) a.state = AGENT_STATE_KEY[a.dot[2]] || 'not-running';
const counts = new Map();
for (const a of all) counts.set(a.org, (counts.get(a.org) || 0) + 1);
const inOrg = all.filter(a => !org || org.includes(a.org)).sort((x, y) => x.org.localeCompare(y.org) || x.name.localeCompare(y.name) || x.key.localeCompare(y.key));
const filteredRows = inOrg.filter(a => !stateF || a.state === stateF);
// SP5 review F1/F2: Open addresses one agent (`#/orgs/<id>/agents?agent=<key>`). The scoped page puts
// that agent first and marks it, and draws no Open of its own, which would only link to itself.
const picked = scope ? String(q?.get?.('agent') || '') : '';
const hit = picked ? filteredRows.find(a => a.key === picked) || null : null;
const rows = hit ? [hit, ...filteredRows.filter(a => a !== hit)] : filteredRows;
const akey = a => `${a.org}:${a.key}`;
const orgChip = a => `<span class="ws-chip ws-o ws-ochip" style="${o.style(a.org)}" data-ws-orgchip><i aria-hidden="true"></i>${esc(o.name(a.org))}</span>`;
const projOf = a => a.project || a.s?.project || null;
const projChip = a => (projOf(a)?.name ? `<span class="ws-chip outline ws-pchip" data-ws-projchip>${esc(projOf(a).name)}</span>` : '');
const work = a => (a.s?.task ? `${esc(a.s.task.title || a.s.task.ref)}${a.s.task.title && a.s.task.ref ? `<span class="t2 mono">${esc(a.s.task.ref)}</span>` : ''}` : '<span class="muted">nothing reported</span>');
const LEVEL_WORD = { root: 'root agent copy', project: 'project agent' };
const row = a => `<tr class="ws-row ws-edge ws-o${a === hit ? ' on' : ''}" style="${o.style(a.org)}" data-ws-agent="${esc(akey(a))}"${a === hit ? ' aria-current="true"' : ''}>
<td class="t1">${esc(a.name)}${a.id ? `<span class="t2${a.level === 'org' ? ' mono' : ''}">${esc(a.id)}</span>` : ''}<span class="ws-ichips">${orgChip(a)}${projChip(a)}<span class="show-m ws-arole">${esc(a.role)}</span></span><span class="t2 show-m">${a.s?.task ? `Working on ${esc(a.s.task.title || a.s.task.ref)}` : 'Working on nothing reported'}</span></td>
<td class="hide-m" data-ws-role>${esc(a.role)}${a.level !== 'org' ? `<span class="t2">${esc(LEVEL_WORD[a.level])}</span>` : ''}</td>
<td>${dot(...a.dot, true)}</td>
<td class="hide-m" data-ws-work>${work(a)}</td>
${scope ? '' : `<td class="ws-act"><a class="ws-btn" href="${esc(`${orgHref(a.org)}/agents?agent=${encodeURIComponent(a.key)}`)}" data-ws-open data-focus-key="${esc(`open:${akey(a)}`)}">Open<span class="ws-sr"> ${esc(a.name)} in ${esc(o.name(a.org))}</span></a>${a.fire ? window.MosaicDecide?.fireControl(a.org, a.name, true, o.name(a.org)) ?? '' : ''}</td>`}
</tr>`;
// An organization that did not answer is named first, with the reason its read gave; one that
// answered but whose instances read failed is named too, never counted as having no agents.
const why = r => `${stateWords(r).replace(/, read .*$/, '')}${r.reason ? `: ${esc(r.reason)}` : ''}${r.at ? ` (read ${esc(hm(r.at))})` : ''}`;
const failed = reads.filter(r => !r.inst && (!org || org.includes(r.id)));
const READ_ERR = { 403: 'refused', 0: 'signed out' };
const unread = (o.unread.length ? `<p class="note ws-unread-note" data-ws-unread>Not read: ${andList(o.unread.map(why))}.</p>` : '')
+ (failed.length ? `<p class="note ws-miss" data-ws-miss="instances">Agents not read in ${andList(failed.map(r => `${esc(o.name(r.id))} (${esc(READ_ERR[r.err?.status] || r.err?.code || 'no answer')})`))}.</p>` : '')
+ missLine(o, ['agents', 'tasks'], 'agents', 'What agents work on was not read in');
const anyRead = reads.some(r => r.inst && (!org || org.includes(r.id)));
const filtered = (org && !scope) || stateF;
const table = rows.length
? `<div class="ws-tbl"><table class="ws-agt" data-ws-agent-list><caption class="ws-sr">Agents</caption><thead><tr><th scope="col">Agent</th><th scope="col" class="hide-m">Role</th><th scope="col">State</th><th scope="col" class="hide-m">Working on</th>${scope ? '' : '<th scope="col"><span class="ws-sr">Actions</span></th>'}</tr></thead><tbody>${rows.map(row).join('')}</tbody></table></div>`
: `<p class="ws-panel tight note" data-ws-agent-list>${!anyRead && o.rows.length ? 'Agents not read' : filtered ? 'Nothing matches these filters' : 'No agents'}</p>`;
const stateCounts = new Map();
for (const a of inOrg) stateCounts.set(a.state, (stateCounts.get(a.state) || 0) + 1);
const shown = AGENT_STATE.filter(([k]) => stateCounts.has(k) || k === stateF);
const stateChips = `<div class="ws-fchips" role="group" aria-label="State"><span class="ws-label">State</span><button type="button" class="f plain" data-ws-astate="" data-focus-key="astate:all" aria-pressed="${!stateF}">All <span class="n">${inOrg.length}</span></button>${shown.map(([k, w]) => `<button type="button" class="f plain" data-ws-astate="${k}" data-focus-key="${esc(`astate:${k}`)}" aria-pressed="${stateF === k}">${esc(w)} <span class="n">${stateCounts.get(k) || 0}</span></button>`).join('')}</div>`;
// The org chips count each answering organization's agents; an organization whose instances read
// failed shows "not read", as one that did not answer shows its state.
const notRead = new Set(reads.filter(r => !r.inst).map(r => r.id));
const chipOrgs = { ...every, rows: every.rows.map(r => (notRead.has(r.org) ? { ...r, errors: { ...(r.errors || {}), instances: 'not read' } } : r)) };
const orgsWith = new Set(rows.map(a => a.org)).size;
const tally = rows.length ? ` ${plural(rows.length, 'agent')}${scope ? '' : ` in ${plural(orgsWith, 'organization')}`}.` : '';
const sub = scope
? `Agents in ${esc(o.name(scope))} only.${tally} <a href="#/agents" data-focus-key="agents-all">Every organization</a>`
: `Every agent on the system, by organization.${tally} Open one to see it in its organization.`;
return `<section class="ws ws-agents-all" data-ws-agents${scope ? ` data-ws-scope="${esc(scope)}"` : ''}>
<div class="ws-vh"><h1 tabindex="-1">Agents</h1><p class="sub">${sub}</p></div>
${unread}
<div class="ws-toolbar ws-ibx-filters">
${scope ? '' : `<div class="ws-fgroup"><span class="ws-label">Org</span>${orgChips(chipOrgs, counts, org, all.length, ['instances'])}</div>`}
${stateChips}
</div>
${table}
${scope ? '' : `<div data-agents-receipt="*" role="status">${window.MosaicDecide?.fireNote('*') ?? ''}</div>`}
</section>`;
}
// ---- controls: filters write the hash query and repaint in place ----
const pathOf = () => route().split('?')[0];
const queryOf = () => new URLSearchParams(route().split('?')[1] || '');
@@ -895,8 +1022,10 @@
if (pathOf() === '/projects') document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));
}
document.addEventListener('click', e => {
const page = e.target.closest?.('[data-ws-projects],[data-ws-inbox]');
const page = e.target.closest?.('[data-ws-projects],[data-ws-inbox],[data-ws-agents]');
if (!page) return;
const st = e.target.closest('[data-ws-astate]');
if (st) { setQuery({ state: st.dataset.wsAstate || null }); return; }
const chip = e.target.closest('[data-ws-org]');
if (chip) {
const id = chip.dataset.wsOrg, now = listParam(queryOf(), 'org', x => ORG_ID.test(x)) || [];
@@ -931,6 +1060,6 @@
// U4b: the org mark (colour and initials) for the task board (tasks-board.js).
const orgMark = Object.freeze({ ready: ensureThemes, av, colours, initials });
window.MosaicWorkspace = Object.freeze({ dashboard, holdLive, graftLive, agentGrid, icon, orgMark, refresh: () => refresh(), av, colours, label, state, dot, themes: ensureThemes, home, projectsPage, inboxPage, inboxCount, setGrouping, get pollMs() { return pollMs; } });
window.MosaicWorkspace = Object.freeze({ dashboard, holdLive, graftLive, agentGrid, icon, orgMark, refresh: () => refresh(), av, colours, label, state, dot, themes: ensureThemes, home, projectsPage, inboxPage, agentsPage, inboxCount, setGrouping, orgsOf, orgChip, projChip, unreadNote, setQuery, get pollMs() { return pollMs; } });
refresh();
})();
+3 -1
View File
@@ -71,7 +71,9 @@ test('shipped strings are unchanged, and the web keeps its write boundary', () =
// 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`']);
// SP5 (#1873): Fire from the system Agents page goes to its row's organization the same way:
// decideBase({org}) is /api/orgs/<org>, and an agent card's Fire (no org) keeps /api.
assert.deepEqual([...read('bus.js').toString().matchAll(/decidePost\(([^,]*),/g)].map(m => m[1]), ["`/api/decisions/hire/${templatesOnly?'templates':'start'}`", "`${decideBase({ org: named })}/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.
@@ -130,8 +130,12 @@ test('an organization opens inside the same shell with a breadcrumb, and its pag
await b.evaluate('performance.clearResourceTimings(); document.querySelector("#workspace-nav a[href=\'#/orgs/beta/organization\']").click()');
await wait('location.hash === "#/orgs/beta/organization" && document.querySelector("[data-org-dashboard] h1")?.textContent === "Beta Works"');
assert.equal(await b.evaluate('window.__marker'), 1);
// A page inside beta scopes its reads to beta.
// #1873 SP5: #/agents is the system Agents page, not beta's; it keeps the address and opens no organization.
await b.evaluate('location.hash = "#/agents"');
await wait('location.hash === "#/agents" && document.querySelector("[data-ws-agents]:not([data-ws-scope])")');
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'beta');
// A page inside beta scopes its reads to beta.
await b.evaluate('location.hash = "#/orgs/beta/agents"');
await wait('location.hash === "#/orgs/beta/agents" && document.querySelector("[data-org-agents]")');
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'beta');
assert.equal(await b.evaluate('window.__marker'), 1);
@@ -0,0 +1,258 @@
// SP5 (#1873, single pane slice 5): one Agents page for the whole system at #/agents. Every
// organization that answers adds its agents to one table: name, role, state, what it works on, its
// organization chip and a project chip when the work has a project. Filters by organization and state
// live in the hash query. Fire on a row goes to the row's own organization, never to the one opened
// last, and is offered only where that organization's own config says this Console decides. A stopped
// organization is named at the top with its reason. #/orgs/<id>/agents shows the same table, scoped.
// 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 HIRE = '0f3c1a2b-4d5e-4f60-8a7b-9c0d1e2f3a4b';
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) }],
// A built-in store task assigned to coder, filed under the Website project.
tasks: [{ task_ref: 'store:1', source: 'store', at: at(20), fields: { title: 'Hero copy', state: 'in-progress', group: 'p-site', assignees: ['coder'] } }],
agents: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'working', at: at(40), working: { since: at(40), messages: 2 } }],
instances: { coder: { definition: 'coder', running: ['run-1'] }, chief: { definition: 'chief-of-staff', running: [] } },
},
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) }],
instances: { pm: { definition: 'pm', running: ['run-9'] }, 'research-2': { definition: `org:hire-${HIRE}`, hireDecision: HIRE, running: [], pack: { id: 'coding', label: 'Coding' } } },
},
};
if (empty) for (const d of Object.values(DATA)) { d.instances = {}; d.agents = []; }
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), appearance: { theme: org.theme } } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
bus: {
messages: async () => ({ messages: [], unread: 0 }), agents: async () => DATA[id].agents, 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [],
instances: async () => ({ revision: 'a'.repeat(64), definitions: {}, instances: DATA[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 decides there, except those named in `noDecide`; decision
// POSTs are recorded and answered from a queue, so no real write happens.
const stubFor = (noDecide = []) => `window.__noDecide = ${JSON.stringify(noDecide)};\n${STUB}`;
const STUB = `(() => {
const real = window.fetch.bind(window);
window.__posts = []; window.__answers = [];
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();
const org = (u.match(/^\\/api\\/orgs\\/([^/]+)\\//) || [])[1];
return answer(200, { ...c, send: true, decide: !window.__noDecide.includes(org) });
}
if (!u.includes('/decisions/')) return real(url, init);
window.__posts.push({ url: u, method: init.method, body: init.body });
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 = '#/agents', empty = false, stub = false, noDecide = [] } = {}) {
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ empty }), log: () => {} });
t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(...viewport);
if (stub) await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stubFor(noDecide) });
const base = `http://127.0.0.1:${server.address().port}`;
await b.navigate(`${base}/${hash}`);
return { b, base, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) };
}
const rows = 'JSON.stringify([...document.querySelectorAll("[data-ws-agent]")].map(r => r.dataset.wsAgent))';
const are = list => `${rows} === ${JSON.stringify(JSON.stringify(list))}`;
const ALL = ['acme:chief', 'acme:coder', 'beta:pm', 'beta:research-2'];
test('#/agents lists both organizations\' agents in one table, each row with its organization chip', async t => {
const { b, wait, text } = await setup(t);
await wait(are(ALL));
// A system route: the address keeps no organization and the sidebar Agents link is this page.
assert.equal(await b.evaluate('location.hash.split("?")[0]'), '#/agents');
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]")?.getAttribute("href")'), '#/agents');
assert.equal(await text('#bus-view h1'), 'Agents');
assert.equal(await text('[data-ws-agent="acme:coder"] [data-ws-orgchip]'), 'Acme Studio');
assert.equal(await text('[data-ws-agent="beta:pm"] [data-ws-orgchip]'), 'Beta Works');
// State from the bus, and what the agent works on: its task, with the task's project chip.
assert.match(await text('[data-ws-agent="acme:coder"]'), /working/);
assert.match(await text('[data-ws-agent="beta:pm"]'), /waiting on you/);
assert.match(await text('[data-ws-agent="acme:coder"] [data-ws-work]'), /Hero copy/);
assert.equal(await text('[data-ws-agent="acme:coder"] [data-ws-projchip]'), 'Website');
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] [data-ws-projchip]\')'), null);
// A hired agent reads by its name and role; the definition id stays as secondary text.
const hired = await b.evaluate('document.querySelector(\'[data-ws-agent="beta:research-2"] .t1\')?.firstChild?.textContent');
assert.equal(hired, 'research-2');
assert.match(await text('[data-ws-agent="beta:research-2"] [data-ws-role]'), /Hired.*Coding/);
assert.match(await text('[data-ws-agent="beta:research-2"] .t2'), new RegExp(`org:hire-${HIRE}`));
assert.match(await text('[data-ws-agent="acme:chief"] [data-ws-role]'), /Chief of staff/);
// Each row opens the agent in its own organization.
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] a[data-ws-open]\')?.getAttribute("href")'), '#/orgs/beta/agents?agent=pm');
});
test('the organization and state filters live in the hash and survive a reload', async t => {
const { b, base, wait } = await setup(t);
await wait(are(ALL));
await b.evaluate('document.querySelector("[data-ws-agents] [data-ws-org=beta]").click()');
await wait(`location.hash.startsWith("#/agents?") && new URLSearchParams(location.hash.split("?")[1]).get("org") === "beta" && ${are(['beta:pm', 'beta:research-2'])}`);
await b.navigate(`${base}/${await b.evaluate('location.hash')}`);
await wait(`${are(['beta:pm', 'beta:research-2'])} && document.querySelector("[data-ws-org=beta]")?.getAttribute("aria-pressed") === "true"`);
// State: one chip per state the rows carry.
await b.evaluate('document.querySelector("[data-ws-agents] [data-ws-org=\'\']").click()');
await wait(are(ALL));
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-astate]")].map(x => x.dataset.wsAstate)'), ['', 'working', 'waiting', 'not-running']);
await b.evaluate('document.querySelector("[data-ws-astate=not-running]").click()');
await wait(`new URLSearchParams(location.hash.split("?")[1]).get("state") === "not-running" && ${are(['acme:chief', 'beta:research-2'])}`);
await b.navigate(`${base}/${await b.evaluate('location.hash')}`);
await wait(`${are(['acme:chief', 'beta:research-2'])} && document.querySelector("[data-ws-astate=not-running]")?.getAttribute("aria-pressed") === "true"`);
// A value the page does not know is dropped, never read as a filter.
await b.evaluate('location.hash = "#/agents?state=bogus&org=Not%20An%20Org"');
await wait(are(ALL));
await b.evaluate('location.hash = "#/agents?org=beta&state=working"');
await wait('document.querySelector("[data-ws-agent-list]")?.textContent === "Nothing matches these filters"');
});
test('firing beta\'s agent from #/agents while acme was opened last reaches beta', async t => {
const { b, wait, text } = await setup(t, { hash: '#/orgs/acme/organization', stub: true });
await wait('document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"');
await b.evaluate('location.hash = "#/agents"');
await wait(`${are(ALL)} && document.querySelector('[data-ws-agent="beta:pm"] [data-agent-fire]')`);
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'acme');
// Never on the chief.
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="acme:chief"] [data-agent-fire]\')'), null);
await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] [data-agent-fire]\').click()');
await wait('document.querySelector(\'[data-ws-agent="beta:pm"] [data-agent-fire-yes]\')');
await b.evaluate('window.__answers.push({ body: { id: "dec-fire-1", op: "resolved", choice: "yes" } })');
await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] [data-agent-fire-yes]\').click()');
await wait('/Fired pm/.test(document.querySelector("[data-ws-agents] [data-agents-receipt]")?.textContent ?? "")');
// The receipt names the organization by its label, not its id.
assert.match(await text('[data-ws-agents] [data-agents-receipt]'), /^Fired pm in Beta Works\./);
const posts = await b.evaluate('window.__posts');
assert.deepEqual(posts.map(p => p.url), ['/api/orgs/beta/decisions/hire/start']);
assert.deepEqual(JSON.parse(posts[0].body), { business: 'beta', hire: { op: 'fire', level: 'org', name: 'pm', reason: 'Fired from the Agents page.' } });
assert.match(await text('[data-ws-agents] [data-agents-receipt]'), /dec-fire-1/);
assert.equal(await b.evaluate('location.hash.split("?")[0]'), '#/agents');
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'acme');
});
test('Fire follows each organization\'s own config, not the organization opened last', async t => {
// Acme is opened last and this Console decides there; beta's own config says it does not.
const { b, wait } = await setup(t, { hash: '#/orgs/acme/organization', stub: true, noDecide: ['beta'] });
await wait('document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"');
await b.evaluate('location.hash = "#/agents"');
await wait(`${are(ALL)} && document.querySelector('[data-ws-agent="acme:coder"] [data-agent-fire]')`);
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] [data-agent-fire]\')'), null);
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:research-2"] [data-agent-fire]\')'), null);
});
test('a stopped organization is named at the top with its reason', async t => {
const { b, wait, text } = await setup(t);
await wait(are(ALL));
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-agent-list]")) & Node.DOCUMENT_POSITION_FOLLOWING)'), true);
});
test('with no agents anywhere the table says so and still names the stopped organization', async t => {
const { wait, text } = await setup(t, { empty: true });
await wait('document.querySelector("[data-ws-agent-list]")');
assert.equal(await text('[data-ws-agent-list]'), 'No agents');
assert.match(await text('[data-ws-unread]'), /Gamma Home.*stopped by the owner/s);
});
test('#/orgs/<id>/agents shows that organization\'s rows only, with a link to every organization', async t => {
const { b, wait, text } = await setup(t, { hash: '#/orgs/beta/agents' });
await wait(are(['beta:pm', 'beta:research-2']));
assert.match(await text('[data-ws-agents] .sub'), /Beta Works/);
assert.equal(await b.evaluate('document.querySelector("[data-ws-agents] a[href=\'#/agents\']") !== null'), true);
assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/agents');
// The organization's own levels grid and held roles still follow, under the one page heading.
await wait('document.querySelector("[data-org-agents]")');
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view h1").length'), 1);
});
test('Open addresses the agent: the scoped page puts it first, marks it and draws no Open of its own', async t => {
const { b, wait } = await setup(t);
await wait(are(ALL));
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:research-2"] [data-ws-open]\').getAttribute("href")'), '#/orgs/beta/agents?agent=research-2');
await b.evaluate('document.querySelector(\'[data-ws-agent="beta:research-2"] [data-ws-open]\').click()');
await wait(are(['beta:research-2', 'beta:pm']));
assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/agents?agent=research-2');
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:research-2"]\').getAttribute("aria-current")'), 'true');
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"]\').hasAttribute("aria-current")'), false);
// No self-link on the scoped page.
assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-agents] [data-ws-open]").length'), 0);
assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-agents] thead th").length'), 4);
// An agent the organization does not have marks nothing.
await b.evaluate('location.hash = "#/orgs/beta/agents?agent=nobody"');
await wait(`${are(['beta:pm', 'beta:research-2'])} && !document.querySelector('[data-ws-agents] [aria-current]')`);
});
test('Cancel puts focus back on the row\'s Fire, which is labelled with the organization\'s name', async t => {
const { b, wait } = await setup(t, { stub: true });
await wait(`${are(ALL)} && document.querySelector('[data-ws-agent="beta:pm"] [data-agent-fire]')`);
const fire = '\'[data-ws-agent="beta:pm"] [data-agent-fire]\'';
assert.equal(await b.evaluate(`document.querySelector(${fire}).getAttribute('aria-label')`), 'Fire pm in Beta Works');
await b.evaluate(`document.querySelector(${fire}).click()`);
await wait('document.querySelector(\'[data-ws-agent="beta:pm"] [data-agent-fire-no]\')');
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] [data-agent-fire-confirm]\').getAttribute("aria-label")'), 'Fire pm in Beta Works?');
await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] [data-agent-fire-no]\').click()');
await wait(`document.activeElement === document.querySelector(${fire})`);
assert.deepEqual(await b.evaluate('window.__posts'), []);
});
test('a picked agent stays in the address through the state filter, and a scope that is not an organization id is refused', async t => {
const { b, wait } = await setup(t, { hash: '#/orgs/beta/agents?agent=research-2' });
await wait(`${are(['beta:research-2', 'beta:pm'])} && document.querySelector('[data-ws-agents] [data-ws-astate="waiting"]')`);
await b.evaluate('document.querySelector(\'[data-ws-agents] [data-ws-astate="waiting"]\').click()');
await wait(are(['beta:pm']));
const q = new URLSearchParams((await b.evaluate('location.hash')).split('?')[1]);
assert.equal(q.get('agent'), 'research-2');
assert.equal(q.get('state'), 'waiting');
assert.equal(await b.evaluate('document.querySelector("[data-ws-agents] [aria-current]")'), null);
await b.evaluate('document.querySelector(\'[data-ws-agents] [data-ws-astate=""]\').click()');
await wait(`${are(['beta:research-2', 'beta:pm'])} && document.querySelector('[data-ws-agent="beta:research-2"][aria-current="true"]')`);
const refused = await b.evaluate('window.MosaicWorkspace.agentsPage(new URLSearchParams(), { scope: "../beta" })');
assert.match(refused, /data-ws-failed/);
assert.doesNotMatch(refused, /data-ws-agent=/);
});
@@ -0,0 +1,303 @@
// SP4 (#1873, single pane slice 4): one task board for the whole system at #/tasks. Every
// organization that answers adds its tasks to one board, in the per-organization page's columns,
// each card with its organization chip and its project chip. The organization and project filters
// live in the hash query. A move goes to the row's own organization, offered only where that
// organization's own config takes task changes, never judged by the organization opened last.
// A stopped organization is named at the top with its reason. #/orgs/<id>/tasks is the same board,
// scoped to one organization, and a card opened from the system board opens there.
// Every bus is a fake: acme and beta keep their tasks in the built-in store, gamma's bus is stopped.
// The real GET /api/workspace and the real POST /api/workspace/tasks 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 D = n => String(n).repeat(64);
function fixture({ empty = false, archived = [] } = {}) {
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 PROJECTS = {
acme: [{ id: 'site', repo: 'site', name: 'Website', archived: false, at: at(30) }],
beta: [{ id: 'shop', repo: 'shop', name: 'Shop', archived: false, at: at(60) }, { id: 'ads', repo: 'ads', name: 'Ads', archived: false, at: at(90) }],
};
const STORE = {
acme: [{ n: 1, title: 'Hero copy', state: 'todo', group: 'site', assignees: ['coder'] }],
beta: [{ n: 2, title: 'Price page', state: 'in-progress', group: 'shop', assignees: [] }, { n: 3, title: 'Spring banner', state: 'todo', group: 'ads', assignees: [] }],
};
if (empty) { STORE.acme = []; STORE.beta = []; }
for (const [org, list] of Object.entries(PROJECTS)) for (const p of list) p.archived = archived.includes(`${org}:${p.id}`);
const tasks = id => (STORE[id] || []).map(t => ({ source: 'store', task_ref: `task:${t.n}`, at: at(10), priority: 0, digest: D(t.n), requirement: 'REQ-WEB-1',
fields: { title: t.title, state: t.state, group: t.group, assignees: t.assignees, due_date: null } }));
const writes = [];
const taskWrite = org => async (verb, args) => { writes.push([org, verb, args]); return { task_ref: args.task_ref, digest: D(9) }; };
const opts = id => ({
bus: { inbox: async () => [], agents: async () => [], 'thread.claims': async () => [], tasks: async () => tasks(id), messages: async () => ({ messages: [], unread: 0 }) },
chat: { projects: async () => PROJECTS[id] || [], threads: async () => [] },
});
const member = org => ({
id: org.id, doc: { appearance: { theme: org.theme }, roles: { coder: {} } },
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 {
writes,
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 () => [] }, taskWrite: taskWrite(id) };
return {
busHealthy: true,
reads: { business: async () => ({ business: { id, human: 'jason', projects: PROJECTS[id].map(p => p.repo), appearance: { theme: org.theme } } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
...opts(id),
bus: { ...opts(id).bus, instances: async () => ({ business: id, instances: {} }) },
taskWrite: taskWrite(id),
};
},
};
}
// 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 takes task changes, except those named in `noWrite`.
// The real server sets orgSetup for the whole Console, so this per-organization difference is faked:
// it proves the board asks each row's own organization, which matters where one config read fails.
const STUB = noWrite => `window.__noWrite = ${JSON.stringify(noWrite)};
(() => {
const real = window.fetch.bind(window);
window.fetch = async (url, init = {}) => {
const u = String(url);
if (!/^\\/api\\/(?:orgs\\/[^/]+\\/)?config$/.test(u)) return real(url, init);
const c = await (await real(url, init)).json();
const org = (u.match(/^\\/api\\/orgs\\/([^/]+)\\//) || [])[1];
return new Response(JSON.stringify({ ...c, orgSetup: c.orgSetup === true && !window.__noWrite.includes(org) }), { status: 200, headers: { 'content-type': 'application/json' } });
};
})();`;
async function setup(t, { viewport = [1440, 900], hash = '#/tasks', empty = false, noWrite = [], archived = [] } = {}) {
const orgs = fixture({ empty, archived });
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: orgs, send: async () => ({}), log: () => {} });
t.after(() => close(server));
const b = await browser({ machine: false, args: ['--disable-extensions'] }); t.after(() => b.close());
await b.viewport(...viewport);
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB(noWrite) });
const base = `http://127.0.0.1:${server.address().port}`;
await b.navigate(`${base}/${hash}`);
return { b, base, orgs, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) };
}
const cards = 'JSON.stringify([...document.querySelectorAll("[data-tasks-board] [data-tb-task]")].map(e => e.dataset.tbTask).sort())';
const ALL = JSON.stringify(JSON.stringify(['acme|task:1', 'beta|task:2', 'beta|task:3']));
const BETA = JSON.stringify(JSON.stringify(['beta|task:2', 'beta|task:3']));
test('#/tasks shows both organizations\' tasks on one board, each card with its organization and project chips', async t => {
const { b, wait, text } = await setup(t);
await wait(`${cards} === ${ALL}`);
// A system route: the address keeps no organization and the sidebar Tasks is this page.
assert.equal(await b.evaluate('location.hash'), '#/tasks');
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]")?.getAttribute("href")'), '#/tasks');
assert.equal(await text('#bus-view h1'), 'Tasks');
// The per-organization page's columns.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".tb-col h3")].map(h => h.textContent)'), ['To do', 'In progress', 'Review', 'Blocked', 'Done']);
// One chip names the organization, with its avatar in place of the dot.
assert.equal(await b.evaluate('[...document.querySelectorAll(\'[data-tb-task="acme|task:1"] [data-ws-orgchip]\')].map(c => [!!c.querySelector(".ws-av"), c.lastChild.textContent]).join("|")'), 'true,Acme Studio');
assert.equal(await b.evaluate('[...document.querySelectorAll(\'[data-tb-task="beta|task:2"] [data-ws-orgchip]\')].map(c => [!!c.querySelector(".ws-av"), c.lastChild.textContent]).join("|")'), 'true,Beta Works');
assert.equal(await text('[data-tb-task="acme|task:1"] [data-ws-projchip]'), 'Website');
assert.equal(await text('[data-tb-task="beta|task:3"] [data-ws-projchip]'), 'Ads');
assert.equal(await b.evaluate('document.getElementById("bus-down").hidden'), true);
// The first load draws the board once, with no "last read failed" banner from a render the next one replaced.
assert.equal(await b.evaluate('document.querySelector("#bus-view .s1-stale")'), null);
});
test('the organization and project filters live in the address and survive a reload', async t => {
const { b, base, wait } = await setup(t);
await wait(`${cards} === ${ALL}`);
await b.evaluate('document.querySelector("[data-tb-org=beta]").click()');
await wait(`location.hash === "#/tasks?org=beta" && ${cards} === ${BETA}`);
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=project]"); s.value = "beta:shop"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait(`location.hash === "#/tasks?org=beta&project=beta%3Ashop" && ${cards} === ${JSON.stringify(JSON.stringify(['beta|task:2']))}`);
await b.navigate(`${base}/?reload=1#/tasks?org=beta&project=beta%3Ashop`);
await wait(`${cards} === ${JSON.stringify(JSON.stringify(['beta|task:2']))}`);
assert.equal(await b.evaluate('document.querySelector("[data-tb-org=beta]").getAttribute("aria-pressed")'), 'true');
assert.equal(await b.evaluate('document.querySelector("[data-tb-filter=project]").value'), 'beta:shop');
// A hand-edited value that is not an organization id or a project is ignored, not read as a filter.
await b.navigate(`${base}/?reload=2#/tasks?org=Not%20An%20Id&project=nope`);
await wait(`${cards} === ${ALL}`);
assert.equal(await b.evaluate('document.querySelector("[data-tb-org=\'\']").getAttribute("aria-pressed")'), 'true');
// An organization or project that is not on the list falls back to all, and the address drops it.
await b.navigate(`${base}/?reload=3#/tasks?org=gone&project=beta%3Agone`);
await wait(`${cards} === ${ALL} && location.hash === "#/tasks"`);
});
test('a move on beta\'s row while acme was opened last reaches beta, and acme\'s own config keeps its rows read only', async t => {
const { b, wait, orgs } = await setup(t, { hash: '#/orgs/acme/organization', noWrite: ['acme'] });
await wait('document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"');
await b.evaluate('location.hash = "#/tasks"');
await wait(`${cards} === ${ALL}`);
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'acme');
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
await wait('document.querySelector("[data-tb-move=\'beta|task:2\']")');
// Acme's config takes no task changes, so its row has no State control and its card does not drag.
assert.equal(await b.evaluate('document.querySelector("[data-tb-move^=acme]")'), null);
await b.evaluate('(() => { const s = document.querySelector("[data-tb-move=\'beta|task:2\']"); s.value = "in-review"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('/moved to Review in Beta Works/.test(document.querySelector("[data-tb-notice]").textContent)');
assert.deepEqual(orgs.writes, [['beta', 'tasks.edit', { task_ref: 'task:2', expect: D(2), state: 'in-review' }]]);
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'acme');
});
test('a stopped organization is named at the top of the board with its reason', async t => {
const { b, wait, text } = await setup(t);
await wait(`${cards} === ${ALL}`);
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(".tb-toolbar")) & Node.DOCUMENT_POSITION_FOLLOWING)'), true);
});
test('with no tasks anywhere the board says so and still names the stopped organization', async t => {
const { b, wait, text } = await setup(t, { empty: true });
await wait('/No tasks yet/.test(document.querySelector("[data-tb-results]")?.textContent || "")');
assert.match(await text('[data-tb-results]'), /Gamma Home could not be read/);
assert.match(await text('[data-ws-unread]'), /Gamma Home/);
});
test('#/orgs/beta/tasks shows beta\'s tasks only, with a link to every organization', async t => {
const { b, base, wait } = await setup(t, { hash: '#/orgs/beta/tasks' });
await wait(`${cards} === ${BETA}`);
assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/tasks');
assert.equal(await b.evaluate('document.querySelector("[data-tasks-board]").dataset.tbScope'), 'beta');
// No filter is on, so no "match the filters" line: the scope is not a filter.
assert.equal(await b.evaluate('document.querySelector("[data-tasks-board] .tb-count")'), null);
assert.equal(await b.evaluate('document.querySelector("[data-tb-org]")'), null);
assert.match(await b.evaluate('document.querySelector("[data-tasks-board] .sub").textContent'), /Tasks from Beta Works only/);
assert.equal(await b.evaluate('document.querySelector("[data-tasks-board] .sub a")?.getAttribute("href")'), '#/tasks');
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]")?.getAttribute("href")'), '#/tasks');
// The sidebar Tasks link opens the system board.
await b.evaluate('document.querySelector("#sections a[href=\'#/tasks\']").click()');
await wait(`location.hash === "#/tasks" && ${cards} === ${ALL}`);
// An address that names no possible organization is not the system board.
await b.navigate(`${base}/?reload=1#/orgs/Not%20An%20Id/tasks`);
await wait('/not on your list/.test(document.querySelector("[data-tasks-board]")?.textContent || "")');
assert.equal(await b.evaluate(cards), '[]');
});
test('a card opened from the system board opens the task in its organization', async t => {
const { b, wait } = await setup(t, { hash: '#/orgs/acme/organization' });
await wait('document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"');
await b.evaluate('location.hash = "#/tasks"');
await wait(`${cards} === ${ALL}`);
await b.evaluate('document.querySelector("[data-tb-open=\'beta|task:3\']").click()');
await wait('document.querySelector("#tb-dialog[open] [data-tb-goto]")');
assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-goto]").getAttribute("href")'), '#/orgs/beta/tasks?task=task%3A3');
assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-goto]").textContent'), 'Open in Beta Works');
await b.evaluate('document.querySelector("#tb-dialog [data-tb-goto]").click()');
await wait('location.hash.startsWith("#/orgs/beta/tasks") && window.MosaicOrg.id === "beta" && document.querySelector("[data-tasks-board]")?.dataset.tbScope === "beta"');
await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")?.dataset.tbKey === "beta|task:3"');
// The scoped page offers no second hop to the organization it is already in.
assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-goto]")'), null);
// Closing the task takes it off the address, so a reload does not open it again.
await b.evaluate('document.querySelector("#tb-dialog [data-tb-cancel]").click()');
await wait('location.hash === "#/orgs/beta/tasks" && !document.querySelector("#tb-dialog").open');
});
// Holds each task board read for ?hold= ms (or window.__hold, set later) and counts the reads that
// started and the ones that came back, so a test can act while a read is in flight. A task write is
// held for window.__holdW ms and counted the same way.
const HOLD = `window.__hold = Number(new URLSearchParams(location.search).get('hold')) || 0; window.__started = 0; window.__landed = 0;
window.__holdW = 0; window.__wStarted = 0; window.__wLanded = 0;
(() => {
const real = window.fetch.bind(window);
window.fetch = async (url, init = {}) => {
if (String(url) === '/api/workspace/tasks' && init.method === 'POST' && window.__holdW) {
window.__wStarted++;
await new Promise(r => setTimeout(r, window.__holdW));
try { return await real(url, init); } finally { window.__wLanded++; }
}
if (!/^\\/api\\/workspace\\?sections=tasks(?:%2C|,)projects/.test(String(url))) return real(url, init);
window.__started++;
if (window.__hold) await new Promise(r => setTimeout(r, window.__hold));
try { return await real(url, init); } finally { window.__landed++; }
};
})();`;
const settle = b => b.evaluate('new Promise(r => setTimeout(r, 200))');
test('a project filter stays in the address while its organization does not answer', async t => {
const { b, orgs, wait } = await setup(t, { hash: '#/tasks?project=beta%3Ashop' });
const SHOP = JSON.stringify(JSON.stringify(['beta|task:2']));
await wait(`${cards} === ${SHOP}`);
const list = await orgs.list();
list[1].busHealthy = false;
await b.evaluate('document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))');
await wait('/Beta Works/.test(document.querySelector("[data-tasks-board] [data-ws-unread]")?.textContent || "")');
await settle(b);
assert.equal(await b.evaluate('location.hash'), '#/tasks?project=beta%3Ashop');
list[1].busHealthy = true;
await b.evaluate('document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))');
await wait(`${cards} === ${SHOP} && !/Beta Works/.test(document.querySelector("[data-tasks-board] [data-ws-unread]")?.textContent || "")`);
assert.equal(await b.evaluate('location.hash'), '#/tasks?project=beta%3Ashop');
assert.equal(await b.evaluate('document.querySelector("[data-tb-filter=project]").value'), 'beta:shop');
});
test('a gone filter is cleaned from the tasks address only, never from a page opened while it was read', async t => {
const { b, base, wait } = await setup(t);
await wait(`${cards} === ${ALL}`);
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: HOLD });
await b.navigate(`${base}/?hold=800#/tasks?org=gone`);
await wait('window.__started >= 1');
await b.evaluate('location.hash = "#/inbox?org=acme"');
await wait('window.__landed >= 1');
await settle(b);
assert.equal(await b.evaluate('location.hash'), '#/inbox?org=acme');
});
test('a read in flight does not paint the board over the page for an address no organization matches', async t => {
const { b, base, wait } = await setup(t);
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: HOLD });
await b.navigate(`${base}/?reload=1#/tasks`);
await wait(`${cards} === ${ALL}`);
await b.evaluate('window.__hold = 800; window.__started = 0; window.__landed = 0');
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=state]"); s.value = "todo"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('window.__started >= 1');
await b.evaluate('location.hash = "#/orgs/Not%20An%20Id/tasks"');
await wait('/not on your list/.test(document.querySelector("[data-tasks-board]")?.textContent || "")');
await wait('window.__landed >= window.__started');
await settle(b);
assert.match(await b.evaluate('document.querySelector("[data-tasks-board]")?.textContent || ""'), /not on your list/);
assert.equal(await b.evaluate(cards), '[]');
});
test('a move still writing when the not-on-your-list page opens does not paint the board over it', async t => {
const { b, base, wait } = await setup(t);
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: HOLD });
await b.navigate(`${base}/?reload=1#/tasks`);
await wait(`${cards} === ${ALL}`);
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
await wait('document.querySelector("[data-tb-move=\'beta|task:2\']")');
await b.evaluate('window.__holdW = 800; window.__started = 0; window.__landed = 0');
await b.evaluate('(() => { const s = document.querySelector("[data-tb-move=\'beta|task:2\']"); s.value = "in-review"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('window.__wStarted >= 1');
await b.evaluate('location.hash = "#/orgs/Not%20An%20Id/tasks"');
await wait('/not on your list/.test(document.querySelector("[data-tasks-board]")?.textContent || "")');
await wait('window.__wLanded >= 1 && window.__landed >= window.__started');
await settle(b);
assert.match(await b.evaluate('document.querySelector("[data-tasks-board]")?.textContent || ""'), /not on your list/);
assert.equal(await b.evaluate(cards), '[]');
});
test('an archived project keeps its filter while it has tasks, and while another filter matches none of them', async t => {
const { b, wait } = await setup(t, { hash: '#/tasks?project=beta%3Ashop', archived: ['beta:shop'] });
await wait(`${cards} === ${JSON.stringify(JSON.stringify(['beta|task:2']))}`);
await settle(b);
assert.equal(await b.evaluate('location.hash'), '#/tasks?project=beta%3Ashop');
// No shop task is done: the read comes back empty because of the state filter, not because shop is gone.
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=state]"); s.value = "done"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait(`${cards} === "[]" && /No tasks match/.test(document.querySelector("[data-tb-results]")?.textContent || "")`);
await settle(b);
assert.equal(await b.evaluate('location.hash'), '#/tasks?project=beta%3Ashop');
});
@@ -73,7 +73,7 @@ async function open(t, { refuse = null, tasksFail = [], send = true, width = 144
t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(width, height);
const url = `http://127.0.0.1:${server.address().port}/#/orgs/forge/tasks`;
const url = `http://127.0.0.1:${server.address().port}/#/tasks`;
await b.navigate(url);
const wait = waitFor(b);
await wait('document.querySelector("[data-tasks-board] [data-tb-tracker=home]")');
@@ -68,7 +68,7 @@ async function open(t, fixture = orgs()) {
t.after(() => close(server));
const b = await browser({ machine: false, args: ['--disable-extensions'] }); t.after(() => b.close());
await b.viewport(1440, 900);
const url = `http://127.0.0.1:${server.address().port}/#/orgs/forge/tasks`;
const url = `http://127.0.0.1:${server.address().port}/#/tasks`;
await b.navigate(url);
const wait = waitFor(b);
await wait('document.querySelector("[data-tasks-board] [data-tb-tracker=forge]")');
@@ -484,7 +484,8 @@ test('agents grid shows each agent\'s level and where it is defined, per project
assert.match(await b.evaluate('document.querySelector("[data-agents-available] .ws-away").innerText'), /social-scheduler \(Spring launch only\), copywriter \(excluded in this project\) and every Forge or Home agent/);
// Home, where the reviewer is turned off: the table says so.
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/home/agents`);
await waitFor(b)('document.querySelector("[data-org-agents] h2")?.innerText === "Agents in Home"');
// #1873 SP5: the scoped page puts the levels grid under its own "Agents by level" h2; this is the section's title.
await waitFor(b)('document.querySelector("[data-org-agents] .ws-split-agents h2")?.innerText === "Agents in Home"');
assert.match(await b.evaluate('document.querySelector("[data-agents-available]").innerText'), /reviewer \(turned off for Home\)/);
});