fix(native-chat): scope composer sends to the active turn

This commit is contained in:
Jinjing
2026-07-13 06:52:35 -07:00
parent 7b3103f890
commit dd6c7f5598
11 changed files with 493 additions and 57 deletions
@@ -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<NativeChatComposerHandle, NativeCha
const [dictationPressed, setDictationPressed] = useState(false)
const skills = useNativeChatSkills(agent, terminalTabId)
const textareaRef = useRef<HTMLTextAreaElement>(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<NativeChatComposerHandle, NativeCha
// chip needs no TUI un-paste: send images, then text, then Enter atomically.
const isSlashCommand = isSlashCommandDraft(text)
if (isSlashCommand) {
sendNativeChatMessage(target.settings, target.ptyId, text)
trackPendingSend(sendNativeChatMessage(target.settings, target.ptyId, text))
} else if (imagePaths.length > 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<NativeChatComposerHandle, NativeCha
resolveTarget,
onOptimisticSend,
onSlashCommand,
trackPendingSend,
setDraft
])
const interrupt = useCallback(() => {
cancelPendingSends()
if (isWorking && onStop) {
onStop()
return
@@ -342,7 +351,7 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
return
}
sendRuntimePtyInput(target.settings, target.ptyId, ESC)
}, [isWorking, onStop, resolveTarget])
}, [cancelPendingSends, isWorking, onStop, resolveTarget])
const chooseSlash = useCallback(
(command: SlashCommandSuggestion) => {
@@ -362,7 +371,7 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
if (!target || disabled) {
return
}
sendNativeChatMessage(target.settings, target.ptyId, next)
trackPendingSend(sendNativeChatMessage(target.settings, target.ptyId, next))
// Surface the command as a system line (this is the autocomplete-menu
// dispatch path; the typed-Enter path in `send` does the same).
onSlashCommand?.(next.trim())
@@ -376,7 +385,7 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
setActiveSuggestion(0)
setNotice(null)
},
[agent, disabled, resolveTarget, onSlashCommand, setDraft]
[agent, disabled, resolveTarget, onSlashCommand, setDraft, trackPendingSend]
)
const handleKeyDown = useNativeChatComposerKeyDown({
@@ -25,6 +25,7 @@ import {
appendCommandMarkerCache,
launchPromptAsMessage,
pendingSendsAsMessages,
nextNativeChatPendingSendId,
prunePendingSends,
readCommandMarkerCache,
readPendingSendCache,
@@ -211,7 +212,6 @@ function NativeChatResolvedView({
const [pending, setPending] = useState<NativeChatPendingSend[]>(() =>
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<NativeChatCommandMarker[]>(() =>
@@ -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<typeof session>(() => {
if (pending.length === 0 && commandMarkers.length === 0 && !streamingText) {
return sessionAfterCommandBoundaries
@@ -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([])
})
})
@@ -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<T extends NativeChatPendingOccurrence>(
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
}
@@ -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' }
@@ -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<string, NativeChatPendingSend[]>()
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<string> {
const texts = new Set<string>()
function matchingUserMessageTextCounts(
messages: readonly NativeChatMessage[]
): Map<string, number> {
const counts = new Map<string, number>()
for (const message of messages) {
const text = userMessageText(message)
if (text) {
texts.add(text)
counts.set(text, (counts.get(text) ?? 0) + 1)
}
}
return texts
return counts
}
function advancedPastUserMessageTexts(messages: NativeChatMessage[]): Set<string> {
const advanced = new Set<string>()
const waiting = new Set<string>()
function advancedPastUserMessageTextCounts(
messages: readonly NativeChatMessage[]
): Map<string, number> {
const advanced = new Map<string, number>()
const waiting = new Map<string, number>()
for (const message of messages) {
if (message.role === 'user') {
const text = userMessageText(message)
if (text) {
waiting.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<string, number>()
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<string, number>()
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 {
@@ -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)
@@ -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
}
}
@@ -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()
})
})
@@ -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<NativeChatSendHandle, ReturnType<typeof setTimeout>>()
)
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 }
}
+16
View File
@@ -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({