From 5080a8ec3b5e812d782c267c1f157004b38a6de5 Mon Sep 17 00:00:00 2001 From: Lesley Murfin Date: Wed, 14 Oct 2026 03:12:01 -0600 Subject: [PATCH 1/2] fix(native-chat): order pending prompts above streaming and tolerate clock skew (#23851) Active pending/launch echoes sort with ordinary transcript order so the prompt that started a turn stays above its streaming reply. Mid-turn follow-ups set queued:true and wait behind the live preview. Wall-clock first-send and launch matching accept LIFECYCLE_CLOCK_SKEW_SLACK_MS when comparing local sentAt/createdAt to host timestamps; same-host transcript boundaries stay strict. Rebased onto current main and keep empty-transcript session claims when recording pending sends. --- .../native-chat/NativeChatResolvedView.tsx | 22 +++--- .../native-chat-message-grouping.test.ts | 21 +++++- .../native-chat/native-chat-pending.test.ts | 74 +++++++++++++++++++ .../native-chat/native-chat-pending.ts | 54 +++++++++++--- .../native-chat-session-assembler.test.ts | 55 +++++++++++++- .../native-chat-session-assembler.ts | 14 ++-- .../use-native-chat-pending-delivery.test.tsx | 5 ++ .../use-native-chat-pending-delivery.ts | 10 ++- 8 files changed, 220 insertions(+), 35 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx index ffceaa14959f..4698f8c6cff2 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -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)) @@ -245,8 +238,7 @@ export function NativeChatResolvedView({ 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. + // Active pending echoes above stream; queued follow-ups wait after it via rank. const pendingMessages = useMemo( () => pendingSendsAsMessages(pending, sessionAfterCommandBoundaries.messages), [pending, sessionAfterCommandBoundaries.messages] @@ -277,11 +269,19 @@ export function NativeChatResolvedView({ messages: [ ...sessionAfterCommandBoundaries.messages, ...commandMarkersAsMessages(commandMarkers), - ...(streamingText ? [nativeChatStreamingMessage(streamingText)] : []), - ...pendingMessages + ...pendingMessages, + ...(streamingText ? [nativeChatStreamingMessage(streamingText)] : []) ] } }, [sessionAfterCommandBoundaries, pending, pendingMessages, commandMarkers, streamingText]) + 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] + ) // 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. 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..ec62be92a868 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. +// Host-queued rows (`queued: true`, accepted but not yet handed to the agent) 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-native-chat-pending-delivery.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx index ec1eebea7a3a..c5bb8258ccab 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,11 @@ 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('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..c66809343265 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 @@ -14,6 +14,10 @@ import { 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 +62,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 +73,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 From f740321f15ee56f59a41d53d91751ed1480c8b1b Mon Sep 17 00:00:00 2001 From: Lesley Murfin Date: Wed, 14 Oct 2026 03:16:43 -0600 Subject: [PATCH 2/2] fix(native-chat): clear sticky queued rank when the live turn goes idle Mid-turn follow-ups kept queued:true until prune, so after the prior stream ended they could still sort behind the next streaming reply. Clear queued once working and stream preview are both idle so remaining pendings rank as ordinary prompts above any later stream. --- .../native-chat/NativeChatResolvedView.tsx | 14 ++++---- ...ve-chat-clear-queued-pending-sends.test.ts | 34 +++++++++++++++++++ .../native-chat-clear-queued-pending-sends.ts | 21 ++++++++++++ .../native-chat-session-assembler.ts | 8 ++--- .../use-clear-native-chat-queued-when-idle.ts | 14 ++++++++ .../use-native-chat-pending-delivery.test.tsx | 13 +++++++ .../use-native-chat-pending-delivery.ts | 7 +++- 7 files changed, 98 insertions(+), 13 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-clear-queued-pending-sends.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-clear-queued-pending-sends.ts create mode 100644 src/renderer/src/components/native-chat/use-clear-native-chat-queued-when-idle.ts diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx index 4698f8c6cff2..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) @@ -237,8 +237,7 @@ export function NativeChatResolvedView({ isFocusedGroup, composerReady: shownPromptCard === null && targetPtyId !== null && canSend }) - - // Active pending echoes above stream; queued follow-ups wait after it via rank. + // Pending above stream; queued follow-ups wait after via rank. const pendingMessages = useMemo( () => pendingSendsAsMessages(pending, sessionAfterCommandBoundaries.messages), [pending, sessionAfterCommandBoundaries.messages] @@ -260,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 @@ -282,9 +282,7 @@ export function NativeChatResolvedView({ }, [liveWorking, record, 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. + // 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-session-assembler.ts b/src/renderer/src/components/native-chat/native-chat-session-assembler.ts index ec62be92a868..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 @@ -91,10 +91,10 @@ function supersedes(candidate: NativeChatMessage, existing: NativeChatMessage): } // Why: the tail bubbles form fixed tiers that timestamps alone can't express. -// Host-queued rows (`queued: true`, accepted but not yet handed to the agent) 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. +// 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 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 c5bb8258ccab..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 @@ -99,6 +99,19 @@ describe('terminal Chat pending delivery', () => { 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 c66809343265..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,6 +11,7 @@ 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' @@ -108,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. @@ -178,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 } }