diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index d85a3a59bbe..cf903d6ae66 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -44,6 +44,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 @@ -126,6 +127,10 @@ export const NativeChatComposer = forwardRef(null) + const { cancelPendingSends, trackPendingSend } = useNativeChatSendLifecycle( + terminalTabId, + targetPtyId + ) const dictationState = useAppStore((store) => store.dictationState) const voiceSettings = useAppStore((store) => store.settings?.voice) const isDictationHoldMode = voiceSettings?.dictationMode === 'hold' @@ -294,11 +299,13 @@ export const NativeChatComposer = forwardRef 0) { - sendNativeChatMessageWithImageAttachments(target.settings, target.ptyId, text, imagePaths) + trackPendingSend( + sendNativeChatMessageWithImageAttachments(target.settings, target.ptyId, text, imagePaths) + ) } else if (text.trim().length > 0) { - sendNativeChatMessage(target.settings, target.ptyId, text) + trackPendingSend(sendNativeChatMessage(target.settings, target.ptyId, text)) } else { submitNativeChatPrompt(target.settings, target.ptyId) } @@ -329,10 +336,12 @@ export const NativeChatComposer = forwardRef { + cancelPendingSends() if (isWorking && onStop) { onStop() return @@ -342,7 +351,7 @@ export const NativeChatComposer = forwardRef { @@ -362,7 +371,7 @@ export const NativeChatComposer = forwardRef(() => 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,16 +246,17 @@ function NativeChatResolvedView({ const onOptimisticSend = useCallback( (text: string, imagePaths?: string[]) => { setWorkingInterrupted(false) - pendingCounter.current += 1 + const sentAt = Date.now() const entry: NativeChatPendingSend = { - id: `${pendingCounter.current}`, + id: nextNativeChatPendingSendId(sentAt), text, - sentAt: Date.now(), + sentAt, + afterMessageId: session.messages.at(-1)?.id ?? null, ...(imagePaths ? { imagePaths } : {}) } setPending(appendPendingSendCache(pendingScope, entry)) }, - [pendingScope] + [pendingScope, session.messages] ) const onSlashCommand = useCallback( (command: string) => { @@ -293,15 +294,17 @@ 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 streamingText = useMemo(() => { + const pendingMessages = pendingSendsAsMessages(pending, sessionAfterCommandBoundaries.messages) + return deriveNativeChatStreamingText({ + messages: + pendingMessages.length > 0 + ? [...sessionAfterCommandBoundaries.messages, ...pendingMessages] + : sessionAfterCommandBoundaries.messages, + previewText: hookPreview, + working: hookWorking + }) + }, [sessionAfterCommandBoundaries.messages, pending, hookPreview, hookWorking]) const sessionWithPending = useMemo(() => { if (pending.length === 0 && commandMarkers.length === 0 && !streamingText) { return sessionAfterCommandBoundaries 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..0d60c97a6ec --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts @@ -0,0 +1,50 @@ +import { stripImagePromptMarker } from './native-chat-image-transcript-markers' + +export type NativeChatPendingOccurrence = { + text: string + sentAt: number + afterMessageId?: string | null + matchingOccurrence?: number + matchingAfterTimestamp?: number +} + +export function normalizeNativeChatPendingText(text: string): string { + return stripImagePromptMarker(text).trim().replace(/\s+/g, ' ') +} + +export function nativeChatPendingMatchKey(pending: NativeChatPendingOccurrence): string { + return `${String(pending.afterMessageId)}\0${normalizeNativeChatPendingText(pending.text)}` +} + +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?.sentAt + } +} + +export function nativeChatPendingMatchingAfter(pending: NativeChatPendingOccurrence): number { + return pending.matchingAfterTimestamp ?? 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..eb4d37b1984 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, @@ -104,6 +105,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', () => { @@ -136,6 +152,35 @@ describe('pendingSendsAsMessages', () => { 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('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 +210,7 @@ describe('launchPromptAsMessage', () => { text: 'Fix failing checks', createdAt: 42 }, - [userMessage('u1', 'Fix failing checks')] + [{ ...userMessage('u1', 'Fix failing checks'), timestamp: 43 }] ) ).toBeNull() }) @@ -180,11 +225,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 +256,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 +298,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..a2d032e9c2e 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.ts @@ -4,9 +4,15 @@ // rule (match on normalized user-message text) is unit-testable without React. import { isTextBlock, type NativeChatMessage } from '../../../../shared/native-chat-types' -import { stripImagePromptMarker } from './native-chat-image-transcript-markers' import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' import type { NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt' +import { + assignNativeChatPendingOccurrence, + nativeChatPendingMatchKey, + nativeChatPendingMatchingAfter, + nativeChatPendingOccurrence, + normalizeNativeChatPendingText +} from './native-chat-pending-occurrence' /** An optimistic, not-yet-confirmed composer send. */ export type NativeChatPendingSend = { @@ -18,6 +24,13 @@ 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 + /** 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 +40,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,15 +71,18 @@ 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, ' ') + return normalizeNativeChatPendingText(text) } /** The prose of a user message, normalized for matching against a pending send. */ @@ -80,35 +97,65 @@ function userMessageText(message: NativeChatMessage): string | null { return normalize(text) } -function matchingUserMessageTexts(messages: NativeChatMessage[]): Set { - const texts = new Set() +function matchingUserMessageTextCounts( + messages: readonly NativeChatMessage[] +): Map { + const counts = new Map() for (const message of messages) { const text = userMessageText(message) if (text) { - texts.add(text) + counts.set(text, (counts.get(text) ?? 0) + 1) } } - return texts + return counts } -function advancedPastUserMessageTexts(messages: NativeChatMessage[]): Set { - const advanced = new Set() - const waiting = new Set() +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.add(text) + waiting.set(text, (waiting.get(text) ?? 0) + 1) } continue } - for (const text of waiting) { - advanced.add(text) + 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 +): readonly NativeChatMessage[] { + if (pending.afterMessageId === undefined) { + return messages + } + if (pending.afterMessageId === null) { + return messages.filter( + (message) => + message.timestamp !== null && message.timestamp >= nativeChatPendingMatchingAfter(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) => + message.timestamp !== null && message.timestamp >= nativeChatPendingMatchingAfter(pending) + ) +} + /** * Drop any pending send only after the transcript has advanced beyond its real * user turn. Keeping the echo through the user-only transcript phase prevents a @@ -122,8 +169,21 @@ 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 text = normalize(entry.text) + const key = nativeChatPendingMatchKey(entry) + const available = + advancedPastUserMessageTextCounts(messagesAfterPendingBoundary(messages, entry)).get(text) ?? + 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 +197,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 text = normalize(entry.text) + const key = nativeChatPendingMatchKey(entry) + const represented = + matchingUserMessageTextCounts(messagesAfterPendingBoundary(existingMessages, entry)).get( + text + ) ?? 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 +241,12 @@ export function launchPromptAsMessage( if (!entry) { return null } - const represented = matchingUserMessageTexts(existingMessages) - if (represented.has(normalize(entry.text))) { + const represented = matchingUserMessageTextCounts( + existingMessages.filter( + (message) => message.timestamp !== null && message.timestamp >= entry.createdAt + ) + ) + if ((represented.get(normalize(entry.text)) ?? 0) > 0) { return null } return { @@ -187,7 +265,15 @@ 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 (advancedPastUserMessageTextCounts(relevant).get(normalize(entry.text)) ?? 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-send-lifecycle.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx new file mode 100644 index 00000000000..54798c62424 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx @@ -0,0 +1,58 @@ +// @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 { result, rerender, unmount } = renderHook( + ({ targetPtyId }) => useNativeChatSendLifecycle('tab-1', targetPtyId), + { initialProps: { targetPtyId: 'pty-1' as string | null } } + ) + + act(() => result.current.trackPendingSend(first)) + rerender({ targetPtyId: 'pty-2' }) + expect(first.cancel).toHaveBeenCalledOnce() + + act(() => result.current.trackPendingSend(second)) + unmount() + expect(second.cancel).toHaveBeenCalledOnce() + }) + + it('cancels pending writes immediately on interrupt without double-cancelling', () => { + vi.useFakeTimers() + const pending = handle() + const { result, unmount } = renderHook(() => useNativeChatSendLifecycle('tab-1', 'pty-1')) + + act(() => result.current.trackPendingSend(pending)) + act(() => result.current.cancelPendingSends()) + expect(pending.cancel).toHaveBeenCalledOnce() + + 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 { result } = renderHook(() => useNativeChatSendLifecycle('tab-1', 'pty-1')) + + act(() => result.current.trackPendingSend(settled)) + act(() => vi.advanceTimersByTime(settled.settleAfterMs)) + act(() => result.current.cancelPendingSends()) + + expect(settled.cancel).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..5244dc82989 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.ts @@ -0,0 +1,35 @@ +import { useCallback, useEffect, useRef } from 'react' +import type { NativeChatSendHandle } from './native-chat-runtime-send' + +export type NativeChatSendLifecycle = { + cancelPendingSends: () => void + trackPendingSend: (handle: NativeChatSendHandle) => void +} + +export function useNativeChatSendLifecycle( + terminalTabId: string, + targetPtyId: string | null +): NativeChatSendLifecycle { + const pendingSendHandlesRef = useRef( + new Map>() + ) + const cancelPendingSends = useCallback(() => { + for (const [handle, cleanupTimer] of pendingSendHandlesRef.current) { + clearTimeout(cleanupTimer) + handle.cancel() + } + pendingSendHandlesRef.current.clear() + }, []) + const trackPendingSend = useCallback((handle: NativeChatSendHandle) => { + const cleanupTimer = setTimeout(() => { + pendingSendHandlesRef.current.delete(handle) + }, handle.settleAfterMs) + pendingSendHandlesRef.current.set(handle, cleanupTimer) + }, []) + + // 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]) + + 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({