Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 78 additions & 13 deletions src/renderer/src/components/native-chat/NativeChatComposer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <div data-testid="native-chat-composer-field" />
}
}))
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: () => ({
Expand All @@ -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: () => ({
Expand All @@ -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(),
Expand Down Expand Up @@ -402,6 +417,7 @@ describe('NativeChatComposer', () => {
agent="codex"
/>
)
const field = view.getByTestId('native-chat-composer-field')

view.rerender(
<NativeChatComposer
Expand All @@ -421,6 +437,48 @@ describe('NativeChatComposer', () => {
)

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(
<NativeChatComposer
terminalTabId="tab-1"
paneKey="tab-1:leaf-1"
targetPtyId="pty-1"
agent="codex"
/>
)
const previousField = view.getByTestId('native-chat-composer-field')
act(() => mocks.fieldProps?.imeEnterGesture?.setComposing(true))
expect(mocks.composerIsComposing?.()).toBe(true)

view.rerender(
<NativeChatComposer
terminalTabId="tab-1"
paneKey="tab-1:leaf-2"
targetPtyId="pty-2"
agent="codex"
/>
)

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(
<NativeChatComposer
terminalTabId="tab-1"
paneKey="tab-1:leaf-1"
targetPtyId="pty-1"
agent="codex"
/>
)

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', () => {
Expand All @@ -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', () => {
Expand All @@ -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', () => {
Expand Down
68 changes: 32 additions & 36 deletions src/renderer/src/components/native-chat/NativeChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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,
Expand All @@ -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<NativeChatComposerHandle, NativeChatComposerProps>(
function NativeChatComposer(
const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatComposerProps>(
function NativeChatComposerPane(
{
terminalTabId,
paneKey,
Expand All @@ -81,8 +81,7 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
// images survive both TUI/GUI toggles and PTY replacement on reconnect.
// Why: local, SSH, and runtime reconnects can replace or temporarily clear
// the PTY id. Pane identity is the stable ownership key for unsent input.
const draftScopeKey = paneKey
const { draft, setDraft } = useNativeChatDraft(draftScopeKey)
const { draft, setDraft } = useNativeChatDraft(paneKey)
const [caret, setCaret] = useState(draft.length)
useNativeChatLaunchDraftAdoption({
terminalTabId,
Expand All @@ -99,31 +98,21 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
const [notice, setNotice] = useState<string | null>(null)
const [dictationPressed, setDictationPressed] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const isComposingRef = useRef(false)
const imeEnterGesture = useImeEnterGestureOwnership()
const { cancelPendingSends, trackPendingSend } = useNativeChatSendLifecycle(
terminalTabId,
targetPtyId,
onOptimisticSendCanceled
)
const dictationState = useAppStore((store) => store.dictationState)
const voiceSettings = useAppStore((store) => store.settings?.voice)
const isDictationHoldMode = voiceSettings?.dictationMode === 'hold'
const dictationDisabled = voiceSettings?.enabled !== true || !voiceSettings.sttModel
const isDictating =
dictationPressed ||
dictationState === 'starting' ||
dictationState === 'listening' ||
dictationState === 'stopping'

// Place the caret at the end of the (possibly restored) draft when the
// composer is reused for a different pane. Adjusted during render (matching
// the draft reload) so caret and text stay consistent on the first paint.
const lastDraftScopeKey = useRef(draftScopeKey)
if (lastDraftScopeKey.current !== draftScopeKey) {
lastDraftScopeKey.current = draftScopeKey
setCaret(readNativeChatDraftCache(draftScopeKey).length)
}

const agentCommands = useMemo(
() =>
structuredTransport
Expand All @@ -134,7 +123,7 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
const picker = useNativeChatPickerState({
agent,
terminalTabId,
draftScopeKey,
draftScopeKey: paneKey,
draft,
caret,
agentCommands,
Expand Down Expand Up @@ -169,17 +158,20 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
setCaret(el.selectionStart ?? el.value.length)
}, [])

const attachments = useNativeChatComposerAttachments({
attachmentScopeKey: paneKey,
allowWithoutTarget: Boolean(structuredTransport),
caret,
disabled,
isComposing: imeEnterGesture.isComposing,
resolveTarget,
textareaRef,
setCaret,
setDraft,
setNotice
})
const { imageAttachments, attachResolvedPaths, clearImageAttachments, removeImageAttachment } =
useNativeChatComposerAttachments({
attachmentScopeKey: paneKey,
allowWithoutTarget: Boolean(structuredTransport),
caret,
resolveTarget,
textareaRef,
setCaret,
setDraft,
setNotice
})
attachments
const sendButtonDisabled = isWorking
? !hasPty || !onStop
: disabled || (draft.trim() === '' && imageAttachments.length === 0)
Expand Down Expand Up @@ -355,7 +347,7 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
activeSuggestion,
draft,
history,
isComposing: () => isComposingRef.current,
isComposing: imeEnterGesture.isComposing,
completePickerItem: completeItem,
dispatchPickerCommand,
dismissPicker: dismiss,
Expand Down Expand Up @@ -394,22 +386,20 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
attachDisabled={disabled}
dictationDisabled={dictationDisabled}
isDictating={isDictating}
isDictationHoldMode={isDictationHoldMode}
isDictationHoldMode={voiceSettings?.dictationMode === 'hold'}
imeEnterGesture={imeEnterGesture}
onDraftChange={handleDraftChange}
onTextareaSelect={(element) => {
syncCaret(element)
handleDraftOrCaretChange(element.value, element.selectionStart ?? element.value.length)
setActiveSuggestion(0)
}}
onKeyDown={handleKeyDown}
onCompositionStart={() => {
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}
Expand Down Expand Up @@ -440,3 +430,9 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
)
}
)

export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeChatComposerProps>(
function NativeChatComposer(props, ref): React.JSX.Element {
return <NativeChatComposerPane key={props.paneKey} {...props} ref={ref} />
}
)
Loading
Loading