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
125 changes: 125 additions & 0 deletions src/renderer/src/components/native-chat/NativeChatComposer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// @vitest-environment happy-dom

import { act, cleanup, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
cancelPendingSends: vi.fn(),
fieldProps: null as { onSend?: () => void; onStop?: () => void } | null,
sendHandle: { cancel: vi.fn(), settleAfterMs: 500 },
sendNativeChatMessage: vi.fn(),
trackPendingSend: vi.fn(),
setDraft: vi.fn()
}))

vi.mock('../../store', () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
selector({ dictationState: 'idle', settings: { voice: { enabled: false } } })
}))

vi.mock('@/runtime/runtime-terminal-inspection', () => ({
isRemoteRuntimePtyId: () => false,
sendRuntimePtyInput: vi.fn()
}))
vi.mock('@/lib/agent-paste-draft', () => ({
getSettingsForAgentTabRuntimeOwner: () => ({})
}))
vi.mock('./native-chat-runtime-send', () => ({
sendNativeChatMessage: (...args: unknown[]) => mocks.sendNativeChatMessage(...args),
sendNativeChatMessageWithImageAttachments: vi.fn(),
submitNativeChatPrompt: vi.fn()
}))
vi.mock('./native-chat-agent-commands', () => ({ getAgentSlashCommands: () => [] }))
vi.mock('@/lib/native-chat-telemetry', () => ({ emitNativeChatMessageSent: vi.fn() }))
vi.mock('./use-native-chat-draft', () => ({
useNativeChatDraft: () => ({ draft: 'hello', setDraft: mocks.setDraft })
}))
vi.mock('./native-chat-draft-cache', () => ({ readNativeChatDraftCache: () => '' }))
vi.mock('./NativeChatComposerField', () => ({
NativeChatComposerField: (props: { onSend?: () => void; onStop?: () => void }) => {
mocks.fieldProps = props
return null
}
}))
vi.mock('./use-native-chat-skills', () => ({ useNativeChatSkills: () => [] }))
vi.mock('./use-native-chat-composer-attachments', () => ({
useNativeChatComposerAttachments: () => ({
imageAttachments: [],
attachResolvedPaths: vi.fn(),
clearImageAttachments: vi.fn(),
removeImageAttachment: vi.fn()
})
}))
vi.mock('./use-native-chat-composer-paste', () => ({
useNativeChatComposerPaste: () => ({ handlePaste: vi.fn(), pasteFromClipboard: vi.fn() })
}))
vi.mock('./use-native-chat-external-attachments', () => ({
useNativeChatExternalAttachments: () => ({
attachExternalPaths: vi.fn(),
resolveAttachmentOwner: vi.fn()
})
}))
vi.mock('../dictation/dictation-control-events', () => ({ dispatchDictationControl: vi.fn() }))
vi.mock('./use-native-chat-composer-keydown', () => ({
useNativeChatComposerKeyDown: () => vi.fn()
}))
vi.mock('./use-native-chat-send-lifecycle', () => ({
useNativeChatSendLifecycle: () => ({
cancelPendingSends: mocks.cancelPendingSends,
trackPendingSend: mocks.trackPendingSend
})
}))

import { NativeChatComposer } from './NativeChatComposer'

describe('NativeChatComposer', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.fieldProps = null
mocks.sendNativeChatMessage.mockReturnValue(mocks.sendHandle)
Object.defineProperty(window, 'api', {
configurable: true,
value: { ui: { onFileDrop: () => vi.fn() } }
})
})

afterEach(() => cleanup())

it('cancels delayed composer writes before the Stop button interrupts the agent', () => {
const onStop = vi.fn()
render(
<NativeChatComposer
terminalTabId="tab-1"
targetPtyId="pty-1"
agent="codex"
isWorking
onStop={onStop}
/>
)

act(() => mocks.fieldProps?.onStop?.())

expect(mocks.cancelPendingSends).toHaveBeenCalledOnce()
expect(onStop).toHaveBeenCalledOnce()
expect(mocks.cancelPendingSends.mock.invocationCallOrder[0]).toBeLessThan(
onStop.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY
)
})

