diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx index 89f286834293..8d6393efed47 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -27,6 +27,7 @@ import { writePendingSendCache, type NativeChatPendingSend } from './native-chat-pending' +import { nativeChatMessagesWithPending } from './native-chat-pending-anchor' import { appendCommandMarkerCache, applyCommandMarkerBoundaries, @@ -283,12 +284,13 @@ export function NativeChatResolvedView({ } return { ...sessionAfterCommandBoundaries, - messages: [ - ...sessionAfterCommandBoundaries.messages, - ...commandMarkersAsMessages(commandMarkers), - ...(streamingText ? [nativeChatStreamingMessage(streamingText)] : []), - ...pendingMessages - ] + messages: nativeChatMessagesWithPending( + sessionAfterCommandBoundaries.messages, + pending, + pendingMessages, + commandMarkersAsMessages(commandMarkers), + streamingText ? [nativeChatStreamingMessage(streamingText)] : [] + ) } }, [sessionAfterCommandBoundaries, pending, pendingMessages, commandMarkers, streamingText]) // Derive the view state from the pending-augmented session so a send into an diff --git a/src/renderer/src/components/native-chat/native-chat-pending-anchor.test.ts b/src/renderer/src/components/native-chat/native-chat-pending-anchor.test.ts new file mode 100644 index 000000000000..7d1768c55d6a --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-pending-anchor.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { + anchorPendingMessagesToSendBoundary, + nativeChatMessagesWithPending +} from './native-chat-pending-anchor' +import { pendingSendsAsMessages, type NativeChatPendingSend } from './native-chat-pending' +import { orderNativeChatMessages } from './native-chat-message-grouping' +import { createNativeChatMessageListProjection } from './native-chat-message-list-projection' +import { NATIVE_CHAT_STREAMING_ID } from '../../../../shared/native-chat-streaming' + +function message( + id: string, + role: 'user' | 'assistant', + text: string, + timestamp = 1 +): NativeChatMessage { + return { id, role, blocks: [{ type: 'text', text }], timestamp, source: 'transcript' } +} + +function send(id: string, text: string, afterMessageId: string | null): NativeChatPendingSend { + return { id, text, sentAt: 100, afterMessageId } +} + +function placed( + messages: readonly NativeChatMessage[], + pending: readonly NativeChatPendingSend[] +): string[] { + const anchored = anchorPendingMessagesToSendBoundary( + messages, + pending, + pendingSendsAsMessages([...pending], [...messages]) + ) + return [...anchored.messages, ...anchored.trailing].map((entry) => entry.id) +} + +// A mid-turn send is absorbed into the running turn: Claude Code writes +// `queue-operation` (`reason: absorbed_mid_turn`) and a `queued_command` +// attachment, never a `type:"user"` row, so the echo has nothing to match. +describe('anchorPendingMessagesToSendBoundary', () => { + it('keeps an unmatchable echo where it was sent as the turn keeps going', () => { + const atSendTime = [message('u1', 'user', 'first'), message('a1', 'assistant', 'working')] + const pending = [send('p1', 'second', 'a1')] + expect(placed(atSendTime, pending)).toEqual(['u1', 'a1', 'pending:p1']) + + const laterTurns = [ + ...atSendTime, + message('a2', 'assistant', 'still working', 2), + message('a3', 'assistant', 'done', 3) + ] + expect(placed(laterTurns, pending)).toEqual(['u1', 'a1', 'pending-at:p1', 'a2', 'a3']) + }) + + // Review note from @pullfrog: a still-at-tail echo must stay on `pending:` and + // rank 2, or it sorts AHEAD of the streaming preview — the tier the comment on + // `messageSortRank` calls load-bearing. Only an echo the transcript moved past + // is repositioned. + it('leaves a still-at-tail echo trailing so the streaming bubble keeps its place', () => { + const atSendTime = [message('u1', 'user', 'first', 10), message('a1', 'assistant', 'go', 20)] + const pending = [send('p1', 'second', 'a1')] + expect(placed(atSendTime, pending)).toEqual(['u1', 'a1', 'pending:p1']) + + const turnMovedOn = [...atSendTime, message('a2', 'assistant', 'done', 30)] + expect(placed(turnMovedOn, pending)).toEqual(['u1', 'a1', 'pending-at:p1', 'a2']) + }) + + it('trails an echo whose boundary a bounded read paged out instead of guessing', () => { + const messages = [message('a2', 'assistant', 'later'), message('a3', 'assistant', 'latest', 3)] + expect(placed(messages, [send('p1', 'second', 'gone')])).toEqual(['a2', 'a3', 'pending:p1']) + }) + + it('trails an echo that recorded no boundary', () => { + const messages = [message('u1', 'user', 'first'), message('a1', 'assistant', 'working', 2)] + expect(placed(messages, [send('p1', 'second', null)])).toEqual(['u1', 'a1', 'pending:p1']) + }) + + it('keeps send order among echoes sharing one boundary', () => { + const messages = [message('a1', 'assistant', 'working'), message('a2', 'assistant', 'done', 2)] + const pending = [send('p1', 'second', 'a1'), send('p2', 'third', 'a1')] + expect(placed(messages, pending)).toEqual(['a1', 'pending-at:p1', 'pending-at:p2', 'a2']) + }) + + it('returns the input untouched when there is nothing pending', () => { + const messages = [message('u1', 'user', 'first')] + const anchored = anchorPendingMessagesToSendBoundary(messages, [], []) + expect(anchored.messages).toBe(messages) + expect(anchored.trailing).toEqual([]) + }) +}) + +describe('nativeChatMessagesWithPending', () => { + it('places an anchored echo before markers and streaming, and a trailing one after', () => { + const messages = [ + message('u1', 'user', 'first'), + message('a1', 'assistant', 'working'), + message('a2', 'assistant', 'done', 2) + ] + const pending = [send('p1', 'sent mid-turn', 'a1'), send('p2', 'boundary paged out', 'gone')] + const combined = nativeChatMessagesWithPending( + messages, + pending, + pendingSendsAsMessages(pending, messages), + [message('marker', 'assistant', '/clear')], + [message('streaming', 'assistant', 'typing', 3)] + ) + expect(combined.map((entry) => entry.id)).toEqual([ + 'u1', + 'a1', + 'pending-at:p1', + 'a2', + 'marker', + 'streaming', + 'pending:p2' + ]) + }) +}) + +// Review note from @pullfrog on #20847: the anchored array never reaches the +// DOM, because `NativeChatMessageList` re-sorts it through +// `orderNativeChatMessages`, whose `messageSortRank` pins every `pending:*` row +// to rank 2 — the tail. Asserting the array alone therefore proves nothing. +describe('anchored order survives the list projection', () => { + it('keeps the anchored echo in place after orderNativeChatMessages', () => { + const messages = [ + message('u1', 'user', 'first', 10), + message('a1', 'assistant', 'opened the turn', 20), + message('a2', 'assistant', 'kept working', 30), + message('a3', 'assistant', 'finished', 40) + ] + const pending = [send('p1', 'sent mid-turn', 'a1')] + const anchoredThenSorted = orderNativeChatMessages([ + ...anchorPendingMessagesToSendBoundary( + messages, + pending, + pendingSendsAsMessages(pending, messages) + ).messages + ]) + expect(anchoredThenSorted.map((entry) => entry.id)).toEqual([ + 'u1', + 'a1', + 'pending-at:p1', + 'a2', + 'a3' + ]) + }) +}) + +// Two regressions @pullfrog caught in the anchoring itself, both verified through +// the same path the list renders. +describe('anchoring does not cost the turn its content or its tiers', () => { + it('keeps a tool result whose call sits after the echo', () => { + const messages: NativeChatMessage[] = [ + message('u1', 'user', 'first', 10), + { + id: 'a1', + role: 'assistant', + timestamp: 20, + source: 'transcript', + blocks: [ + { type: 'text', text: 'opened the turn' }, + { type: 'tool-call', name: 'Bash', input: { command: 'ls' } } + ] + }, + { + id: 'tr', + role: 'tool', + timestamp: 40, + source: 'transcript', + blocks: [{ type: 'tool-result', output: 'a.txt' }] + } + ] + const pending = [send('p1', 'sent mid-turn', 'a1')] + const project = createNativeChatMessageListProjection() + const rows = project([ + ...anchorPendingMessagesToSendBoundary( + messages, + pending, + pendingSendsAsMessages(pending, messages) + ).messages + ]) + // Ending the fold run on the echo strands the result, and + // `dropUnattributableToolResults` then deletes the row outright. + const outputs = rows.flatMap((row) => + row.blocks.filter((block) => block.type === 'tool-result').map((block) => block.output) + ) + expect(outputs).toEqual(['a.txt']) + expect(rows.map((row) => row.id)).toContain('pending-at:p1') + }) + + it('keeps a still-at-tail echo behind the streaming preview', () => { + const messages = [message('u1', 'user', 'first', 10), message('a1', 'assistant', 'go', 20)] + const pending = [send('p1', 'just sent', 'a1')] + const streaming: NativeChatMessage = { + id: NATIVE_CHAT_STREAMING_ID, + role: 'assistant', + timestamp: null, + source: 'scrape', + blocks: [{ type: 'text', text: 'typing' }] + } + const anchored = anchorPendingMessagesToSendBoundary( + messages, + pending, + pendingSendsAsMessages(pending, messages) + ) + const ordered = orderNativeChatMessages([...anchored.messages, streaming, ...anchored.trailing]) + expect(ordered.map((row) => row.id)).toEqual([ + 'u1', + 'a1', + NATIVE_CHAT_STREAMING_ID, + 'pending:p1' + ]) + }) +}) + +// Review note from @coderabbitai: an anchored echo carries its anchor's +// timestamp, so a following row can tie with it. Breaking that tie on id sorts +// the echo away from the row it was sent against. +describe('anchored echoes hold their place across timestamp ties', () => { + it('stays beside its anchor when the next row shares that millisecond', () => { + const messages = [ + message('u1', 'user', 'first', 10), + message('a1', 'assistant', 'opened the turn', 20), + message('a2', 'assistant', 'same millisecond', 20) + ] + const pending = [send('p1', 'sent mid-turn', 'a1')] + const ordered = orderNativeChatMessages([ + ...anchorPendingMessagesToSendBoundary( + messages, + pending, + pendingSendsAsMessages(pending, messages) + ).messages + ]) + expect(ordered.map((row) => row.id)).toEqual(['u1', 'a1', 'pending-at:p1', 'a2']) + }) + + it('keeps send order among echoes sharing one anchor after sorting', () => { + const messages = [ + message('a1', 'assistant', 'opened the turn', 20), + message('a2', 'assistant', 'later', 30) + ] + const pending = [send('p1', 'first send', 'a1'), send('p2', 'second send', 'a1')] + const ordered = orderNativeChatMessages([ + ...anchorPendingMessagesToSendBoundary( + messages, + pending, + pendingSendsAsMessages(pending, messages) + ).messages + ]) + expect(ordered.map((row) => row.id)).toEqual(['a1', 'pending-at:p1', 'pending-at:p2', 'a2']) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-pending-anchor.ts b/src/renderer/src/components/native-chat/native-chat-pending-anchor.ts new file mode 100644 index 000000000000..33394b21c862 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-pending-anchor.ts @@ -0,0 +1,82 @@ +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { ANCHORED_PENDING_ID_PREFIX, type NativeChatPendingSend } from './native-chat-pending' + +/** + * Render an optimistic echo after the row it was sent against, not at the tail. + * + * Claude consumes a mid-turn send through a `queued_command` attachment and + * writes no `type:"user"` row for it, so that echo has no row to match — ever. + * Held at the tail it re-reads below every turn of the reply it triggered, which + * is what makes the conversation look re-ordered. Mobile anchors for the same + * reason (`baselineTailMessageId`); desktop already records the boundary as + * `afterMessageId` and only ever used it for matching. + * + * Position has to survive `orderNativeChatMessages`, which sorts by rank before + * timestamp and pins every tail echo to rank 2 — array order alone never reaches + * the DOM. So an anchored echo is re-minted under `pending-at:`, which the + * assembler ranks as content, and carries its anchor's own timestamp; ties there + * keep placement order rather than breaking on id, so it stays beside the anchor + * even when the next row shares that millisecond. + * + * An echo still at the tail keeps `pending:` and rank 2, so the streaming preview + * stays ahead of it — the tier `messageSortRank` exists to hold. Only an echo the + * transcript has moved past is repositioned. A boundary that is absent, or that + * carries no timestamp to sort against, falls back to the tail rather than guess. + */ +export function anchorPendingMessagesToSendBoundary( + messages: readonly NativeChatMessage[], + pending: readonly NativeChatPendingSend[], + pendingMessages: readonly NativeChatMessage[] +): { messages: readonly NativeChatMessage[]; trailing: readonly NativeChatMessage[] } { + if (pendingMessages.length === 0) { + return { messages, trailing: pendingMessages } + } + const boundaryByMessageId = new Map( + pending.map((entry) => [`pending:${entry.id}`, entry.afterMessageId ?? null]) + ) + const tailId = messages.at(-1)?.id ?? null + const anchorById = new Map(messages.map((message) => [message.id, message])) + const anchored = new Map() + const trailing: NativeChatMessage[] = [] + for (const message of pendingMessages) { + const boundaryId = boundaryByMessageId.get(message.id) ?? null + const anchor = + boundaryId === null || boundaryId === tailId ? undefined : anchorById.get(boundaryId) + if (!anchor || anchor.timestamp === null) { + trailing.push(message) + continue + } + const sharing = anchored.get(anchor.id) ?? [] + sharing.push({ + ...message, + id: `${ANCHORED_PENDING_ID_PREFIX}${message.id.slice('pending:'.length)}`, + timestamp: anchor.timestamp + }) + anchored.set(anchor.id, sharing) + } + if (anchored.size === 0) { + return { messages, trailing } + } + const placed: NativeChatMessage[] = [] + for (const message of messages) { + placed.push(message) + const sharing = anchored.get(message.id) + if (sharing) { + placed.push(...sharing) + } + } + return { messages: placed, trailing } +} + +/** Transcript, then anything the view renders after it, then the echoes that + * still belong at the tail. */ +export function nativeChatMessagesWithPending( + messages: readonly NativeChatMessage[], + pending: readonly NativeChatPendingSend[], + pendingMessages: readonly NativeChatMessage[], + markers: readonly NativeChatMessage[], + streaming: readonly NativeChatMessage[] +): NativeChatMessage[] { + const anchored = anchorPendingMessagesToSendBoundary(messages, pending, pendingMessages) + return [...anchored.messages, ...markers, ...streaming, ...anchored.trailing] +} 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 406fee3b5795..800e49d9b1db 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.ts @@ -3,7 +3,11 @@ // user turn lands in the transcript. Kept separate from the view so the prune // rule (match on normalized user-message content) is unit-testable without React. -import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { + isNativeChatPendingRowId, + NATIVE_CHAT_ANCHORED_PENDING_ID_PREFIX, + 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 { @@ -262,9 +266,14 @@ export function pendingSendsAsMessages( })) } +export const ANCHORED_PENDING_ID_PREFIX = NATIVE_CHAT_ANCHORED_PENDING_ID_PREFIX + /** True when a message id was minted for an optimistic pending send. */ -export function isPendingMessageId(id: string): boolean { - return id.startsWith('pending:') +export const isPendingMessageId = isNativeChatPendingRowId + +/** True for a pending echo that carries a position of its own. */ +export function isAnchoredPendingMessageId(id: string): boolean { + return id.startsWith(ANCHORED_PENDING_ID_PREFIX) } // Why: the seeded prompt has a synthetic id that never matches the real turn's, 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 9416967374cd..98d0bd85a14e 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 @@ -12,7 +12,11 @@ import { isImageSourceUserTurn, normalizeImageTranscriptMessages } from '../../../../shared/native-chat-image-transcript-markers' -import { isLaunchPromptMessageId, isPendingMessageId } from './native-chat-pending' +import { + isAnchoredPendingMessageId, + 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. */ @@ -98,6 +102,10 @@ function messageSortRank(message: NativeChatMessage): number { if (message.id === NATIVE_CHAT_STREAMING_ID) { return 1 } + // An anchored echo owns a position between real rows, so it sorts as content. + if (isAnchoredPendingMessageId(message.id)) { + return 0 + } if (isPendingMessageId(message.id) || isLaunchPromptMessageId(message.id)) { return 2 } @@ -118,6 +126,12 @@ export function compareMessages(a: NativeChatMessage, b: NativeChatMessage): num if (at !== bt) { return at - bt } + // An anchored echo carries its anchor's timestamp so it stays beside it. Ties + // must then keep the order it was placed in — breaking on id would sort it + // away from the row it was sent against. `sort` is stable, so 0 is enough. + if (isAnchoredPendingMessageId(a.id) || isAnchoredPendingMessageId(b.id)) { + return 0 + } if (a.id < b.id) { return -1 } diff --git a/src/shared/native-chat-tool-fold.ts b/src/shared/native-chat-tool-fold.ts index 32a2531b9ee1..e3ec641603ae 100644 --- a/src/shared/native-chat-tool-fold.ts +++ b/src/shared/native-chat-tool-fold.ts @@ -1,4 +1,5 @@ import { + isNativeChatPendingRowId, isSubagentGroupBlock, isToolCallBlock, isToolResultBlock, @@ -116,6 +117,10 @@ export function foldToolMessages(messages: readonly NativeChatMessage[]): Native clonedAssistantIndex = -1 } else if ( !isSubagentRosterMessage(message) && + // An optimistic echo is a view artefact sitting where the user typed, not a + // transcript row. Ending the run on it strands the tool results that follow, + // and `dropUnattributableToolResults` then deletes them outright. + !isNativeChatPendingRowId(message.id) && (!isNoiseMessage(message) || isInterruptionBoundary(message)) ) { mutableAssistantIndex = -1 diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index c6835301c889..e5b3d0cc1e42 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -231,3 +231,16 @@ export function isSubagentGroupBlock( ): block is NativeChatSubagentGroupBlock { return block.type === 'subagent-group' } + +/** Optimistic composer echoes. `pending:` sits at the tail; `pending-at:` carries + * a position of its own. Declared here because the fold pass must recognise them + * as view artefacts, not transcript rows. */ +export const NATIVE_CHAT_PENDING_ID_PREFIX = 'pending:' +export const NATIVE_CHAT_ANCHORED_PENDING_ID_PREFIX = 'pending-at:' + +export function isNativeChatPendingRowId(id: string): boolean { + return ( + id.startsWith(NATIVE_CHAT_PENDING_ID_PREFIX) || + id.startsWith(NATIVE_CHAT_ANCHORED_PENDING_ID_PREFIX) + ) +}