Keep overlays open when focusing the chat composer (#37403)

* Preserve open overlays when focusing the chat composer

* Fix chat composer focus after launch menus close

* Fix chat composer focus handoff after overlays close

* Fix composer focus after custom overlays close

* Prevent chat composer from stealing overlay focus

Scope recovery overlays to their own pane and let menus dismiss before
returning focus to the composer. This keeps the quick launcher open
when delayed chat actions complete.

* Fix composer focus across nested overlays

Track focus ownership through portaled overlays so the launch prompt can
regain focus while its model list is open. Close the context menu before
inserting a quote so the caret lands after it.

* Fix overlay focus CI regressions

* Complete overlay focus ownership for inline and retained surfaces

* Declare public overlay focus hook return type
This commit is contained in:
Jinjing
2026-11-02 14:50:41 -07:00
committed by GitHub
parent b14e705093
commit b96d19d933
38 changed files with 1662 additions and 299 deletions
@@ -102,6 +102,7 @@ function LoudRestartOverlay({
<div
ref={rootRef}
role="dialog"
data-overlay-focus-scope="parent"
// Allow programmatic focus while keeping Tab navigation on Restart/Keep.
tabIndex={-1}
aria-live="assertive"
@@ -12,6 +12,7 @@ import { AgentLaunchSetupPanel } from './AgentLaunchSetupPanel'
import { i18n } from '@/i18n/i18n'
import de from '../../i18n/locales/de.json'
import { agentLaunchPromptScopeKey } from './AgentLaunchPromptComposer'
import { focusMountedNativeChatEditor } from '../native-chat/native-chat-mounted-editor-view'
import { changePrompt, promptEditor } from '../native-chat/native-chat-prompt-editor.test-support'
import {
clearNativeChatComposerDraftsForTests,
@@ -372,3 +373,54 @@ it.each(['claude', 'codex'] as const)(
)
}
)
it.each(['inline', 'menu'])(
'focuses the setup prompt imperatively with the model list open in %s configuration',
async (surface) => {
act(() =>
root.render(
<TooltipProvider>
{surface === 'menu' ? (
<DropdownMenu defaultOpen modal={false}>
<DropdownMenuTrigger>New tab</DropdownMenuTrigger>
<DropdownMenuContent>
<AgentLaunchPickerMenu entries={[launchEntry]} runLaunch={() => false} />
</DropdownMenuContent>
</DropdownMenu>
) : (
<AgentLaunchSetupPanel entry={launchEntry} onUse={onUse} />
)}
</TooltipProvider>
)
)
if (surface === 'menu') {
fireEvent.click(getByRole(document.body, 'button', { name: 'Configure Claude' }))
}
const editor = document.querySelector<HTMLElement>('[contenteditable][aria-multiline="true"]')!
await waitFor(() => expect(promptEditor(editor)).toBeDefined())
const models = document.querySelector<HTMLInputElement>('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()
}
}
)
@@ -215,7 +215,7 @@ export function AgentLaunchSetupPanel({
agent: entry.label
})}
/>
<CommandList className="max-h-56">
<CommandList className="max-h-56" data-focus-behavior="page">
<CommandEmpty>
{translate('agentPicker.noModels', 'No models match your search.')}
</CommandEmpty>
@@ -53,6 +53,7 @@ export function BrowserMobileDriverOverlay({ driver, onTakeBack }: Props): React
<div
ref={setOverlayRef}
role="dialog"
data-overlay-focus-scope="parent"
aria-modal="true"
aria-labelledby={OVERLAY_TITLE_ID}
aria-live="assertive"
@@ -200,6 +200,7 @@ export function bindFocusedFloatingPanelKeydown(element: unknown): {
Object.setPrototypeOf(panelElement, HTMLElement.prototype)
attachRef(panel.props.ref, panelElement)
vi.stubGlobal('document', {
...document,
activeElement: panelElement,
addEventListener: vi.fn(),
removeEventListener: vi.fn()
@@ -248,11 +248,20 @@ export function setupFloatingTerminalPanelTest(): void {
})
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
vi.stubGlobal('HTMLElement', class {})
vi.stubGlobal(
'MutationObserver',
class {
observe = vi.fn()
disconnect = vi.fn()
}
)
vi.stubGlobal('localStorage', localStorage)
// Provide focus/listener defaults; tests may replace activeElement while retaining effect-safe listeners.
vi.stubGlobal('document', {
activeElement: null,
addEventListener: vi.fn(),
documentElement: {},
querySelectorAll: vi.fn(() => []),
removeEventListener: vi.fn()
})
}
@@ -272,6 +272,7 @@ function PickerListbox({
<div
id={id}
role="listbox"
data-focus-behavior="composer-suggestions"
style={{
position: 'fixed',
left: bounds.left,
@@ -371,15 +372,14 @@ export const NativeChatMentionMenu = memo(function NativeChatMentionMenu({
}): React.JSX.Element {
useUiLanguageRevision()
const activeItemRef = useRef<HTMLButtonElement | null>(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 (
<PickerListbox id={listboxId}>
{files.map((path, index) => (
{mention.files.map((path, index) => (
<PickerOptionButton
key={path}
id={`${listboxId}-option-${index}`}
@@ -1,4 +1,3 @@
import type { RefObject } from 'react'
import { Copy, ExternalLink, FileText, Globe, Quote, Search, Undo2 } from 'lucide-react'
import { DropdownMenuItem, DropdownMenuShortcut } from '@/components/ui/dropdown-menu'
import { formatOptionalPrimaryShortcutLabel } from '@/hooks/useShortcutLabel'
@@ -6,9 +5,7 @@ import { useAppStore } from '@/store'
import { useConfirmationDialog } from '@/components/confirmation-dialog-context'
import type { LinkAction } from '@/components/link-actions/link-action-request'
import { translate } from '@/i18n/i18n'
import type { NativeChatComposerHandle } from './native-chat-composer-types'
import type { NativeChatContextMenuTarget } from './native-chat-context-menu-target'
import { formatNativeChatQuote } from './native-chat-quote-selection'
import type { NativeChatRewindSurface } from './use-native-chat-rewind'
function copyText(text: string): void {
@@ -19,11 +16,11 @@ function copyText(text: string): void {
/** The rows for what was right-clicked: each reuses the action its hover or click surface runs. */
export function NativeChatContextMenuTargetItems({
target,
composerRef,
onQuote,
rewind
}: {
target: NativeChatContextMenuTarget
composerRef: RefObject<NativeChatComposerHandle | null>
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 ? (
<DropdownMenuItem
onSelect={() => composerRef.current?.appendText(formatNativeChatQuote(quoteText))}
>
<DropdownMenuItem onSelect={() => onQuote(quoteText)}>
<Quote />
{translate('components.native-chat.addSelectionToChat', 'Add to chat')}
</DropdownMenuItem>
@@ -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<NativeChatComposerInput | null>) {
return (
<NativeChatPromptEditor
inputRef={inputRef}
initialValue="Draft"
disabled={false}
placeholder="Message"
onChange={() => {}}
onSelect={() => {}}
/>
)
}
function setup() {
vi.useFakeTimers()
const inputRef = createRef<NativeChatComposerInput>()
render(
<>
{editor(inputRef)}
<DropdownMenu modal={false}>
<DropdownMenuTrigger>New tab</DropdownMenuTrigger>
<DropdownMenuContent id="launcher-menu">
<input
role="combobox"
aria-label="Search launcher"
aria-controls="launcher-menu"
aria-expanded="true"
/>
<DropdownMenuItem>New Terminal</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)
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<NativeChatComposerInput>()
render(
<Popover defaultOpen>
<PopoverTrigger>Launch agent</PopoverTrigger>
<PopoverContent>{editor(inputRef)}</PopoverContent>
</Popover>
)
act(() => inputRef.current!.focus())
expect(document.activeElement).toBe(screen.getByRole('textbox', { name: 'Message' }))
})
})
@@ -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
@@ -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)
})
})
@@ -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)
}
@@ -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' ? (
<NativeChatPickerMenu
autocomplete={{
mode: 'slash',
prefix: '/',
query: '',
triggerKey: '/',
items: [],
dispatchable: true,
grouped: false,
commandsEnabled: true,
skillsEnabled: false,
skillStatus: 'ready'
}}
activeIndex={0}
listboxId="slash-suggestions"
onChoose={() => {}}
onRetry={() => {}}
/>
) : null,
props.suggestions === 'mention' ? (
<NativeChatMentionMenu
mention={{ files: [], loading: false, failed: false }}
activeIndex={0}
listboxId="mention-suggestions"
onChoose={() => {}}
/>
) : 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 (
<>
<Harness isVisible isFocusedGroup />
<button onClick={() => setOpen(true)}>Dashboard preview</button>
<Sheet open={open} onOpenChange={setOpen} modal={false}>
<SheetContent aria-describedby={undefined}>
<SheetTitle>Dashboard</SheetTitle>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent aria-describedby={undefined}>
<DialogTitle>Preview</DialogTitle>
<button
onClick={() => {
requestNativeChatComposerFocus('chat-tab')
setOpen(false)
}}
>
Open worktree
</button>
</DialogContent>
</Dialog>
</SheetContent>
</Sheet>
</>
)
}
try {
act(() => root.render(<Dashboard />))
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)
@@ -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()
}
}
@@ -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<NativeChatComposerInput | null>
revealed: boolean
}) {
const rootRef = useRef<HTMLDivElement>(null)
const insertion = useNativeChatTypedInsertion({
textareaRef: inputRef,
isComposing: () => false,
setDraft: noop,
setCaret: noop,
setActiveSuggestion: noop
})
const composerRef = useRef<NativeChatComposerHandle>({
...insertion,
insertAgentReferences: () => false,
handlePasteEvent: noop,
pasteFromClipboard: noop
})
useNativeChatComposerRevealFocus({
tabId: 'chat',
rootRef,
composerRef,
isVisible: revealed,
isFocusedGroup: true,
composerReady: true,
scheduleFrame
})
return (
<div ref={rootRef}>
<NativeChatPromptEditor
inputRef={inputRef}
initialValue=""
disabled={false}
placeholder="Message"
onChange={noop}
onSelect={noop}
/>
</div>
)
}
function Recovery({ browser }: { browser: boolean }) {
return browser ? (
<BrowserMobileDriverOverlay driver={{ kind: 'mobile', clientId: 'phone' }} onTakeBack={noop} />
) : (
<MobileDriverOverlay driver={{ kind: 'idle' }} hasFitOverride onAction={noop} />
)
}
function setup(browser: boolean, samePane = false) {
const inputRef = createRef<NativeChatComposerInput>()
function Split({ revealed }: { revealed: boolean }) {
return (
<TooltipProvider>
<div>
<Recovery browser={browser} />
{samePane ? <Chat inputRef={inputRef} revealed={revealed} /> : null}
</div>
{!samePane ? <Chat inputRef={inputRef} revealed={revealed} /> : null}
<button onClick={() => requestNativeChatComposerFocus('chat')}>Activate chat</button>
</TooltipProvider>
)
}
const view = render(<Split revealed={false} />)
const restore = screen.getByRole('button', {
name: browser ? 'Take back' : 'Restore'
})
act(() => restore.focus())
return {
inputRef,
restore,
reveal: () => view.rerender(<Split revealed />)
}
}
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)
})
})
@@ -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<HTMLDivElement>(null)
const composerRef = useRef<NativeChatComposerHandle>(null)
const { menu, onContextMenuCapture, onPointerDownCapture } = useNativeChatContextMenu({
rootRef,
composerRef,
showTerminalPaneActions: false,
actions: { ...emptyNativeChatContextMenuActions, onPaste: () => {} }
})
return (
<div
ref={rootRef}
onContextMenuCapture={onContextMenuCapture}
onPointerDownCapture={onPointerDownCapture}
>
<div data-native-chat-scroll>
<p data-native-chat-quote-source="">Reply to quote</p>
</div>
<AgentLaunchPromptComposer entry={entry} composerRef={composerRef} onLaunch={() => false} />
{menu}
</div>
)
}
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(<Chat />, { wrapper: TooltipProvider })
const editor = document.querySelector<HTMLElement>('[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
)
})
@@ -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<string, (onlyIfFocusUnclaimed?: boolean) => void>()
const mountedComposers = new Set<RefObject<NativeChatComposerHandle | null>>()
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<readonly HTMLElement[]>([])
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
}
@@ -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}
<NativeChatContextMenuTargetItems
target={target}
composerRef={composerRef}
onQuote={(text) => {
flushSync(() => setState((prev) => ({ ...prev, open: false })))
composerRef.current?.appendText(formatNativeChatQuote(text))
}}
rewind={rewind}
/>
{state.canPaste ? (
@@ -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({
))}
</div>
) : null}
<CommandList className="!max-h-none min-h-0 flex-1">
<CommandList className="!max-h-none min-h-0 flex-1" data-focus-behavior={focusBehavior}>
{beforeResults}
{typedTextActionRow ? (
<div
@@ -43,7 +43,11 @@ export function CrewRowMenu({
onPointerDownOutside={menu.onPointerDownOutside}
onCloseAutoFocus={menu.onCloseAutoFocus}
>
<DropdownMenuItem disabled={!canMention || !row.sendAddress} onSelect={mention}>
<DropdownMenuItem
disabled={!canMention || !row.sendAddress}
// Let Radix dismiss the source before the composer claims focus.
onSelect={() => queueMicrotask(mention)}
>
{translate('components.orchestrationCrew.mentionInMessage', 'Mention in message')}
</DropdownMenuItem>
<DropdownMenuItem onSelect={open}>
@@ -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))
@@ -258,6 +258,7 @@ export function WorkspaceAttachmentSearch({
{query.trim() ? (
<div className="mt-2 flex max-h-60 min-h-0 shrink flex-col overflow-hidden">
<SmartWorkspaceSourceResultList
focusBehavior="page"
// Jira URL status owns its message; a Linear read of that URL cannot diagnose it.
model={
taskUrlProvider === 'jira' ? { ...controller, linearReadError: null } : controller
@@ -148,6 +148,7 @@ export function SkillsList({
<div
ref={listRef}
role="listbox"
data-focus-behavior="page"
aria-multiselectable={selectionMode !== null || undefined}
aria-label={translate('auto.components.skills.SkillsList.listLabel', 'Skills')}
aria-orientation="vertical"
@@ -24,7 +24,7 @@ export function useSkillsPageKeyboardNavigation({
return
}
// Why: menus and dialogs own Escape before page-level navigation.
if (hasVisibleOverlay({ ignoreSelector: '[data-skills-page-list="true"]' })) {
if (hasVisibleOverlay()) {
return
}
const target = event.target
@@ -215,6 +215,7 @@ function LoudOverlay({
<div
ref={setRootRef}
role="dialog"
data-overlay-focus-scope="parent"
aria-live="assertive"
aria-labelledby={titleId}
aria-describedby={bodyId}
+31 -25
View File
@@ -7,6 +7,8 @@ import { SearchIcon } 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 { useImeTextFieldProps } from '@/lib/ime-text-field'
import { DialogOverlay } from '@/components/ui/dialog'
@@ -74,40 +76,44 @@ function CommandDialog({
surface?: 'default' | 'palette'
commandProps?: React.ComponentProps<typeof CommandPrimitive>
}) {
const { ref: focusScopeRef, scope } = useOverlayFocusScope()
const { className: commandClassName, ...commandRootProps } = commandProps ?? {}
return (
<DialogPrimitive.Root {...props}>
<DialogPrimitive.Portal>
<DialogOverlay className={overlayClassName} />
<DialogPrimitive.Content
data-slot="command-dialog-content"
// Match DialogContent’s surface/border/shadow recipe so the palette remains distinct in dark mode.
className={cn(
'fixed top-[20%] left-[50%] z-50 w-[660px] max-w-[90vw] translate-x-[-50%] rounded-lg border border-black/14 bg-dialog-surface text-foreground shadow-[0_20px_60px_rgba(0,0,0,0.28),inset_0_1px_0_rgba(255,255,255,0.08)] outline-none dark:border-white/14 dark:shadow-[0_24px_72px_rgba(0,0,0,0.55),inset_0_1px_0_rgba(255,255,255,0.06)] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
surface === 'palette' &&
'rounded-xl border-border/70 shadow-[0_26px_84px_rgba(0,0,0,0.32)]',
contentClassName
)}
onEscapeKeyDown={(event) => handleImeOverlayEscape(event)}
onOpenAutoFocus={onOpenAutoFocus}
onCloseAutoFocus={onCloseAutoFocus}
>
<DialogPrimitive.Title className="sr-only">{title}</DialogPrimitive.Title>
<DialogPrimitive.Description className="sr-only">
{description}
</DialogPrimitive.Description>
<Command
shouldFilter={shouldFilter}
<OverlayFocusScope value={scope}>
<DialogPrimitive.Content
data-slot="command-dialog-content"
ref={focusScopeRef}
// Match DialogContent’s surface/border/shadow recipe so the palette remains distinct in dark mode.
className={cn(
'[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3',
commandClassName
'fixed top-[20%] left-[50%] z-50 w-[660px] max-w-[90vw] translate-x-[-50%] rounded-lg border border-black/14 bg-dialog-surface text-foreground shadow-[0_20px_60px_rgba(0,0,0,0.28),inset_0_1px_0_rgba(255,255,255,0.08)] outline-none dark:border-white/14 dark:shadow-[0_24px_72px_rgba(0,0,0,0.55),inset_0_1px_0_rgba(255,255,255,0.06)] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
surface === 'palette' &&
'rounded-xl border-border/70 shadow-[0_26px_84px_rgba(0,0,0,0.32)]',
contentClassName
)}
{...commandRootProps}
onEscapeKeyDown={(event) => handleImeOverlayEscape(event)}
onOpenAutoFocus={onOpenAutoFocus}
onCloseAutoFocus={(event) => handleOverlayCloseAutoFocus(event, onCloseAutoFocus)}
>
{children}
</Command>
</DialogPrimitive.Content>
<DialogPrimitive.Title className="sr-only">{title}</DialogPrimitive.Title>
<DialogPrimitive.Description className="sr-only">
{description}
</DialogPrimitive.Description>
<Command
shouldFilter={shouldFilter}
className={cn(
'[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3',
commandClassName
)}
{...commandRootProps}
>
{children}
</Command>
</DialogPrimitive.Content>
</OverlayFocusScope>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
)
+39 -28
View File
@@ -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<typeof ContextMenuPrimitive.SubContent> &
VariantProps<typeof contentAnimationVariants>) {
const pointerHighlight = useMenuPointerHighlight(props)
const { ref: focusScopeRef, scope } = useOverlayFocusScope(pointerHighlight.ref)
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
// Level the first submenu row with its trigger (border + padding).
alignOffset={alignOffset}
className={cn(
'z-[70] min-w-[11rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-[11px] border border-black/10 bg-[rgba(255,255,255,0.78)] p-[4px] text-black dark:text-white shadow-[0_6px_16px_rgba(0,0,0,0.14),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-xl backdrop-saturate-150 supports-[not_(backdrop-filter:blur(1px))]:bg-popover dark:supports-[not_(backdrop-filter:blur(1px))]:bg-popover reduce-effects:bg-popover dark:reduce-effects:bg-popover dark:border-black/60 dark:bg-popover/75 dark:shadow-[0_16px_34px_rgba(0,0,0,0.40),inset_0_0_0_1px_rgba(255,255,255,0.12)]',
contentAnimationVariants({ animation }),
className
)}
// Portal submenus outside the scrollable parent to prevent overflow clipping.
style={{ ...style, WebkitAppRegion: 'no-drag' } as React.CSSProperties}
{...props}
{...pointerHighlight}
onEscapeKeyDown={(event) => handleImeOverlayEscape(event, props.onEscapeKeyDown)}
/>
<OverlayFocusScope value={scope}>
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
// Level the first submenu row with its trigger (border + padding).
alignOffset={alignOffset}
className={cn(
'z-[70] min-w-[11rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-[11px] border border-black/10 bg-[rgba(255,255,255,0.78)] p-[4px] text-black dark:text-white shadow-[0_6px_16px_rgba(0,0,0,0.14),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-xl backdrop-saturate-150 supports-[not_(backdrop-filter:blur(1px))]:bg-popover dark:supports-[not_(backdrop-filter:blur(1px))]:bg-popover reduce-effects:bg-popover dark:reduce-effects:bg-popover dark:border-black/60 dark:bg-popover/75 dark:shadow-[0_16px_34px_rgba(0,0,0,0.40),inset_0_0_0_1px_rgba(255,255,255,0.12)]',
contentAnimationVariants({ animation }),
className
)}
// Portal submenus outside the scrollable parent to prevent overflow clipping.
style={{ ...style, WebkitAppRegion: 'no-drag' } as React.CSSProperties}
{...props}
{...pointerHighlight}
ref={focusScopeRef}
onEscapeKeyDown={(event) => handleImeOverlayEscape(event, props.onEscapeKeyDown)}
/>
</OverlayFocusScope>
</ContextMenuPrimitive.Portal>
)
}
@@ -101,21 +107,26 @@ function ContextMenuContent({
}: React.ComponentProps<typeof ContextMenuPrimitive.Content> &
VariantProps<typeof contentAnimationVariants>) {
const pointerHighlight = useMenuPointerHighlight(props)
const { ref: focusScopeRef, scope } = useOverlayFocusScope(pointerHighlight.ref)
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
'z-[70] max-h-(--radix-context-menu-content-available-height) min-w-[11rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto scrollbar-sleek rounded-[11px] border border-black/10 bg-[rgba(255,255,255,0.78)] p-[4px] text-black dark:text-white shadow-[0_6px_16px_rgba(0,0,0,0.14),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-xl backdrop-saturate-150 supports-[not_(backdrop-filter:blur(1px))]:bg-popover dark:supports-[not_(backdrop-filter:blur(1px))]:bg-popover reduce-effects:bg-popover dark:reduce-effects:bg-popover dark:border-black/60 dark:bg-popover/75 dark:shadow-[0_16px_34px_rgba(0,0,0,0.40),inset_0_0_0_1px_rgba(255,255,255,0.12)]',
contentAnimationVariants({ animation }),
className
)}
// Disable native dragging so overlapping titlebar regions cannot intercept menu clicks.
style={{ ...style, WebkitAppRegion: 'no-drag' } as React.CSSProperties}
{...props}
{...pointerHighlight}
onEscapeKeyDown={(event) => handleImeOverlayEscape(event, props.onEscapeKeyDown)}
/>
<OverlayFocusScope value={scope}>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
'z-[70] max-h-(--radix-context-menu-content-available-height) min-w-[11rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto scrollbar-sleek rounded-[11px] border border-black/10 bg-[rgba(255,255,255,0.78)] p-[4px] text-black dark:text-white shadow-[0_6px_16px_rgba(0,0,0,0.14),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-xl backdrop-saturate-150 supports-[not_(backdrop-filter:blur(1px))]:bg-popover dark:supports-[not_(backdrop-filter:blur(1px))]:bg-popover reduce-effects:bg-popover dark:reduce-effects:bg-popover dark:border-black/60 dark:bg-popover/75 dark:shadow-[0_16px_34px_rgba(0,0,0,0.40),inset_0_0_0_1px_rgba(255,255,255,0.12)]',
contentAnimationVariants({ animation }),
className
)}
// Disable native dragging so overlapping titlebar regions cannot intercept menu clicks.
style={{ ...style, WebkitAppRegion: 'no-drag' } as React.CSSProperties}
{...props}
onCloseAutoFocus={(event) => handleOverlayCloseAutoFocus(event, props.onCloseAutoFocus)}
{...pointerHighlight}
ref={focusScopeRef}
onEscapeKeyDown={(event) => handleImeOverlayEscape(event, props.onEscapeKeyDown)}
/>
</OverlayFocusScope>
</ContextMenuPrimitive.Portal>
)
}
+9 -2
View File
@@ -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 (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay className={overlayClassName} />
@@ -120,10 +123,14 @@ function DialogContent({
className
)}
{...props}
ref={focusScopeRef}
onCloseAutoFocus={(event) => handleOverlayCloseAutoFocus(event, props.onCloseAutoFocus)}
onEscapeKeyDown={(event) => handleImeOverlayEscape(event, props.onEscapeKeyDown)}
>
{children}
{showCloseButton && <DialogContentCloseButton variant={closeButtonVariant} />}
<OverlayFocusScope value={scope}>
{children}
{showCloseButton && <DialogContentCloseButton variant={closeButtonVariant} />}
</OverlayFocusScope>
</DialogPrimitive.Content>
</DialogPortal>
)
@@ -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<typeof DropdownMenuPrimitive.Content>) {
const pointerHighlight = useMenuPointerHighlight(props)
const { ref: focusScopeRef, scope } = useOverlayFocusScope(pointerHighlight.ref)
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
'z-[70] max-h-(--radix-dropdown-menu-content-available-height) min-w-[11rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto scrollbar-sleek rounded-[11px] border border-black/10 bg-[rgba(255,255,255,0.78)] p-[4px] text-black dark:text-white shadow-[0_6px_16px_rgba(0,0,0,0.14),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-xl backdrop-saturate-150 supports-[not_(backdrop-filter:blur(1px))]:bg-popover dark:supports-[not_(backdrop-filter:blur(1px))]:bg-popover reduce-effects:bg-popover dark:reduce-effects:bg-popover dark:border-black/60 dark:bg-popover/75 dark:shadow-[0_16px_34px_rgba(0,0,0,0.40),inset_0_0_0_1px_rgba(255,255,255,0.12)] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in motion-reduce:data-[state=open]:animate-none motion-reduce:data-[state=closed]:animate-none data-[state=open]:fade-in-0 data-[state=open]:duration-150 data-[state=open]:zoom-in-[0.98]',
className
)}
// Disable native dragging: titlebar drag regions intercept menu clicks regardless of z-index.
style={{ ...style, WebkitAppRegion: 'no-drag' } as React.CSSProperties}
{...props}
{...pointerHighlight}
onEscapeKeyDown={(event) => handleImeOverlayEscape(event, props.onEscapeKeyDown)}
/>
<OverlayFocusScope value={scope}>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
'z-[70] max-h-(--radix-dropdown-menu-content-available-height) min-w-[11rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto scrollbar-sleek rounded-[11px] border border-black/10 bg-[rgba(255,255,255,0.78)] p-[4px] text-black dark:text-white shadow-[0_6px_16px_rgba(0,0,0,0.14),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-xl backdrop-saturate-150 supports-[not_(backdrop-filter:blur(1px))]:bg-popover dark:supports-[not_(backdrop-filter:blur(1px))]:bg-popover reduce-effects:bg-popover dark:reduce-effects:bg-popover dark:border-black/60 dark:bg-popover/75 dark:shadow-[0_16px_34px_rgba(0,0,0,0.40),inset_0_0_0_1px_rgba(255,255,255,0.12)] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in motion-reduce:data-[state=open]:animate-none motion-reduce:data-[state=closed]:animate-none data-[state=open]:fade-in-0 data-[state=open]:duration-150 data-[state=open]:zoom-in-[0.98]',
className
)}
// Disable native dragging: titlebar drag regions intercept menu clicks regardless of z-index.
style={{ ...style, WebkitAppRegion: 'no-drag' } as React.CSSProperties}
{...props}
onCloseAutoFocus={(event) => handleOverlayCloseAutoFocus(event, props.onCloseAutoFocus)}
{...pointerHighlight}
ref={focusScopeRef}
onEscapeKeyDown={(event) => handleImeOverlayEscape(event, props.onEscapeKeyDown)}
/>
</OverlayFocusScope>
</DropdownMenuPrimitive.Portal>
)
}
@@ -220,24 +227,28 @@ function DropdownMenuSubContent({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent> & { scrollable?: boolean }) {
const pointerHighlight = useMenuPointerHighlight(props)
const { ref: focusScopeRef, scope } = useOverlayFocusScope(pointerHighlight.ref)
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
// Level the first submenu row with its trigger (border + padding).
alignOffset={alignOffset}
className={cn(
'z-[70] min-w-[11rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-[11px] border border-black/10 bg-[rgba(255,255,255,0.78)] p-[4px] text-black dark:text-white shadow-[0_6px_16px_rgba(0,0,0,0.14),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-xl backdrop-saturate-150 supports-[not_(backdrop-filter:blur(1px))]:bg-popover dark:supports-[not_(backdrop-filter:blur(1px))]:bg-popover reduce-effects:bg-popover dark:reduce-effects:bg-popover dark:border-black/60 dark:bg-popover/75 dark:shadow-[0_16px_34px_rgba(0,0,0,0.40),inset_0_0_0_1px_rgba(255,255,255,0.12)] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in motion-reduce:data-[state=open]:animate-none motion-reduce:data-[state=closed]:animate-none data-[state=open]:fade-in-0 data-[state=open]:duration-150 data-[state=open]:zoom-in-[0.98]',
scrollable &&
'max-h-(--radix-dropdown-menu-content-available-height) overflow-x-hidden overflow-y-auto scrollbar-sleek',
className
)}
// Disable native dragging where submenu content overlaps the titlebar.
style={{ ...style, WebkitAppRegion: 'no-drag' } as React.CSSProperties}
{...props}
{...pointerHighlight}
onEscapeKeyDown={(event) => handleImeOverlayEscape(event, props.onEscapeKeyDown)}
/>
<OverlayFocusScope value={scope}>
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
// Level the first submenu row with its trigger (border + padding).
alignOffset={alignOffset}
className={cn(
'z-[70] min-w-[11rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-[11px] border border-black/10 bg-[rgba(255,255,255,0.78)] p-[4px] text-black dark:text-white shadow-[0_6px_16px_rgba(0,0,0,0.14),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-xl backdrop-saturate-150 supports-[not_(backdrop-filter:blur(1px))]:bg-popover dark:supports-[not_(backdrop-filter:blur(1px))]:bg-popover reduce-effects:bg-popover dark:reduce-effects:bg-popover dark:border-black/60 dark:bg-popover/75 dark:shadow-[0_16px_34px_rgba(0,0,0,0.40),inset_0_0_0_1px_rgba(255,255,255,0.12)] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in motion-reduce:data-[state=open]:animate-none motion-reduce:data-[state=closed]:animate-none data-[state=open]:fade-in-0 data-[state=open]:duration-150 data-[state=open]:zoom-in-[0.98]',
scrollable &&
'max-h-(--radix-dropdown-menu-content-available-height) overflow-x-hidden overflow-y-auto scrollbar-sleek',
className
)}
// Disable native dragging where submenu content overlaps the titlebar.
style={{ ...style, WebkitAppRegion: 'no-drag' } as React.CSSProperties}
{...props}
{...pointerHighlight}
ref={focusScopeRef}
onEscapeKeyDown={(event) => handleImeOverlayEscape(event, props.onEscapeKeyDown)}
/>
</OverlayFocusScope>
</DropdownMenuPrimitive.Portal>
)
}
+30 -23
View File
@@ -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 (
<PopoverPrimitive.Portal container={portalContainer ?? undefined}>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
// Match dropdown surface/border/shadow tokens so popovers remain distinct from the dark canvas.
className={cn(
'z-[60] overflow-hidden rounded-md border border-black/14 bg-floating-surface text-popover-foreground shadow-[0_16px_36px_rgba(0,0,0,0.24),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-2xl outline-none dark:border-white/14 dark:shadow-[0_20px_44px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.04)] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in motion-reduce:data-[state=open]:animate-none motion-reduce:data-[state=closed]:animate-none data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
wheelScroll && 'popover-wheel-scroll',
className
)}
ref={setContentRef}
// Disable native dragging: titlebar drag regions intercept overlapping popover clicks regardless of z-index.
style={
{
...style,
WebkitAppRegion: 'no-drag'
} as React.CSSProperties
}
onWheel={handleConsumerWheel}
onWheelCapture={handleConsumerWheelCapture}
{...props}
onEscapeKeyDown={(event) => handleImeOverlayEscape(event, props.onEscapeKeyDown)}
/>
<OverlayFocusScope value={scope}>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
// Match dropdown surface/border/shadow tokens so popovers remain distinct from the dark canvas.
className={cn(
'z-[60] overflow-hidden rounded-md border border-black/14 bg-floating-surface text-popover-foreground shadow-[0_16px_36px_rgba(0,0,0,0.24),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-2xl outline-none dark:border-white/14 dark:shadow-[0_20px_44px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.04)] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in motion-reduce:data-[state=open]:animate-none motion-reduce:data-[state=closed]:animate-none data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
wheelScroll && 'popover-wheel-scroll',
className
)}
ref={focusScopeRef}
// Disable native dragging: titlebar drag regions intercept overlapping popover clicks regardless of z-index.
style={
{
...style,
WebkitAppRegion: 'no-drag'
} as React.CSSProperties
}
onWheel={handleConsumerWheel}
onWheelCapture={handleConsumerWheelCapture}
{...props}
onCloseAutoFocus={(event) => handleOverlayCloseAutoFocus(event, props.onCloseAutoFocus)}
onEscapeKeyDown={(event) => handleImeOverlayEscape(event, props.onEscapeKeyDown)}
/>
</OverlayFocusScope>
</PopoverPrimitive.Portal>
)
}
+28 -21
View File
@@ -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<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
@@ -78,33 +80,38 @@ function SelectContent({
}: React.ComponentProps<typeof SelectPrimitive.Content> & {
portalContainer?: HTMLElement | null
}) {
const { ref: focusScopeRef, scope } = useOverlayFocusScope(props.ref)
return (
<SelectPrimitive.Portal container={portalContainer ?? undefined}>
<SelectPrimitive.Content
data-slot="select-content"
// Match dropdown surface/border/shadow tokens so options remain distinct from the dark canvas.
className={cn(
'relative z-[70] max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto scrollbar-sleek rounded-md border border-black/14 bg-floating-surface text-popover-foreground shadow-[0_16px_36px_rgba(0,0,0,0.24),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-2xl dark:border-white/14 dark:shadow-[0_20px_44px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.04)] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
<OverlayFocusScope value={scope}>
<SelectPrimitive.Content
data-slot="select-content"
// Match dropdown surface/border/shadow tokens so options remain distinct from the dark canvas.
className={cn(
'p-1',
'relative z-[70] max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto scrollbar-sleek rounded-md border border-black/14 bg-floating-surface text-popover-foreground shadow-[0_16px_36px_rgba(0,0,0,0.24),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-2xl dark:border-white/14 dark:shadow-[0_20px_44px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.04)] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1'
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
align={align}
{...props}
ref={focusScopeRef}
onCloseAutoFocus={(event) => handleOverlayCloseAutoFocus(event, props.onCloseAutoFocus)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</OverlayFocusScope>
</SelectPrimitive.Portal>
)
}
+28 -21
View File
@@ -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 (
<SheetPortal>
<SheetOverlay className={overlayClassName} style={overlayStyle} />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(sheetContentVariants({ side }), className)}
// Disable native dragging where portaled sheet headers overlap the titlebar.
style={{ ...style, WebkitAppRegion: 'no-drag' } as React.CSSProperties}
{...props}
onEscapeKeyDown={(event) => handleImeOverlayEscape(event, props.onEscapeKeyDown)}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">
{translate('auto.components.ui.sheet.1189e9fe0a', 'Close')}
</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
<OverlayFocusScope value={scope}>
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(sheetContentVariants({ side }), className)}
// Disable native dragging where portaled sheet headers overlap the titlebar.
style={{ ...style, WebkitAppRegion: 'no-drag' } as React.CSSProperties}
{...props}
ref={focusScopeRef}
onCloseAutoFocus={(event) => handleOverlayCloseAutoFocus(event, props.onCloseAutoFocus)}
onEscapeKeyDown={(event) => handleImeOverlayEscape(event, props.onEscapeKeyDown)}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">
{translate('auto.components.ui.sheet.1189e9fe0a', 'Close')}
</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</OverlayFocusScope>
</SheetPortal>
)
}
@@ -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 ? <div ref={ref} role="menu" data-state={open ? 'open' : 'closed'} /> : null
}
it.each([false, true])(
'restores focus ownership when a retained overlay reopens (initially mounted: %s)',
async (initiallyMounted) => {
const view = render(<RetainedOverlay mounted={initiallyMounted} open />)
if (!initiallyMounted) {
view.rerender(<RetainedOverlay mounted open />)
}
const owner = screen.getByRole('menu')
expect(getOverlayFocusOwners()).toContain(owner)
view.rerender(<RetainedOverlay mounted open={false} />)
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(<RetainedOverlay mounted open />)
expect(screen.getByRole('menu')).toBe(owner)
await waitFor(() => {
expect(getOverlayFocusOwners()).toContain(owner)
expect(getOverlayFocusSources()).toContain(owner)
expect(isOverlayFocusSourceDismissed(owner)).toBe(false)
})
}
)
@@ -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<RefObject<HTMLDivElement | null> | null>(null)
export function useOverlayFocusScope(forwardedRef?: Ref<HTMLDivElement>): {
ref: RefCallback<HTMLDivElement>
scope: RefObject<HTMLDivElement | null>
} {
const parent = useContext(OverlayFocusScope)
const scope = useRef<HTMLDivElement>(null)
const [owner, setOwner] = useState<HTMLDivElement | null>(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 }
}
@@ -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, () => HTMLElement | null>()
const dismissedOwners = new WeakSet<HTMLElement>()
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())
})
}
@@ -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
}
+39 -29
View File
@@ -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<HTMLElement>(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),
+50
View File
@@ -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<void>((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