fix(webui): sp5 review round, Open addresses the agent and the scoped page marks it, Fire words name the organization, a named Fire keeps its outcome, Cancel restores focus (#1873, lane sp5)
- Open on #/agents goes to #/orgs/<id>/agents?agent=<key>; the scoped table puts that agent first, marks it (aria-current) and draws no Open or Actions column (review F1, F2). - Fire's aria-label, confirm group and receipt use the organization's label (F5). - A Fire sent to a named organization keeps its receipt after the opened organization changes (F7). - Cancel moves focus back to the row's Fire (F10). - agentsPage refuses a scope that is not an organization id instead of listing every organization (F11).
This commit is contained in:
1 parent
5373b110ac
commit
eebdb2f0ce
4 files changed
+62
-18
No files matched your search
@@ -496,7 +496,7 @@
|
||||
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(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){
|
||||
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;
|
||||
@@ -504,10 +504,12 @@
|
||||
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(`${decideBase({ org: named })}/decisions/hire/start`,{business:org,hire:{op:'fire',level:'org',name,reason:FIRE_REASON}});
|
||||
if(epoch!==orgEpoch){if(res?.type==='opaqueredirect')signOut();return;}
|
||||
// SP5 review F7: a named organization's Fire went to that organization, so its outcome still
|
||||
// stands after the opened organization changes.
|
||||
if(epoch!==orgEpoch&&!named){if(res?.type==='opaqueredirect')signOut();return;}
|
||||
fireBusy=false;fireAsk=null;
|
||||
if(res?.type==='opaqueredirect'){signOut();return;}
|
||||
const where=named?` in ${org}`:'';
|
||||
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();
|
||||
@@ -519,28 +521,30 @@
|
||||
const r=decideRefusal(res,json,'outcome-unknown');
|
||||
fireReceipt={org,system:!!named,failed:true,text:`${name}${where} was not fired: ${r.message}`,code:r.code};
|
||||
paintFireNote();
|
||||
for(const c of document.querySelectorAll(fireSel('data-agent-fire-confirm',name,named)))c.outerHTML=fireButton(name,named);
|
||||
for(const c of document.querySelectorAll(fireSel('data-agent-fire-confirm',name,named)))c.outerHTML=fireButton(name,named,orgName);
|
||||
}
|
||||
}
|
||||
const fireOrgAttr=org=>org?` data-agent-fire-org="${esc(org)}"`:'';
|
||||
const fireFocus=(kind,name,org)=>esc(org?`${kind}:${org}:${name}`:`${kind}:${name}`);
|
||||
const fireButton=(name,org=null)=>`<button type="button" class="btn small" data-agent-fire="${esc(name)}"${fireOrgAttr(org)} data-focus-key="${fireFocus('fire',name,org)}" aria-label="Fire ${esc(name)}${org?` in ${esc(org)}`:''}">Fire</button>`;
|
||||
const fireConfirm=(name,org=null)=>`<span class="ws-fire-confirm" role="group" aria-label="Fire ${esc(name)}?" data-agent-fire-confirm="${esc(name)}"${fireOrgAttr(org)}>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>`;
|
||||
// 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){
|
||||
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):fireButton(name,named?org:null);
|
||||
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 named=t.dataset.agentFireOrg??null,org=named??hireOrg();
|
||||
if(t.hasAttribute('data-agent-fire')){const name=t.dataset.agentFire;fireAsk=fireKey(org,name);t.outerHTML=fireConfirm(name,named);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);announce('Cancelled. Nothing was fired.');}
|
||||
else void fireAgent(t.dataset.agentFireYes,named);
|
||||
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);});
|
||||
|
||||
@@ -699,6 +699,7 @@ 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-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}
|
||||
|
||||
@@ -902,7 +902,9 @@
|
||||
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 = {}) {
|
||||
const scope = typeof opts.scope === 'string' && ORG_ID.test(opts.scope) ? opts.scope : null;
|
||||
// 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;
|
||||
@@ -942,19 +944,24 @@
|
||||
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 rows = inOrg.filter(a => !stateF || a.state === stateF);
|
||||
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" style="${o.style(a.org)}" data-ws-agent="${esc(akey(a))}">
|
||||
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>
|
||||
<td class="ws-act"><a class="ws-btn" href="${esc(`${orgHref(a.org)}/agents`)}" 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) ?? '' : ''}</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.
|
||||
@@ -967,7 +974,7 @@
|
||||
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><th scope="col"><span class="ws-sr">Actions</span></th></tr></thead><tbody>${rows.map(row).join('')}</tbody></table></div>`
|
||||
? `<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);
|
||||
|
||||
@@ -126,7 +126,7 @@ test('#/agents lists both organizations\' agents in one table, each row with its
|
||||
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');
|
||||
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 => {
|
||||
@@ -164,6 +164,8 @@ test('firing beta\'s agent from #/agents while acme was opened last reaches beta
|
||||
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.' } });
|
||||
@@ -208,3 +210,33 @@ test('#/orgs/<id>/agents shows that organization\'s rows only, with a link to ev
|
||||
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'), []);
|
||||
});
|
||||
Reference in new issue
Block a user