mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 16:02:24 +00:00
Fix select all in native chat composer (#17294)
Co-authored-by: Merge Sim <sim@local>
This commit is contained in:
co-authored by
Merge Sim
parent
3ab9766e38
commit
c539b38856
@@ -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,
|
||||
|
||||
+147
@@ -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')
|
||||
})
|
||||
})
|
||||
+38
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user