From b96d19d933890b405f28fa445e2f4dff4e1eab1c Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:50:41 -0700 Subject: [PATCH] Keep overlays open when focusing the chat composer (#37403) * Preserve open overlays when focusing the chat composer * Fix chat composer focus after launch menus close * Fix chat composer focus handoff after overlays close * Fix composer focus after custom overlays close * Prevent chat composer from stealing overlay focus Scope recovery overlays to their own pane and let menus dismiss before returning focus to the composer. This keeps the quick launcher open when delayed chat actions complete. * Fix composer focus across nested overlays Track focus ownership through portaled overlays so the launch prompt can regain focus while its model list is open. Close the context menu before inserting a quote so the caret lands after it. * Fix overlay focus CI regressions * Complete overlay focus ownership for inline and retained surfaces * Declare public overlay focus hook return type --- .../src/components/CodexRestartChip.tsx | 1 + .../AgentLaunchSetupPanel.prompt.test.tsx | 52 +++ .../agent/AgentLaunchSetupPanel.tsx | 2 +- .../BrowserMobileDriverOverlay.tsx | 1 + .../floating-terminal-panel-render-probe.ts | 1 + .../floating-terminal-panel-test-harness.ts | 9 + .../NativeChatAutocompleteMenus.tsx | 10 +- .../NativeChatContextMenuItems.tsx | 11 +- ...iveChatPromptEditor.overlay-focus.test.tsx | 119 ++++++ .../native-chat/NativeChatPromptEditor.tsx | 13 +- .../native-chat-composer-focus-intent.test.ts | 134 ++++++ .../native-chat-composer-focus-intent.ts | 133 ++++++ ...native-chat-composer-reveal-focus.test.tsx | 382 +++++++++++++++++- .../native-chat-mounted-editor-view.ts | 13 + ...ive-chat-pane-local-overlay-focus.test.tsx | 163 ++++++++ .../native-chat-quote-focus.test.tsx | 78 ++++ .../use-native-chat-composer-reveal-focus.ts | 115 ++---- .../use-native-chat-context-menu.tsx | 11 +- .../SmartWorkspaceSourceResultList.tsx | 6 +- .../orchestration-crew/CrewRowMenu.tsx | 6 +- ...kspaceAttachmentSearch.resolution.test.tsx | 9 + .../sidebar/WorkspaceAttachmentSearch.tsx | 1 + .../src/components/skills/SkillsList.tsx | 1 + .../use-skills-page-keyboard-navigation.ts | 2 +- .../terminal-pane/MobileDriverOverlay.tsx | 1 + src/renderer/src/components/ui/command.tsx | 56 +-- .../src/components/ui/context-menu.tsx | 67 +-- src/renderer/src/components/ui/dialog.tsx | 11 +- .../src/components/ui/dropdown-menu.tsx | 69 ++-- src/renderer/src/components/ui/popover.tsx | 53 +-- src/renderer/src/components/ui/select.tsx | 49 ++- src/renderer/src/components/ui/sheet.tsx | 49 ++- .../ui/use-overlay-focus-scope.test.tsx | 47 +++ .../components/ui/use-overlay-focus-scope.ts | 38 ++ .../src/lib/overlay-focus-ownership.ts | 122 ++++++ .../lib/pane-manager/pane-overlay-focus.ts | 8 +- src/renderer/src/lib/visible-overlay.ts | 68 ++-- tests/e2e/native-chat-send-focus.spec.ts | 50 +++ 38 files changed, 1662 insertions(+), 299 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatPromptEditor.overlay-focus.test.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-focus-intent.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-focus-intent.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-pane-local-overlay-focus.test.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-quote-focus.test.tsx create mode 100644 src/renderer/src/components/ui/use-overlay-focus-scope.test.tsx create mode 100644 src/renderer/src/components/ui/use-overlay-focus-scope.ts create mode 100644 src/renderer/src/lib/overlay-focus-ownership.ts diff --git a/src/renderer/src/components/CodexRestartChip.tsx b/src/renderer/src/components/CodexRestartChip.tsx index d5f9ddf73c3..76a4f0252ee 100644 --- a/src/renderer/src/components/CodexRestartChip.tsx +++ b/src/renderer/src/components/CodexRestartChip.tsx @@ -102,6 +102,7 @@ function LoudRestartOverlay({
{ + act(() => + root.render( + + {surface === 'menu' ? ( + + New tab + + false} /> + + + ) : ( + + )} + + ) + ) + if (surface === 'menu') { + fireEvent.click(getByRole(document.body, 'button', { name: 'Configure Claude' })) + } + const editor = document.querySelector('[contenteditable][aria-multiline="true"]')! + await waitFor(() => expect(promptEditor(editor)).toBeDefined()) + const models = document.querySelector('input[aria-label="Search models"]')! + act(() => models.focus()) + expect(document.activeElement).toBe(models) + expect(document.querySelector('[cmdk-list]')).not.toBeNull() + if (surface === 'menu') { + expect(document.querySelector('[role="menu"][data-state="open"]')).not.toBeNull() + } + + act(() => focusMountedNativeChatEditor(promptEditor(editor))) + expect(document.activeElement).toBe(editor) + + // A sibling popup still owns focus above the setup prompt. + const popup = document.createElement('div') + popup.setAttribute('role', 'dialog') + const input = document.createElement('input') + popup.append(input) + document.body.append(popup) + try { + input.focus() + act(() => focusMountedNativeChatEditor(promptEditor(editor))) + expect(document.activeElement).toBe(input) + } finally { + popup.remove() + } + } +) diff --git a/src/renderer/src/components/agent/AgentLaunchSetupPanel.tsx b/src/renderer/src/components/agent/AgentLaunchSetupPanel.tsx index 2104ef62c99..c067c2d1969 100644 --- a/src/renderer/src/components/agent/AgentLaunchSetupPanel.tsx +++ b/src/renderer/src/components/agent/AgentLaunchSetupPanel.tsx @@ -215,7 +215,7 @@ export function AgentLaunchSetupPanel({ agent: entry.label })} /> - + {translate('agentPicker.noModels', 'No models match your search.')} diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserMobileDriverOverlay.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserMobileDriverOverlay.tsx index 1190fcc8598..7cc39d35daf 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserMobileDriverOverlay.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserMobileDriverOverlay.tsx @@ -53,6 +53,7 @@ export function BrowserMobileDriverOverlay({ driver, onTakeBack }: Props): React
[]), removeEventListener: vi.fn() }) } diff --git a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx index f437ca1dce0..7c8c1860e02 100644 --- a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx @@ -272,6 +272,7 @@ function PickerListbox({
(null) - const { files } = mention - const selectedIndex = Math.min(activeIndex, files.length - 1) + const selectedIndex = Math.min(activeIndex, mention.files.length - 1) useEffect(() => { activeItemRef.current?.scrollIntoView({ block: 'nearest' }) - }, [selectedIndex, files]) + }, [selectedIndex, mention.files]) const statusText = - files.length > 0 + mention.files.length > 0 ? null : mention.loading ? translate('components.native-chat.composer.loadingFiles', 'Loading files...') @@ -389,7 +389,7 @@ export const NativeChatMentionMenu = memo(function NativeChatMentionMenu({ return ( - {files.map((path, index) => ( + {mention.files.map((path, index) => ( + onQuote: (text: string) => void rewind: NativeChatRewindSurface | undefined }): React.JSX.Element { const { quoteText, link, codeText, messageText, rewindItemId } = target @@ -31,9 +28,7 @@ export function NativeChatContextMenuTargetItems({ return ( <> {quoteText ? ( - composerRef.current?.appendText(formatNativeChatQuote(quoteText))} - > + onQuote(quoteText)}> {translate('components.native-chat.addSelectionToChat', 'Add to chat')} diff --git a/src/renderer/src/components/native-chat/NativeChatPromptEditor.overlay-focus.test.tsx b/src/renderer/src/components/native-chat/NativeChatPromptEditor.overlay-focus.test.tsx new file mode 100644 index 00000000000..178c00ab223 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatPromptEditor.overlay-focus.test.tsx @@ -0,0 +1,119 @@ +// @vitest-environment happy-dom +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { createRef } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { NativeChatPromptEditor } from './NativeChatPromptEditor' +import { + writeNativeChatComposerText, + type NativeChatComposerInput +} from './native-chat-composer-input' + +afterEach(() => { + cleanup() + vi.useRealTimers() +}) + +function editor(inputRef: React.RefObject) { + return ( + {}} + onSelect={() => {}} + /> + ) +} + +function setup() { + vi.useFakeTimers() + const inputRef = createRef() + render( + <> + {editor(inputRef)} + + New tab + + + New Terminal + + + + ) + act(() => inputRef.current!.focus()) + fireEvent.pointerDown(screen.getByRole('button', { name: 'New tab' }), { + button: 0, + ctrlKey: false, + pointerType: 'mouse' + }) + act(() => screen.getByRole('combobox', { name: 'Search launcher' }).focus()) + return inputRef.current! +} + +describe('composer focus while the quick launcher is open', () => { + it('keeps launcher search focused when a late composer focus call arrives', async () => { + const input = setup() + const search = screen.getByRole('combobox', { name: 'Search launcher' }) + expect(document.activeElement).toBe(search) + + await act(async () => { + input.focus() + await vi.runOnlyPendingTimersAsync() + }) + + expect(screen.getByRole('menu')).toBeTruthy() + expect(document.activeElement).toBe(search) + }) + + it('updates a returned draft without taking focus from the launcher', async () => { + const input = setup() + const search = screen.getByRole('combobox', { name: 'Search launcher' }) + + await act(async () => { + writeNativeChatComposerText(input, 'Returned message', 16) + await vi.runOnlyPendingTimersAsync() + }) + + expect(input.value).toBe('Returned message') + expect(screen.getByRole('menu')).toBeTruthy() + expect(document.activeElement).toBe(search) + }) + + it('allows composer focus again after the launcher closes', async () => { + const input = setup() + fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' }) + await act(async () => vi.runOnlyPendingTimersAsync()) + expect(screen.queryByRole('menu')).toBeNull() + + act(() => input.focus()) + + expect(document.activeElement).toBe(screen.getByRole('textbox', { name: 'Message' })) + }) + + it('allows a launch prompt inside its own popover to receive focus', () => { + const inputRef = createRef() + render( + + Launch agent + {editor(inputRef)} + + ) + + act(() => inputRef.current!.focus()) + + expect(document.activeElement).toBe(screen.getByRole('textbox', { name: 'Message' })) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx b/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx index 3f96ea7edc1..9d0b0bf63fb 100644 --- a/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx @@ -35,7 +35,7 @@ import type { NativeChatPromptEditorProps } from './native-chat-composer-input' import { NativeChatImageRowProvider } from './NativeChatImageReferencePill' -import { mountedEditorView } from './native-chat-mounted-editor-view' +import { focusMountedNativeChatEditor, mountedEditorView } from './native-chat-mounted-editor-view' import { NativeChatUnkernedRunDecorations } from './native-chat-unkerned-run-decorations' import { useNativeChatComposerMount } from './native-chat-composer-mount' @@ -314,16 +314,7 @@ function NativePromptEditor({ get selectionEnd() { return promptTextOffset(editor.state.doc, editor.state.selection.to) }, - focus: () => { - // A focus request can arrive before the view mounts (a chat opened while its - // workspace switches in); tiptap throws then, so wait for the next reveal instead. - const view = mountedEditorView(editor) - // Focus the editor view so an inserted caret replaces the stale DOM selection. - view?.focus() - if (view && !editor.isEditable) { - view.dom.focus() - } - }, + focus: () => focusMountedNativeChatEditor(editor), contains: (node) => mountedEditorView(editor)?.dom.contains(node) === true, isCaretOnVisualEdge: (edge) => { const { selection, doc } = editor.state diff --git a/src/renderer/src/components/native-chat/native-chat-composer-focus-intent.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-focus-intent.test.ts new file mode 100644 index 00000000000..90be34574fa --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-focus-intent.test.ts @@ -0,0 +1,134 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + captureNativeChatComposerFocusIntent, + registerNativeChatComposerReclaim, + type NativeChatComposerFocusIntent +} from './native-chat-composer-focus-intent' + +let intent: NativeChatComposerFocusIntent | undefined +let unregister: (() => void) | undefined + +beforeEach(() => vi.useFakeTimers()) + +afterEach(() => { + intent?.cancel() + unregister?.() + document.body.replaceChildren() + vi.useRealTimers() +}) + +function mountDestination(): HTMLTextAreaElement { + const composer = document.createElement('textarea') + document.body.append(composer) + unregister = registerNativeChatComposerReclaim('destination', () => composer.focus(), composer) + return composer +} + +function mountSource(role: string): HTMLDivElement { + const source = document.createElement('div') + source.setAttribute('role', role) + source.tabIndex = -1 + document.body.append(source) + source.focus() + intent = captureNativeChatComposerFocusIntent() + intent.request('destination') + expect(document.activeElement).toBe(source) + return source +} + +describe('custom overlay launch handoff', () => { + it.each(['dialog', 'menu'])( + 'delivers to an already registered destination when a custom %s is removed', + async (role) => { + const composer = mountDestination() + const source = mountSource(role) + + source.remove() + await vi.advanceTimersByTimeAsync(0) + + expect(document.activeElement).toBe(composer) + } + ) + + it.each(['display', 'visibility', 'class', 'hidden'])( + 'delivers when a retained source is hidden via %s', + async (dismissal) => { + const stylesheet = document.createElement('style') + stylesheet.textContent = '.dismissed-overlay, [hidden] { display: none; }' + document.body.append(stylesheet) + const composer = mountDestination() + const source = mountSource('dialog') + + if (dismissal === 'display') { + source.style.display = 'none' + } + if (dismissal === 'visibility') { + source.style.visibility = 'hidden' + } + if (dismissal === 'class') { + source.className = 'dismissed-overlay' + } + if (dismissal === 'hidden') { + source.hidden = true + } + await vi.advanceTimersByTimeAsync(0) + + expect(source.isConnected).toBe(true) + expect(document.activeElement).toBe(composer) + } + ) + + it('waits for a source that is still painted during its exit animation', async () => { + const composer = mountDestination() + const source = mountSource('dialog') + source.dataset.state = 'closed' + await vi.advanceTimersByTimeAsync(0) + expect(document.activeElement).toBe(source) + + source.remove() + await vi.advanceTimersByTimeAsync(0) + expect(document.activeElement).toBe(composer) + }) + + it('releases a hidden source when delivery is requested before mutation notification', () => { + const composer = mountDestination() + const source = mountSource('menu') + + source.style.display = 'none' + intent?.request('destination') + + expect(source.isConnected).toBe(true) + expect(document.activeElement).toBe(composer) + }) + + it('keeps an accessibility-hidden parent source until it actually dismisses', async () => { + const composer = mountDestination() + const source = mountSource('dialog') + source.setAttribute('aria-hidden', 'true') + source.className = 'still-open' + await vi.advanceTimersByTimeAsync(0) + expect(document.activeElement).toBe(source) + + source.style.display = 'none' + await vi.advanceTimersByTimeAsync(0) + expect(document.activeElement).toBe(composer) + }) + + it('cancels when a newer custom overlay appears before dismissal', async () => { + const composer = mountDestination() + const source = mountSource('menu') + const newer = document.createElement('div') + newer.setAttribute('role', 'dialog') + newer.tabIndex = -1 + document.body.append(newer) + newer.focus() + await vi.advanceTimersByTimeAsync(0) + expect(document.activeElement).toBe(newer) + + source.remove() + newer.remove() + await vi.advanceTimersByTimeAsync(0) + expect(document.activeElement).not.toBe(composer) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-focus-intent.ts b/src/renderer/src/components/native-chat/native-chat-composer-focus-intent.ts new file mode 100644 index 00000000000..90ac36c12d1 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-focus-intent.ts @@ -0,0 +1,133 @@ +import { + getOverlayFocusOwners, + getOverlayFocusSources, + isOverlayFocusSourceDismissed, + overlayFocusAppliesTo, + overlayFocusContains, + subscribeOverlayFocusChanges +} from '@/lib/overlay-focus-ownership' + +const PENDING_FOCUS_MS = 1000 + +type ComposerReclaim = ( + onlyIfFocusUnclaimed?: boolean, + sourceOverlays?: readonly HTMLElement[] +) => void +const revealedComposerReclaims = new Map< + string, + { reclaim: ComposerReclaim; root: HTMLElement | null } +>() +type PendingComposerFocusRequest = { + tabId: string | null + cancel: () => void + deliver: () => void +} +let pendingFocusRequest: PendingComposerFocusRequest | null = null +let focusRequestGeneration = 0 + +export type NativeChatComposerFocusIntent = { + request: (tabId: string) => void + cancel: () => void +} + +export function getNativeChatComposerFocusRequestGeneration(): number { + return focusRequestGeneration +} + +export function registerNativeChatComposerReclaim( + tabId: string, + reclaim: ComposerReclaim, + root: HTMLElement | null +): () => void { + const destination = { reclaim, root } + revealedComposerReclaims.set(tabId, destination) + if (pendingFocusRequest?.tabId === tabId) { + pendingFocusRequest.deliver() + } + return () => { + if (revealedComposerReclaims.get(tabId) === destination) { + revealedComposerReclaims.delete(tabId) + } + } +} + +/** Keep a new-chat action's focus intent through source dismissal, destination claim, or expiry. */ +export function captureNativeChatComposerFocusIntent(): NativeChatComposerFocusIntent { + focusRequestGeneration += 1 + pendingFocusRequest?.cancel() + if (typeof document === 'undefined') { + return { request: () => undefined, cancel: () => undefined } + } + const sourceOverlays = getOverlayFocusSources() + const cancel = (): void => { + if (pendingFocusRequest !== pending) { + return + } + pendingFocusRequest = null + clearTimeout(timeout) + unsubscribe() + document.removeEventListener('pointerdown', cancel, true) + document.removeEventListener('keydown', cancelForNavigation, true) + } + const cancelForNavigation = (event: KeyboardEvent): void => { + if (event.key === 'Tab') { + cancel() + } + } + const deliver = (): void => { + if (pendingFocusRequest !== pending) { + return + } + const destination = pending.tabId ? revealedComposerReclaims.get(pending.tabId) : undefined + const sources = sourceOverlays.filter( + (owner) => + !destination?.root || + (!overlayFocusContains(owner, destination.root) && + overlayFocusAppliesTo(owner, destination.root)) + ) + if ( + getOverlayFocusOwners(destination?.root ?? undefined).some( + (owner) => !sourceOverlays.includes(owner) + ) + ) { + cancel() + return + } + if (sources.some((owner) => !isOverlayFocusSourceDismissed(owner))) { + return + } + if (destination) { + cancel() + destination.reclaim(false, sources) + } + } + const pending: PendingComposerFocusRequest = { tabId: null, cancel, deliver } + const unsubscribe = subscribeOverlayFocusChanges(deliver) + const timeout = setTimeout(cancel, PENDING_FOCUS_MS) + pendingFocusRequest = pending + document.addEventListener('pointerdown', cancel, true) + document.addEventListener('keydown', cancelForNavigation, true) + return { + request: (tabId) => { + if (pendingFocusRequest !== pending) { + return + } + pending.tabId = tabId + deliver() + }, + cancel + } +} + +/** Re-clicking a chat's active tab claims its composer again, as a terminal tab refocuses xterm. */ +export function requestNativeChatComposerFocus( + tabId: string, + options: { onlyIfFocusUnclaimed?: boolean } = {} +): void { + if (options.onlyIfFocusUnclaimed) { + // A rename return is one-shot, not an activation to replay after a hidden composer reveals. + revealedComposerReclaims.get(tabId)?.reclaim(true) + return + } + captureNativeChatComposerFocusIntent().request(tabId) +} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx index 4774a5c3274..d1f4b3c7adc 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx @@ -1,16 +1,24 @@ // @vitest-environment happy-dom -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { act, createElement, useRef } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, createElement, useRef, useState } from 'react' import { createRoot, type Root } from 'react-dom/client' import { captureNativeChatComposerFocusIntent, requestNativeChatComposerFocus, useNativeChatComposerRevealFocus } from './use-native-chat-composer-reveal-focus' +import { + handleOverlayCloseAutoFocus, + registerOverlayFocusOwner +} from '@/lib/overlay-focus-ownership' +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' +import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet' +import { NativeChatMentionMenu, NativeChatPickerMenu } from './NativeChatAutocompleteMenus' import type { NativeChatComposerHandle } from './NativeChatComposer' type HarnessProps = { tabId?: string + suggestions?: 'slash' | 'mention' isVisible: boolean isFocusedGroup: boolean composerReady?: boolean @@ -79,7 +87,35 @@ function Harness(props: HarnessProps): React.JSX.Element { createElement('textarea', { ref: fieldRef, 'data-testid': 'composer' }), createElement('button', { type: 'button', 'data-testid': 'in-pane-button' }), createElement('input', { 'data-testid': 'find-input' }), - createElement('input', { 'data-testid': 'question-input' }) + createElement('input', { 'data-testid': 'question-input' }), + props.suggestions === 'slash' ? ( + {}} + onRetry={() => {}} + /> + ) : null, + props.suggestions === 'mention' ? ( + {}} + /> + ) : null ) } @@ -89,6 +125,26 @@ function render(props: HarnessProps): void { }) } +function mountOverlay(role: 'menu' | 'dialog' = 'menu'): HTMLDivElement { + const overlay = document.createElement('div') + overlay.setAttribute('role', role) + overlay.setAttribute('data-state', 'open') + overlay.tabIndex = -1 + document.body.appendChild(overlay) + overlay.focus() + return overlay +} + +function dismissOverlay(overlay: HTMLElement, remove = true): void { + overlay.setAttribute('data-state', 'closed') + const event = new Event('focusScope.autoFocusOnUnmount') + overlay.addEventListener(event.type, handleOverlayCloseAutoFocus, { once: true }) + overlay.dispatchEvent(event) + if (remove) { + overlay.remove() + } +} + beforeEach(() => { container = document.createElement('div') document.body.appendChild(container) @@ -315,6 +371,326 @@ describe('useNativeChatComposerRevealFocus', () => { outside.remove() }) + it.each(['menu', 'dialog'] as const)( + 'preserves a newer %s when the composer becomes ready', + (role) => { + render({ isVisible: true, isFocusedGroup: true, composerReady: false }) + const intent = captureNativeChatComposerFocusIntent() + intent.request('chat-tab') + const overlay = mountOverlay(role) + + render({ isVisible: true, isFocusedGroup: true, composerReady: true }) + drainFrames() + + expect(document.activeElement).toBe(overlay) + expect(focusCalls).toBe(0) + overlay.remove() + render({ isVisible: true, isFocusedGroup: true, nonce: 1 }) + drainFrames() + expect(document.activeElement).toBe(document.body) + } + ) + + it.each([ + { dismissal: 'removed', readyAfterAttempts: 0 }, + { dismissal: 'closed', readyAfterAttempts: 0 }, + { dismissal: 'closed', readyAfterAttempts: 1 } + ])( + 'hands launch focus to a ready composer ($dismissal, retries: $readyAfterAttempts)', + async ({ dismissal, readyAfterAttempts }) => { + render({ isVisible: true, isFocusedGroup: true, composerReady: false }) + const menu = mountOverlay() + const search = document.createElement('input') + menu.appendChild(search) + search.focus() + const intent = captureNativeChatComposerFocusIntent() + intent.request('chat-tab') + + render({ isVisible: true, isFocusedGroup: true, readyAfterAttempts }) + drainFrames() + expect(document.activeElement).toBe(search) + expect(focusCalls).toBe(0) + + await act(async () => { + dismissOverlay(menu, dismissal === 'removed') + }) + drainFrames() + expect(document.activeElement).toBe(container.querySelector('textarea')) + } + ) + + it.each([false, true])( + 'hands launch focus to a late composer (tab published after dismissal: %s)', + async (lateTab) => { + render({ isVisible: true, isFocusedGroup: true, composerReady: false }) + const menu = mountOverlay() + const intent = captureNativeChatComposerFocusIntent() + if (!lateTab) { + intent.request('chat-tab') + } + await act(async () => dismissOverlay(menu)) + if (lateTab) { + intent.request('chat-tab') + } + + render({ isVisible: true, isFocusedGroup: true }) + drainFrames() + + expect(document.activeElement).toBe(container.querySelector('textarea')) + } + ) + + it('waits for both the launch menu and its portaled setup to dismiss', async () => { + render({ isVisible: true, isFocusedGroup: true }) + drainFrames() + const menu = mountOverlay() + const setup = mountOverlay('dialog') + const intent = captureNativeChatComposerFocusIntent() + act(() => intent.request('chat-tab')) + drainFrames() + const before = focusCalls + + await act(async () => dismissOverlay(menu)) + drainFrames() + expect(document.activeElement).toBe(setup) + expect(focusCalls).toBe(before) + + await act(async () => dismissOverlay(setup)) + drainFrames() + expect(document.activeElement).toBe(container.querySelector('textarea')) + }) + + it.each(['menu', 'dialog'] as const)( + 'cancels a launch handoff when a newer %s opens before the source closes', + async (role) => { + render({ isVisible: true, isFocusedGroup: true, composerReady: false }) + const source = mountOverlay() + const intent = captureNativeChatComposerFocusIntent() + intent.request('chat-tab') + render({ isVisible: true, isFocusedGroup: true }) + drainFrames() + + let newer: HTMLDivElement + await act(async () => { + newer = mountOverlay(role) + registerOverlayFocusOwner(newer) + }) + await act(async () => { + source.remove() + newer.remove() + }) + drainFrames() + + expect(document.activeElement).toBe(document.body) + expect(focusCalls).toBe(0) + } + ) + + it.each(['pointer', 'Tab', 'expiry'] as const)( + 'cancels a source menu handoff after %s', + async (cancellation) => { + vi.useFakeTimers() + try { + render({ isVisible: true, isFocusedGroup: true, composerReady: false }) + const menu = mountOverlay() + const intent = captureNativeChatComposerFocusIntent() + intent.request('chat-tab') + render({ isVisible: true, isFocusedGroup: true }) + drainFrames() + + if (cancellation === 'pointer') { + document.dispatchEvent(new Event('pointerdown')) + } else if (cancellation === 'Tab') { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' })) + } else { + act(() => vi.advanceTimersByTime(1000)) + } + await act(async () => dismissOverlay(menu)) + drainFrames() + + expect(document.activeElement).toBe(document.body) + expect(focusCalls).toBe(0) + } finally { + vi.useRealTimers() + } + } + ) + + it('cancels queued focus recovery when a menu opens between frames', () => { + render({ isVisible: true, isFocusedGroup: true }) + drainFrames(1) + const menu = mountOverlay() + + drainFrames(1) + + expect(document.activeElement).toBe(menu) + expect(focusCalls).toBe(1) + menu.remove() + drainFrames() + expect(document.activeElement).toBe(document.body) + }) + + it.each([false, true])( + 'yields an immediate focus request to a menu (onlyIfFocusUnclaimed: %s)', + (onlyIfFocusUnclaimed) => { + render({ isVisible: true, isFocusedGroup: true }) + const menu = mountOverlay() + menu.blur() + + act(() => requestNativeChatComposerFocus('chat-tab', { onlyIfFocusUnclaimed })) + menu.remove() + drainFrames() + + expect(document.activeElement).toBe(document.body) + expect(focusCalls).toBe(0) + } + ) + + it.each(['slash', 'mention'] as const)( + 'reclaims focus after switching away from %s suggestions', + (mode) => { + render({ isVisible: true, isFocusedGroup: true, suggestions: mode }) + drainFrames() + expect(document.querySelector('[role="listbox"]')).not.toBeNull() + render({ isVisible: false, isFocusedGroup: true, suggestions: mode }) + const tab = document.createElement('button') + document.body.append(tab) + tab.focus() + + render({ isVisible: true, isFocusedGroup: true, suggestions: mode }) + drainFrames() + expect(document.activeElement).toBe(container.querySelector('textarea')) + tab.focus() + act(() => requestNativeChatComposerFocus('chat-tab')) + drainFrames() + expect(document.activeElement).toBe(container.querySelector('textarea')) + } + ) + + it('launches a floating chat above persistent Skills content while protecting a nested popup', async () => { + const page = document.createElement('div') + page.setAttribute('role', 'listbox') + page.dataset.focusBehavior = 'page' + document.body.append(page) + render({ isVisible: true, isFocusedGroup: true, composerReady: false }) + const intent = captureNativeChatComposerFocusIntent() + intent.request('chat-tab') + render({ isVisible: true, isFocusedGroup: true }) + drainFrames() + expect(document.activeElement).toBe(container.querySelector('textarea')) + + const popup = mountOverlay() + page.append(popup) + popup.focus() + const before = focusCalls + render({ isVisible: false, isFocusedGroup: true }) + render({ isVisible: true, isFocusedGroup: true }) + drainFrames() + expect(document.activeElement).toBe(popup) + expect(focusCalls).toBe(before) + await act(async () => dismissOverlay(popup)) + drainFrames() + expect(focusCalls).toBe(before) + }) + + it('delivers an active-tab handoff after both dashboard scopes dismiss and trigger focus restores', async () => { + render({ isVisible: true, isFocusedGroup: true }) + drainFrames() + const sheet = mountOverlay('dialog') + registerOverlayFocusOwner(sheet) + const preview = mountOverlay('dialog') + registerOverlayFocusOwner(preview) + sheet.setAttribute('aria-hidden', 'true') + const trigger = document.createElement('button') + document.body.append(trigger) + act(() => requestNativeChatComposerFocus('chat-tab')) + drainFrames() + expect(document.activeElement).toBe(preview) + + await act(async () => { + dismissOverlay(preview) + sheet.removeAttribute('aria-hidden') + }) + drainFrames() + expect(document.activeElement).not.toBe(container.querySelector('textarea')) + await act(async () => { + dismissOverlay(sheet) + trigger.focus() + }) + drainFrames() + expect(document.activeElement).toBe(container.querySelector('textarea')) + }) + + it('hands composer focus through real sheet and dialog dismissal callbacks', async () => { + vi.useFakeTimers() + function Dashboard(): React.JSX.Element { + const [open, setOpen] = useState(false) + return ( + <> + + + + + Dashboard + + + Preview + + + + + + + ) + } + try { + act(() => root.render()) + drainFrames() + const composer = container.querySelector('textarea')! + await act(async () => { + ;[...container.querySelectorAll('button')] + .find((button) => button.textContent === 'Dashboard preview')! + .click() + }) + const action = [...document.querySelectorAll('button')].find( + (button) => button.textContent === 'Open worktree' + )! + await act(async () => action.click()) + await act(async () => vi.advanceTimersByTime(0)) + drainFrames() + expect(document.activeElement).toBe(composer) + } finally { + vi.useRealTimers() + } + }) + + it('allows focus recovery while a dismissed menu animates out', () => { + const menu = mountOverlay() + menu.setAttribute('data-state', 'closed') + + render({ isVisible: true, isFocusedGroup: true }) + drainFrames() + + expect(document.activeElement).toBe(container.querySelector('textarea')) + }) + + it('allows a chat hosted inside a dialog to claim its composer', () => { + const dialog = mountOverlay('dialog') + dialog.appendChild(container) + + render({ isVisible: true, isFocusedGroup: true }) + drainFrames() + + expect(document.activeElement).toBe(container.querySelector('textarea')) + }) + it('does not chase focus after the user moves to a control outside the pane', () => { render({ isVisible: true, isFocusedGroup: true }) drainFrames(1) diff --git a/src/renderer/src/components/native-chat/native-chat-mounted-editor-view.ts b/src/renderer/src/components/native-chat/native-chat-mounted-editor-view.ts index 48ac21f20f5..c426922bb1f 100644 --- a/src/renderer/src/components/native-chat/native-chat-mounted-editor-view.ts +++ b/src/renderer/src/components/native-chat/native-chat-mounted-editor-view.ts @@ -1,5 +1,6 @@ import type { Editor } from '@tiptap/react' import type { EditorView } from '@tiptap/pm/view' +import { getOverlayFocusOwners } from '@/lib/overlay-focus-ownership' /** The editor's view once mounted; null before mount or after destroy, when tiptap's view throws. */ export function mountedEditorView(editor: Editor): EditorView | null { @@ -14,3 +15,15 @@ export function mountedEditorView(editor: Editor): EditorView | null { return null } } + +export function focusMountedNativeChatEditor(editor: Editor): void { + const view = mountedEditorView(editor) + if (!view || getOverlayFocusOwners(view.dom).length > 0) { + return + } + // Focus the view so an inserted caret replaces the stale DOM selection. + view.focus() + if (!editor.isEditable) { + view.dom.focus() + } +} diff --git a/src/renderer/src/components/native-chat/native-chat-pane-local-overlay-focus.test.tsx b/src/renderer/src/components/native-chat/native-chat-pane-local-overlay-focus.test.tsx new file mode 100644 index 00000000000..4da3c1750ca --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-pane-local-overlay-focus.test.tsx @@ -0,0 +1,163 @@ +// @vitest-environment happy-dom +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { createRef, useRef, type RefObject } from 'react' +import { afterEach, describe, expect, it } from 'vitest' +import { MobileDriverOverlay } from '@/components/terminal-pane/MobileDriverOverlay' +import { BrowserMobileDriverOverlay } from '@/components/browser-pane/assemble-chrome/BrowserMobileDriverOverlay' +import { TooltipProvider } from '@/components/ui/tooltip' +import { NativeChatPromptEditor } from './NativeChatPromptEditor' +import type { NativeChatComposerInput } from './native-chat-composer-input' +import type { NativeChatComposerHandle } from './native-chat-composer-types' +import { useNativeChatTypedInsertion } from './use-native-chat-typed-insertion' +import { + requestNativeChatComposerFocus, + useNativeChatComposerRevealFocus +} from './use-native-chat-composer-reveal-focus' + +let frames: (() => void)[] = [] +const scheduleFrame = (callback: () => void): void => { + frames.push(callback) +} +const noop = (): void => {} + +afterEach(() => { + document.dispatchEvent(new Event('pointerdown')) + cleanup() + frames = [] +}) + +function drainFrames(): void { + act(() => { + while (frames.length) { + const queued = frames + frames = [] + queued.forEach((frame) => frame()) + } + }) +} + +function Chat({ + inputRef, + revealed +}: { + inputRef: RefObject + revealed: boolean +}) { + const rootRef = useRef(null) + const insertion = useNativeChatTypedInsertion({ + textareaRef: inputRef, + isComposing: () => false, + setDraft: noop, + setCaret: noop, + setActiveSuggestion: noop + }) + const composerRef = useRef({ + ...insertion, + insertAgentReferences: () => false, + handlePasteEvent: noop, + pasteFromClipboard: noop + }) + useNativeChatComposerRevealFocus({ + tabId: 'chat', + rootRef, + composerRef, + isVisible: revealed, + isFocusedGroup: true, + composerReady: true, + scheduleFrame + }) + return ( +
+ +
+ ) +} + +function Recovery({ browser }: { browser: boolean }) { + return browser ? ( + + ) : ( + + ) +} + +function setup(browser: boolean, samePane = false) { + const inputRef = createRef() + function Split({ revealed }: { revealed: boolean }) { + return ( + +
+ + {samePane ? : null} +
+ {!samePane ? : null} + +
+ ) + } + const view = render() + const restore = screen.getByRole('button', { + name: browser ? 'Take back' : 'Restore' + }) + act(() => restore.focus()) + return { + inputRef, + restore, + reveal: () => view.rerender() + } +} + +describe.each([false, true])('chat focus beside phone recovery (browser: %s)', (browser) => { + it('automatically focuses a revealed chat in another pane', () => { + const chat = setup(browser) + chat.reveal() + drainFrames() + + expect(chat.inputRef.current?.contains?.(document.activeElement)).toBe(true) + expect(chat.restore.isConnected).toBe(true) + }) + + it.each([false, true])( + 'activates chat without waiting for another pane’s Restore (already revealed: %s)', + (revealed) => { + const chat = setup(browser) + if (revealed) { + chat.reveal() + drainFrames() + act(() => chat.restore.focus()) + } + fireEvent.click(screen.getByRole('button', { name: 'Activate chat' })) + if (!revealed) { + chat.reveal() + } + drainFrames() + + expect(chat.inputRef.current?.contains?.(document.activeElement)).toBe(true) + expect(chat.restore.isConnected).toBe(true) + } + ) + + it('allows a direct editor focus request from another pane', () => { + const chat = setup(browser) + + act(() => chat.inputRef.current?.focus()) + + expect(chat.inputRef.current?.contains?.(document.activeElement)).toBe(true) + }) + + it('keeps recovery ownership within its own pane', () => { + const chat = setup(browser, true) + chat.reveal() + drainFrames() + act(() => chat.inputRef.current?.focus()) + + expect(document.activeElement).toBe(chat.restore) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-quote-focus.test.tsx b/src/renderer/src/components/native-chat/native-chat-quote-focus.test.tsx new file mode 100644 index 00000000000..135af46ea81 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-quote-focus.test.tsx @@ -0,0 +1,78 @@ +// @vitest-environment happy-dom +import { useRef } from 'react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import { AgentLaunchPromptComposer } from '../agent/AgentLaunchPromptComposer' +import type { AgentPickerEntry } from '../../../../shared/agent-launch-picker' +import type { NativeChatComposerHandle } from './native-chat-composer-types' +import { + emptyNativeChatContextMenuActions, + useNativeChatContextMenu +} from './use-native-chat-context-menu' +import { clearNativeChatComposerDraftsForTests } from './native-chat-composer-draft-store' +import { promptEditor, promptValue } from './native-chat-prompt-editor.test-support' + +const entry: AgentPickerEntry = { + agent: 'claude', + label: 'Claude', + rows: [{ agent: 'claude', kind: 'cli', executionHostId: 'local' }], + surfaces: ['chat'], + defaultSurface: 'chat', + terminal: {}, + structured: {}, + catalog: { origin: 'unknown' } +} + +function Chat() { + const rootRef = useRef(null) + const composerRef = useRef(null) + const { menu, onContextMenuCapture, onPointerDownCapture } = useNativeChatContextMenu({ + rootRef, + composerRef, + showTerminalPaneActions: false, + actions: { ...emptyNativeChatContextMenuActions, onPaste: () => {} } + }) + return ( +
+
+

Reply to quote

+
+ false} /> + {menu} +
+ ) +} + +afterEach(() => { + cleanup() + clearNativeChatComposerDraftsForTests() + window.getSelection()?.removeAllRanges() + vi.restoreAllMocks() +}) + +it('places the caret after the quote when Add to chat closes the context menu', async () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(0) + render(, { wrapper: TooltipProvider }) + const editor = document.querySelector('[contenteditable][aria-multiline="true"]')! + await waitFor(() => expect(promptEditor(editor)).toBeDefined()) + const reply = screen.getByText('Reply to quote') + window.getSelection()!.selectAllChildren(reply) + fireEvent.contextMenu(reply) + await screen.findByRole('menu') + now.mockReturnValue(1000) + fireEvent.click(screen.getByRole('menuitem', { name: 'Add to chat' })) + + expect(promptValue(editor)).toBe('> Reply to quote\n\n') + expect(document.activeElement).toBe(editor) + await act(async () => {}) + expect(document.activeElement).toBe(editor) + expect(promptEditor(editor).state.selection.empty).toBe(true) + expect(promptEditor(editor).state.selection.to).toBe( + promptEditor(editor).state.doc.content.size - 1 + ) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts index d30daa2ff70..1ac37005e56 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts @@ -2,77 +2,22 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { RefObject } from 'react' import { shouldPreserveEditableFocus } from '@/components/terminal-pane/pane-helpers' import { scheduleNextFrame } from '@/components/terminal-pane/terminal-ime-input-context-refresh' +import { getOverlayFocusOwners } from '@/lib/overlay-focus-ownership' import type { NativeChatComposerHandle } from './NativeChatComposer' import { registerNativeChatPane } from './native-chat-composer-mount' +import { + getNativeChatComposerFocusRequestGeneration, + registerNativeChatComposerReclaim +} from './native-chat-composer-focus-intent' +export { + captureNativeChatComposerFocusIntent, + requestNativeChatComposerFocus, + type NativeChatComposerFocusIntent +} from './native-chat-composer-focus-intent' /** Focus attempts per reveal, including recovery from delayed programmatic restoration. */ const REVEAL_FOCUS_FRAMES = 6 -const PENDING_FOCUS_MS = 1000 - -const revealedComposerReclaims = new Map void>() const mountedComposers = new Set>() -let pendingFocusRequest: { tabId: string | null; cancel: () => void } | null = null -let focusRequestGeneration = 0 - -export type NativeChatComposerFocusIntent = { - request: (tabId: string) => void - cancel: () => void -} - -/** Keep a new-chat action's focus intent through destination claim, navigation, or expiry. */ -export function captureNativeChatComposerFocusIntent(): NativeChatComposerFocusIntent { - focusRequestGeneration += 1 - pendingFocusRequest?.cancel() - if (typeof document === 'undefined') { - return { request: () => undefined, cancel: () => undefined } - } - const cancel = (): void => { - if (pendingFocusRequest !== pending) { - return - } - pendingFocusRequest = null - clearTimeout(timeout) - document.removeEventListener('pointerdown', cancel, true) - document.removeEventListener('keydown', cancelForNavigation, true) - } - const cancelForNavigation = (event: KeyboardEvent): void => { - if (event.key === 'Tab') { - cancel() - } - } - const pending: { tabId: string | null; cancel: () => void } = { tabId: null, cancel } - const timeout = setTimeout(cancel, PENDING_FOCUS_MS) - pendingFocusRequest = pending - document.addEventListener('pointerdown', cancel, true) - document.addEventListener('keydown', cancelForNavigation, true) - return { - request: (tabId) => { - if (pendingFocusRequest !== pending) { - return - } - pending.tabId = tabId - const reclaim = revealedComposerReclaims.get(tabId) - if (reclaim) { - cancel() - reclaim() - } - }, - cancel - } -} - -/** Re-clicking a chat's active tab claims its composer again, as a terminal tab refocuses xterm. */ -export function requestNativeChatComposerFocus( - tabId: string, - options: { onlyIfFocusUnclaimed?: boolean } = {} -): void { - if (options.onlyIfFocusUnclaimed) { - // A rename return is one-shot, not an activation to replay after a hidden composer reveals. - revealedComposerReclaims.get(tabId)?.(true) - return - } - captureNativeChatComposerFocusIntent().request(tabId) -} export type NativeChatComposerRevealFocusArgs = { /** The tab whose activation re-runs the claim (the terminal tab for a chat view over a TUI). */ @@ -113,6 +58,7 @@ export function useNativeChatComposerRevealFocus({ const claimedRef = useRef(false) // An explicit tab activation takes focus from inside the pane too, short of a text field. const explicitRef = useRef(false) + const sourceOverlaysRef = useRef([]) const [reclaimCount, setReclaimCount] = useState(0) useLayoutEffect(() => { @@ -126,7 +72,16 @@ export function useNativeChatComposerRevealFocus({ if (!revealed || !composerReady) { return } - const reclaim = (onlyIfFocusUnclaimed = false): void => { + const reclaim = ( + onlyIfFocusUnclaimed = false, + sourceOverlays: readonly HTMLElement[] = [] + ): void => { + if (getOverlayFocusOwners(rootRef.current ?? undefined).length > 0) { + claimedRef.current = true + explicitRef.current = false + setReclaimCount((count) => count + 1) + return + } if (onlyIfFocusUnclaimed) { const root = rootRef.current const active = root?.ownerDocument.activeElement @@ -137,34 +92,29 @@ export function useNativeChatComposerRevealFocus({ } claimedRef.current = false explicitRef.current = true + sourceOverlaysRef.current = sourceOverlays const active = rootRef.current?.ownerDocument.activeElement ?? null const siblingComposer = [...mountedComposers].some( (ownerRef) => ownerRef !== composerRef && ownerRef.current?.contains(active) ) if ( - (!shouldPreserveEditableFocus(active) || siblingComposer) && + (!shouldPreserveEditableFocus(active) || + siblingComposer || + sourceOverlays.some((overlay) => overlay.contains(active))) && composerRef.current?.focus() ) { explicitRef.current = false } setReclaimCount((count) => count + 1) } - revealedComposerReclaims.set(tabId, reclaim) - if (pendingFocusRequest?.tabId === tabId) { - pendingFocusRequest.cancel() - reclaim() - } - return () => { - if (revealedComposerReclaims.get(tabId) === reclaim) { - revealedComposerReclaims.delete(tabId) - } - } + return registerNativeChatComposerReclaim(tabId, reclaim, rootRef.current) }, [composerReady, composerRef, revealed, rootRef, tabId]) useEffect(() => { if (!revealed || !composerReady) { claimedRef.current = false explicitRef.current = false + sourceOverlaysRef.current = [] return } if (claimedRef.current) { @@ -173,7 +123,7 @@ export function useNativeChatComposerRevealFocus({ let cancelled = false let userInteracted = false let attempts = 0 - const requestGeneration = focusRequestGeneration + const requestGeneration = getNativeChatComposerFocusRequestGeneration() const ownerDocument = rootRef.current?.ownerDocument const cancelForPointer = (): void => { userInteracted = true @@ -190,6 +140,7 @@ export function useNativeChatComposerRevealFocus({ const finish = (): void => { claimedRef.current = true explicitRef.current = false + sourceOverlaysRef.current = [] stopWatchingUserIntent() } ownerDocument?.addEventListener('pointerdown', cancelForPointer, true) @@ -198,11 +149,11 @@ export function useNativeChatComposerRevealFocus({ if (cancelled) { return } - if (requestGeneration !== focusRequestGeneration) { + if (requestGeneration !== getNativeChatComposerFocusRequestGeneration()) { stopWatchingUserIntent() return } - if (userInteracted) { + if (userInteracted || getOverlayFocusOwners(rootRef.current ?? undefined).length > 0) { finish() return } @@ -221,7 +172,9 @@ export function useNativeChatComposerRevealFocus({ explicitRef.current && rootRef.current?.contains(active) !== true && [...mountedComposers].some((ownerRef) => ownerRef.current?.contains(active)) - if (shouldPreserveEditableFocus(active) && !editingSiblingComposer) { + const editingSourceOverlay = + explicitRef.current && sourceOverlaysRef.current.some((overlay) => overlay.contains(active)) + if (shouldPreserveEditableFocus(active) && !editingSiblingComposer && !editingSourceOverlay) { finish() return } diff --git a/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx b/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx index 20db902da16..f3b12bb3807 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx @@ -7,6 +7,7 @@ import { type PointerEventHandler, type RefObject } from 'react' +import { flushSync } from 'react-dom' import { chatMathSelectionText } from '../sidebar/chat-markdown-math-selection' import { Clipboard, Copy, GitFork, Image as ImageIcon, SquareTerminal } from 'lucide-react' import { @@ -30,7 +31,10 @@ import { isEditableTarget } from '@/lib/editable-target' import { findEditablePrimarySelectionPasteTarget } from '@/lib/primary-selection-paste' import { NativeChatCopyOrcaSessionIdMenuItem } from './NativeChatCopyOrcaSessionIdMenuItem' import { NativeChatSelectionQuote } from './NativeChatSelectionQuote' -import { readNativeChatTranscriptSelection } from './native-chat-quote-selection' +import { + formatNativeChatQuote, + readNativeChatTranscriptSelection +} from './native-chat-quote-selection' import { NativeChatPaneMenuItems } from './NativeChatPaneMenuItems' import { NativeChatStructuredPaneMenuItems } from './NativeChatStructuredPaneMenuItems' import type { NativeChatComposerHandle } from './native-chat-composer-types' @@ -295,7 +299,10 @@ export function useNativeChatContextMenu({ ) : null} { + flushSync(() => setState((prev) => ({ ...prev, open: false }))) + composerRef.current?.appendText(formatNativeChatQuote(text)) + }} rewind={rewind} /> {state.canPaste ? ( diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceSourceResultList.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceSourceResultList.tsx index 91a0de259c6..3ab629a6226 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceSourceResultList.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceSourceResultList.tsx @@ -15,7 +15,8 @@ export function SmartWorkspaceSourceResultList({ isLinked, beforeResults, emptyHint, - isRowDisabled + isRowDisabled, + focusBehavior }: { model: Pick< WorkItemSourceSearch, @@ -40,6 +41,7 @@ export function SmartWorkspaceSourceResultList({ beforeResults?: React.ReactNode emptyHint?: string isRowDisabled?: (row: RowEntry) => boolean + focusBehavior?: 'page' }): React.JSX.Element { const { mode, @@ -82,7 +84,7 @@ export function SmartWorkspaceSourceResultList({ ))}
) : null} - + {beforeResults} {typedTextActionRow ? (
- + queueMicrotask(mention)} + > {translate('components.orchestrationCrew.mentionInMessage', 'Mention in message')} diff --git a/src/renderer/src/components/sidebar/WorkspaceAttachmentSearch.resolution.test.tsx b/src/renderer/src/components/sidebar/WorkspaceAttachmentSearch.resolution.test.tsx index 37154753a54..bbd32ffcb0b 100644 --- a/src/renderer/src/components/sidebar/WorkspaceAttachmentSearch.resolution.test.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceAttachmentSearch.resolution.test.tsx @@ -4,6 +4,7 @@ import React, { createRef, type ReactNode } from 'react' import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' +import { getOverlayFocusOwners } from '@/lib/overlay-focus-ownership' import type { JiraConnectionStatus, JiraIssue, JiraSite } from '../../../../shared/jira/jira-types' import type { LinearIssue } from '../../../../shared/linear/issue-types' import type { @@ -186,6 +187,14 @@ describe('workspace reference URL resolution and recovery', () => { }) afterEach(cleanup) + it('keeps inline attachment results from claiming composer focus', () => { + render(composer(jiraUrl)) + + const results = screen.getByRole('listbox') + expect(results.getClientRects().length).toBeGreaterThan(0) + expect(getOverlayFocusOwners()).not.toContain(results) + }) + it('blocks pasted Jira URLs while loading, including plain Enter', () => { Object.assign(sourceMock.jiraState, { intent: true, loading: true }) render(composer(jiraUrl)) diff --git a/src/renderer/src/components/sidebar/WorkspaceAttachmentSearch.tsx b/src/renderer/src/components/sidebar/WorkspaceAttachmentSearch.tsx index d0b7bca04c9..2a6577d01f1 100644 --- a/src/renderer/src/components/sidebar/WorkspaceAttachmentSearch.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceAttachmentSearch.tsx @@ -258,6 +258,7 @@ export function WorkspaceAttachmentSearch({ {query.trim() ? (
}) { + const { ref: focusScopeRef, scope } = useOverlayFocusScope() const { className: commandClassName, ...commandRootProps } = commandProps ?? {} return ( - handleImeOverlayEscape(event)} - onOpenAutoFocus={onOpenAutoFocus} - onCloseAutoFocus={onCloseAutoFocus} - > - {title} - - {description} - - + handleImeOverlayEscape(event)} + onOpenAutoFocus={onOpenAutoFocus} + onCloseAutoFocus={(event) => handleOverlayCloseAutoFocus(event, onCloseAutoFocus)} > - {children} - - + {title} + + {description} + + + {children} + + + ) diff --git a/src/renderer/src/components/ui/context-menu.tsx b/src/renderer/src/components/ui/context-menu.tsx index f2cb1e8ffb6..587b5607019 100644 --- a/src/renderer/src/components/ui/context-menu.tsx +++ b/src/renderer/src/components/ui/context-menu.tsx @@ -4,6 +4,8 @@ import { ChevronRightIcon, CircleIcon } from 'lucide-react' import * as ContextMenuPrimitive from 'radix-ui/context-menu' import { cn } from '@/lib/utils' +import { handleOverlayCloseAutoFocus } from '@/lib/overlay-focus-ownership' +import { OverlayFocusScope, useOverlayFocusScope } from './use-overlay-focus-scope' import { handleImeOverlayEscape } from '@/lib/ime-overlay-escape' import { keepFocusWhileMenuCloses } from './menu-item-closing-focus' import { useMenuPointerHighlight } from './menu-pointer-highlight' @@ -72,23 +74,27 @@ function ContextMenuSubContent({ }: React.ComponentProps & VariantProps) { const pointerHighlight = useMenuPointerHighlight(props) + const { ref: focusScopeRef, scope } = useOverlayFocusScope(pointerHighlight.ref) return ( - handleImeOverlayEscape(event, props.onEscapeKeyDown)} - /> + + handleImeOverlayEscape(event, props.onEscapeKeyDown)} + /> + ) } @@ -101,21 +107,26 @@ function ContextMenuContent({ }: React.ComponentProps & VariantProps) { const pointerHighlight = useMenuPointerHighlight(props) + const { ref: focusScopeRef, scope } = useOverlayFocusScope(pointerHighlight.ref) return ( - handleImeOverlayEscape(event, props.onEscapeKeyDown)} - /> + + handleOverlayCloseAutoFocus(event, props.onCloseAutoFocus)} + {...pointerHighlight} + ref={focusScopeRef} + onEscapeKeyDown={(event) => handleImeOverlayEscape(event, props.onEscapeKeyDown)} + /> + ) } diff --git a/src/renderer/src/components/ui/dialog.tsx b/src/renderer/src/components/ui/dialog.tsx index 99dc87ec644..08bf29b70b2 100644 --- a/src/renderer/src/components/ui/dialog.tsx +++ b/src/renderer/src/components/ui/dialog.tsx @@ -7,6 +7,8 @@ import { Minus, XIcon } from 'lucide-react' import * as DialogPrimitive from 'radix-ui/dialog' import { cn } from '@/lib/utils' +import { handleOverlayCloseAutoFocus } from '@/lib/overlay-focus-ownership' +import { OverlayFocusScope, useOverlayFocusScope } from './use-overlay-focus-scope' import { handleImeOverlayEscape } from '@/lib/ime-overlay-escape' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' @@ -106,6 +108,7 @@ function DialogContent({ /** The whole dialog scrolls within the viewport instead of growing past it. */ scrollable?: boolean }) { + const { ref: focusScopeRef, scope } = useOverlayFocusScope(props.ref) return ( @@ -120,10 +123,14 @@ function DialogContent({ className )} {...props} + ref={focusScopeRef} + onCloseAutoFocus={(event) => handleOverlayCloseAutoFocus(event, props.onCloseAutoFocus)} onEscapeKeyDown={(event) => handleImeOverlayEscape(event, props.onEscapeKeyDown)} > - {children} - {showCloseButton && } + + {children} + {showCloseButton && } + ) diff --git a/src/renderer/src/components/ui/dropdown-menu.tsx b/src/renderer/src/components/ui/dropdown-menu.tsx index 708daefd8f4..2db04acabcd 100644 --- a/src/renderer/src/components/ui/dropdown-menu.tsx +++ b/src/renderer/src/components/ui/dropdown-menu.tsx @@ -3,6 +3,8 @@ import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react' import * as DropdownMenuPrimitive from 'radix-ui/dropdown-menu' import { cn } from '@/lib/utils' +import { handleOverlayCloseAutoFocus } from '@/lib/overlay-focus-ownership' +import { OverlayFocusScope, useOverlayFocusScope } from './use-overlay-focus-scope' import { handleImeOverlayEscape } from '@/lib/ime-overlay-escape' import { keepFocusWhileMenuCloses } from './menu-item-closing-focus' import { useMenuPointerHighlight } from './menu-pointer-highlight' @@ -30,21 +32,26 @@ function DropdownMenuContent({ ...props }: React.ComponentProps) { const pointerHighlight = useMenuPointerHighlight(props) + const { ref: focusScopeRef, scope } = useOverlayFocusScope(pointerHighlight.ref) return ( - handleImeOverlayEscape(event, props.onEscapeKeyDown)} - /> + + handleOverlayCloseAutoFocus(event, props.onCloseAutoFocus)} + {...pointerHighlight} + ref={focusScopeRef} + onEscapeKeyDown={(event) => handleImeOverlayEscape(event, props.onEscapeKeyDown)} + /> + ) } @@ -220,24 +227,28 @@ function DropdownMenuSubContent({ ...props }: React.ComponentProps & { scrollable?: boolean }) { const pointerHighlight = useMenuPointerHighlight(props) + const { ref: focusScopeRef, scope } = useOverlayFocusScope(pointerHighlight.ref) return ( - handleImeOverlayEscape(event, props.onEscapeKeyDown)} - /> + + handleImeOverlayEscape(event, props.onEscapeKeyDown)} + /> + ) } diff --git a/src/renderer/src/components/ui/popover.tsx b/src/renderer/src/components/ui/popover.tsx index 913c8ee3904..c2bb384d8ad 100644 --- a/src/renderer/src/components/ui/popover.tsx +++ b/src/renderer/src/components/ui/popover.tsx @@ -4,6 +4,8 @@ import * as React from 'react' import * as PopoverPrimitive from 'radix-ui/popover' import { cn } from '@/lib/utils' +import { handleOverlayCloseAutoFocus } from '@/lib/overlay-focus-ownership' +import { OverlayFocusScope, useOverlayFocusScope } from './use-overlay-focus-scope' import { handleImeOverlayEscape } from '@/lib/ime-overlay-escape' // React delegates wheel passively, so native defaultPrevented may not reflect synthetic cancellation. @@ -156,31 +158,36 @@ function PopoverContent({ [forwardedRef, portalContainer] ) + const { ref: focusScopeRef, scope } = useOverlayFocusScope(setContentRef) + return ( - handleImeOverlayEscape(event, props.onEscapeKeyDown)} - /> + + handleOverlayCloseAutoFocus(event, props.onCloseAutoFocus)} + onEscapeKeyDown={(event) => handleImeOverlayEscape(event, props.onEscapeKeyDown)} + /> + ) } diff --git a/src/renderer/src/components/ui/select.tsx b/src/renderer/src/components/ui/select.tsx index 5a6b4ad064e..196027547d2 100644 --- a/src/renderer/src/components/ui/select.tsx +++ b/src/renderer/src/components/ui/select.tsx @@ -6,6 +6,8 @@ import * as SelectPrimitive from 'radix-ui/select' import { cva, type VariantProps } from 'class-variance-authority' import { cn } from '@/lib/utils' +import { handleOverlayCloseAutoFocus } from '@/lib/overlay-focus-ownership' +import { OverlayFocusScope, useOverlayFocusScope } from './use-overlay-focus-scope' function Select({ ...props }: React.ComponentProps) { return @@ -78,33 +80,38 @@ function SelectContent({ }: React.ComponentProps & { portalContainer?: HTMLElement | null }) { + const { ref: focusScopeRef, scope } = useOverlayFocusScope(props.ref) return ( - - - + handleOverlayCloseAutoFocus(event, props.onCloseAutoFocus)} > - {children} - - - + + + {children} + + + + ) } diff --git a/src/renderer/src/components/ui/sheet.tsx b/src/renderer/src/components/ui/sheet.tsx index 76d2b2ead24..83ece453f21 100644 --- a/src/renderer/src/components/ui/sheet.tsx +++ b/src/renderer/src/components/ui/sheet.tsx @@ -6,6 +6,8 @@ import * as SheetPrimitive from 'radix-ui/dialog' import { cva, type VariantProps } from 'class-variance-authority' import { cn } from '@/lib/utils' +import { handleOverlayCloseAutoFocus } from '@/lib/overlay-focus-ownership' +import { OverlayFocusScope, useOverlayFocusScope } from './use-overlay-focus-scope' import { handleImeOverlayEscape } from '@/lib/ime-overlay-escape' import { translate } from '@/i18n/i18n' @@ -75,30 +77,35 @@ function SheetContent({ overlayClassName?: string overlayStyle?: React.CSSProperties }) { + const { ref: focusScopeRef, scope } = useOverlayFocusScope(props.ref) return ( - handleImeOverlayEscape(event, props.onEscapeKeyDown)} - > - {children} - {showCloseButton && ( - - - - {translate('auto.components.ui.sheet.1189e9fe0a', 'Close')} - - - )} - + + handleOverlayCloseAutoFocus(event, props.onCloseAutoFocus)} + onEscapeKeyDown={(event) => handleImeOverlayEscape(event, props.onEscapeKeyDown)} + > + {children} + {showCloseButton && ( + + + + {translate('auto.components.ui.sheet.1189e9fe0a', 'Close')} + + + )} + + ) } diff --git a/src/renderer/src/components/ui/use-overlay-focus-scope.test.tsx b/src/renderer/src/components/ui/use-overlay-focus-scope.test.tsx new file mode 100644 index 00000000000..43af804254a --- /dev/null +++ b/src/renderer/src/components/ui/use-overlay-focus-scope.test.tsx @@ -0,0 +1,47 @@ +// @vitest-environment happy-dom +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, expect, it } from 'vitest' +import { + getOverlayFocusOwners, + getOverlayFocusSources, + handleOverlayCloseAutoFocus, + isOverlayFocusSourceDismissed +} from '@/lib/overlay-focus-ownership' +import { useOverlayFocusScope } from './use-overlay-focus-scope' + +afterEach(cleanup) + +function RetainedOverlay({ mounted, open }: { mounted: boolean; open: boolean }) { + const { ref } = useOverlayFocusScope() + return mounted ?
: null +} + +it.each([false, true])( + 'restores focus ownership when a retained overlay reopens (initially mounted: %s)', + async (initiallyMounted) => { + const view = render() + if (!initiallyMounted) { + view.rerender() + } + const owner = screen.getByRole('menu') + expect(getOverlayFocusOwners()).toContain(owner) + + view.rerender() + await act(async () => { + const event = new Event('focusScope.autoFocusOnUnmount') + owner.addEventListener(event.type, handleOverlayCloseAutoFocus, { once: true }) + owner.dispatchEvent(event) + }) + expect(getOverlayFocusOwners()).not.toContain(owner) + expect(getOverlayFocusSources()).not.toContain(owner) + expect(isOverlayFocusSourceDismissed(owner)).toBe(true) + + view.rerender() + expect(screen.getByRole('menu')).toBe(owner) + await waitFor(() => { + expect(getOverlayFocusOwners()).toContain(owner) + expect(getOverlayFocusSources()).toContain(owner) + expect(isOverlayFocusSourceDismissed(owner)).toBe(false) + }) + } +) diff --git a/src/renderer/src/components/ui/use-overlay-focus-scope.ts b/src/renderer/src/components/ui/use-overlay-focus-scope.ts new file mode 100644 index 00000000000..4cd10e9719f --- /dev/null +++ b/src/renderer/src/components/ui/use-overlay-focus-scope.ts @@ -0,0 +1,38 @@ +import { + createContext, + useContext, + useLayoutEffect, + useRef, + useState, + type Ref, + type RefCallback, + type RefObject +} from 'react' +import { useComposedRefs } from 'radix-ui/internal' +import { registerOverlayFocusOwner } from '@/lib/overlay-focus-ownership' + +export const OverlayFocusScope = createContext | null>(null) + +export function useOverlayFocusScope(forwardedRef?: Ref): { + ref: RefCallback + scope: RefObject +} { + const parent = useContext(OverlayFocusScope) + const scope = useRef(null) + const [owner, setOwner] = useState(null) + useLayoutEffect(() => { + if (owner) { + registerOverlayFocusOwner(owner, () => parent?.current ?? null) + const observer = new MutationObserver(() => { + if (owner.dataset.state === 'open') { + registerOverlayFocusOwner(owner, () => parent?.current ?? null) + } + }) + observer.observe(owner, { attributes: true, attributeFilter: ['data-state'] }) + return () => observer.disconnect() + } + return undefined + }, [owner, parent]) + const ref = useComposedRefs(forwardedRef, scope, setOwner) + return { ref, scope } +} diff --git a/src/renderer/src/lib/overlay-focus-ownership.ts b/src/renderer/src/lib/overlay-focus-ownership.ts new file mode 100644 index 00000000000..05b1f02454f --- /dev/null +++ b/src/renderer/src/lib/overlay-focus-ownership.ts @@ -0,0 +1,122 @@ +import { getVisibleOverlays, isVisibleOverlay } from './visible-overlay' + +const NON_OWNING_SURFACES = + '[data-focus-behavior="page"], [data-focus-behavior="composer-suggestions"]' +const overlayParents = new WeakMap HTMLElement | null>() +const dismissedOwners = new WeakSet() +const listeners = new Set<() => void>() + +type FocusEventHandler = (event: Event) => void + +/** Pane recovery cards cover their parent surface; portaled menus keep window ownership. */ +export function overlayFocusAppliesTo(owner: HTMLElement, target?: Element | null): boolean { + return ( + owner.dataset.overlayFocusScope !== 'parent' || + (target != null && owner.parentElement?.contains(target) === true) + ) +} + +/** Portaled descendants belong to the same focus scope as their containing surface. */ +export function overlayFocusContains(owner: HTMLElement, target: Element): boolean { + for (let scope: Element | null = target; scope;) { + if (owner.contains(scope)) { + return true + } + scope = + (scope instanceof HTMLElement ? overlayParents.get(scope)?.() : undefined) ?? + scope.parentElement + } + return false +} + +/** Page lists and editor suggestions do not take focus; their nested popups still do. */ +export function getOverlayFocusOwners(containing?: Element): HTMLElement[] { + return getVisibleOverlays({ + ignoreMatches: NON_OWNING_SURFACES, + ignoreContaining: containing, + ignoreDismissed: true + }).filter( + (owner) => + !hasDismissedFocusScope(owner) && + (!containing || !overlayFocusContains(owner, containing)) && + overlayFocusAppliesTo(owner, containing) + ) +} + +/** Include parent scopes hidden from accessibility by a nested modal. */ +export function getOverlayFocusSources(): HTMLElement[] { + return getVisibleOverlays({ + ignoreMatches: NON_OWNING_SURFACES, + ignoreDismissed: true, + includeAriaHidden: true + }).filter( + (owner) => + !hasDismissedFocusScope(owner) && overlayFocusAppliesTo(owner, document.activeElement) + ) +} + +export function isOverlayFocusSourceDismissed(owner: HTMLElement): boolean { + return ( + !owner.isConnected || + hasDismissedFocusScope(owner) || + !isVisibleOverlay(owner, { includeAriaHidden: true }) + ) +} + +function hasDismissedFocusScope(owner: HTMLElement): boolean { + for (let scope: HTMLElement | null = owner; scope; scope = scope.parentElement) { + if (dismissedOwners.has(scope)) { + return true + } + } + return false +} + +export function subscribeOverlayFocusChanges(listener: () => void): () => void { + listeners.add(listener) + // Custom role-based overlays do not emit the primitives' focus-scope callbacks. + const observer = new MutationObserver(listener) + observer.observe(document.documentElement, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: [ + 'class', + 'style', + 'hidden', + 'role', + 'data-state', + 'data-focus-behavior', + 'data-overlay-focus-scope' + ] + }) + return () => { + listeners.delete(listener) + observer.disconnect() + } +} + +export function registerOverlayFocusOwner( + owner: HTMLElement, + parent?: () => HTMLElement | null +): void { + if (parent) { + overlayParents.set(owner, parent) + } else { + overlayParents.delete(owner) + } + dismissedOwners.delete(owner) + queueMicrotask(() => listeners.forEach((listener) => listener())) +} + +export function handleOverlayCloseAutoFocus(event: Event, handler?: FocusEventHandler): void { + const owner = event.target + handler?.(event) + // Radix restores the trigger after this callback returns; deliver the handoff afterward. + queueMicrotask(() => { + if (owner instanceof HTMLElement) { + dismissedOwners.add(owner) + } + listeners.forEach((listener) => listener()) + }) +} diff --git a/src/renderer/src/lib/pane-manager/pane-overlay-focus.ts b/src/renderer/src/lib/pane-manager/pane-overlay-focus.ts index ed9230d9da9..0a413eb2aea 100644 --- a/src/renderer/src/lib/pane-manager/pane-overlay-focus.ts +++ b/src/renderer/src/lib/pane-manager/pane-overlay-focus.ts @@ -1,5 +1,5 @@ import { focusNativeChatCover } from '@/components/terminal-pane/native-chat-covered-pane' -import { hasVisibleOverlay } from '../visible-overlay' +import { getOverlayFocusOwners } from '../overlay-focus-ownership' import type { ManagedPane } from './pane-manager-types' export function focusPanePreservingOverlays( @@ -8,11 +8,7 @@ export function focusPanePreservingOverlays( if ( typeof document !== 'undefined' && (document.activeElement?.matches('[data-worktree-sidebar][data-keyboard-navigation]') || - hasVisibleOverlay({ - ignoreMatches: '[role="listbox"][data-worktree-sidebar]', - ignoreContaining: pane.container, - ignoreDismissed: true - })) + getOverlayFocusOwners(pane.container).length > 0) ) { return } diff --git a/src/renderer/src/lib/visible-overlay.ts b/src/renderer/src/lib/visible-overlay.ts index 06b962d2192..85bd7083235 100644 --- a/src/renderer/src/lib/visible-overlay.ts +++ b/src/renderer/src/lib/visible-overlay.ts @@ -1,8 +1,10 @@ // The always-mounted worktree sidebar is page chrome, not an Escape-owning popup. const OVERLAY_SELECTOR = - '[role="dialog"], [role="alertdialog"], [role="listbox"]:not([data-worktree-sidebar]), [role="menu"]' + '[role="dialog"], [role="alertdialog"], [role="listbox"]:not([data-worktree-sidebar]):not([data-focus-behavior="page"]), [role="menu"]' type VisibleOverlayOptions = { + /** Capture source scopes even when a nested modal hides them from accessibility. */ + includeAriaHidden?: boolean /** Overlays inside a match are treated as page content, not as a layer above it. */ ignoreSelector?: string /** Ignore matching chrome itself while retaining overlays nested within it. */ @@ -13,35 +15,43 @@ type VisibleOverlayOptions = { ignoreDismissed?: boolean } -/** Live overlays own Escape before page handlers and outrank queued terminal focus. Ignore page chrome while retaining nested layers. */ +/** Live overlays own Escape before page handlers and outrank queued terminal focus. */ export function hasVisibleOverlay(options?: VisibleOverlayOptions): boolean { - return Array.from(document.querySelectorAll(OVERLAY_SELECTOR)).some((element) => { - if (!(element instanceof HTMLElement)) { - return false - } - if (element.closest('[aria-hidden="true"]')) { - return false - } - if (options?.ignoreSelector && element.closest(options.ignoreSelector)) { - return false - } - if (options?.ignoreMatches && element.matches(options.ignoreMatches)) { - return false - } - if (options?.ignoreContaining && element.contains(options.ignoreContaining)) { - return false - } - // Dismissed overlays remain painted during ~300ms exit animation; exclude them from focus ownership. Escape checks run before closed attributes update and must opt out. - if (options?.ignoreDismissed && element.getAttribute('data-state') === 'closed') { - return false - } - const style = window.getComputedStyle(element) - return ( - style.display !== 'none' && - style.visibility !== 'hidden' && - element.getClientRects().length > 0 - ) - }) + return Array.from(document.querySelectorAll(OVERLAY_SELECTOR)).some((element) => + isVisibleOverlay(element, options) + ) +} + +export function getVisibleOverlays(options?: VisibleOverlayOptions): HTMLElement[] { + return Array.from(document.querySelectorAll(OVERLAY_SELECTOR)).filter((element) => + isVisibleOverlay(element, options) + ) +} + +export function isVisibleOverlay(element: Element, options?: VisibleOverlayOptions): boolean { + if (!(element instanceof HTMLElement)) { + return false + } + if (!options?.includeAriaHidden && element.closest('[aria-hidden="true"]')) { + return false + } + if (options?.ignoreSelector && element.closest(options.ignoreSelector)) { + return false + } + if (options?.ignoreMatches && element.matches(options.ignoreMatches)) { + return false + } + if (options?.ignoreContaining && element.contains(options.ignoreContaining)) { + return false + } + // Dismissed overlays remain painted during ~300ms exit animation; exclude them from focus ownership. Escape checks run before closed attributes update and must opt out. + if (options?.ignoreDismissed && element.getAttribute('data-state') === 'closed') { + return false + } + const style = window.getComputedStyle(element) + return ( + style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0 + ) } // These don't own Escape, but still paint above pages: popper surfaces (tooltips, hover cards), diff --git a/tests/e2e/native-chat-send-focus.spec.ts b/tests/e2e/native-chat-send-focus.spec.ts index 46cbe33b322..07332a49e4d 100644 --- a/tests/e2e/native-chat-send-focus.spec.ts +++ b/tests/e2e/native-chat-send-focus.spec.ts @@ -47,6 +47,56 @@ async function openChat(page: Page, claudeCommand: string) { test.describe('native chat composer focus', () => { test.skip(!SUPPORTED, 'the Claude stub wrapper is POSIX-only') + test('quick launcher keeps focus when Copy Context completes', async ({ + electronApp, + orcaPage: page, + claudeStub + }, testInfo) => { + await page.setViewportSize({ width: 1280, height: 900 }) + const { composer, send, reply } = await openChat(page, claudeStub.command) + await page.keyboard.type('hello') + await send.click() + await expect(reply).toBeVisible({ timeout: 30_000 }) + await electronApp.evaluate(({ ipcMain }) => { + ipcMain.removeHandler('clipboard:writeTerminalText') + ipcMain.handle( + 'clipboard:writeTerminalText', + () => + new Promise((resolve) => { + const gate: { pid: number; releaseContextCopy?: () => void } = process + gate.releaseContextCopy = resolve + }) + ) + }) + await reply.click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Copy Context', exact: true }).click() + await expect + .poll(() => + electronApp.evaluate(() => { + const gate: { pid: number; releaseContextCopy?: () => void } = process + return typeof gate.releaseContextCopy === 'function' + }) + ) + .toBe(true) + await page.getByRole('button', { name: 'New tab', exact: true }).click() + const menu = page.getByRole('menu', { name: 'New tab', exact: true }) + const search = menu.getByRole('combobox') + await expect(search).toBeFocused() + + await electronApp.evaluate(() => { + const gate: { pid: number; releaseContextCopy?: () => void } = process + gate.releaseContextCopy?.() + }) + + await expect(page.getByText('Context copied', { exact: true })).toBeVisible() + await expect(menu).toBeVisible() + await expect(search).toBeFocused() + await page.keyboard.type('terminal') + await expect(search).toHaveValue('terminal') + await expect(composer).toHaveText('') + await page.screenshot({ path: testInfo.outputPath('quick-launch-focus.png') }) + }) + test('keyboard tab rename returns input to the native chat composer', async ({ orcaPage: page, claudeStub