diff --git a/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx b/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx index 17494f22b77..c932bb1e661 100644 --- a/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx +++ b/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx @@ -5191,6 +5191,63 @@ describe('ManagedSessionsPage', () => { ); }); + it('moves focus to the composer after a discard inside a shadow root with a host nav slot', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const shadow = host.attachShadow({ mode: 'open' }); + const shadowContainer = document.createElement('div'); + shadow.appendChild(shadowContainer); + const navigationTarget = document.createElement('nav'); + document.body.appendChild(navigationTarget); + const shadowRoot = createRoot(shadowContainer); + try { + seedPending({ + idempotencyKey: 'key-1', + text: 'Session draft', + sessionId: 's1', + }); + await act(async () => { + shadowRoot.render( + + + , + ); + await flush(); + }); + const discard = [...shadowContainer.querySelectorAll('button')].find( + (item) => item.textContent === 'Discard this request', + ); + expect(discard).toBeDefined(); + discard!.focus(); + await act(async () => { + discard!.click(); + await flush(); + }); + // In Chromium the discard button's unmount leaves document.activeElement + // at body and shadow.activeElement null; the latch must treat that as + // unclaimed and land focus on the composer that just got the draft back, + // not drop the latch. jsdom keeps the detached button as the shadow + // root's activeElement, so assert only the outcome, not the browser's + // exact blur state. + expect(shadow.activeElement).toBe( + shadowContainer.querySelector('textarea'), + ); + expect(shadowContainer.querySelector('textarea')?.value).toBe( + 'Session draft', + ); + } finally { + await act(async () => shadowRoot.unmount()); + navigationTarget.remove(); + host.remove(); + } + }); + it('leaves focus alone when the user moved on during a discard’s loading window', async () => { let releaseSummary!: (value: ManagedAgentSessionSummary) => void; mocks.client.getSession.mockImplementationOnce( diff --git a/packages/web-shell/client/components/managed/ManagedSessionsPage.tsx b/packages/web-shell/client/components/managed/ManagedSessionsPage.tsx index aa86a06aca2..07b203c1baf 100644 --- a/packages/web-shell/client/components/managed/ManagedSessionsPage.tsx +++ b/packages/web-shell/client/components/managed/ManagedSessionsPage.tsx @@ -866,12 +866,16 @@ function ManagedSessionsContent({ // document.activeElement retargets to the shadow host in portal mode; read // the focused node from the page wrapper's own root instead. const parked = getShadowAwareActiveElement(pageRef.current); + // A shadow root with nothing focused reports null here, not body, so a + // shadow-root mount whose discard dropped focus to the outer document + // body must still be recognised as unclaimed. + const unclaimed = parked === null || parked === document.body; // hostSessionOnly renders no list, so the fallback below parks focus on the // page wrapper; the latch accepts the node it parked on itself, so a user // who merely clicked inside that pane is still recognised as having moved // on. const ours = - parked === document.body || + unclaimed || parked === sessionsNavRef.current || (discardParked.current !== null && parked === discardParked.current); if (composer) {