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 cf903d6ae66..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 { @@ -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(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 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 ) } diff --git a/src/renderer/src/components/native-chat/NativeChatView.tsx b/src/renderer/src/components/native-chat/NativeChatView.tsx index 02eb347b25a..47e8dc8b9af 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.tsx @@ -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} 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 index 0d60c97a6ec..3c94e5bfadd 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts @@ -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 +): 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${normalizeNativeChatPendingText(pending.text)}` + return `${String(pending.afterMessageId)}\0${nativeChatPendingContentKey(pending)}` } export function assignNativeChatPendingOccurrence( @@ -34,12 +102,13 @@ export function assignNativeChatPendingOccurrence ({ 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( 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 a2d032e9c2e..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,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 { - const counts = new Map() - 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 { - const advanced = new Map() - const waiting = new Map() - 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() 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() 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 { 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 index 54798c62424..63525c1e3e7 100644 --- 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 @@ -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() }) }) 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 index 5244dc82989..41e46148bfe 100644 --- 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 @@ -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>() + new Map< + NativeChatSendHandle, + { cleanupTimer: ReturnType; 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 } }