fix(webui): sp12 review, the agent drawer holds at most one history entry: a row opened from the page pushes one marked entry, another row replaces it, the open row changes nothing, and Close goes Back only from that marked entry, so a shared link never leaves the Console; Escape while Stop asks cancels only the question; the Tasks board drops the assignee filter the address set when the address drops it; tests cover a Stop the bus takes (#1873, lane sp12-agent-drawer)
This commit was merged in pull request #1976.
This commit is contained in:
1 parent
307f88be3e
commit
69ce460f91
3 files changed
+89
-19
No files matched your search
@@ -362,7 +362,8 @@
|
||||
// SP12: `assignee` in the address (the agent drawer's link) sets the Assignee filter once per new
|
||||
// value; the select changes it after that, and a poll does not put the address's value back.
|
||||
const who = String(q.get('assignee') || '');
|
||||
if (who !== addrAssignee) { addrAssignee = who; if (ROLE.test(who)) filter.assignee = who; }
|
||||
// When the address drops it, the filter it set goes too, unless the select has changed it since.
|
||||
if (who !== addrAssignee) { const was = addrAssignee; addrAssignee = who; if (ROLE.test(who)) filter.assignee = who; else if (!who && was && filter.assignee === was) filter.assignee = ''; }
|
||||
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))]);
|
||||
// Two renders at once (a first load, a poll during a filter change): the replaced one draws from the newer read.
|
||||
|
||||
@@ -1106,7 +1106,13 @@
|
||||
// The Stop state of the drawer's agent survives repaints: `confirm` while it asks, `busy` while the
|
||||
// request is open, then the result in words beside the button.
|
||||
let stopState = null; // { id, confirm, busy, text }
|
||||
let drawerPushed = null, drawerFocus = false, drawerShown = null;
|
||||
let drawerFocus = false, drawerShown = null;
|
||||
// The drawer holds at most one history entry. A row opened from the page without a drawer pushes one
|
||||
// entry marked in history.state; switching rows replaces it. Close goes Back only from that marked
|
||||
// entry, so a shared link or a reload of an unmarked one never leaves the Console.
|
||||
const DRAWER_MARK = 'mosaicAgentDrawer';
|
||||
const stateOf = () => (history.state && typeof history.state === 'object' ? history.state : null);
|
||||
const unmarked = () => { const st = stateOf(); if (!st || !(DRAWER_MARK in st)) return history.state; const { [DRAWER_MARK]: _, ...rest } = st; return rest; };
|
||||
const drawerHref = (q, id) => { const n = new URLSearchParams(q?.toString?.() || ''); n.set('agent', id); return `#${pathOf()}?${n.toString()}`; };
|
||||
// The address of the page under the drawer: the organization prefix and path, the query without `agent`.
|
||||
const sansAgent = () => { const n = queryOf(); n.delete('agent'); const s = n.toString(); return `${/^#\/orgs\/[^/?]+/.exec(location.hash)?.[0] ?? '#'}${pathOf()}${s ? `?${s}` : ''}`; };
|
||||
@@ -1195,13 +1201,12 @@
|
||||
<section class="ws-drawer-sec" data-drawer-decisions aria-label="Decisions ${esc(a.name)} raised"><h3>Decisions waiting on you</h3>${decBody}</section>
|
||||
${actions}`, ` data-drawer-org-id="${esc(org)}" data-drawer-name="${esc(a.name)}" data-drawer-org-name="${esc(orgName)}"`);
|
||||
}
|
||||
// Closing goes Back when the drawer was opened by a row link on this page; otherwise it drops
|
||||
// Closing goes Back when a row link on this page pushed the current entry; otherwise it drops
|
||||
// `agent` from the address in place. Either way the page repaints without it and focus returns to the row.
|
||||
function closeDrawer() {
|
||||
if (!stopState?.busy) stopState = null;
|
||||
if (drawerPushed && location.hash === drawerPushed) { drawerPushed = null; history.back(); return; }
|
||||
drawerPushed = null;
|
||||
history.replaceState(history.state, '', sansAgent());
|
||||
if (stateOf()?.[DRAWER_MARK] && queryOf().has('agent')) { history.back(); return; }
|
||||
history.replaceState(unmarked(), '', sansAgent());
|
||||
document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));
|
||||
}
|
||||
// bus.js calls this after each paint: focus moves into a drawer a row opened, and back to the row
|
||||
@@ -1253,7 +1258,14 @@
|
||||
const link = e.target.closest?.('[data-ws-drawer-open]');
|
||||
if (link) {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
|
||||
drawerPushed = link.getAttribute('href'); drawerFocus = true;
|
||||
e.preventDefault();
|
||||
const to = new URL(link.href).hash;
|
||||
// The open row again: the address stays, focus goes to the drawer's heading.
|
||||
if (to === location.hash) { document.querySelector('#bus-view [data-ws-drawer] h2')?.focus({ preventScroll: true }); return; }
|
||||
drawerFocus = true;
|
||||
if (queryOf().has('agent')) history.replaceState(history.state, '', to);
|
||||
else history.pushState({ ...(stateOf() || {}), [DRAWER_MARK]: true }, '', to);
|
||||
document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));
|
||||
return;
|
||||
}
|
||||
const d = e.target.closest?.('[data-ws-drawer]');
|
||||
@@ -1270,19 +1282,23 @@
|
||||
d.querySelector('[data-drawer-stop-no]')?.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('[data-drawer-stop-no]')) {
|
||||
stopState = null;
|
||||
d.querySelector('[data-drawer-stop-confirm]')?.remove();
|
||||
const b = d.querySelector('[data-drawer-stop]'); if (b) { b.disabled = false; b.focus({ preventScroll: true }); }
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('[data-drawer-stop-no]')) { cancelStop(d); return; }
|
||||
if (e.target.closest('[data-drawer-stop-yes]')) stopAgent(d);
|
||||
});
|
||||
// Escape closes the drawer, unless a dialog is open or something else took the key.
|
||||
// Cancel the Stop question: nothing sent, Stop enabled again and focused.
|
||||
function cancelStop(d) {
|
||||
stopState = null;
|
||||
d.querySelector('[data-drawer-stop-confirm]')?.remove();
|
||||
const b = d.querySelector('[data-drawer-stop]'); if (b) { b.disabled = false; b.focus({ preventScroll: true }); }
|
||||
}
|
||||
// Escape cancels the Stop question while it asks, else closes the drawer, unless a dialog is open
|
||||
// or something else took the key.
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key !== 'Escape' || e.defaultPrevented || document.querySelector('dialog[open]')) return;
|
||||
if (!document.querySelector('#bus-view [data-ws-drawer]')) return;
|
||||
e.preventDefault(); closeDrawer();
|
||||
const d = document.querySelector('#bus-view [data-ws-drawer]');
|
||||
if (!d) return;
|
||||
e.preventDefault();
|
||||
if (d.querySelector('[data-drawer-stop-confirm]')) cancelStop(d); else closeDrawer();
|
||||
});
|
||||
|
||||
// ---- controls: filters write the hash query and repaint in place ----
|
||||
@@ -1293,7 +1309,8 @@
|
||||
for (const [k, v] of Object.entries(change)) { if (v === null || v === undefined || v === '' || (Array.isArray(v) && !v.length)) q.delete(k); else q.set(k, Array.isArray(v) ? v.join(',') : String(v)); }
|
||||
const s = q.toString();
|
||||
// SP3: the address keeps its own form, an organization's or the system's (#/inbox).
|
||||
history.replaceState(history.state, '', `${/^#\/orgs\/[^/?]+/.exec(location.hash)?.[0] ?? '#'}${pathOf()}${s ? `?${s}` : ''}`);
|
||||
// A filter change makes the entry differ from the one before the drawer, so Close then stays in place.
|
||||
history.replaceState(unmarked(), '', `${/^#\/orgs\/[^/?]+/.exec(location.hash)?.[0] ?? '#'}${pathOf()}${s ? `?${s}` : ''}`);
|
||||
if (render) document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));
|
||||
}
|
||||
function setGrouping(g) {
|
||||
|
||||
@@ -140,7 +140,8 @@ test('a row on #/agents opens the drawer: a region with a heading, the page stay
|
||||
// At the right of the page.
|
||||
const box = await b.evaluate(`(() => { const r = document.querySelector('${d}').getBoundingClientRect(); return [Math.round(r.left), Math.round(r.right), innerWidth]; })()`);
|
||||
assert.ok(box[0] > box[2] / 2, `drawer left edge ${box[0]} should be in the right half of ${box[2]}`);
|
||||
assert.ok(box[1] >= box[2] - 20, `drawer right edge ${box[1]} should reach the right of ${box[2]}`); // At 1440 wide nothing on the page sits under it: the agents table ends left of the drawer.
|
||||
assert.ok(box[1] >= box[2] - 20, `drawer right edge ${box[1]} should reach the right of ${box[2]}`);
|
||||
// At 1440 wide nothing on the page sits under it: the agents table ends left of the drawer.
|
||||
const table = await b.evaluate(`Math.round(document.querySelector('[data-ws-agent-list]').getBoundingClientRect().right)`);
|
||||
assert.ok(table <= box[0], `the agents table right edge ${table} should be left of the drawer at ${box[0]}`);
|
||||
});
|
||||
@@ -170,11 +171,15 @@ test('the drawer shows each field from its read: name, organization, role and so
|
||||
// Decisions waiting on a human that it raised, each into the Inbox. Chief's is not its own.
|
||||
const decisions = await b.evaluate(`[...document.querySelectorAll('${d} [data-drawer-decisions] li a')].map(a => [a.textContent.trim(), a.getAttribute("href")])`);
|
||||
assert.deepEqual(decisions, [['Ship the hero copy?', '#/inbox?d=acme%3Adec-1']]);
|
||||
// No read gives a last-activity time for an agent, so the drawer draws none.
|
||||
// No read gives the time of an agent's last activity. The sessions read's `since` is when its
|
||||
// current state began, which is not the same thing, so the drawer draws no time.
|
||||
assert.equal(await has(`${d} [data-drawer-activity]`), false);
|
||||
// The Tasks board link opens the board filtered to the agent.
|
||||
await b.evaluate(`document.querySelector('${d} [data-drawer-tasks-all]').click()`);
|
||||
await wait(`document.querySelector('[data-tasks-board] select[data-tb-filter="assignee"]')?.value === 'coder'`);
|
||||
// When the address drops the assignee, the board drops the filter it took from the address.
|
||||
await b.evaluate('location.hash = "#/tasks"');
|
||||
await wait(`document.querySelector('[data-tasks-board] select[data-tb-filter="assignee"]')?.value === ''`);
|
||||
});
|
||||
|
||||
test('a hired agent shows where its role came from; a not-running agent says not running', async t => {
|
||||
@@ -222,6 +227,37 @@ test('the address round trip: Back closes the drawer, Forward and a reload open
|
||||
await wait(`document.querySelector("[data-ws-agent-list]") && ${noDrawer}`);
|
||||
});
|
||||
|
||||
test('the drawer holds at most one history entry: Close after switching agents, or after a shared link, stays in the Console', async t => {
|
||||
const { b, base, wait } = await setup(t);
|
||||
await wait(`document.querySelector(${JSON.stringify(rowLink('acme:coder'))})`);
|
||||
const before = await b.evaluate('history.length');
|
||||
await b.evaluate(`document.querySelector(${JSON.stringify(rowLink('acme:coder'))}).click()`);
|
||||
await wait(open('acme:coder'));
|
||||
// Another row while the drawer is open switches it in place: no second entry.
|
||||
await b.evaluate(`document.querySelector(${JSON.stringify(rowLink('beta:pm'))}).click()`);
|
||||
await wait(`${open('beta:pm')} && !${drawer('acme:coder')}`);
|
||||
assert.equal(await b.evaluate(agentParam), 'beta:pm');
|
||||
assert.equal(await b.evaluate('history.length'), before + 1);
|
||||
// The open row again does nothing to the address.
|
||||
await b.evaluate(`document.querySelector(${JSON.stringify(rowLink('beta:pm'))}).click()`);
|
||||
assert.equal(await b.evaluate('history.length'), before + 1);
|
||||
// One Close goes back to the page without the drawer.
|
||||
await b.evaluate(`${drawer('beta:pm')}.querySelector("[data-drawer-close]").click()`);
|
||||
await wait(`${noDrawer} && location.hash === "#/agents"`);
|
||||
// A shared link opened after another site: clicking its row and then Close stays here.
|
||||
await b.navigate('about:blank');
|
||||
await b.navigate(`${base}/#/agents?agent=acme%3Acoder`);
|
||||
await wait(open('acme:coder'));
|
||||
await b.evaluate(`document.querySelector(${JSON.stringify(rowLink('acme:coder'))}).click()`);
|
||||
await b.evaluate(`${drawer('acme:coder')}.querySelector("[data-drawer-close]").click()`);
|
||||
await wait(`location.href.startsWith(${JSON.stringify(base)}) && ${noDrawer} && location.hash === "#/agents"`);
|
||||
// Escape on a shared link does the same.
|
||||
await b.navigate(`${base}/#/agents?agent=beta%3Apm`);
|
||||
await wait(open('beta:pm'));
|
||||
await b.key('Escape');
|
||||
await wait(`location.href.startsWith(${JSON.stringify(base)}) && ${noDrawer} && location.hash === "#/agents"`);
|
||||
});
|
||||
|
||||
test('the keyboard path: Tab reaches the row, Enter opens, Escape closes and focus returns to the row', async t => {
|
||||
const { b, wait } = await setup(t);
|
||||
const link = JSON.stringify(rowLink('acme:coder'));
|
||||
@@ -295,6 +331,22 @@ test('Message opens the agent\'s thread in an Assistant pane; Stop asks first an
|
||||
await b.evaluate(`document.querySelector('${d} [data-drawer-stop-yes]').click()`);
|
||||
await wait(`/read-only|only reads/.test(document.querySelector('${d} [data-drawer-status]')?.textContent ?? "")`);
|
||||
assert.match(await text(`${d} [data-drawer-status]`), /^Not stopped: this Console only reads agents in Acme Studio \(read-only\)\.$/);
|
||||
// Escape while it asks cancels only the question: the drawer stays and focus is back on Stop.
|
||||
await b.evaluate(`document.querySelector('${d} [data-drawer-stop]').click()`);
|
||||
await wait(`document.querySelector('${d} [data-drawer-stop-no]') === document.activeElement`);
|
||||
await b.key('Escape');
|
||||
await wait(`!document.querySelector('${d} [data-drawer-stop-confirm]') && document.activeElement === document.querySelector('${d} [data-drawer-stop]')`);
|
||||
assert.equal(await b.evaluate(`document.querySelector('${d} [data-drawer-stop]').disabled`), false);
|
||||
assert.equal(await b.evaluate(agentParam), 'acme:coder');
|
||||
assert.equal(await b.evaluate('window.__posts').then(p => p.length), 2);
|
||||
// A Stop the bus takes: the request is followed until it closes, then the drawer says it stopped.
|
||||
const REQ = '0f0e0d0c-0b0a-4908-8706-050403020100';
|
||||
await b.evaluate(`window.__answers.push({ status: 200, body: { sent: true, request: '${REQ}' } }, { status: 200, body: { request: '${REQ}', state: 'closed' } })`);
|
||||
await b.evaluate(`document.querySelector('${d} [data-drawer-stop]').click()`);
|
||||
await wait(`document.querySelector('${d} [data-drawer-stop-yes]')`);
|
||||
await b.evaluate(`document.querySelector('${d} [data-drawer-stop-yes]').click()`);
|
||||
await wait(`/^Stopped coder\.$/.test(document.querySelector('${d} [data-drawer-status]')?.textContent ?? "")`);
|
||||
assert.deepEqual((await b.evaluate('window.__posts')).slice(2).map(p => [p.url, p.method]), [['/api/orgs/acme/agent/terminate', 'POST'], [`/api/orgs/acme/agent/request?id=${REQ}`, 'GET']]);
|
||||
// A not-running agent has nothing to stop.
|
||||
await b.evaluate('location.hash = "#/agents?agent=acme%3Achief"');
|
||||
await wait(open('acme:chief'));
|
||||
|
||||
Reference in new issue
Block a user