it('associates a delayed submit with its optimistic cache entry', () => {
const onOptimisticSend = vi.fn(() => 'pending-1')
render(
<NativeChatComposer
terminalTabId="tab-1"
targetPtyId="pty-1"
agent="codex"
onOptimisticSend={onOptimisticSend}
/>
)

act(() => mocks.fieldProps?.onSend?.())

expect(onOptimisticSend).toHaveBeenCalledWith('hello', [])
expect(mocks.trackPendingSend).toHaveBeenCalledWith(mocks.sendHandle, 'pending-1')
})
})
42 changes: 33 additions & 9 deletions src/renderer/src/components/native-chat/NativeChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
sendNativeChatMessageWithImageAttachments,
submitNativeChatPrompt
} from './native-chat-runtime-send'
import type { NativeChatSendHandle } from './native-chat-runtime-send'
import { getAgentSlashCommands } from './native-chat-agent-commands'
import { emitNativeChatMessageSent } from '@/lib/native-chat-telemetry'
import {
Expand Down Expand Up @@ -44,6 +45,7 @@ import { useNativeChatComposerPaste } from './use-native-chat-composer-paste'
import { useNativeChatExternalAttachments } from './use-native-chat-external-attachments'
import { dispatchDictationControl } from '../dictation/dictation-control-events'
import { useNativeChatComposerKeyDown } from './use-native-chat-composer-keydown'
import { useNativeChatSendLifecycle } from './use-native-chat-send-lifecycle'

// Why: a plain ESC byte is what the agent TUIs read as the interrupt key over a
// PTY (matching how xterm forwards Escape). The richer interrupt-intent
Expand All @@ -70,7 +72,9 @@ export type NativeChatComposerProps = {
onStop?: () => void
/** Optional optimistic-send hook: called with the sent text so the view can
* render a "queued" echo until the real transcript turn lands (mobile parity). */
onOptimisticSend?: (text: string, imagePaths?: string[]) => void
onOptimisticSend?: (text: string, imagePaths?: string[]) => string | undefined
/** Remove an optimistic echo when its delayed submit is canceled. */
onOptimisticSendCanceled?: (pendingId: string) => void
/** Called with a dispatched slash command (e.g. `/clear`) so the view can show
* a small "Ran /clear" system line — slash commands aren't chat turns and
* otherwise leave no visible trace that anything happened. */
Expand Down Expand Up @@ -111,6 +115,7 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
isWorking = false,
onStop,
onOptimisticSend,
onOptimisticSendCanceled,
onSlashCommand
},
ref
Expand All @@ -126,6 +131,11 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
const [dictationPressed, setDictationPressed] = useState(false)
const skills = useNativeChatSkills(agent, terminalTabId)
const textareaRef = useRef<HTMLTextAreaElement>(null)
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'
Expand Down Expand Up @@ -293,21 +303,33 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
// (like text) so the GUI chips and TUI input stay in sync and removing a
// chip needs no TUI un-paste: send images, then text, then Enter atomically.
const isSlashCommand = isSlashCommandDraft(text)
let pendingHandle: NativeChatSendHandle | null = null
if (isSlashCommand) {
sendNativeChatMessage(target.settings, target.ptyId, text)
pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text)
} else if (imagePaths.length > 0) {
sendNativeChatMessageWithImageAttachments(target.settings, target.ptyId, text, imagePaths)
pendingHandle = sendNativeChatMessageWithImageAttachments(
target.settings,
target.ptyId,
text,
imagePaths
)
} else if (text.trim().length > 0) {
sendNativeChatMessage(target.settings, target.ptyId, text)
pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text)
} else {
submitNativeChatPrompt(target.settings, target.ptyId)
}
// Slash commands don't echo a user bubble, but DO surface a small
// "Ran /clear" system line so the command leaves a visible trace.
if (isSlashCommand) {
if (pendingHandle) {
trackPendingSend(pendingHandle)
}
onSlashCommand?.(text.trim())
} else {
onOptimisticSend?.(text, imagePaths)
const pendingId = onOptimisticSend?.(text, imagePaths)
if (pendingHandle) {
trackPendingSend(pendingHandle, pendingId)
}
}
// Why: U10 telemetry — record adoption + local-vs-remote runtime split. The
// agent prop is the loose AgentType; the emitter narrows unknowns to 'other'.
Expand All @@ -329,10 +351,12 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
resolveTarget,
onOptimisticSend,
onSlashCommand,
trackPendingSend,
setDraft
])

const interrupt = useCallback(() => {
cancelPendingSends()
if (isWorking && onStop) {
onStop()
return
Expand All @@ -342,7 +366,7 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
return
}
sendRuntimePtyInput(target.settings, target.ptyId, ESC)
}, [isWorking, onStop, resolveTarget])
}, [cancelPendingSends, isWorking, onStop, resolveTarget])

const chooseSlash = useCallback(
(command: SlashCommandSuggestion) => {
Expand All @@ -362,7 +386,7 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
if (!target || disabled) {
return
}
sendNativeChatMessage(target.settings, target.ptyId, next)
trackPendingSend(sendNativeChatMessage(target.settings, target.ptyId, next))
// Surface the command as a system line (this is the autocomplete-menu
// dispatch path; the typed-Enter path in `send` does the same).
onSlashCommand?.(next.trim())
Expand All @@ -376,7 +400,7 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
setActiveSuggestion(0)
setNotice(null)
},
[agent, disabled, resolveTarget, onSlashCommand, setDraft]
[agent, disabled, resolveTarget, onSlashCommand, setDraft, trackPendingSend]
)

