mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
fix(native-chat): scope composer sends to the active turn (#8568)
* fix(native-chat): scope composer sends to the active turn * fix(native-chat): close pending send lifecycle gaps * fix(native-chat): deduplicate pending message derivation
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
cancelPendingSends: vi.fn(),
|
||||
fieldProps: null as { onSend?: () => void; onStop?: () => void } | null,
|
||||
sendHandle: { cancel: vi.fn(), settleAfterMs: 500 },
|
||||
sendNativeChatMessage: vi.fn(),
|
||||
trackPendingSend: vi.fn(),
|
||||
setDraft: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
useAppStore: (selector: (state: unknown) => unknown) =>
|
||||
selector({ dictationState: 'idle', settings: { voice: { enabled: false } } })
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/runtime-terminal-inspection', () => ({
|
||||
isRemoteRuntimePtyId: () => false,
|
||||
sendRuntimePtyInput: vi.fn()
|
||||
}))
|
||||
vi.mock('@/lib/agent-paste-draft', () => ({
|
||||
getSettingsForAgentTabRuntimeOwner: () => ({})
|
||||
}))
|
||||
vi.mock('./native-chat-runtime-send', () => ({
|
||||
sendNativeChatMessage: (...args: unknown[]) => mocks.sendNativeChatMessage(...args),
|
||||
sendNativeChatMessageWithImageAttachments: vi.fn(),
|
||||
submitNativeChatPrompt: vi.fn()
|
||||
}))
|
||||
vi.mock('./native-chat-agent-commands', () => ({ getAgentSlashCommands: () => [] }))
|
||||
vi.mock('@/lib/native-chat-telemetry', () => ({ emitNativeChatMessageSent: vi.fn() }))
|
||||
vi.mock('./use-native-chat-draft', () => ({
|
||||
useNativeChatDraft: () => ({ draft: 'hello', setDraft: mocks.setDraft })
|
||||
}))
|
||||
vi.mock('./native-chat-draft-cache', () => ({ readNativeChatDraftCache: () => '' }))
|
||||
vi.mock('./NativeChatComposerField', () => ({
|
||||
NativeChatComposerField: (props: { onSend?: () => void; onStop?: () => void }) => {
|
||||
mocks.fieldProps = props
|
||||
return null
|
||||
}
|
||||
}))
|
||||
vi.mock('./use-native-chat-skills', () => ({ useNativeChatSkills: () => [] }))
|
||||
vi.mock('./use-native-chat-composer-attachments', () => ({
|
||||
useNativeChatComposerAttachments: () => ({
|
||||
imageAttachments: [],
|
||||
attachResolvedPaths: vi.fn(),
|
||||
clearImageAttachments: vi.fn(),
|
||||
removeImageAttachment: vi.fn()
|
||||
})
|
||||
}))
|
||||
vi.mock('./use-native-chat-composer-paste', () => ({
|
||||
useNativeChatComposerPaste: () => ({ handlePaste: vi.fn(), pasteFromClipboard: vi.fn() })
|
||||
}))
|
||||
vi.mock('./use-native-chat-external-attachments', () => ({
|
||||
useNativeChatExternalAttachments: () => ({
|
||||
attachExternalPaths: vi.fn(),
|
||||
resolveAttachmentOwner: vi.fn()
|
||||
})
|
||||
}))
|
||||
vi.mock('../dictation/dictation-control-events', () => ({ dispatchDictationControl: vi.fn() }))
|
||||
vi.mock('./use-native-chat-composer-keydown', () => ({
|
||||
useNativeChatComposerKeyDown: () => vi.fn()
|
||||
}))
|
||||
vi.mock('./use-native-chat-send-lifecycle', () => ({
|
||||
useNativeChatSendLifecycle: () => ({
|
||||
cancelPendingSends: mocks.cancelPendingSends,
|
||||
trackPendingSend: mocks.trackPendingSend
|
||||
})
|
||||
}))
|
||||
|
||||
import { NativeChatComposer } from './NativeChatComposer'
|
||||
|
||||
describe('NativeChatComposer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.fieldProps = null
|
||||
mocks.sendNativeChatMessage.mockReturnValue(mocks.sendHandle)
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { ui: { onFileDrop: () => vi.fn() } }
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
it('cancels delayed composer writes before the Stop button interrupts the agent', () => {
|
||||
const onStop = vi.fn()
|
||||
render(
|
||||
<NativeChatComposer
|
||||
terminalTabId="tab-1"
|
||||
targetPtyId="pty-1"
|
||||
agent="codex"
|
||||
isWorking
|
||||
onStop={onStop}
|
||||
/>
|
||||
)
|
||||
|
||||
act(() => mocks.fieldProps?.onStop?.())
|
||||
|
||||
expect(mocks.cancelPendingSends).toHaveBeenCalledOnce()
|
||||
expect(onStop).toHaveBeenCalledOnce()
|
||||
expect(mocks.cancelPendingSends.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
onStop.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY
|
||||
)
|
||||
})
|
||||
|
||||
it('associates a delayed submit with its optimistic cache entry', () => {
|
||||
const onOptimisticSend = vi.fn(() => 'pending-1')
|
||||
render(
|
||||
<NativeChatComposer
|
||||
terminalTabId="tab-1"
|
||||
targetPtyId="pty-1"
|
||||
agent="codex"
|
||||
onOptimisticSend={onOptimisticSend}
|
||||
/>
|
||||
)
|
||||
|
||||
act(() => mocks.fieldProps?.onSend?.())
|
||||
|
||||
expect(onOptimisticSend).toHaveBeenCalledWith('hello', [])
|
||||
expect(mocks.trackPendingSend).toHaveBeenCalledWith(mocks.sendHandle, 'pending-1')
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
sendNativeChatMessageWithImageAttachments,
|
||||
submitNativeChatPrompt
|
||||
} from './native-chat-runtime-send'
|
||||
import type { NativeChatSendHandle } from './native-chat-runtime-send'
|
||||
import { getAgentSlashCommands } from './native-chat-agent-commands'
|
||||
import { emitNativeChatMessageSent } from '@/lib/native-chat-telemetry'
|
||||
import {
|
||||
@@ -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<NativeChatComposerHandle, NativeCha
|
||||
isWorking = false,
|
||||
onStop,
|
||||
onOptimisticSend,
|
||||
onOptimisticSendCanceled,
|
||||
onSlashCommand
|
||||
},
|
||||
ref
|
||||
@@ -126,6 +131,11 @@ 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,
|
||||
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<NativeChatComposerHandle, NativeCha
|
||||
// (like text) so the GUI chips and TUI input stay in sync and removing a
|
||||
// chip needs no TUI un-paste: send images, then text, then Enter atomically.
|
||||
const isSlashCommand = isSlashCommandDraft(text)
|
||||
let pendingHandle: NativeChatSendHandle | null = null
|
||||
if (isSlashCommand) {
|
||||
sendNativeChatMessage(target.settings, target.ptyId, text)
|
||||
pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text)
|
||||
} else if (imagePaths.length > 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<NativeChatComposerHandle, NativeCha
|
||||
resolveTarget,
|
||||
onOptimisticSend,
|
||||
onSlashCommand,
|
||||
trackPendingSend,
|
||||
setDraft
|
||||
])
|
||||
|
||||
const interrupt = useCallback(() => {
|
||||
cancelPendingSends()
|
||||
if (isWorking && onStop) {
|
||||
onStop()
|
||||
return
|
||||
@@ -342,7 +366,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 +386,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 +400,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({
|
||||
@@ -444,7 +468,7 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
|
||||
onDictationHoldStart={startHoldDictation}
|
||||
onDictationHoldEnd={stopHoldDictation}
|
||||
onSend={send}
|
||||
onStop={onStop}
|
||||
onStop={interrupt}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,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<typeof session>(() => {
|
||||
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}
|
||||
|
||||
@@ -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,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<NativeChatPendingOccurrence, 'text' | 'imagePaths'>
|
||||
): string {
|
||||
const text = normalizeNativeChatPendingText(pending.text)
|
||||
if (text) {
|
||||
return `text:${text}`
|
||||
}
|
||||
const imagePaths = pending.imagePaths?.filter(Boolean) ?? []
|
||||
return imagePaths.length > 0 ? `images:${JSON.stringify(imagePaths)}` : 'empty'
|
||||
}
|
||||
|
||||
function nativeChatUserMessageContentKey(message: NativeChatMessage): string | null {
|
||||
if (message.role !== 'user') {
|
||||
return null
|
||||
}
|
||||
const text = message.blocks
|
||||
.filter(isTextBlock)
|
||||
.map((block) => block.text)
|
||||
.join(' ')
|
||||
const imagePaths = message.blocks
|
||||
.filter(isImageRefBlock)
|
||||
.map((block) => block.path)
|
||||
.filter((path): path is string => Boolean(path))
|
||||
const key = nativeChatPendingContentKey({ text, imagePaths })
|
||||
return key === 'empty' ? null : key
|
||||
}
|
||||
|
||||
export function matchingNativeChatUserContentCounts(
|
||||
messages: readonly NativeChatMessage[]
|
||||
): Map<string, number> {
|
||||
const counts = new Map<string, number>()
|
||||
for (const message of messages) {
|
||||
const key = nativeChatUserMessageContentKey(message)
|
||||
if (key) {
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
export function advancedNativeChatUserContentCounts(
|
||||
messages: readonly NativeChatMessage[]
|
||||
): Map<string, number> {
|
||||
const advanced = new Map<string, number>()
|
||||
const waiting = new Map<string, number>()
|
||||
for (const message of messages) {
|
||||
if (message.role === 'user') {
|
||||
const key = nativeChatUserMessageContentKey(message)
|
||||
if (key) {
|
||||
waiting.set(key, (waiting.get(key) ?? 0) + 1)
|
||||
}
|
||||
continue
|
||||
}
|
||||
for (const [key, count] of waiting) {
|
||||
advanced.set(key, (advanced.get(key) ?? 0) + count)
|
||||
}
|
||||
waiting.clear()
|
||||
}
|
||||
return advanced
|
||||
}
|
||||
|
||||
export function nativeChatPendingMatchKey(pending: NativeChatPendingOccurrence): string {
|
||||
return `${String(pending.afterMessageId)}\0${nativeChatPendingContentKey(pending)}`
|
||||
}
|
||||
|
||||
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?.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
|
||||
}
|
||||
@@ -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' }
|
||||
|
||||
@@ -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<string, NativeChatPendingSend[]>()
|
||||
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<string> {
|
||||
const texts = new Set<string>()
|
||||
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<string> {
|
||||
const advanced = new Set<string>()
|
||||
const waiting = new Set<string>()
|
||||
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<string, number>()
|
||||
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<string, number>()
|
||||
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 {
|
||||
|
||||
@@ -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,61 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
cancel: vi.fn(),
|
||||
sendRuntimePtyInput: vi.fn(),
|
||||
sendNativeChatAnswer: vi.fn(),
|
||||
sendNativeChatMessage: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/runtime-terminal-inspection', () => ({
|
||||
sendRuntimePtyInput: (...args: unknown[]) => mocks.sendRuntimePtyInput(...args)
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/agent-paste-draft', () => ({
|
||||
getSettingsForAgentTabRuntimeOwner: (terminalTabId: string) => ({ terminalTabId })
|
||||
}))
|
||||
|
||||
vi.mock('./native-chat-runtime-send', () => ({
|
||||
sendNativeChatAnswer: (...args: unknown[]) => mocks.sendNativeChatAnswer(...args),
|
||||
sendNativeChatMessage: (...args: unknown[]) => mocks.sendNativeChatMessage(...args)
|
||||
}))
|
||||
|
||||
import { useNativeChatInteractiveSend } from './use-native-chat-interactive-send'
|
||||
|
||||
describe('useNativeChatInteractiveSend', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
const handle = { cancel: mocks.cancel, settleAfterMs: 500 }
|
||||
mocks.sendNativeChatAnswer.mockReturnValue(handle)
|
||||
mocks.sendNativeChatMessage.mockReturnValue(handle)
|
||||
})
|
||||
|
||||
it('cancels delayed answer writes when the PTY target changes', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ targetPtyId }) => useNativeChatInteractiveSend('tab-1', targetPtyId, 'codex'),
|
||||
{ initialProps: { targetPtyId: 'pty-1' as string | null } }
|
||||
)
|
||||
|
||||
act(() => result.current.sendAnswer('continue'))
|
||||
rerender({ targetPtyId: 'pty-2' })
|
||||
|
||||
expect(mocks.cancel).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('cancels delayed answer writes before interrupting the active PTY', () => {
|
||||
const { result } = renderHook(() => useNativeChatInteractiveSend('tab-1', 'pty-1', 'claude'))
|
||||
|
||||
act(() => result.current.sendAnswer('one\ntwo'))
|
||||
act(() => result.current.cancel())
|
||||
|
||||
expect(mocks.cancel).toHaveBeenCalledOnce()
|
||||
expect(mocks.sendRuntimePtyInput).toHaveBeenCalledWith(
|
||||
{ terminalTabId: 'tab-1' },
|
||||
'pty-1',
|
||||
'\x1b'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useCallback, useLayoutEffect, useRef } from 'react'
|
||||
import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection'
|
||||
import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
|
||||
import type { AgentType } from '../../../../shared/native-chat-types'
|
||||
@@ -42,7 +42,9 @@ export function useNativeChatInteractiveSend(
|
||||
inFlightRef.current?.cancel()
|
||||
inFlightRef.current = null
|
||||
}, [])
|
||||
useEffect(() => cancelInFlight, [cancelInFlight])
|
||||
// Why: a split can be rebound without unmounting this view. Cancel during
|
||||
// commit so no delayed answer write can race the replacement PTY.
|
||||
useLayoutEffect(() => cancelInFlight, [cancelInFlight, targetPtyId, terminalTabId])
|
||||
|
||||
const sendRaw = useCallback(
|
||||
(raw: string) => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<typeof setTimeout>; 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 }
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user