fix(native-chat): close pending send lifecycle gaps

This commit is contained in:
Jinjing
2026-07-13 06:52:35 -07:00
parent dd6c7f5598
commit 69ec4af3ec
10 changed files with 419 additions and 102 deletions
@@ -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')
})
})
@@ -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 {
@@ -71,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. */
@@ -112,6 +115,7 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
isWorking = false,
onStop,
onOptimisticSend,
onOptimisticSendCanceled,
onSlashCommand
},
ref
@@ -129,7 +133,8 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
const textareaRef = useRef<HTMLTextAreaElement>(null)
const { cancelPendingSends, trackPendingSend } = useNativeChatSendLifecycle(
terminalTabId,
targetPtyId
targetPtyId,
onOptimisticSendCanceled
)
const dictationState = useAppStore((store) => store.dictationState)
const voiceSettings = useAppStore((store) => store.settings?.voice)
@@ -298,23 +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) {
trackPendingSend(sendNativeChatMessage(target.settings, target.ptyId, text))
pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text)
} else if (imagePaths.length > 0) {
trackPendingSend(
sendNativeChatMessageWithImageAttachments(target.settings, target.ptyId, text, imagePaths)
pendingHandle = sendNativeChatMessageWithImageAttachments(
target.settings,
target.ptyId,
text,
imagePaths
)
} else if (text.trim().length > 0) {
trackPendingSend(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'.
@@ -453,7 +468,7 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
onDictationHoldStart={startHoldDictation}
onDictationHoldEnd={stopHoldDictation}
onSend={send}
onStop={onStop}
onStop={interrupt}
/>
)
}
@@ -247,17 +247,29 @@ function NativeChatResolvedView({
(text: string, imagePaths?: string[]) => {
setWorkingInterrupted(false)
const sentAt = Date.now()
const boundary = session.messages.at(-1)
const entry: NativeChatPendingSend = {
id: nextNativeChatPendingSendId(sentAt),
text,
sentAt,
afterMessageId: session.messages.at(-1)?.id ?? null,
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]
)
const onSlashCommand = useCallback(
(command: string) => {
setCommandMarkers(appendCommandMarkerCache(commandMarkerScope, command))
@@ -439,6 +451,7 @@ function NativeChatResolvedView({
isWorking={isWorking}
onStop={stopAgent}
onOptimisticSend={onOptimisticSend}
onOptimisticSendCanceled={onOptimisticSendCanceled}
onSlashCommand={onSlashCommand}
/>
{contextMenu.menu}
@@ -1,9 +1,16 @@
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
}
@@ -12,8 +19,69 @@ export function normalizeNativeChatPendingText(text: string): string {
return stripImagePromptMarker(text).trim().replace(/\s+/g, ' ')
}
export function nativeChatPendingContentKey(
pending: Pick<NativeChatPendingOccurrence, 'text' | 'imagePaths'>
): 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<string, number> {
const counts = new Map<string, number>()
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<string, number> {
const advanced = new Map<string, number>()
const waiting = new Map<string, number>()
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${normalizeNativeChatPendingText(pending.text)}`
return `${String(pending.afterMessageId)}\0${nativeChatPendingContentKey(pending)}`
}
export function assignNativeChatPendingOccurrence<T extends NativeChatPendingOccurrence>(
@@ -34,12 +102,13 @@ export function assignNativeChatPendingOccurrence<T extends NativeChatPendingOcc
return {
...entry,
matchingOccurrence: previousOccurrence + 1,
matchingAfterTimestamp: first?.matchingAfterTimestamp ?? first?.sentAt
matchingAfterTimestamp:
first?.matchingAfterTimestamp ?? first?.afterMessageTimestamp ?? first?.sentAt
}
}
export function nativeChatPendingMatchingAfter(pending: NativeChatPendingOccurrence): number {
return pending.matchingAfterTimestamp ?? pending.sentAt
return pending.matchingAfterTimestamp ?? pending.afterMessageTimestamp ?? pending.sentAt
}
export function nativeChatPendingOccurrence(
@@ -42,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', () => {
@@ -85,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')])
@@ -146,6 +166,12 @@ 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')]
@@ -175,6 +201,24 @@ describe('pendingSendsAsMessages', () => {
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(
@@ -1,17 +1,19 @@
// 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 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,
normalizeNativeChatPendingText
nativeChatPendingOccurrence
} from './native-chat-pending-occurrence'
/** An optimistic, not-yet-confirmed composer send. */
@@ -27,6 +29,8 @@ export type NativeChatPendingSend = {
/** 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. */
@@ -81,56 +85,6 @@ export function clearPendingSendCacheForTests(): void {
pendingSendCounter = 0
}
function normalize(text: string): string {
return normalizeNativeChatPendingText(text)
}
/** 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
}
const text = message.blocks
.filter(isTextBlock)
.map((block) => block.text)
.join(' ')
return normalize(text)
}
function matchingUserMessageTextCounts(
messages: readonly NativeChatMessage[]
): Map<string, number> {
const counts = new Map<string, number>()
for (const message of messages) {
const text = userMessageText(message)
if (text) {
counts.set(text, (counts.get(text) ?? 0) + 1)
}
}
return counts
}
function advancedPastUserMessageTextCounts(
messages: readonly NativeChatMessage[]
): Map<string, number> {
const advanced = new Map<string, number>()
const waiting = new Map<string, number>()
for (const message of messages) {
if (message.role === 'user') {
const text = userMessageText(message)
if (text) {
waiting.set(text, (waiting.get(text) ?? 0) + 1)
}
continue
}
for (const [text, count] of waiting) {
advanced.set(text, (advanced.get(text) ?? 0) + count)
}
waiting.clear()
}
return advanced
}
function messagesAfterPendingBoundary(
messages: readonly NativeChatMessage[],
pending: NativeChatPendingSend
@@ -139,10 +93,7 @@ function messagesAfterPendingBoundary(
return messages
}
if (pending.afterMessageId === null) {
return messages.filter(
(message) =>
message.timestamp !== null && message.timestamp >= nativeChatPendingMatchingAfter(pending)
)
return messages.filter((message) => messageIsAfterPendingTimestamp(message, pending))
}
const boundaryIndex = messages.findIndex((message) => message.id === pending.afterMessageId)
if (boundaryIndex >= 0) {
@@ -150,10 +101,22 @@ function messagesAfterPendingBoundary(
}
// 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) =>
message.timestamp !== null && message.timestamp >= nativeChatPendingMatchingAfter(pending)
)
return messages.filter((message) => messageIsAfterPendingTimestamp(message, pending))
}
function messageIsAfterPendingTimestamp(
message: NativeChatMessage,
pending: NativeChatPendingSend
): boolean {
if (message.timestamp === null) {
return false
}
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
}
/**
@@ -171,11 +134,12 @@ export function prunePendingSends(
}
const consumed = new Map<string, number>()
const next = pending.filter((entry) => {
const text = normalize(entry.text)
const contentKey = nativeChatPendingContentKey(entry)
const key = nativeChatPendingMatchKey(entry)
const available =
advancedPastUserMessageTextCounts(messagesAfterPendingBoundary(messages, entry)).get(text) ??
0
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))
@@ -200,12 +164,12 @@ export function pendingSendsAsMessages(
const consumed = new Map<string, number>()
return pending
.filter((entry) => {
const text = normalize(entry.text)
const contentKey = nativeChatPendingContentKey(entry)
const key = nativeChatPendingMatchKey(entry)
const represented =
matchingUserMessageTextCounts(messagesAfterPendingBoundary(existingMessages, entry)).get(
text
) ?? 0
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))
@@ -241,12 +205,12 @@ export function launchPromptAsMessage(
if (!entry) {
return null
}
const represented = matchingUserMessageTextCounts(
const represented = matchingNativeChatUserContentCounts(
existingMessages.filter(
(message) => message.timestamp !== null && message.timestamp >= entry.createdAt
)
)
if ((represented.get(normalize(entry.text)) ?? 0) > 0) {
if ((represented.get(nativeChatPendingContentKey(entry)) ?? 0) > 0) {
return null
}
return {
@@ -268,7 +232,9 @@ export function shouldPruneLaunchPrompt(
const relevant = messages.filter(
(message) => message.timestamp !== null && message.timestamp >= entry.createdAt
)
return (advancedPastUserMessageTextCounts(relevant).get(normalize(entry.text)) ?? 0) > 0
return (
(advancedNativeChatUserContentCounts(relevant).get(nativeChatPendingContentKey(entry)) ?? 0) > 0
)
}
export function nextNativeChatPendingSendId(now = Date.now()): string {
@@ -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'
)
})
})
@@ -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) => {
@@ -17,28 +17,35 @@ describe('useNativeChatSendLifecycle', () => {
vi.useFakeTimers()
const first = handle()
const second = handle()
const onPendingSendCanceled = vi.fn()
const { result, rerender, unmount } = renderHook(
({ targetPtyId }) => useNativeChatSendLifecycle('tab-1', targetPtyId),
({ targetPtyId }) => useNativeChatSendLifecycle('tab-1', targetPtyId, onPendingSendCanceled),
{ initialProps: { targetPtyId: 'pty-1' as string | null } }
)
act(() => result.current.trackPendingSend(first))
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))
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 { result, unmount } = renderHook(() => useNativeChatSendLifecycle('tab-1', 'pty-1'))
const onPendingSendCanceled = vi.fn()
const { result, unmount } = renderHook(() =>
useNativeChatSendLifecycle('tab-1', 'pty-1', onPendingSendCanceled)
)
act(() => result.current.trackPendingSend(pending))
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()
@@ -47,12 +54,16 @@ describe('useNativeChatSendLifecycle', () => {
it('drops settled handles so a later interrupt does not revisit completed sends', () => {
vi.useFakeTimers()
const settled = handle(800)
const { result } = renderHook(() => useNativeChatSendLifecycle('tab-1', 'pty-1'))
const onPendingSendCanceled = vi.fn()
const { result } = renderHook(() =>
useNativeChatSendLifecycle('tab-1', 'pty-1', onPendingSendCanceled)
)
act(() => result.current.trackPendingSend(settled))
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()
})
})
@@ -1,35 +1,46 @@
import { useCallback, useEffect, useRef } from 'react'
import { useCallback, useLayoutEffect, useRef } from 'react'
import type { NativeChatSendHandle } from './native-chat-runtime-send'
export type NativeChatSendLifecycle = {
cancelPendingSends: () => void
trackPendingSend: (handle: NativeChatSendHandle) => void
trackPendingSend: (handle: NativeChatSendHandle, pendingId?: string) => void
}
export function useNativeChatSendLifecycle(
terminalTabId: string,
targetPtyId: string | null
targetPtyId: string | null,
onPendingSendCanceled?: (pendingId: string) => void
): NativeChatSendLifecycle {
const pendingSendHandlesRef = useRef(
new Map<NativeChatSendHandle, ReturnType<typeof setTimeout>>()
new Map<
NativeChatSendHandle,
{ cleanupTimer: ReturnType<typeof setTimeout>; pendingId?: string }
>()
)
const cancelPendingSends = useCallback(() => {
for (const [handle, cleanupTimer] of pendingSendHandlesRef.current) {
for (const [handle, entry] of pendingSendHandlesRef.current) {
const { cleanupTimer, pendingId } = entry
clearTimeout(cleanupTimer)
handle.cancel()
if (pendingId) {
onPendingSendCanceled?.(pendingId)
}
}
pendingSendHandlesRef.current.clear()
}, [])
const trackPendingSend = useCallback((handle: NativeChatSendHandle) => {
}, [onPendingSendCanceled])
const trackPendingSend = useCallback((handle: NativeChatSendHandle, pendingId?: string) => {
const cleanupTimer = setTimeout(() => {
pendingSendHandlesRef.current.delete(handle)
}, handle.settleAfterMs)
pendingSendHandlesRef.current.set(handle, cleanupTimer)
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.
useEffect(() => cancelPendingSends, [cancelPendingSends, targetPtyId, terminalTabId])
useLayoutEffect(() => cancelPendingSends, [cancelPendingSends, targetPtyId, terminalTabId])
return { cancelPendingSends, trackPendingSend }
}