diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx index ffceaa14959f..0d00f7a22585 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -33,6 +33,7 @@ import { shouldPruneLaunchPrompt } from './native-chat-pending' import { useNativeChatPendingDelivery } from './use-native-chat-pending-delivery' +import { useClearNativeChatQueuedWhenIdle } from './use-clear-native-chat-queued-when-idle' import { appendCommandMarkerCache, applyCommandMarkerBoundaries, @@ -156,14 +157,13 @@ export function NativeChatResolvedView({ [paneKey, agent, sessionId] ) const delivery = useNativeChatPendingDelivery({ paneKey, agent, session }) - const { pending, record, clear } = delivery + const { pending, record, clear, clearQueued } = delivery // 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(() => readCommandMarkerCache(commandMarkerScope) ) - // Command markers are session-scoped because slash commands like /clear are - // local feedback for a specific transcript boundary. + // Command markers are session-scoped (/clear is local transcript feedback). useEffect(() => { setCommandMarkers(readCommandMarkerCache(commandMarkerScope)) setWorkingInterrupted(false) @@ -174,13 +174,6 @@ export function NativeChatResolvedView({ } clearNativeChatLaunchPrompt(terminalTabId) }, [clearNativeChatLaunchPrompt, paneLaunchPrompt, session.messages, terminalTabId]) - const onOptimisticSend = useCallback( - (text: string, imagePaths?: string[]) => { - setWorkingInterrupted(false) - return record(text, imagePaths) - }, - [record] - ) const onSlashCommand = useCallback( (command: string, output?: string) => { setCommandMarkers(appendCommandMarkerCache(commandMarkerScope, command, Date.now(), output)) @@ -244,9 +237,7 @@ export function NativeChatResolvedView({ isFocusedGroup, composerReady: shownPromptCard === null && targetPtyId !== null && canSend }) - - // The streaming preview bubble (if any) sits after the transcript but before - // the optimistic user echoes — same order mobile uses. + // Pending above stream; queued follow-ups wait after via rank. const pendingMessages = useMemo( () => pendingSendsAsMessages(pending, sessionAfterCommandBoundaries.messages), [pending, sessionAfterCommandBoundaries.messages] @@ -268,6 +259,7 @@ export function NativeChatResolvedView({ liveWorking, hookPreviewIsToolOutput ]) + useClearNativeChatQueuedWhenIdle(liveWorking, streamingText, clearQueued) const sessionWithPending = useMemo(() => { if (pending.length === 0 && commandMarkers.length === 0 && !streamingText) { return sessionAfterCommandBoundaries @@ -277,14 +269,20 @@ export function NativeChatResolvedView({ messages: [ ...sessionAfterCommandBoundaries.messages, ...commandMarkersAsMessages(commandMarkers), - ...(streamingText ? [nativeChatStreamingMessage(streamingText)] : []), - ...pendingMessages + ...pendingMessages, + ...(streamingText ? [nativeChatStreamingMessage(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. + const onOptimisticSend = useCallback( + (text: string, imagePaths?: string[]) => { + setWorkingInterrupted(false) + // Mid-turn follow-ups queue behind the live reply; first prompt of the turn does not. + return record(text, imagePaths, { queued: Boolean(liveWorking || streamingText) }) + }, + [liveWorking, record, streamingText] + ) + // Pending-augmented session drives empty→list flip on first send. const viewState = selectNativeChatViewState(sessionWithPending) const isConversation = viewState.kind === 'ready' diff --git a/src/renderer/src/components/native-chat/native-chat-clear-queued-pending-sends.test.ts b/src/renderer/src/components/native-chat/native-chat-clear-queued-pending-sends.test.ts new file mode 100644 index 000000000000..3a3dcfde59c5 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-clear-queued-pending-sends.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { NATIVE_CHAT_STREAMING_ID } from '../../../../shared/native-chat-streaming' +import { clearQueuedOnPendingSends } from './native-chat-clear-queued-pending-sends' +import { pendingSendsAsMessages } from './native-chat-pending' +import { compareMessages } from './native-chat-session-assembler' + +describe('clearQueuedOnPendingSends', () => { + it('clears sticky queued so a follow-up sorts as an ordinary prompt above a later stream', () => { + const pending = [ + { id: 'p1', text: 'trigger', sentAt: 10 }, + { id: 'p2', text: 'follow-up', sentAt: 20, queued: true as const } + ] + const cleared = clearQueuedOnPendingSends(pending) + expect(cleared).toEqual([ + { id: 'p1', text: 'trigger', sentAt: 10 }, + { id: 'p2', text: 'follow-up', sentAt: 20 } + ]) + expect(clearQueuedOnPendingSends(cleared)).toBe(cleared) + + const projected = pendingSendsAsMessages(cleared) + const streaming = { + id: NATIVE_CHAT_STREAMING_ID, + role: 'assistant' as const, + blocks: [{ type: 'text' as const, text: '…' }], + timestamp: null, + source: 'hook' as const + } + expect([...projected, streaming].sort(compareMessages).map((message) => message.id)).toEqual([ + 'pending:p1', + 'pending:p2', + NATIVE_CHAT_STREAMING_ID + ]) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-clear-queued-pending-sends.ts b/src/renderer/src/components/native-chat/native-chat-clear-queued-pending-sends.ts new file mode 100644 index 000000000000..3593ce3e24a9 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-clear-queued-pending-sends.ts @@ -0,0 +1,21 @@ +import type { NativeChatPendingSend } from './native-chat-pending' + +/** + * Drop sticky mid-turn `queued` once the live turn that required queueing has + * gone idle. Remaining pendings then sort as ordinary rank-0 prompts above any + * later stream instead of staying pinned behind it. + */ +export function clearQueuedOnPendingSends( + pending: NativeChatPendingSend[] +): NativeChatPendingSend[] { + if (!pending.some((entry) => entry.queued)) { + return pending + } + return pending.map((entry) => { + if (!entry.queued) { + return entry + } + const { queued: _queued, ...rest } = entry + return rest + }) +} diff --git a/src/renderer/src/components/native-chat/native-chat-message-grouping.test.ts b/src/renderer/src/components/native-chat/native-chat-message-grouping.test.ts index 3b436dc11c9e..3b51a17726e9 100644 --- a/src/renderer/src/components/native-chat/native-chat-message-grouping.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-message-grouping.test.ts @@ -33,12 +33,29 @@ describe('orderNativeChatMessages', () => { expect(ordered.map((m) => m.id)).toEqual(['a', 'z']) }) - it('sorts the streaming preview after real content but before optimistic echoes', () => { + it('sorts a pending user prompt before an active streaming assistant preview triggered by it', () => { const ordered = orderNativeChatMessages([ msg({ id: 'pending:abc', role: 'user', timestamp: 20, source: 'scrape' }), msg({ id: NATIVE_CHAT_STREAMING_ID, timestamp: null }), msg({ id: 'real-user', role: 'user', timestamp: 10 }) ]) - expect(ordered.map((m) => m.id)).toEqual(['real-user', 'streaming', 'pending:abc']) + expect(ordered.map((m) => m.id)).toEqual(['real-user', 'pending:abc', 'streaming']) + }) + + it('sorts a pending user prompt before a subsequent assistant response', () => { + const ordered = orderNativeChatMessages([ + msg({ id: 'resp-1', role: 'assistant', timestamp: 200 }), + msg({ id: 'pending:abc', role: 'user', timestamp: 100, source: 'scrape' }) + ]) + expect(ordered.map((m) => m.id)).toEqual(['pending:abc', 'resp-1']) + }) + + it('sorts a queued prompt after an active streaming assistant preview', () => { + const ordered = orderNativeChatMessages([ + msg({ id: 'pending:queued', role: 'user', timestamp: 30, source: 'scrape', queued: true }), + msg({ id: NATIVE_CHAT_STREAMING_ID, timestamp: null }), + msg({ id: 'pending:trigger', role: 'user', timestamp: 20, source: 'scrape' }) + ]) + expect(ordered.map((m) => m.id)).toEqual(['pending:trigger', 'streaming', 'pending:queued']) }) }) 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 0491b9e1373f..5d9fe5448d53 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 @@ -22,6 +22,7 @@ import { readCommandMarkerCache } from './native-chat-command-marker' import { stripNoiseMessages } from './native-chat-noise' +import { LIFECYCLE_CLOCK_SKEW_SLACK_MS } from './native-chat-live-status' function userMessage(id: string, text: string, timestamp = 1): NativeChatMessage { return { @@ -206,6 +207,33 @@ describe('prunePendingSends', () => { expect(prunePendingSends(pending, transcript)).toEqual([]) }) + it('prunes a wall-clock first send when host timestamp is within clock skew slack', () => { + const sentAt = 1_700_000_000_000 + const pending: NativeChatPendingSend[] = [ + { id: 'p1', text: 'fix the bug', sentAt, afterMessageId: null } + ] + const transcript = [ + userMessage('u1', 'fix the bug', sentAt - 1500), + assistantMessage('a1', 'done', sentAt - 1000) + ] + + expect(prunePendingSends(pending, transcript)).toEqual([]) + }) + + it('does not prune a wall-clock first send when host timestamp is beyond clock skew slack', () => { + const sentAt = 1_700_000_000_000 + const pending: NativeChatPendingSend[] = [ + { id: 'p1', text: 'fix the bug', sentAt, afterMessageId: null } + ] + const hostTs = sentAt - (LIFECYCLE_CLOCK_SKEW_SLACK_MS + 500) + const transcript = [ + userMessage('u1', 'fix the bug', hostTs), + assistantMessage('a1', 'done', hostTs + 1) + ] + + expect(prunePendingSends(pending, transcript)).toEqual(pending) + }) + it('prunes only one of two identical pending sends for one completed turn', () => { const pending = [pendingOf('p1', 'repeat'), pendingOf('p2', 'repeat')] expect( @@ -469,6 +497,17 @@ describe('pendingSendsAsMessages', () => { ]) }) + it('projects entry.queued onto the optimistic message so follow-ups rank after streaming', () => { + const messages = pendingSendsAsMessages([ + { id: 'p1', text: 'trigger', sentAt: 10 }, + { id: 'p2', text: 'follow-up', sentAt: 20, queued: true } + ]) + expect(messages.map((message) => ({ id: message.id, queued: message.queued }))).toEqual([ + { id: 'pending:p1', queued: undefined }, + { id: 'pending:p2', queued: true } + ]) + }) + it('includes image refs for pending attachment sends', () => { const messages = pendingSendsAsMessages([ { id: 'p1', text: 'what do you see?', imagePaths: ['/tmp/shot.png'], sentAt: 42 } @@ -684,6 +723,41 @@ describe('launchPromptAsMessage', () => { expect(launchPromptAsMessage(entry, oldHistory)).not.toBeNull() expect(shouldPruneLaunchPrompt(entry, oldHistory)).toBe(false) }) + + it('hides and prunes a launch prompt when host turn is within clock skew slack', () => { + const createdAt = 1_700_000_000_000 + const entry = { + tabId: 'tab-1', + agent: 'claude' as const, + text: 'run tests', + createdAt + } + const hostTs = createdAt - 1500 + const userOnly = [userMessage('u1', 'run tests', hostTs)] + const advanced = [...userOnly, assistantMessage('a1', 'done', hostTs + 1)] + + expect(launchPromptAsMessage(entry, userOnly)).toBeNull() + expect(shouldPruneLaunchPrompt(entry, userOnly)).toBe(false) + expect(shouldPruneLaunchPrompt(entry, advanced)).toBe(true) + }) + + it('does not bind a launch prompt to a turn beyond clock skew slack', () => { + const createdAt = 1_700_000_000_000 + const entry = { + tabId: 'tab-1', + agent: 'claude' as const, + text: 'run tests', + createdAt + } + const oldTs = createdAt - (LIFECYCLE_CLOCK_SKEW_SLACK_MS + 500) + const oldHistory = [ + userMessage('old-user', 'run tests', oldTs), + assistantMessage('old-answer', 'passed', oldTs + 1) + ] + + expect(launchPromptAsMessage(entry, oldHistory)).not.toBeNull() + expect(shouldPruneLaunchPrompt(entry, oldHistory)).toBe(false) + }) }) describe('pending send cache', () => { 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 7bdec0caedbb..bd52224ac5c2 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.ts @@ -20,6 +20,7 @@ import { type NativeChatGluedUserRow, type NativeChatUserRow } from './native-chat-pending-occurrence' +import { LIFECYCLE_CLOCK_SKEW_SLACK_MS } from './native-chat-live-status' /** An optimistic, not-yet-confirmed composer send. */ export type NativeChatPendingSend = { @@ -47,6 +48,8 @@ export type NativeChatPendingSend = { matchingOccurrence?: number /** Shared time boundary when that message boundary is unavailable. */ matchingAfterTimestamp?: number + /** Queued while the agent was already streaming/working — sorts after the live reply. */ + queued?: boolean } export type NativeChatPendingSendScope = { @@ -163,11 +166,21 @@ function messageIsAfterPendingTimestamp( return true } 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 + if (pending.afterMessageTimestamp == null) { + // Local send time has no existing record. Slack accounts for cross-host + // clock skew between the client renderer's sentAt and the host transcript clock. + if ( + message.timestamp > 1e11 && + boundary > 1e11 && + message.timestamp + LIFECYCLE_CLOCK_SKEW_SLACK_MS >= boundary + ) { + return true + } + return message.timestamp >= boundary + } + // A transcript-clock boundary describes an existing message from the same host, + // so no cross-host clock skew exists and ties are strictly excluded. + return message.timestamp > boundary } /** @@ -254,10 +267,11 @@ export function prunePendingSends( } /** - * Turn pending sends into chat messages so they render in the list as queued + * Turn pending sends into chat messages so they render in the list as optimistic * user bubbles. They carry the `scrape` source (lowest priority) so the real * transcript turn always supersedes them if both are briefly present, and the - * send time as the timestamp so they sort to the end (most recent) of the list. + * send time as the timestamp so they sort with ordinary transcript order. + * Mid-turn follow-ups set `queued: true` so they wait behind the streaming preview. */ export function pendingSendsAsMessages( pending: NativeChatPendingSend[], @@ -303,7 +317,8 @@ export function pendingSendsAsMessages( ...(entry.text.trim().length > 0 ? [{ type: 'text' as const, text: entry.text }] : []) ], timestamp: entry.sentAt, - source: 'scrape' as const + source: 'scrape' as const, + ...(entry.queued ? { queued: true as const } : {}) })) } @@ -312,6 +327,21 @@ export function isPendingMessageId(id: string): boolean { return id.startsWith('pending:') } +/** Include transcript rows at/after a launch seed, with wall-clock skew slack. */ +function messageIsAtOrAfterLaunchCreatedAt(message: NativeChatMessage, createdAt: number): boolean { + if (message.timestamp === null) { + return true + } + if ( + message.timestamp > 1e11 && + createdAt > 1e11 && + message.timestamp + LIFECYCLE_CLOCK_SKEW_SLACK_MS >= createdAt + ) { + return true + } + return message.timestamp >= createdAt +} + // Why: the seeded prompt has a synthetic id that never matches the real turn's, // so dedup/prune match on normalized user-message text instead — this hides the // optimistic bubble once the transcript's own copy of the turn catches up. @@ -325,8 +355,8 @@ export function launchPromptAsMessage( // Why: a launch prompt seeds a brand-new session, so a matching user turn // with no timestamp (e.g. Grok transcripts) can only be its own delivery. const represented = matchingNativeChatUserContentCounts( - existingMessages.filter( - (message) => message.timestamp === null || message.timestamp >= entry.createdAt + existingMessages.filter((message) => + messageIsAtOrAfterLaunchCreatedAt(message, entry.createdAt) ) ) if ((represented.get(nativeChatPendingContentKey(entry)) ?? 0) > 0) { @@ -348,8 +378,8 @@ export function shouldPruneLaunchPrompt( entry: NativeChatLaunchPrompt, messages: NativeChatMessage[] ): boolean { - const relevant = messages.filter( - (message) => message.timestamp === null || message.timestamp >= entry.createdAt + const relevant = messages.filter((message) => + messageIsAtOrAfterLaunchCreatedAt(message, entry.createdAt) ) return ( (advancedNativeChatUserContentCounts(relevant).get(nativeChatPendingContentKey(entry)) ?? 0) > 0 diff --git a/src/renderer/src/components/native-chat/native-chat-session-assembler.test.ts b/src/renderer/src/components/native-chat/native-chat-session-assembler.test.ts index 5d4366a32867..6983d791467f 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-assembler.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-assembler.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest' import type { NativeChatMessage } from '../../../../shared/native-chat-types' -import { assembleNativeChatSession } from './native-chat-session-assembler' +import { assembleNativeChatSession, compareMessages } from './native-chat-session-assembler' +import { NATIVE_CHAT_STREAMING_ID } from '../../../../shared/native-chat-streaming' function msg( overrides: Partial & Pick @@ -413,3 +414,55 @@ describe('assembleNativeChatSession', () => { expect(session.error).toBe('transcript unreadable') }) }) + +describe('compareMessages', () => { + it('sorts a pending user prompt before a subsequent assistant response', () => { + const pending = msg({ id: 'pending:abc', role: 'user', timestamp: 100 }) + const assistant = msg({ id: 'assistant-1', role: 'assistant', timestamp: 200 }) + expect(compareMessages(pending, assistant)).toBeLessThan(0) + expect([assistant, pending].sort(compareMessages).map((m) => m.id)).toEqual([ + 'pending:abc', + 'assistant-1' + ]) + }) + + it('sorts a launch pending user prompt before a subsequent assistant response', () => { + const launchPending = msg({ id: 'launch-pending:tab-1', role: 'user', timestamp: 100 }) + const assistant = msg({ id: 'assistant-1', role: 'assistant', timestamp: 200 }) + expect(compareMessages(launchPending, assistant)).toBeLessThan(0) + expect([assistant, launchPending].sort(compareMessages).map((m) => m.id)).toEqual([ + 'launch-pending:tab-1', + 'assistant-1' + ]) + }) + + it('sorts a pending user prompt before an active streaming assistant preview triggered by it', () => { + const pending = msg({ id: 'pending:abc', role: 'user', timestamp: 100 }) + const streaming = msg({ id: NATIVE_CHAT_STREAMING_ID, role: 'assistant', timestamp: null }) + expect(compareMessages(pending, streaming)).toBeLessThan(0) + expect([streaming, pending].sort(compareMessages).map((m) => m.id)).toEqual([ + 'pending:abc', + NATIVE_CHAT_STREAMING_ID + ]) + }) + + it('sorts a launching prompt before an active streaming assistant preview', () => { + const launching = msg({ id: 'launch-pending:tab-1', role: 'user', timestamp: 100 }) + const streaming = msg({ id: NATIVE_CHAT_STREAMING_ID, role: 'assistant', timestamp: null }) + expect(compareMessages(launching, streaming)).toBeLessThan(0) + expect([streaming, launching].sort(compareMessages).map((m) => m.id)).toEqual([ + 'launch-pending:tab-1', + NATIVE_CHAT_STREAMING_ID + ]) + }) + + it('sorts a queued user prompt after an active streaming assistant preview', () => { + const queued = msg({ id: 'pending:queued', role: 'user', timestamp: 100, queued: true }) + const streaming = msg({ id: NATIVE_CHAT_STREAMING_ID, role: 'assistant', timestamp: null }) + expect(compareMessages(queued, streaming)).toBeGreaterThan(0) + expect([queued, streaming].sort(compareMessages).map((m) => m.id)).toEqual([ + NATIVE_CHAT_STREAMING_ID, + 'pending:queued' + ]) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-session-assembler.ts b/src/renderer/src/components/native-chat/native-chat-session-assembler.ts index 30f818d8d035..ad567f86c79d 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-assembler.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-assembler.ts @@ -13,7 +13,6 @@ import { isImageSourceUserTurn, normalizeImageTranscriptMessages } from '../../../../shared/native-chat-image-transcript-markers' -import { isLaunchPromptMessageId, isPendingMessageId } from './native-chat-pending' /** Messages grouped by source. Higher-priority sources (transcript > hook > * scrape) supersede lower ones when they describe the same turn. */ @@ -92,16 +91,17 @@ function supersedes(candidate: NativeChatMessage, existing: NativeChatMessage): } // Why: the tail bubbles form fixed tiers that timestamps alone can't express. -// The streaming preview (null timestamp) must follow real content but sit ahead -// of the optimistic composer echoes and queued sends, which carry finite timestamps -// that would otherwise sort past it. Rank first, then timestamp within a tier. +// Mid-turn follow-ups (`queued: true`) wait after the live turn. The streaming +// preview is the in-progress reply. Everything else — including the optimistic +// echo for the prompt that triggered that reply — sorts with ordinary transcript +// order so the prompt stays above its answer. function messageSortRank(message: NativeChatMessage): number { + if (message.queued) { + return 2 + } if (message.id === NATIVE_CHAT_STREAMING_ID) { return 1 } - if (message.queued || isPendingMessageId(message.id) || isLaunchPromptMessageId(message.id)) { - return 2 - } return 0 } diff --git a/src/renderer/src/components/native-chat/use-clear-native-chat-queued-when-idle.ts b/src/renderer/src/components/native-chat/use-clear-native-chat-queued-when-idle.ts new file mode 100644 index 000000000000..6e3b7531da8b --- /dev/null +++ b/src/renderer/src/components/native-chat/use-clear-native-chat-queued-when-idle.ts @@ -0,0 +1,14 @@ +import { useEffect } from 'react' + +/** Drop sticky mid-turn queue rank once working and stream preview are both idle. */ +export function useClearNativeChatQueuedWhenIdle( + liveWorking: boolean, + streamingText: string | null | undefined, + clearQueued: () => void +): void { + useEffect(() => { + if (!liveWorking && !streamingText) { + clearQueued() + } + }, [liveWorking, streamingText, clearQueued]) +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx index ec1eebea7a3a..a70bbb9b8954 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx @@ -94,6 +94,24 @@ describe('terminal Chat pending delivery', () => { expect(result.current.notices.size).toBe(0) expect(result.current.pending[0]?.delivery).toBeUndefined() }) + it('records a pending send with queued true when options.queued is true', () => { + const { result } = render() + act(() => result.current.record('queued prompt', undefined, { queued: true })) + expect(result.current.pending[0]?.queued).toBe(true) + }) + it('clears sticky queued once the live turn that forced queueing is idle', () => { + const { result } = render() + act(() => { + result.current.record('follow-up', undefined, { queued: true }) + }) + expect(result.current.pending[0]?.queued).toBe(true) + act(() => { + result.current.clearQueued() + }) + expect(result.current.pending[0]?.queued).toBeUndefined() + expect(result.current.pending[0]?.text).toBe('follow-up') + }) + it('marks a refused write as not sent, keeps its text, and dismisses it', () => { const { result } = render() act(() => { diff --git a/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.ts b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.ts index a84f8145f4e3..ed086eee21e8 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.ts @@ -11,9 +11,14 @@ import { writePendingSendCache, type NativeChatPendingSend } from './native-chat-pending' +import { clearQueuedOnPendingSends } from './native-chat-clear-queued-pending-sends' import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' import type { ReadState } from './native-chat-live-session-contract' +export type NativeChatRecordPendingOptions = { + queued?: boolean +} + /** How long a send whose write acknowledgment was lost waits for its row; the phone's hold matches. */ export const NATIVE_CHAT_UNCONFIRMED_SEND_HOLD_MS = 20_000 @@ -58,9 +63,10 @@ export function useNativeChatPendingDelivery(args: { save((entries) => prunePendingSends(releaseEmptyTranscriptClaims(entries, sessionId), messages)) }, [messages, save, sessionId]) const record = useCallback( - (text: string, imagePaths?: string[]) => { + (text: string, imagePaths?: string[], options?: NativeChatRecordPendingOptions) => { const sentAt = Date.now() const boundary = messages.at(-1) + const isQueued = options?.queued const entry: NativeChatPendingSend = { id: nextNativeChatPendingSendId(sentAt), text, @@ -68,7 +74,8 @@ export function useNativeChatPendingDelivery(args: { afterMessageId: boundary?.id ?? null, afterMessageTimestamp: boundary?.timestamp ?? null, ...(readEmptySessionId ? { afterEmptyTranscriptSessionId: readEmptySessionId } : {}), - ...(imagePaths ? { imagePaths } : {}) + ...(imagePaths ? { imagePaths } : {}), + ...(isQueued ? { queued: true } : {}) } setPending(appendPendingSendCache(scope, entry)) return entry.id @@ -102,6 +109,10 @@ export function useNativeChatPendingDelivery(args: { () => save((entries) => entries.filter((entry) => entry.delivery)), [save] ) + const clearQueued = useCallback( + () => save((entries) => clearQueuedOnPendingSends(entries)), + [save] + ) // A lost acknowledgment is the only unconfirmed trigger: an ordinary send, including one Claude // queues mid-turn, has no transport doubt and stays pending until its row lands. @@ -172,5 +183,5 @@ export function useNativeChatPendingDelivery(args: { } return result }, [pending, cancel]) - return { pending, record, cancel, reject, holdUnconfirmed, clear, notices } + return { pending, record, cancel, reject, holdUnconfirmed, clear, clearQueued, notices } }