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({