From 39b2ec832ed2ce6f337840a1e7ed4bf54232e47b Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Mon, 13 Jul 2026 13:07:25 -0700
Subject: [PATCH] fix(native-chat): scope composer sends to the active turn
(#8568)
* fix(native-chat): scope composer sends to the active turn
* fix(native-chat): close pending send lifecycle gaps
* fix(native-chat): deduplicate pending message derivation
---
.../native-chat/NativeChatComposer.test.tsx | 125 ++++++++++++++
.../native-chat/NativeChatComposer.tsx | 42 +++--
.../components/native-chat/NativeChatView.tsx | 47 ++++--
.../native-chat-pending-occurrence.test.ts | 66 ++++++++
.../native-chat-pending-occurrence.ts | 119 ++++++++++++++
.../native-chat/native-chat-pending.test.ts | 137 ++++++++++++++--
.../native-chat/native-chat-pending.ts | 152 ++++++++++++------
.../native-chat-runtime-send.test.ts | 37 ++++-
.../native-chat/native-chat-runtime-send.ts | 17 +-
.../use-native-chat-interactive-send.test.tsx | 61 +++++++
.../use-native-chat-interactive-send.ts | 6 +-
.../use-native-chat-send-lifecycle.test.tsx | 69 ++++++++
.../use-native-chat-send-lifecycle.ts | 46 ++++++
src/shared/native-chat-streaming.test.ts | 16 ++
14 files changed, 848 insertions(+), 92 deletions(-)
create mode 100644 src/renderer/src/components/native-chat/NativeChatComposer.test.tsx
create mode 100644 src/renderer/src/components/native-chat/native-chat-pending-occurrence.test.ts
create mode 100644 src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts
create mode 100644 src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx
create mode 100644 src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx
create mode 100644 src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.ts
diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx
new file mode 100644
index 00000000000..c8be699f8f6
--- /dev/null
+++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx
@@ -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(
+
+ )
+
+ 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(
+
+ )
+
+ act(() => mocks.fieldProps?.onSend?.())
+
+ expect(onOptimisticSend).toHaveBeenCalledWith('hello', [])
+ expect(mocks.trackPendingSend).toHaveBeenCalledWith(mocks.sendHandle, 'pending-1')
+ })
+})
diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx
index d85a3a59bbe..41b254639dc 100644
--- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx
@@ -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 {
@@ -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
@@ -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. */
@@ -111,6 +115,7 @@ export const NativeChatComposer = forwardRef(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'
@@ -293,21 +303,33 @@ export const NativeChatComposer = forwardRef 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'.
@@ -329,10 +351,12 @@ export const NativeChatComposer = forwardRef {
+ cancelPendingSends()
if (isWorking && onStop) {
onStop()
return
@@ -342,7 +366,7 @@ export const NativeChatComposer = forwardRef {
@@ -362,7 +386,7 @@ export const NativeChatComposer = forwardRef
)
}
diff --git a/src/renderer/src/components/native-chat/NativeChatView.tsx b/src/renderer/src/components/native-chat/NativeChatView.tsx
index a9b3bca01ce..1f8af9bcef7 100644
--- a/src/renderer/src/components/native-chat/NativeChatView.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatView.tsx
@@ -25,6 +25,7 @@ import {
appendCommandMarkerCache,
launchPromptAsMessage,
pendingSendsAsMessages,
+ nextNativeChatPendingSendId,
prunePendingSends,
readCommandMarkerCache,
readPendingSendCache,
@@ -211,7 +212,6 @@ function NativeChatResolvedView({
const [pending, setPending] = useState(() =>
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(() =>
@@ -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]
)
@@ -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(() => {
if (pending.length === 0 && commandMarkers.length === 0 && !streamingText) {
return sessionAfterCommandBoundaries
@@ -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.
@@ -436,6 +454,7 @@ function NativeChatResolvedView({
isWorking={isWorking}
onStop={stopAgent}
onOptimisticSend={onOptimisticSend}
+ onOptimisticSendCanceled={onOptimisticSendCanceled}
onSlashCommand={onSlashCommand}
/>
{contextMenu.menu}
diff --git a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.test.ts b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.test.ts
new file mode 100644
index 00000000000..06d7111095d
--- /dev/null
+++ b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.test.ts
@@ -0,0 +1,66 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+import type { NativeChatMessage } from '../../../../shared/native-chat-types'
+import {
+ appendPendingSendCache,
+ clearPendingSendCacheForTests,
+ pendingSendsAsMessages,
+ prunePendingSends,
+ type NativeChatPendingSendScope
+} from './native-chat-pending'
+
+const scope: NativeChatPendingSendScope = { paneKey: 'tab:leaf', agent: 'codex' }
+
+function message(
+ id: string,
+ role: 'user' | 'assistant',
+ text: string,
+ timestamp: number
+): NativeChatMessage {
+ return {
+ id,
+ role,
+ blocks: [{ type: 'text', text }],
+ timestamp,
+ source: 'transcript'
+ }
+}
+
+describe('pending send occurrence reconciliation', () => {
+ beforeEach(() => clearPendingSendCacheForTests())
+
+ it('keeps the next identical echo after pruning an earlier occurrence', () => {
+ const first = appendPendingSendCache(scope, {
+ id: 'p1',
+ text: 'repeat',
+ sentAt: 100,
+ afterMessageId: 'paged-out-boundary'
+ })
+ const repeated = appendPendingSendCache(scope, {
+ id: 'p2',
+ text: 'repeat',
+ sentAt: 200,
+ afterMessageId: 'paged-out-boundary'
+ })
+ expect(first[0]?.matchingOccurrence).toBeUndefined()
+ expect(repeated[1]).toMatchObject({ matchingOccurrence: 2, matchingAfterTimestamp: 100 })
+
+ const firstCompletedTurn = [
+ message('u1', 'user', 'repeat', 150),
+ message('a1', 'assistant', 'done', 160)
+ ]
+ const afterFirstPrune = prunePendingSends(repeated, firstCompletedTurn)
+
+ expect(afterFirstPrune.map((entry) => entry.id)).toEqual(['p2'])
+ expect(
+ pendingSendsAsMessages(afterFirstPrune, firstCompletedTurn).map((entry) => entry.id)
+ ).toEqual(['pending:p2'])
+
+ const secondCompletedTurn = [
+ ...firstCompletedTurn,
+ message('u2', 'user', 'repeat', 250),
+ message('a2', 'assistant', 'done again', 260)
+ ]
+ expect(pendingSendsAsMessages(afterFirstPrune, secondCompletedTurn)).toEqual([])
+ expect(prunePendingSends(afterFirstPrune, secondCompletedTurn)).toEqual([])
+ })
+})
diff --git a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts
new file mode 100644
index 00000000000..3c94e5bfadd
--- /dev/null
+++ b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts
@@ -0,0 +1,119 @@
+import { stripImagePromptMarker } from './native-chat-image-transcript-markers'
+import {
+ isImageRefBlock,
+ isTextBlock,
+ type NativeChatMessage
+} from '../../../../shared/native-chat-types'
+
+export type NativeChatPendingOccurrence = {
+ text: string
+ imagePaths?: readonly string[]
+ sentAt: number
+ afterMessageId?: string | null
+ afterMessageTimestamp?: number | null
+ matchingOccurrence?: number
+ matchingAfterTimestamp?: number
+}
+
+export function normalizeNativeChatPendingText(text: string): string {
+ return stripImagePromptMarker(text).trim().replace(/\s+/g, ' ')
+}
+
+export function nativeChatPendingContentKey(
+ pending: Pick
+): string {
+ const text = normalizeNativeChatPendingText(pending.text)
+ if (text) {
+ return `text:${text}`
+ }
+ const imagePaths = pending.imagePaths?.filter(Boolean) ?? []
+ return imagePaths.length > 0 ? `images:${JSON.stringify(imagePaths)}` : 'empty'
+}
+
+function nativeChatUserMessageContentKey(message: NativeChatMessage): string | null {
+ if (message.role !== 'user') {
+ return null
+ }
+ const text = message.blocks
+ .filter(isTextBlock)
+ .map((block) => block.text)
+ .join(' ')
+ const imagePaths = message.blocks
+ .filter(isImageRefBlock)
+ .map((block) => block.path)
+ .filter((path): path is string => Boolean(path))
+ const key = nativeChatPendingContentKey({ text, imagePaths })
+ return key === 'empty' ? null : key
+}
+
+export function matchingNativeChatUserContentCounts(
+ messages: readonly NativeChatMessage[]
+): Map {
+ const counts = new Map()
+ for (const message of messages) {
+ const key = nativeChatUserMessageContentKey(message)
+ if (key) {
+ counts.set(key, (counts.get(key) ?? 0) + 1)
+ }
+ }
+ return counts
+}
+
+export function advancedNativeChatUserContentCounts(
+ messages: readonly NativeChatMessage[]
+): Map {
+ const advanced = new Map()
+ const waiting = new Map()
+ for (const message of messages) {
+ if (message.role === 'user') {
+ const key = nativeChatUserMessageContentKey(message)
+ if (key) {
+ waiting.set(key, (waiting.get(key) ?? 0) + 1)
+ }
+ continue
+ }
+ for (const [key, count] of waiting) {
+ advanced.set(key, (advanced.get(key) ?? 0) + count)
+ }
+ waiting.clear()
+ }
+ return advanced
+}
+
+export function nativeChatPendingMatchKey(pending: NativeChatPendingOccurrence): string {
+ return `${String(pending.afterMessageId)}\0${nativeChatPendingContentKey(pending)}`
+}
+
+export function assignNativeChatPendingOccurrence(
+ existing: readonly T[],
+ entry: T
+): T {
+ const key = nativeChatPendingMatchKey(entry)
+ const matching = existing.filter((candidate) => nativeChatPendingMatchKey(candidate) === key)
+ if (matching.length === 0) {
+ return entry
+ }
+ const previousOccurrence = Math.max(
+ ...matching.map((candidate, index) => candidate.matchingOccurrence ?? index + 1)
+ )
+ const first = matching[0]
+ // Why: pruning an earlier echo must not let a later identical send reuse the
+ // same transcript occurrence, even after the read pages out its boundary.
+ return {
+ ...entry,
+ matchingOccurrence: previousOccurrence + 1,
+ matchingAfterTimestamp:
+ first?.matchingAfterTimestamp ?? first?.afterMessageTimestamp ?? first?.sentAt
+ }
+}
+
+export function nativeChatPendingMatchingAfter(pending: NativeChatPendingOccurrence): number {
+ return pending.matchingAfterTimestamp ?? pending.afterMessageTimestamp ?? pending.sentAt
+}
+
+export function nativeChatPendingOccurrence(
+ pending: NativeChatPendingOccurrence,
+ alreadyConsumed: number
+): number {
+ return pending.matchingOccurrence ?? alreadyConsumed + 1
+}
diff --git a/src/renderer/src/components/native-chat/native-chat-pending.test.ts b/src/renderer/src/components/native-chat/native-chat-pending.test.ts
index 58393460467..b63dba307b2 100644
--- a/src/renderer/src/components/native-chat/native-chat-pending.test.ts
+++ b/src/renderer/src/components/native-chat/native-chat-pending.test.ts
@@ -11,6 +11,7 @@ import {
isLaunchPromptMessageId,
isPendingMessageId,
launchPromptAsMessage,
+ nextNativeChatPendingSendId,
pendingSendsAsMessages,
prunePendingSends,
readCommandMarkerCache,
@@ -41,6 +42,16 @@ function assistantMessage(id: string, text: string): NativeChatMessage {
}
}
+function imageMessage(id: string, ...paths: string[]): NativeChatMessage {
+ return {
+ id,
+ role: 'user',
+ blocks: paths.map((path) => ({ type: 'image-ref' as const, path })),
+ timestamp: 1,
+ source: 'transcript'
+ }
+}
+
const pendingOf = (id: string, text: string): NativeChatPendingSend => ({ id, text, sentAt: 100 })
describe('prunePendingSends', () => {
@@ -84,6 +95,16 @@ describe('prunePendingSends', () => {
expect(next).toEqual([])
})
+ it('drops an attachment-only pending send once its image turn advances', () => {
+ const pending = [{ ...pendingOf('p1', ''), imagePaths: ['/tmp/first.png', '/tmp/second.png'] }]
+ const transcript = [
+ imageMessage('m1', '/tmp/first.png', '/tmp/second.png'),
+ assistantMessage('m2', 'two images')
+ ]
+
+ expect(prunePendingSends(pending, transcript)).toEqual([])
+ })
+
it('keeps a pending send that has not landed yet', () => {
const pending = [pendingOf('p1', 'not yet')]
const next = prunePendingSends(pending, [assistantMessage('m1', 'working on it')])
@@ -104,6 +125,21 @@ describe('prunePendingSends', () => {
])
expect(next).toEqual([pendingOf('p2', 'second')])
})
+
+ it('does not prune a repeated prompt against a turn before its send boundary', () => {
+ const oldUser = userMessage('old-user', 'run tests')
+ const oldAnswer = assistantMessage('old-answer', 'passed')
+ const pending = [{ ...pendingOf('new-send', 'run tests'), afterMessageId: oldAnswer.id }]
+
+ expect(prunePendingSends(pending, [oldUser, oldAnswer])).toEqual(pending)
+ })
+
+ it('prunes only one of two identical pending sends for one completed turn', () => {
+ const pending = [pendingOf('p1', 'repeat'), pendingOf('p2', 'repeat')]
+ expect(
+ prunePendingSends(pending, [userMessage('u1', 'repeat'), assistantMessage('a1', 'done')])
+ ).toEqual([pendingOf('p2', 'repeat')])
+ })
})
describe('pendingSendsAsMessages', () => {
@@ -130,12 +166,65 @@ describe('pendingSendsAsMessages', () => {
])
})
+ it('hides an attachment-only pending send while its real image turn is visible', () => {
+ const pending = [{ ...pendingOf('p1', ''), imagePaths: ['/tmp/shot.png'] }]
+
+ expect(pendingSendsAsMessages(pending, [imageMessage('u1', '/tmp/shot.png')])).toEqual([])
+ })
+
it('hides a pending send while its real user turn is visible', () => {
const pending = [pendingOf('p1', 'first prompt')]
expect(pendingSendsAsMessages(pending, [userMessage('u1', 'first prompt')])).toEqual([])
expect(pendingSendsAsMessages(pending, [])).toHaveLength(1)
})
+
+ it('keeps a repeated prompt visible when its only match predates the send boundary', () => {
+ const history = [userMessage('old-user', 'run tests'), assistantMessage('old-answer', 'passed')]
+ const pending = [{ ...pendingOf('new-send', 'run tests'), afterMessageId: 'old-answer' }]
+
+ expect(pendingSendsAsMessages(pending, history).map((message) => message.id)).toEqual([
+ 'pending:new-send'
+ ])
+ })
+
+ it('keeps a loading-time send visible when older matching history arrives later', () => {
+ const history = [
+ { ...userMessage('old-user', 'run tests'), timestamp: 10 },
+ { ...assistantMessage('old-answer', 'passed'), timestamp: 20 }
+ ]
+ const pending = [{ ...pendingOf('new-send', 'run tests'), sentAt: 100, afterMessageId: null }]
+
+ expect(pendingSendsAsMessages(pending, history).map((message) => message.id)).toEqual([
+ 'pending:new-send'
+ ])
+ expect(prunePendingSends(pending, history)).toEqual(pending)
+ })
+
+ it('uses the transcript boundary clock after pagination, not the renderer send clock', () => {
+ const pending = [
+ {
+ ...pendingOf('new-send', 'run tests'),
+ sentAt: 100_000,
+ afterMessageId: 'paged-out-answer',
+ afterMessageTimestamp: 20
+ }
+ ]
+ const remoteTranscript = [
+ { ...userMessage('new-user', 'run tests'), timestamp: 30 },
+ { ...assistantMessage('new-answer', 'passed'), timestamp: 40 }
+ ]
+
+ expect(pendingSendsAsMessages(pending, remoteTranscript)).toEqual([])
+ expect(prunePendingSends(pending, remoteTranscript)).toEqual([])
+ })
+
+ it('hides only one of two identical pending sends for one real user turn', () => {
+ const pending = [pendingOf('p1', 'repeat'), pendingOf('p2', 'repeat')]
+ expect(pendingSendsAsMessages(pending, [userMessage('u1', 'repeat')]).map((m) => m.id)).toEqual(
+ ['pending:p2']
+ )
+ })
})
describe('launchPromptAsMessage', () => {
@@ -165,7 +254,7 @@ describe('launchPromptAsMessage', () => {
text: 'Fix failing checks',
createdAt: 42
},
- [userMessage('u1', 'Fix failing checks')]
+ [{ ...userMessage('u1', 'Fix failing checks'), timestamp: 43 }]
)
).toBeNull()
})
@@ -180,11 +269,14 @@ describe('launchPromptAsMessage', () => {
' fix spacing'
].join('\n')
const transcript = [
- userMessage(
- 'u1',
- 'Resolve the failing checks: Resolve the failing checks: - lint failed fix spacing'
- ),
- assistantMessage('a1', 'I will fix it')
+ {
+ ...userMessage(
+ 'u1',
+ 'Resolve the failing checks: Resolve the failing checks: - lint failed fix spacing'
+ ),
+ timestamp: 43
+ },
+ { ...assistantMessage('a1', 'I will fix it'), timestamp: 44 }
]
expect(
@@ -208,14 +300,34 @@ describe('launchPromptAsMessage', () => {
createdAt: 42
}
- expect(shouldPruneLaunchPrompt(prompt, [userMessage('u1', 'Fix failing checks')])).toBe(false)
expect(
shouldPruneLaunchPrompt(prompt, [
- userMessage('u1', 'Fix failing checks'),
- assistantMessage('a1', 'working')
+ { ...userMessage('u1', 'Fix failing checks'), timestamp: 43 }
+ ])
+ ).toBe(false)
+ expect(
+ shouldPruneLaunchPrompt(prompt, [
+ { ...userMessage('u1', 'Fix failing checks'), timestamp: 43 },
+ { ...assistantMessage('a1', 'working'), timestamp: 44 }
])
).toBe(true)
})
+
+ it('does not bind a launch prompt to an older identical completed turn', () => {
+ const entry = {
+ tabId: 'tab-1',
+ agent: 'claude' as const,
+ text: 'run tests',
+ createdAt: 100
+ }
+ const oldHistory = [
+ { ...userMessage('old-user', 'run tests'), timestamp: 10 },
+ { ...assistantMessage('old-answer', 'passed'), timestamp: 20 }
+ ]
+
+ expect(launchPromptAsMessage(entry, oldHistory)).not.toBeNull()
+ expect(shouldPruneLaunchPrompt(entry, oldHistory)).toBe(false)
+ })
})
describe('pending send cache', () => {
@@ -230,6 +342,13 @@ describe('pending send cache', () => {
expect(readPendingSendCache({ ...scope, agent: 'claude' })).toEqual([])
})
+ it('mints unique ids across chat-view remounts while the cache survives', () => {
+ clearPendingSendCacheForTests()
+ const first = nextNativeChatPendingSendId(100)
+ const second = nextNativeChatPendingSendId(100)
+ expect(second).not.toBe(first)
+ })
+
it('clears cached pending sends when pruning removes all entries', () => {
clearPendingSendCacheForTests()
const scope = { paneKey: 'tab-a:leaf-a', agent: 'codex' }
diff --git a/src/renderer/src/components/native-chat/native-chat-pending.ts b/src/renderer/src/components/native-chat/native-chat-pending.ts
index c03f4c0b96a..92dbaf17ef7 100644
--- a/src/renderer/src/components/native-chat/native-chat-pending.ts
+++ b/src/renderer/src/components/native-chat/native-chat-pending.ts
@@ -1,12 +1,20 @@
// Pure logic for desktop optimistic "queued" composer sends (mobile parity).
// A sent prompt is echoed immediately as a queued entry and pruned once its real
// user turn lands in the transcript. Kept separate from the view so the prune
-// rule (match on normalized user-message text) is unit-testable without React.
+// rule (match on normalized user-message content) is unit-testable without React.
-import { isTextBlock, type NativeChatMessage } from '../../../../shared/native-chat-types'
-import { stripImagePromptMarker } from './native-chat-image-transcript-markers'
+import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache'
import type { NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt'
+import {
+ advancedNativeChatUserContentCounts,
+ assignNativeChatPendingOccurrence,
+ matchingNativeChatUserContentCounts,
+ nativeChatPendingContentKey,
+ nativeChatPendingMatchKey,
+ nativeChatPendingMatchingAfter,
+ nativeChatPendingOccurrence
+} from './native-chat-pending-occurrence'
/** An optimistic, not-yet-confirmed composer send. */
export type NativeChatPendingSend = {
@@ -18,6 +26,15 @@ export type NativeChatPendingSend = {
imagePaths?: string[]
/** Epoch ms when the send was issued, so the queued bubble sorts to the end. */
sentAt: number
+ /** Last authoritative transcript message visible when this send was issued.
+ * Matching starts after it so repeated prompts cannot bind to an old turn. */
+ afterMessageId?: string | null
+ /** Timestamp of that boundary in the transcript host's clock domain. */
+ afterMessageTimestamp?: number | null
+ /** 1-based occurrence among identical sends sharing the same boundary. */
+ matchingOccurrence?: number
+ /** Shared time boundary when that message boundary is unavailable. */
+ matchingAfterTimestamp?: number
}
export type NativeChatPendingSendScope = {
@@ -27,6 +44,7 @@ export type NativeChatPendingSendScope = {
const PENDING_SEND_LIMIT = 8
const pendingSendCache = new Map()
+let pendingSendCounter = 0
function pendingSendScopeKey(scope: NativeChatPendingSendScope): string {
return `${scope.paneKey}\0${scope.agent}`
@@ -57,56 +75,48 @@ export function appendPendingSendCache(
scope: NativeChatPendingSendScope,
entry: NativeChatPendingSend
): NativeChatPendingSend[] {
- return writePendingSendCache(scope, [...readPendingSendCache(scope), entry])
+ const existing = readPendingSendCache(scope)
+ const next = assignNativeChatPendingOccurrence(existing, entry)
+ return writePendingSendCache(scope, [...existing, next])
}
export function clearPendingSendCacheForTests(): void {
pendingSendCache.clear()
+ pendingSendCounter = 0
}
-function normalize(text: string): string {
- return stripImagePromptMarker(text).trim().replace(/\s+/g, ' ')
-}
-
-/** The prose of a user message, normalized for matching against a pending send. */
-function userMessageText(message: NativeChatMessage): string | null {
- if (message.role !== 'user') {
- return null
+function messagesAfterPendingBoundary(
+ messages: readonly NativeChatMessage[],
+ pending: NativeChatPendingSend
+): readonly NativeChatMessage[] {
+ if (pending.afterMessageId === undefined) {
+ return messages
}
- const text = message.blocks
- .filter(isTextBlock)
- .map((block) => block.text)
- .join(' ')
- return normalize(text)
+ if (pending.afterMessageId === null) {
+ return messages.filter((message) => messageIsAfterPendingTimestamp(message, pending))
+ }
+ const boundaryIndex = messages.findIndex((message) => message.id === pending.afterMessageId)
+ if (boundaryIndex >= 0) {
+ return messages.slice(boundaryIndex + 1)
+ }
+ // A bounded authoritative read can page the boundary out. Fall back to the
+ // send time instead of matching an arbitrary older identical prompt.
+ return messages.filter((message) => messageIsAfterPendingTimestamp(message, pending))
}
-function matchingUserMessageTexts(messages: NativeChatMessage[]): Set {
- const texts = new Set()
- for (const message of messages) {
- const text = userMessageText(message)
- if (text) {
- texts.add(text)
- }
+function messageIsAfterPendingTimestamp(
+ message: NativeChatMessage,
+ pending: NativeChatPendingSend
+): boolean {
+ if (message.timestamp === null) {
+ return false
}
- return texts
-}
-
-function advancedPastUserMessageTexts(messages: NativeChatMessage[]): Set {
- const advanced = new Set()
- const waiting = new Set()
- for (const message of messages) {
- if (message.role === 'user') {
- const text = userMessageText(message)
- if (text) {
- waiting.add(text)
- }
- continue
- }
- for (const text of waiting) {
- advanced.add(text)
- }
- }
- return advanced
+ const boundary = nativeChatPendingMatchingAfter(pending)
+ // A transcript-clock boundary describes an existing message, so exclude ties.
+ // Local send time has no existing record and remains inclusive.
+ return pending.afterMessageTimestamp == null
+ ? message.timestamp >= boundary
+ : message.timestamp > boundary
}
/**
@@ -122,8 +132,22 @@ export function prunePendingSends(
if (pending.length === 0) {
return pending
}
- const advanced = advancedPastUserMessageTexts(messages)
- const next = pending.filter((entry) => !advanced.has(normalize(entry.text)))
+ const consumed = new Map()
+ const next = pending.filter((entry) => {
+ const contentKey = nativeChatPendingContentKey(entry)
+ const key = nativeChatPendingMatchKey(entry)
+ const available =
+ advancedNativeChatUserContentCounts(messagesAfterPendingBoundary(messages, entry)).get(
+ contentKey
+ ) ?? 0
+ const used = consumed.get(key) ?? 0
+ const occurrence = nativeChatPendingOccurrence(entry, used)
+ consumed.set(key, Math.max(used, occurrence))
+ if (occurrence > available) {
+ return true
+ }
+ return false
+ })
return next.length === pending.length ? pending : next
}
@@ -137,9 +161,23 @@ export function pendingSendsAsMessages(
pending: NativeChatPendingSend[],
existingMessages: NativeChatMessage[] = []
): NativeChatMessage[] {
- const represented = matchingUserMessageTexts(existingMessages)
+ const consumed = new Map()
return pending
- .filter((entry) => !represented.has(normalize(entry.text)))
+ .filter((entry) => {
+ const contentKey = nativeChatPendingContentKey(entry)
+ const key = nativeChatPendingMatchKey(entry)
+ const represented =
+ matchingNativeChatUserContentCounts(
+ messagesAfterPendingBoundary(existingMessages, entry)
+ ).get(contentKey) ?? 0
+ const used = consumed.get(key) ?? 0
+ const occurrence = nativeChatPendingOccurrence(entry, used)
+ consumed.set(key, Math.max(used, occurrence))
+ if (occurrence > represented) {
+ return true
+ }
+ return false
+ })
.map((entry) => ({
id: `pending:${entry.id}`,
role: 'user' as const,
@@ -167,8 +205,12 @@ export function launchPromptAsMessage(
if (!entry) {
return null
}
- const represented = matchingUserMessageTexts(existingMessages)
- if (represented.has(normalize(entry.text))) {
+ const represented = matchingNativeChatUserContentCounts(
+ existingMessages.filter(
+ (message) => message.timestamp !== null && message.timestamp >= entry.createdAt
+ )
+ )
+ if ((represented.get(nativeChatPendingContentKey(entry)) ?? 0) > 0) {
return null
}
return {
@@ -187,7 +229,17 @@ export function shouldPruneLaunchPrompt(
entry: NativeChatLaunchPrompt,
messages: NativeChatMessage[]
): boolean {
- return advancedPastUserMessageTexts(messages).has(normalize(entry.text))
+ const relevant = messages.filter(
+ (message) => message.timestamp !== null && message.timestamp >= entry.createdAt
+ )
+ return (
+ (advancedNativeChatUserContentCounts(relevant).get(nativeChatPendingContentKey(entry)) ?? 0) > 0
+ )
+}
+
+export function nextNativeChatPendingSendId(now = Date.now()): string {
+ pendingSendCounter += 1
+ return `${now}-${pendingSendCounter}`
}
export function isLaunchPromptMessageId(id: string): boolean {
diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts
index 20badc4d9e4..25efca9af09 100644
--- a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts
+++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts
@@ -37,7 +37,7 @@ describe('sendNativeChatMessage', () => {
})
it('writes the framed body immediately, before the Enter', () => {
- sendNativeChatMessage(SETTINGS, PTY, 'hello world')
+ const handle = sendNativeChatMessage(SETTINGS, PTY, 'hello world')
// Body lands synchronously; Enter is still pending on the timer.
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
expect(sendRuntimePtyInput).toHaveBeenCalledWith(
@@ -45,6 +45,7 @@ describe('sendNativeChatMessage', () => {
PTY,
buildNativeChatPasteBytes('hello world')
)
+ expect(handle.settleAfterMs).toBe(NATIVE_CHAT_SUBMIT_DELAY_MS)
})
it('does not fire Enter before the proven 500ms gap (busy-agent safety)', () => {
@@ -62,6 +63,14 @@ describe('sendNativeChatMessage', () => {
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
})
+ it('cancels the delayed Enter when its owning composer is detached', () => {
+ const handle = sendNativeChatMessage(SETTINGS, PTY, 'hi')
+ handle.cancel()
+ vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS)
+
+ expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
+ })
+
it('matches orca-runtime writeTerminalAction Enter gap (500ms)', () => {
expect(NATIVE_CHAT_SUBMIT_DELAY_MS).toBe(500)
})
@@ -77,10 +86,14 @@ describe('sendNativeChatMessageWithImageAttachments', () => {
})
it('bracket-pastes image paths before prompt text so the TUI creates image chips', () => {
- sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, 'what do you see?', [
+ const handle = sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, 'what do you see?', [
'/tmp/orca-paste-image.png'
])
+ expect(handle.settleAfterMs).toBe(
+ NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS
+ )
+
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(
SETTINGS,
@@ -102,7 +115,11 @@ describe('sendNativeChatMessageWithImageAttachments', () => {
})
it('waits the normal submit gap for an attachment-only send', () => {
- sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, '', ['/tmp/orca-paste-image.png'])
+ const handle = sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, '', [
+ '/tmp/orca-paste-image.png'
+ ])
+
+ expect(handle.settleAfterMs).toBe(NATIVE_CHAT_SUBMIT_DELAY_MS)
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS - 1)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
@@ -111,6 +128,16 @@ describe('sendNativeChatMessageWithImageAttachments', () => {
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(2)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
})
+
+ it('cancels deferred prompt and Enter writes after the attachment path', () => {
+ const handle = sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, 'describe', [
+ '/tmp/orca-paste-image.png'
+ ])
+ handle.cancel()
+ vi.runAllTimers()
+
+ expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
+ })
})
describe('empty prompt submit', () => {
@@ -160,7 +187,9 @@ describe('sendNativeChatAnswer', () => {
it('multi-line: 3 bodies + 3 Enters in order, each Enter 500ms after its body, next body only after prior Enter+buffer', () => {
const lines = ['answer one', 'answer two', 'answer three']
- sendNativeChatAnswer(SETTINGS, PTY, lines)
+ const handle = sendNativeChatAnswer(SETTINGS, PTY, lines)
+
+ expect(handle.settleAfterMs).toBe(nativeChatQuestionOffsets(lines.length - 1).enterAt)
// Nothing fires synchronously: even question 0's body is scheduled (setTimeout 0).
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(0)
diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts
index 4a2b9e54381..1f5721e07df 100644
--- a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts
+++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts
@@ -51,7 +51,11 @@ export function nativeChatQuestionOffsets(index: number): {
/** Cancels an in-flight send's pending pty writes (the delayed Enter, and any
* later question bodies/Enters). Safe to call after the send completes. */
-export type NativeChatSendHandle = { cancel: () => void }
+export type NativeChatSendHandle = {
+ cancel: () => void
+ /** Time after which every scheduled write has fired and the handle can drop. */
+ settleAfterMs: number
+}
/**
* Send a native-chat message through the verified runtime pty path: framed body
@@ -68,7 +72,7 @@ export function sendNativeChatMessage(
const timer = setTimeout(() => {
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
}, NATIVE_CHAT_SUBMIT_DELAY_MS)
- return { cancel: () => clearTimeout(timer) }
+ return { cancel: () => clearTimeout(timer), settleAfterMs: NATIVE_CHAT_SUBMIT_DELAY_MS }
}
export function sendNativeChatMessageWithImageAttachments(
@@ -107,7 +111,11 @@ export function sendNativeChatMessageWithImageAttachments(
for (const timer of timers) {
clearTimeout(timer)
}
- }
+ },
+ settleAfterMs:
+ trimmedText.length > 0
+ ? NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS
+ : NATIVE_CHAT_SUBMIT_DELAY_MS
}
}
@@ -157,6 +165,7 @@ export function sendNativeChatAnswer(
for (const timer of timers) {
clearTimeout(timer)
}
- }
+ },
+ settleAfterMs: nativeChatQuestionOffsets(lines.length - 1).enterAt
}
}
diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx
new file mode 100644
index 00000000000..09075d99903
--- /dev/null
+++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx
@@ -0,0 +1,61 @@
+// @vitest-environment happy-dom
+
+import { act, renderHook } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ cancel: vi.fn(),
+ sendRuntimePtyInput: vi.fn(),
+ sendNativeChatAnswer: vi.fn(),
+ sendNativeChatMessage: vi.fn()
+}))
+
+vi.mock('@/runtime/runtime-terminal-inspection', () => ({
+ sendRuntimePtyInput: (...args: unknown[]) => mocks.sendRuntimePtyInput(...args)
+}))
+
+vi.mock('@/lib/agent-paste-draft', () => ({
+ getSettingsForAgentTabRuntimeOwner: (terminalTabId: string) => ({ terminalTabId })
+}))
+
+vi.mock('./native-chat-runtime-send', () => ({
+ sendNativeChatAnswer: (...args: unknown[]) => mocks.sendNativeChatAnswer(...args),
+ sendNativeChatMessage: (...args: unknown[]) => mocks.sendNativeChatMessage(...args)
+}))
+
+import { useNativeChatInteractiveSend } from './use-native-chat-interactive-send'
+
+describe('useNativeChatInteractiveSend', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ const handle = { cancel: mocks.cancel, settleAfterMs: 500 }
+ mocks.sendNativeChatAnswer.mockReturnValue(handle)
+ mocks.sendNativeChatMessage.mockReturnValue(handle)
+ })
+
+ it('cancels delayed answer writes when the PTY target changes', () => {
+ const { result, rerender } = renderHook(
+ ({ targetPtyId }) => useNativeChatInteractiveSend('tab-1', targetPtyId, 'codex'),
+ { initialProps: { targetPtyId: 'pty-1' as string | null } }
+ )
+
+ act(() => result.current.sendAnswer('continue'))
+ rerender({ targetPtyId: 'pty-2' })
+
+ expect(mocks.cancel).toHaveBeenCalledOnce()
+ })
+
+ it('cancels delayed answer writes before interrupting the active PTY', () => {
+ const { result } = renderHook(() => useNativeChatInteractiveSend('tab-1', 'pty-1', 'claude'))
+
+ act(() => result.current.sendAnswer('one\ntwo'))
+ act(() => result.current.cancel())
+
+ expect(mocks.cancel).toHaveBeenCalledOnce()
+ expect(mocks.sendRuntimePtyInput).toHaveBeenCalledWith(
+ { terminalTabId: 'tab-1' },
+ 'pty-1',
+ '\x1b'
+ )
+ })
+})
diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts
index 7065fc19cea..36d7d8cb1ea 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts
+++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useRef } from 'react'
+import { useCallback, useLayoutEffect, useRef } from 'react'
import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection'
import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
import type { AgentType } from '../../../../shared/native-chat-types'
@@ -42,7 +42,9 @@ export function useNativeChatInteractiveSend(
inFlightRef.current?.cancel()
inFlightRef.current = null
}, [])
- useEffect(() => cancelInFlight, [cancelInFlight])
+ // Why: a split can be rebound without unmounting this view. Cancel during
+ // commit so no delayed answer write can race the replacement PTY.
+ useLayoutEffect(() => cancelInFlight, [cancelInFlight, targetPtyId, terminalTabId])
const sendRaw = useCallback(
(raw: string) => {
diff --git a/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx
new file mode 100644
index 00000000000..63525c1e3e7
--- /dev/null
+++ b/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx
@@ -0,0 +1,69 @@
+// @vitest-environment happy-dom
+
+import { act, renderHook } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { useNativeChatSendLifecycle } from './use-native-chat-send-lifecycle'
+
+function handle(settleAfterMs = 500) {
+ return { cancel: vi.fn<() => void>(), settleAfterMs }
+}
+
+describe('useNativeChatSendLifecycle', () => {
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('cancels owned writes when the PTY target changes and when the composer unmounts', () => {
+ vi.useFakeTimers()
+ const first = handle()
+ const second = handle()
+ const onPendingSendCanceled = vi.fn()
+ const { result, rerender, unmount } = renderHook(
+ ({ targetPtyId }) => useNativeChatSendLifecycle('tab-1', targetPtyId, onPendingSendCanceled),
+ { initialProps: { targetPtyId: 'pty-1' as string | null } }
+ )
+
+ act(() => result.current.trackPendingSend(first, 'pending-1'))
+ rerender({ targetPtyId: 'pty-2' })
+ expect(first.cancel).toHaveBeenCalledOnce()
+ expect(onPendingSendCanceled).toHaveBeenCalledWith('pending-1')
+
+ act(() => result.current.trackPendingSend(second, 'pending-2'))
+ unmount()
+ expect(second.cancel).toHaveBeenCalledOnce()
+ expect(onPendingSendCanceled).toHaveBeenCalledWith('pending-2')
+ })
+
+ it('cancels pending writes immediately on interrupt without double-cancelling', () => {
+ vi.useFakeTimers()
+ const pending = handle()
+ const onPendingSendCanceled = vi.fn()
+ const { result, unmount } = renderHook(() =>
+ useNativeChatSendLifecycle('tab-1', 'pty-1', onPendingSendCanceled)
+ )
+
+ act(() => result.current.trackPendingSend(pending, 'pending-1'))
+ act(() => result.current.cancelPendingSends())
+ expect(pending.cancel).toHaveBeenCalledOnce()
+ expect(onPendingSendCanceled).toHaveBeenCalledWith('pending-1')
+
+ unmount()
+ expect(pending.cancel).toHaveBeenCalledOnce()
+ })
+
+ it('drops settled handles so a later interrupt does not revisit completed sends', () => {
+ vi.useFakeTimers()
+ const settled = handle(800)
+ const onPendingSendCanceled = vi.fn()
+ const { result } = renderHook(() =>
+ useNativeChatSendLifecycle('tab-1', 'pty-1', onPendingSendCanceled)
+ )
+
+ act(() => result.current.trackPendingSend(settled, 'pending-1'))
+ act(() => vi.advanceTimersByTime(settled.settleAfterMs))
+ act(() => result.current.cancelPendingSends())
+
+ expect(settled.cancel).not.toHaveBeenCalled()
+ expect(onPendingSendCanceled).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.ts b/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.ts
new file mode 100644
index 00000000000..41e46148bfe
--- /dev/null
+++ b/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.ts
@@ -0,0 +1,46 @@
+import { useCallback, useLayoutEffect, useRef } from 'react'
+import type { NativeChatSendHandle } from './native-chat-runtime-send'
+
+export type NativeChatSendLifecycle = {
+ cancelPendingSends: () => void
+ trackPendingSend: (handle: NativeChatSendHandle, pendingId?: string) => void
+}
+
+export function useNativeChatSendLifecycle(
+ terminalTabId: string,
+ targetPtyId: string | null,
+ onPendingSendCanceled?: (pendingId: string) => void
+): NativeChatSendLifecycle {
+ const pendingSendHandlesRef = useRef(
+ new Map<
+ NativeChatSendHandle,
+ { cleanupTimer: ReturnType; pendingId?: string }
+ >()
+ )
+ const cancelPendingSends = useCallback(() => {
+ for (const [handle, entry] of pendingSendHandlesRef.current) {
+ const { cleanupTimer, pendingId } = entry
+ clearTimeout(cleanupTimer)
+ handle.cancel()
+ if (pendingId) {
+ onPendingSendCanceled?.(pendingId)
+ }
+ }
+ pendingSendHandlesRef.current.clear()
+ }, [onPendingSendCanceled])
+ const trackPendingSend = useCallback((handle: NativeChatSendHandle, pendingId?: string) => {
+ const cleanupTimer = setTimeout(() => {
+ pendingSendHandlesRef.current.delete(handle)
+ }, handle.settleAfterMs)
+ pendingSendHandlesRef.current.set(handle, {
+ cleanupTimer,
+ ...(pendingId ? { pendingId } : {})
+ })
+ }, [])
+
+ // Why: delayed Enter/image writes belong to the exact PTY target. A pane
+ // swap or unmount must cancel them before that PTY can close or be reused.
+ useLayoutEffect(() => cancelPendingSends, [cancelPendingSends, targetPtyId, terminalTabId])
+
+ return { cancelPendingSends, trackPendingSend }
+}
diff --git a/src/shared/native-chat-streaming.test.ts b/src/shared/native-chat-streaming.test.ts
index 689f83f9d5b..8795b1b4430 100644
--- a/src/shared/native-chat-streaming.test.ts
+++ b/src/shared/native-chat-streaming.test.ts
@@ -47,6 +47,22 @@ describe('deriveNativeChatStreamingText', () => {
).toBe('Working on it')
})
+ it('treats an optimistic user echo as the active streaming-turn boundary', () => {
+ const optimistic = {
+ ...user('new prompt'),
+ id: 'pending:send-1',
+ timestamp: 20,
+ source: 'scrape' as const
+ }
+ expect(
+ deriveNativeChatStreamingText({
+ messages: [assistant('A much longer answer from the completed prior turn'), optimistic],
+ previewText: 'New reply',
+ working: true
+ })
+ ).toBe('New reply')
+ })
+
it('drops the preview once the real assistant turn contains it (no duplicate)', () => {
expect(
deriveNativeChatStreamingText({