diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index d3cc335357cb..be54ac6ac003 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -12,11 +12,17 @@ import { clearNativeChatModelEnrichmentForTests } from './native-chat-session-op const mocks = vi.hoisted(() => ({ cancelPendingSends: vi.fn(), + composerIsComposing: null as (() => boolean) | null, + attachmentIsComposing: null as (() => boolean) | null, + flushPendingAttachments: vi.fn(), fieldProps: null as { onSend?: () => void onStop?: () => void - onCompositionStart?: () => void - onCompositionEnd?: (event: { currentTarget: HTMLTextAreaElement }) => void + imeEnterGesture?: { + isComposing: () => boolean + setComposing: (active: boolean) => void + } + onImeSettled?: (element: HTMLTextAreaElement) => void sessionOptionsSurface?: SessionOptionsSurface | null sessionOptionsSnapshot?: SessionOptionDescriptor[] attachDisabled?: boolean @@ -103,19 +109,23 @@ vi.mock('./native-chat-draft-cache', () => ({ vi.mock('./NativeChatComposerField', () => ({ NativeChatComposerField: (props: { onSend?: () => void; onStop?: () => void }) => { mocks.fieldProps = props - return null + return
} })) vi.mock('./use-native-chat-skills', () => ({ useNativeChatSkills: () => ({ status: 'ready', skills: [], error: null, retry: () => {} }) })) vi.mock('./use-native-chat-composer-attachments', () => ({ - useNativeChatComposerAttachments: () => ({ - imageAttachments: mocks.imageAttachments, - attachResolvedPaths: vi.fn(), - clearImageAttachments: vi.fn(), - removeImageAttachment: vi.fn() - }) + useNativeChatComposerAttachments: (args: { isComposing: () => boolean }) => { + mocks.attachmentIsComposing = args.isComposing + return { + imageAttachments: mocks.imageAttachments, + attachResolvedPaths: vi.fn(), + clearImageAttachments: vi.fn(), + flushPendingAttachments: mocks.flushPendingAttachments, + removeImageAttachment: vi.fn() + } + } })) vi.mock('./use-native-chat-composer-paste', () => ({ useNativeChatComposerPaste: () => ({ @@ -133,7 +143,10 @@ vi.mock('../dictation/dictation-control-events', () => ({ dispatchDictationControl: vi.fn() })) vi.mock('./use-native-chat-composer-keydown', () => ({ - useNativeChatComposerKeyDown: () => vi.fn() + useNativeChatComposerKeyDown: (args: { isComposing: () => boolean }) => { + mocks.composerIsComposing = args.isComposing + return vi.fn() + } })) vi.mock('./use-native-chat-send-lifecycle', () => ({ useNativeChatSendLifecycle: () => ({ @@ -155,6 +168,8 @@ describe('NativeChatComposer', () => { mocks.imageAttachments = [] mocks.draftScopeKeys.length = 0 mocks.confirmationObserver = null + mocks.composerIsComposing = null + mocks.attachmentIsComposing = null mocks.createClaudeModelSwitchConfirmationObserver.mockImplementation(() => { const observer = { ready: Promise.resolve(), @@ -402,6 +417,7 @@ describe('NativeChatComposer', () => { agent="codex" /> ) + const field = view.getByTestId('native-chat-composer-field') view.rerender( { ) expect(new Set(mocks.draftScopeKeys)).toEqual(new Set(['tab-1:leaf-1'])) + expect(view.getByTestId('native-chat-composer-field')).toBe(field) + }) + + it('remounts the composer field when draft ownership moves to another pane', () => { + const view = render( + + ) + const previousField = view.getByTestId('native-chat-composer-field') + act(() => mocks.fieldProps?.imeEnterGesture?.setComposing(true)) + expect(mocks.composerIsComposing?.()).toBe(true) + + view.rerender( + + ) + + expect(view.getByTestId('native-chat-composer-field')).not.toBe(previousField) + expect(mocks.composerIsComposing?.()).toBe(false) + }) + + it('shares one stable composition owner between keyboard and attachment paths', () => { + render( + + ) + + expect(mocks.attachmentIsComposing).toBe(mocks.composerIsComposing) + act(() => mocks.fieldProps?.imeEnterGesture?.setComposing(true)) + expect(mocks.attachmentIsComposing?.()).toBe(true) }) it('adopts an IME deletion delivered only by compositionend', () => { @@ -435,14 +493,19 @@ describe('NativeChatComposer', () => { const textarea = document.createElement('textarea') textarea.value = '' mocks.setDraft.mockClear() + mocks.flushPendingAttachments.mockClear() act(() => { - mocks.fieldProps?.onCompositionStart?.() - mocks.fieldProps?.onCompositionEnd?.({ currentTarget: textarea }) + mocks.fieldProps?.imeEnterGesture?.setComposing(false) + mocks.fieldProps?.onImeSettled?.(textarea) }) expect(mocks.setDraft).toHaveBeenCalledOnce() expect(mocks.setDraft).toHaveBeenCalledWith('') + expect(mocks.flushPendingAttachments).toHaveBeenCalledOnce() + expect(mocks.setDraft.mock.invocationCallOrder[0]).toBeLessThan( + mocks.flushPendingAttachments.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY + ) }) it('does not duplicate a composition value already adopted by onChange', () => { @@ -457,10 +520,12 @@ describe('NativeChatComposer', () => { const textarea = document.createElement('textarea') textarea.value = 'hello' mocks.setDraft.mockClear() + mocks.flushPendingAttachments.mockClear() - act(() => mocks.fieldProps?.onCompositionEnd?.({ currentTarget: textarea })) + act(() => mocks.fieldProps?.onImeSettled?.(textarea)) expect(mocks.setDraft).not.toHaveBeenCalled() + expect(mocks.flushPendingAttachments).toHaveBeenCalledOnce() }) it('renders the Claude model picker while host discovery is still pending', () => { diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 8e47eb1a8f47..b11da6676f9d 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -14,7 +14,6 @@ import { pushHistory, type HistoryState } from './native-chat-composer-state' -import { readNativeChatDraftCache } from './native-chat-draft-cache' import { useNativeChatDraft } from './use-native-chat-draft' import { useNativeChatLaunchDraftAdoption } from './use-native-chat-launch-draft-adoption' import { NativeChatComposerField } from './NativeChatComposerField' @@ -37,6 +36,7 @@ import type { } from './native-chat-composer-types' 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' export type { NativeChatComposerHandle, @@ -57,8 +57,8 @@ const ESC = '\x1b' * Slash-command and `@file` autocomplete are agent-aware; image paste persists a * temp file and injects the agent-appropriate path (or reports unsupported). */ -export const NativeChatComposer = forwardRef( - function NativeChatComposer( +const NativeChatComposerPane = forwardRef( + function NativeChatComposerPane( { terminalTabId, paneKey, @@ -81,8 +81,7 @@ export const NativeChatComposer = forwardRef(null) const [dictationPressed, setDictationPressed] = useState(false) const textareaRef = useRef(null) - const isComposingRef = useRef(false) + const imeEnterGesture = useImeEnterGestureOwnership() const { cancelPendingSends, trackPendingSend } = useNativeChatSendLifecycle( terminalTabId, targetPtyId, @@ -107,7 +106,6 @@ export const NativeChatComposer = forwardRef store.dictationState) const voiceSettings = useAppStore((store) => store.settings?.voice) - const isDictationHoldMode = voiceSettings?.dictationMode === 'hold' const dictationDisabled = voiceSettings?.enabled !== true || !voiceSettings.sttModel const isDictating = dictationPressed || @@ -115,15 +113,6 @@ export const NativeChatComposer = forwardRef structuredTransport @@ -134,7 +123,7 @@ export const NativeChatComposer = forwardRef isComposingRef.current, + isComposing: imeEnterGesture.isComposing, completePickerItem: completeItem, dispatchPickerCommand, dismissPicker: dismiss, @@ -394,7 +386,8 @@ export const NativeChatComposer = forwardRef { syncCaret(element) @@ -402,14 +395,11 @@ export const NativeChatComposer = forwardRef { - isComposingRef.current = true - }} - onCompositionEnd={(event) => { - isComposingRef.current = false - if (event.currentTarget.value !== draft) { - handleDraftChange(event.currentTarget.value, event.currentTarget) + onImeSettled={(element) => { + if (element.value !== draft) { + handleDraftChange(element.value, element) } + attachments.flushPendingAttachments() }} onPaste={handlePaste} pickerListboxId={picker.listboxId} @@ -440,3 +430,9 @@ export const NativeChatComposer = forwardRef( + function NativeChatComposer(props, ref): React.JSX.Element { + return + } +) diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index 7206b0288bd2..28e973ff9fdb 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -1,11 +1,8 @@ -import type { - ClipboardEventHandler, - CompositionEventHandler, - KeyboardEventHandler, - RefObject -} from 'react' +import type { ClipboardEventHandler, KeyboardEventHandler, RefObject } from 'react' +import { useLayoutEffect } from 'react' import { Image as ImageIcon, ImageOff, X } from 'lucide-react' import { translate } from '@/i18n/i18n' +import type { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event' import { cn } from '@/lib/utils' import { NATIVE_FILE_DROP_TARGET } from '../../../../shared/native-file-drop' import { basename } from '@/lib/path' @@ -36,11 +33,11 @@ export type NativeChatComposerFieldProps = { dictationDisabled: boolean isDictating: boolean isDictationHoldMode: boolean + imeEnterGesture: ReturnType onDraftChange: (value: string, element: HTMLTextAreaElement) => void onTextareaSelect: (element: HTMLTextAreaElement) => void onKeyDown: KeyboardEventHandler - onCompositionStart: CompositionEventHandler - onCompositionEnd: CompositionEventHandler + onImeSettled: (element: HTMLTextAreaElement) => void onPaste: ClipboardEventHandler pickerListboxId: string onChoosePickerItem: (item: NativeChatPickerItem) => void @@ -79,11 +76,11 @@ export function NativeChatComposerField({ dictationDisabled, isDictating, isDictationHoldMode, + imeEnterGesture, onDraftChange, onTextareaSelect, onKeyDown, - onCompositionStart, - onCompositionEnd, + onImeSettled, onPaste, pickerListboxId, onChoosePickerItem, @@ -100,6 +97,18 @@ export function NativeChatComposerField({ sessionOptionsSnapshot, sessionOptionsPickerRequest }: NativeChatComposerFieldProps): React.JSX.Element { + // Browser owns the provisional value; React synchronizes drafts only between IME sessions. + useLayoutEffect(() => { + const textarea = textareaRef.current + if (!textarea || imeEnterGesture.isComposing()) { + return + } + if (textarea.value === draft) { + return + } + textarea.value = draft + }, [draft, imeEnterGesture, textareaRef]) + return (
{/* Extra bottom padding keeps the input box off the window rim. */} @@ -167,13 +176,33 @@ export function NativeChatComposerField({ ) : null}