Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed4f45ed23 | ||
|
|
79df01f1de | ||
|
|
d7b98b4698 | ||
|
|
0f29fe0d92 | ||
|
|
32be3a01bc | ||
|
|
f97e0d3aeb | ||
|
|
eebdb2f0ce | ||
|
|
5373b110ac |
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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\)/);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user