[]),
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
+
+
+ >
+ )
+ 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
+
+
+
+ >
+ )
+ }
+ 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 (
+
+ )
+}
+
+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