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