fix(web-shell): treat an unfocused shadow root as unclaimed in the discard latch

The discard focus latch read the parked node from the page wrapper's own
root, so a shadow-root mount whose discard dropped focus to the outer
document body produced ShadowRoot.activeElement === null. Every `ours`
operand was false, the latch was dropped, and the composer that just got
the draft back was never focused. Treat an unfocused shadow root as
unclaimed, matching the body-focus operand. The latch still dies when the
user moves on (ManagedSessionsPage.test.tsx pins that).

Pinned by a new shadow-root regression: the page mounts into
host.attachShadow({mode:'open'}) with a host-side navigationTarget, a
pending record is discarded, and shadow.activeElement must be the
composer. The test is red without the fix and green with it.

Addresses review thread R13-1 on #16200.

Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
yiliang114andQwen-Coder committed 2026-11-07 23:30:33 +08:00
1 parent 32cd433c02
commit ec9763fa60
2 files changed
+62 -1

No files matched your search

@@ -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(
<I18nProvider language="en">
<ManagedSessionsPage
sessionId="s1"
onSelectSession={onSelect}
managedAgentProvider={provider}
navigationTarget={navigationTarget}
interactive={true}
/>
</I18nProvider>,
);
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(
@@ -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) {