const handleKeyDown = useNativeChatComposerKeyDown({
Expand Down Expand Up @@ -444,7 +468,7 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
onDictationHoldStart={startHoldDictation}
onDictationHoldEnd={stopHoldDictation}
onSend={send}
onStop={onStop}
onStop={interrupt}
/>
)
}
Expand Down
47 changes: 33 additions & 14 deletions src/renderer/src/components/native-chat/NativeChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
appendCommandMarkerCache,
launchPromptAsMessage,
pendingSendsAsMessages,
nextNativeChatPendingSendId,
prunePendingSends,
readCommandMarkerCache,
readPendingSendCache,
Expand Down Expand Up @@ -211,7 +212,6 @@ function NativeChatResolvedView({
const [pending, setPending] = useState<NativeChatPendingSend[]>(() =>
readPendingSendCache(pendingScope)
)
const pendingCounter = useRef(0)
// Slash commands aren't chat turns, so they get a small local "Ran /clear"
// system line instead of a user bubble. Capped + cached per conversation.
const [commandMarkers, setCommandMarkers] = useState<NativeChatCommandMarker[]>(() =>
Expand Down Expand Up @@ -246,14 +246,27 @@ function NativeChatResolvedView({
const onOptimisticSend = useCallback(
(text: string, imagePaths?: string[]) => {
setWorkingInterrupted(false)
pendingCounter.current += 1
const sentAt = Date.now()
const boundary = session.messages.at(-1)
const entry: NativeChatPendingSend = {
id: `${pendingCounter.current}`,
id: nextNativeChatPendingSendId(sentAt),
text,
sentAt: Date.now(),
sentAt,
afterMessageId: boundary?.id ?? null,
afterMessageTimestamp: boundary?.timestamp ?? null,
...(imagePaths ? { imagePaths } : {})
}
setPending(appendPendingSendCache(pendingScope, entry))
return entry.id
},
[pendingScope, session.messages]
)
const onOptimisticSendCanceled = useCallback(
(pendingId: string) => {
// Why: detach/interrupt cancels the delayed Enter, so its optimistic echo
// must not come back from the pane cache as a prompt that was delivered.
const next = readPendingSendCache(pendingScope).filter((entry) => entry.id !== pendingId)
setPending(writePendingSendCache(pendingScope, next))
},
[pendingScope]
)
Expand Down Expand Up @@ -293,15 +306,20 @@ function NativeChatResolvedView({

// The streaming preview bubble (if any) sits after the transcript but before
// the optimistic user echoes — same order mobile uses.
const streamingText = useMemo(
() =>
deriveNativeChatStreamingText({
messages: sessionAfterCommandBoundaries.messages,
previewText: hookPreview,
working: hookWorking
}),
[sessionAfterCommandBoundaries.messages, hookPreview, hookWorking]
const pendingMessages = useMemo(
() => pendingSendsAsMessages(pending, sessionAfterCommandBoundaries.messages),
[pending, sessionAfterCommandBoundaries.messages]
)
const streamingText = useMemo(() => {
return deriveNativeChatStreamingText({
messages:
pendingMessages.length > 0
? [...sessionAfterCommandBoundaries.messages, ...pendingMessages]
: sessionAfterCommandBoundaries.messages,
previewText: hookPreview,
working: hookWorking
})
}, [sessionAfterCommandBoundaries.messages, pendingMessages, hookPreview, hookWorking])
const sessionWithPending = useMemo<typeof session>(() => {
if (pending.length === 0 && commandMarkers.length === 0 && !streamingText) {
return sessionAfterCommandBoundaries
Expand All @@ -312,10 +330,10 @@ function NativeChatResolvedView({
...sessionAfterCommandBoundaries.messages,
...commandMarkersAsMessages(commandMarkers),
...(streamingText ? [nativeChatStreamingMessage(streamingText)] : []),
...pendingSendsAsMessages(pending, sessionAfterCommandBoundaries.messages)
...pendingMessages
]
}
}, [sessionAfterCommandBoundaries, pending, commandMarkers, streamingText])
}, [sessionAfterCommandBoundaries, pending, pendingMessages, commandMarkers, streamingText])
// Derive the view state from the pending-augmented session so a send into an
// otherwise-empty conversation flips to the list (showing the queued bubble)
// instead of staying on the empty state.
Expand Down Expand Up @@ -436,6 +454,7 @@ function NativeChatResolvedView({
isWorking={isWorking}
onStop={stopAgent}
onOptimisticSend={onOptimisticSend}
onOptimisticSendCanceled={onOptimisticSendCanceled}
onSlashCommand={onSlashCommand}
/>
{contextMenu.menu}
Expand Down
Loading
Loading