diff --git a/packages/webui/src/public/tasks-board.js b/packages/webui/src/public/tasks-board.js index 9d1dae18f..9293d3fb8 100644 --- a/packages/webui/src/public/tasks-board.js +++ b/packages/webui/src/public/tasks-board.js @@ -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. diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index 5c208847b..f72a8673f 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -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 @@

Decisions waiting on you

${decBody}
${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) { diff --git a/packages/webui/tests/agent-drawer-browser.test.mjs b/packages/webui/tests/agent-drawer-browser.test.mjs index 4cff4dcf8..6e2a8cb08 100644 --- a/packages/webui/tests/agent-drawer-browser.test.mjs +++ b/packages/webui/tests/agent-drawer-browser.test.mjs @@ -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'));