Fix select all in native chat composer (#17294)

Co-authored-by: Merge Sim <sim@local>
This commit is contained in:
Brennan Benson
2026-08-30 12:20:51 -07:00
committed by GitHub
co-authored by Merge Sim
parent 3ab9766e38
commit c539b38856
4 changed files with 233 additions and 2 deletions
@@ -1,4 +1,4 @@
import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { forwardRef, useCallback, useImperativeHandle, useMemo, useState } from 'react'
import { useAppStore } from '../../store'
import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection'
import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
@@ -37,6 +37,7 @@ import type {
import { dispatchNativeChatStructuredComposerText } from './native-chat-structured-composer-dispatch'
import { useNativeChatPtyComposerSend } from './use-native-chat-pty-composer-send'
import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event'
import { useNativeChatComposerAppMenuSelection } from './use-native-chat-composer-app-menu-selection'
export type {
NativeChatComposerHandle,
@@ -97,8 +98,8 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
const [activeSuggestion, setActiveSuggestion] = useState(0)
const [notice, setNotice] = useState<string | null>(null)
const [dictationPressed, setDictationPressed] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const imeEnterGesture = useImeEnterGestureOwnership()
const { textareaRef } = useNativeChatComposerAppMenuSelection(imeEnterGesture.isComposing)
const { cancelPendingSends, trackPendingSend } = useNativeChatSendLifecycle(
terminalTabId,
targetPtyId,
@@ -0,0 +1,147 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
APP_MENU_SELECTION_ACTION_EVENT,
type AppMenuSelectionAction
} from '@/lib/app-menu-selection-actions'
import { useAppMenuSelectionActions } from '@/hooks/useAppMenuSelectionActions'
import { useNativeChatComposerAppMenuSelection } from './use-native-chat-composer-app-menu-selection'
let appMenuListener: ((action: AppMenuSelectionAction) => void) | null = null
const performNativeSelectionAction = vi.fn()
function AppMenuBoundary(): null {
useAppMenuSelectionActions()
return null
}
function ComposerHarness(): React.JSX.Element {
const { textareaRef, isComposingRef } = useNativeChatComposerAppMenuSelection()
return (
<div>
<textarea
aria-label="Composer"
ref={textareaRef}
defaultValue={'first line\nsecond line'}
onCompositionStart={() => {
isComposingRef.current = true
}}
onCompositionEnd={() => {
isComposingRef.current = false
}}
/>
<button type="button">Outside composer</button>
</div>
)
}
function emitAppMenuAction(action: AppMenuSelectionAction): void {
act(() => {
if (!appMenuListener) {
throw new Error('app menu listener is not registered')
}
appMenuListener(action)
})
}
function renderBoundaryAndComposer() {
const boundary = render(<AppMenuBoundary />)
const composer = render(<ComposerHarness />)
return { boundary, composer }
}
beforeEach(() => {
appMenuListener = null
performNativeSelectionAction.mockReset()
Object.defineProperty(window, 'api', {
configurable: true,
value: {
ui: {
onAppMenuSelectionAction: vi.fn((listener: typeof appMenuListener) => {
appMenuListener = listener
return () => {
appMenuListener = null
}
}),
performNativeSelectionAction
}
}
})
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
describe('native chat app-menu selection ownership', () => {
it('selects the focused multiline composer through the app-menu boundary', () => {
const { composer } = renderBoundaryAndComposer()
const textarea = composer.getByRole('textbox', { name: 'Composer' }) as HTMLTextAreaElement
textarea.focus()
textarea.setSelectionRange(4, 4)
emitAppMenuAction('select-all')
expect(textarea.selectionStart).toBe(0)
expect(textarea.selectionEnd).toBe(textarea.value.length)
expect(performNativeSelectionAction).not.toHaveBeenCalled()
})
it('preserves global fallback when focus is outside the composer', () => {
const { composer } = renderBoundaryAndComposer()
const textarea = composer.getByRole('textbox', { name: 'Composer' }) as HTMLTextAreaElement
textarea.setSelectionRange(4, 4)
composer.getByRole('button', { name: 'Outside composer' }).focus()
emitAppMenuAction('select-all')
expect(textarea.selectionStart).toBe(4)
expect(textarea.selectionEnd).toBe(4)
expect(performNativeSelectionAction).toHaveBeenCalledOnce()
expect(performNativeSelectionAction).toHaveBeenCalledWith('select-all')
})
it('leaves non-select-all actions unclaimed', () => {
const { composer } = renderBoundaryAndComposer()
const textarea = composer.getByRole('textbox', { name: 'Composer' }) as HTMLTextAreaElement
textarea.focus()
emitAppMenuAction('copy')
expect(performNativeSelectionAction).toHaveBeenCalledOnce()
expect(performNativeSelectionAction).toHaveBeenCalledWith('copy')
})
it('claims select-all during active IME composition without selecting or falling back', () => {
const { composer } = renderBoundaryAndComposer()
const textarea = composer.getByRole('textbox', { name: 'Composer' }) as HTMLTextAreaElement
textarea.focus()
textarea.setSelectionRange(4, 4)
fireEvent.compositionStart(textarea)
emitAppMenuAction('select-all')
expect(textarea.selectionStart).toBe(4)
expect(textarea.selectionEnd).toBe(4)
expect(performNativeSelectionAction).not.toHaveBeenCalled()
})
it('removes composer ownership on cleanup', () => {
const addEventListener = vi.spyOn(window, 'addEventListener')
const removeEventListener = vi.spyOn(window, 'removeEventListener')
const { composer } = renderBoundaryAndComposer()
const listener = addEventListener.mock.calls.find(
([eventName]) => eventName === APP_MENU_SELECTION_ACTION_EVENT
)?.[1]
composer.unmount()
expect(listener).toBeDefined()
expect(removeEventListener).toHaveBeenCalledWith(APP_MENU_SELECTION_ACTION_EVENT, listener)
emitAppMenuAction('select-all')
expect(performNativeSelectionAction).toHaveBeenCalledWith('select-all')
})
})
@@ -0,0 +1,38 @@
import { useCallback, useEffect, useRef } from 'react'
import {
APP_MENU_SELECTION_ACTION_EVENT,
type AppMenuSelectionAction
} from '@/lib/app-menu-selection-actions'
export function useNativeChatComposerAppMenuSelection(
isComposingOverride?: () => boolean
) {
const textareaRef = useRef<HTMLTextAreaElement>(null)
const isComposingRef = useRef(false)
const isComposing = useCallback(
() => isComposingOverride?.() ?? isComposingRef.current,
[isComposingOverride]
)
useEffect(() => {
const onSelectionAction = (event: Event): void => {
const textarea = textareaRef.current
if (
(event as CustomEvent<AppMenuSelectionAction>).detail !== 'select-all' ||
!textarea ||
document.activeElement !== textarea
) {
return
}
event.preventDefault()
if (!isComposing()) {
textarea.select()
}
}
window.addEventListener(APP_MENU_SELECTION_ACTION_EVENT, onSelectionAction)
return () => window.removeEventListener(APP_MENU_SELECTION_ACTION_EVENT, onSelectionAction)
}, [isComposing, textareaRef])
return { textareaRef, isComposingRef, isComposing }
}
@@ -0,0 +1,45 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it } from 'vitest'
import { isSelectAllShortcut } from './editable-target'
const originalUserAgent = navigator.userAgent
function setPlatformUserAgent(userAgent: string): void {
Object.defineProperty(navigator, 'userAgent', { configurable: true, value: userAgent })
}
function keyEvent(overrides: Partial<Parameters<typeof isSelectAllShortcut>[0]> = {}) {
return {
altKey: false,
ctrlKey: false,
key: 'a',
metaKey: false,
shiftKey: false,
...overrides
}
}
afterEach(() => setPlatformUserAgent(originalUserAgent))
describe('isSelectAllShortcut', () => {
it.each([
['macOS Cmd+A', 'Macintosh', { metaKey: true }],
['Linux Ctrl+A', 'Linux x86_64', { ctrlKey: true }],
['Windows Ctrl+A', 'Windows NT 10.0', { ctrlKey: true }]
])('recognizes %s', (_label, userAgent, modifiers) => {
setPlatformUserAgent(userAgent)
expect(isSelectAllShortcut(keyEvent(modifiers))).toBe(true)
})
it.each([
['macOS Ctrl+A', 'Macintosh', { ctrlKey: true }],
['Linux Cmd+A', 'Linux x86_64', { metaKey: true }],
['Windows Cmd+A', 'Windows NT 10.0', { metaKey: true }]
])('rejects wrong-modifier %s', (_label, userAgent, modifiers) => {
setPlatformUserAgent(userAgent)
expect(isSelectAllShortcut(keyEvent(modifiers))).toBe(false)
})
})