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}