From 62d0c9490ed0174349dc9feccf64ee9db6d87689 Mon Sep 17 00:00:00 2001 From: Top Nguyen Date: Tue, 15 Sep 2026 21:40:14 +0700 Subject: [PATCH 1/5] fix(native-chat): anchor a mid-turn send where it was issued MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude consumes a send made mid-turn through a `queued_command` attachment and writes no `type:"user"` row for it, so its optimistic echo has no row to match — ever. Held at the list tail, it re-reads below every turn of the reply it triggered, which is what makes the conversation look re-ordered: the answer appears above the question still sitting underneath it. Observed in a Claude Code transcript, line numbers as written: 6 {"type":"user", ...} "check status, first time" 25 {"type":"assistant", ...} "I'll check the current state..." 27 {"type":"queue-operation","operation":"enqueue","content":"check status, second time"} 28 {"type":"queue-operation","operation":"remove", ...,"reason":"absorbed_mid_turn"} 33 {"type":"attachment","attachment":{"type":"queued_command","prompt":"check status, second time"}} 41 {"type":"assistant", ...} "Working tree is clean..." 68 {"type":"assistant", ...} "The discriminating test has fired..." The echo belongs at 33; it rendered after 68. Mobile already anchors for this reason (`baselineTailMessageId`). Desktop records the same boundary as `NativeChatPendingSend.afterMessageId` and only ever used it for matching, so this reuses it for placement. Only an echo the transcript has since moved past is repositioned: one still at the tail keeps today's order, after the streaming bubble. A boundary a bounded read has paged out falls back to the tail rather than guessing. Co-Authored-By: Claude Opus 5 (1M context) --- .../native-chat/NativeChatResolvedView.tsx | 14 ++-- .../native-chat-pending-anchor.test.ts | 81 +++++++++++++++++++ .../native-chat/native-chat-pending-anchor.ts | 71 ++++++++++++++++ 3 files changed, 160 insertions(+), 6 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-pending-anchor.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-pending-anchor.ts 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..09161e507d33 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-pending-anchor.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { anchorPendingMessagesToSendBoundary } from './native-chat-pending-anchor' +import { pendingSendsAsMessages, type NativeChatPendingSend } from './native-chat-pending' + +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:p1', 'a2', 'a3']) + }) + + it('leaves an echo still at the tail trailing, so the streaming bubble keeps its place', () => { + const messages = [message('u1', 'user', 'first'), message('a1', 'assistant', 'working')] + const anchored = anchorPendingMessagesToSendBoundary( + messages, + [send('p1', 'second', 'a1')], + pendingSendsAsMessages([send('p1', 'second', 'a1')], messages) + ) + expect(anchored.messages).toBe(messages) + expect(anchored.trailing.map((entry) => entry.id)).toEqual(['pending:p1']) + }) + + 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:p1', 'pending: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([]) + }) +}) 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..47ea619962ad --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-pending-anchor.ts @@ -0,0 +1,71 @@ +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import 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. + * + * A send still at the tail keeps today's order — after the streaming bubble — so + * only an echo the transcript has since moved past is repositioned. A boundary + * paged out by a bounded read falls back to the tail rather than guessing. + */ +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 tailId = messages.at(-1)?.id ?? null + const boundaryByMessageId = new Map( + pending.map((entry) => [`pending:${entry.id}`, entry.afterMessageId ?? null]) + ) + const presentIds = new Set(messages.map((message) => message.id)) + const anchored = new Map() + const trailing: NativeChatMessage[] = [] + for (const message of pendingMessages) { + const boundary = boundaryByMessageId.get(message.id) ?? null + if (boundary === null || boundary === tailId || !presentIds.has(boundary)) { + trailing.push(message) + continue + } + const sharing = anchored.get(boundary) + if (sharing) { + sharing.push(message) + } else { + anchored.set(boundary, [message]) + } + } + 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] +} From 053ce516e3984dd5cad88e383658da0e88fbc046 Mon Sep 17 00:00:00 2001 From: Top Nguyen Date: Tue, 15 Sep 2026 22:04:44 +0700 Subject: [PATCH 2/5] test(native-chat): cover the assembled order around markers and streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review note on #20847: the tests exercised `anchorPendingMessagesToSendBoundary` directly, so nothing pinned what `NativeChatResolvedView` actually renders — the anchored echo has to land before the command markers and the streaming bubble, and the trailing one after both. Asserts the whole sequence for one anchored and one trailing send, so a regression in the assembly order fails rather than just the anchoring. Co-Authored-By: Claude Opus 5 (1M context) --- .../native-chat-pending-anchor.test.ts | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) 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 index 09161e507d33..0dd9e691cb91 100644 --- 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 @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest' import type { NativeChatMessage } from '../../../../shared/native-chat-types' -import { anchorPendingMessagesToSendBoundary } from './native-chat-pending-anchor' +import { + anchorPendingMessagesToSendBoundary, + nativeChatMessagesWithPending +} from './native-chat-pending-anchor' import { pendingSendsAsMessages, type NativeChatPendingSend } from './native-chat-pending' function message( @@ -79,3 +82,30 @@ describe('anchorPendingMessagesToSendBoundary', () => { 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', 'sent at the tail', 'a2')] + 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:p1', + 'a2', + 'marker', + 'streaming', + 'pending:p2' + ]) + }) +}) From 69a11fc5255811ddf530b7ef2e76f8eb3ca7f777 Mon Sep 17 00:00:00 2001 From: Top Nguyen Date: Tue, 15 Sep 2026 22:31:20 +0700 Subject: [PATCH 3/5] fix(native-chat): let an anchored echo outrank the tail pin so it survives sorting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @pullfrog was right on #20847: the anchored array never reached the DOM. `NativeChatMessageList` re-sorts through `orderNativeChatMessages`, and `messageSortRank` pins every `pending:*` row to rank 2 — the tail — so array order alone proved nothing, and the earlier tests asserted the wrong layer. A test driving `orderNativeChatMessages` over the anchored array reproduces it: - "pending:p1" "a2", "a3", + "pending:p1" Fix, keeping the tier the rank exists to protect: - `pending-at:` is a second pending id form. `isPendingMessageId` accepts both, so pruning and visibility are unchanged; `isAnchoredPendingMessageId` marks only the positioned ones. - `messageSortRank` returns 0 for an anchored echo — it owns a place between real rows — while a still-at-tail echo keeps rank 2, so the streaming bubble still sorts ahead of it, which is what the existing comment guards. - An anchored echo takes its anchor's timestamp plus half a millisecond, so it sorts after the anchor and before the next row: transcript rows carry whole milliseconds. Echoes sharing an anchor step 0.5/1.0/1.5 and keep send order. - An anchor with a null timestamp (transcripts that supply none) falls back to the tail rather than guessing. Side effect worth a maintainer's eye: the projection folds after ordering, so tool rows produced after the send no longer fold into the assistant message above the echo — they render as their own rows beneath it. That matches the terminal pane, where those calls also come after the prompt. Verified in a local `build:unpack` on Windows: sent a second message mid-turn and the bubble held its place while the rest of the reply landed below it, with the chat view and the terminal pane finally in the same order. Co-Authored-By: Claude Opus 5 (1M context) --- .../native-chat-pending-anchor.test.ts | 64 +++++++++++++++---- .../native-chat/native-chat-pending-anchor.ts | 39 +++++++---- .../native-chat/native-chat-pending.ts | 12 +++- .../native-chat-session-assembler.ts | 10 ++- 4 files changed, 95 insertions(+), 30 deletions(-) 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 index 0dd9e691cb91..e5a9142716dd 100644 --- 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 @@ -5,6 +5,7 @@ import { nativeChatMessagesWithPending } from './native-chat-pending-anchor' import { pendingSendsAsMessages, type NativeChatPendingSend } from './native-chat-pending' +import { orderNativeChatMessages } from './native-chat-message-grouping' function message( id: string, @@ -38,25 +39,30 @@ 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']) + expect(placed(atSendTime, pending)).toEqual(['u1', 'a1', 'pending-at:p1']) const laterTurns = [ ...atSendTime, message('a2', 'assistant', 'still working', 2), message('a3', 'assistant', 'done', 3) ] - expect(placed(laterTurns, pending)).toEqual(['u1', 'a1', 'pending:p1', 'a2', 'a3']) + expect(placed(laterTurns, pending)).toEqual(['u1', 'a1', 'pending-at:p1', 'a2', 'a3']) }) - it('leaves an echo still at the tail trailing, so the streaming bubble keeps its place', () => { - const messages = [message('u1', 'user', 'first'), message('a1', 'assistant', 'working')] - const anchored = anchorPendingMessagesToSendBoundary( - messages, - [send('p1', 'second', 'a1')], - pendingSendsAsMessages([send('p1', 'second', 'a1')], messages) - ) - expect(anchored.messages).toBe(messages) - expect(anchored.trailing.map((entry) => entry.id)).toEqual(['pending:p1']) + // The case the first attempt at this fix missed: `foldToolMessages` folds a + // whole turn into the assistant message that opened it, so that message keeps + // its id and grows in place. A mid-turn send therefore names a boundary that is + // STILL the tail once the reply is complete — treating "at the tail" as "nothing + // came after" left the echo below the entire answer, exactly the reported bug. + it('anchors even when the boundary is still the tail, because a folded turn grows in place', () => { + const atSendTime = [message('u1', 'user', 'first'), message('a1', 'assistant', 'working')] + const pending = [send('p1', 'second', 'a1')] + const grownInPlace = [ + message('u1', 'user', 'first'), + message('a1', 'assistant', 'working, ran 3 commands, done') + ] + expect(placed(atSendTime, pending)).toEqual(['u1', 'a1', 'pending-at:p1']) + expect(placed(grownInPlace, pending)).toEqual(['u1', 'a1', 'pending-at:p1']) }) it('trails an echo whose boundary a bounded read paged out instead of guessing', () => { @@ -72,7 +78,7 @@ describe('anchorPendingMessagesToSendBoundary', () => { 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:p1', 'pending:p2', 'a2']) + expect(placed(messages, pending)).toEqual(['a1', 'pending-at:p1', 'pending-at:p2', 'a2']) }) it('returns the input untouched when there is nothing pending', () => { @@ -90,7 +96,7 @@ describe('nativeChatMessagesWithPending', () => { message('a1', 'assistant', 'working'), message('a2', 'assistant', 'done', 2) ] - const pending = [send('p1', 'sent mid-turn', 'a1'), send('p2', 'sent at the tail', 'a2')] + const pending = [send('p1', 'sent mid-turn', 'a1'), send('p2', 'boundary paged out', 'gone')] const combined = nativeChatMessagesWithPending( messages, pending, @@ -101,7 +107,7 @@ describe('nativeChatMessagesWithPending', () => { expect(combined.map((entry) => entry.id)).toEqual([ 'u1', 'a1', - 'pending:p1', + 'pending-at:p1', 'a2', 'marker', 'streaming', @@ -109,3 +115,33 @@ describe('nativeChatMessagesWithPending', () => { ]) }) }) + +// 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' + ]) + }) +}) 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 index 47ea619962ad..fa81d478f726 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending-anchor.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending-anchor.ts @@ -1,5 +1,9 @@ import type { NativeChatMessage } from '../../../../shared/native-chat-types' -import type { NativeChatPendingSend } from './native-chat-pending' +import { ANCHORED_PENDING_ID_PREFIX, type NativeChatPendingSend } from './native-chat-pending' + +/** Places the echo strictly after its anchor without reaching the next row: the + * list re-sorts by timestamp, and transcript rows carry whole milliseconds. */ +const ANCHOR_NUDGE_MS = 0.5 /** * Render an optimistic echo after the row it was sent against, not at the tail. @@ -11,9 +15,15 @@ import type { NativeChatPendingSend } from './native-chat-pending' * reason (`baselineTailMessageId`); desktop already records the boundary as * `afterMessageId` and only ever used it for matching. * - * A send still at the tail keeps today's order — after the streaming bubble — so - * only an echo the transcript has since moved past is repositioned. A boundary - * paged out by a bounded read falls back to the tail rather than guessing. + * 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 takes its anchor's timestamp plus a nudge. + * + * "Still at the tail" is NOT a reason to leave an echo trailing: a folded turn + * grows in place under the id that opened it, so a mid-turn send names a + * boundary that is still the tail when the reply is complete. Only a boundary + * that is absent, or carries no timestamp to sort against, falls back. */ export function anchorPendingMessagesToSendBoundary( messages: readonly NativeChatMessage[], @@ -23,25 +33,26 @@ export function anchorPendingMessagesToSendBoundary( if (pendingMessages.length === 0) { return { messages, trailing: pendingMessages } } - const tailId = messages.at(-1)?.id ?? null const boundaryByMessageId = new Map( pending.map((entry) => [`pending:${entry.id}`, entry.afterMessageId ?? null]) ) - const presentIds = new Set(messages.map((message) => message.id)) + const anchorById = new Map(messages.map((message) => [message.id, message])) const anchored = new Map() const trailing: NativeChatMessage[] = [] for (const message of pendingMessages) { - const boundary = boundaryByMessageId.get(message.id) ?? null - if (boundary === null || boundary === tailId || !presentIds.has(boundary)) { + const boundaryId = boundaryByMessageId.get(message.id) ?? null + const anchor = boundaryId === null ? undefined : anchorById.get(boundaryId) + if (!anchor || anchor.timestamp === null) { trailing.push(message) continue } - const sharing = anchored.get(boundary) - if (sharing) { - sharing.push(message) - } else { - anchored.set(boundary, [message]) - } + const sharing = anchored.get(anchor.id) ?? [] + sharing.push({ + ...message, + id: `${ANCHORED_PENDING_ID_PREFIX}${message.id.slice('pending:'.length)}`, + timestamp: anchor.timestamp + ANCHOR_NUDGE_MS * (sharing.length + 1) + }) + anchored.set(anchor.id, sharing) } if (anchored.size === 0) { return { messages, 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..c50719d9c4cb 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.ts @@ -262,9 +262,19 @@ export function pendingSendsAsMessages( })) } +/** Id form for an echo placed at its send boundary rather than pinned to the + * tail. Separate so `messageSortRank` can order it as content while a + * still-at-tail echo keeps its tier behind the streaming bubble. */ +export const ANCHORED_PENDING_ID_PREFIX = 'pending-at:' + /** True when a message id was minted for an optimistic pending send. */ export function isPendingMessageId(id: string): boolean { - return id.startsWith('pending:') + return id.startsWith('pending:') || id.startsWith(ANCHORED_PENDING_ID_PREFIX) +} + +/** 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..b457dd3d2bc0 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 } From cf5608175fa92870e63042b020bc14039324bed2 Mon Sep 17 00:00:00 2001 From: Top Nguyen Date: Tue, 15 Sep 2026 22:52:50 +0700 Subject: [PATCH 4/5] fix(native-chat): stop the anchored echo costing the turn its tool output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regressions @pullfrog found in the anchoring itself, both real, both mine. **A tool result went missing.** An echo placed between an assistant message and the tool rows that followed ends the `foldToolMessages` run, so a tool-result-only row is no longer attributed and `dropUnattributableToolResults` deletes it — the call renders in flight, with no output. Worse than the bug this PR set out to fix, and only in the scenario it targets. An optimistic echo is a view artefact sitting where the user typed, not a transcript row, so it no longer ends the run. The id prefixes move to `native-chat-types` because the shared fold pass has to recognise them; `isPendingMessageId` now just re-exports the shared predicate. **The streaming tier flipped.** Dropping the tail-boundary case in the previous commit gave a still-at-tail echo rank 0, so it sorted ahead of the streaming preview — the tier the comment on `messageSortRank` and this PR both call load-bearing. The guard is back: an echo still at the tail keeps `pending:` and rank 2, and only an echo the transcript has moved past is repositioned. Worth recording why that guard was dropped at all: I read its failure as folded turns growing in place. Wrong — the first attempt failed because of the rank pin alone, which is now fixed, so the guard does what it was written to do. Both are pinned by tests through the path the list actually renders. The tool-result one fails without the fold change — `expected [] to deeply equal [ 'a.txt' ]` — which my first version of it did not, because I modelled the call as a text assistant message that re-opened the fold run on its own. 1547 tests in native-chat, 457 in shared/native-chat, typecheck and oxlint clean. Mobile deps do not install on this host (react-native patch fails to parse), so mobile is left to CI; the fold change touches it. Co-Authored-By: Claude Opus 5 (1M context) --- .../native-chat-pending-anchor.test.ts | 94 ++++++++++++++++--- .../native-chat/native-chat-pending-anchor.ts | 12 ++- .../native-chat/native-chat-pending.ts | 15 ++- src/shared/native-chat-tool-fold.ts | 5 + src/shared/native-chat-types.ts | 13 +++ 5 files changed, 112 insertions(+), 27 deletions(-) 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 index e5a9142716dd..652a10743a9a 100644 --- 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 @@ -6,6 +6,8 @@ import { } 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, @@ -39,7 +41,7 @@ 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-at:p1']) + expect(placed(atSendTime, pending)).toEqual(['u1', 'a1', 'pending:p1']) const laterTurns = [ ...atSendTime, @@ -49,20 +51,17 @@ describe('anchorPendingMessagesToSendBoundary', () => { expect(placed(laterTurns, pending)).toEqual(['u1', 'a1', 'pending-at:p1', 'a2', 'a3']) }) - // The case the first attempt at this fix missed: `foldToolMessages` folds a - // whole turn into the assistant message that opened it, so that message keeps - // its id and grows in place. A mid-turn send therefore names a boundary that is - // STILL the tail once the reply is complete — treating "at the tail" as "nothing - // came after" left the echo below the entire answer, exactly the reported bug. - it('anchors even when the boundary is still the tail, because a folded turn grows in place', () => { - const atSendTime = [message('u1', 'user', 'first'), message('a1', 'assistant', 'working')] + // 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')] - const grownInPlace = [ - message('u1', 'user', 'first'), - message('a1', 'assistant', 'working, ran 3 commands, done') - ] - expect(placed(atSendTime, pending)).toEqual(['u1', 'a1', 'pending-at:p1']) - expect(placed(grownInPlace, pending)).toEqual(['u1', 'a1', 'pending-at:p1']) + 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', () => { @@ -145,3 +144,70 @@ describe('anchored order survives the list projection', () => { ]) }) }) + +// 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' + ]) + }) +}) 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 index fa81d478f726..3b9ef55c7997 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending-anchor.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending-anchor.ts @@ -20,10 +20,10 @@ const ANCHOR_NUDGE_MS = 0.5 * the DOM. So an anchored echo is re-minted under `pending-at:`, which the * assembler ranks as content, and takes its anchor's timestamp plus a nudge. * - * "Still at the tail" is NOT a reason to leave an echo trailing: a folded turn - * grows in place under the id that opened it, so a mid-turn send names a - * boundary that is still the tail when the reply is complete. Only a boundary - * that is absent, or carries no timestamp to sort against, falls back. + * 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[], @@ -36,12 +36,14 @@ export function anchorPendingMessagesToSendBoundary( 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 ? undefined : anchorById.get(boundaryId) + const anchor = + boundaryId === null || boundaryId === tailId ? undefined : anchorById.get(boundaryId) if (!anchor || anchor.timestamp === null) { trailing.push(message) continue 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 c50719d9c4cb..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,15 +266,10 @@ export function pendingSendsAsMessages( })) } -/** Id form for an echo placed at its send boundary rather than pinned to the - * tail. Separate so `messageSortRank` can order it as content while a - * still-at-tail echo keeps its tier behind the streaming bubble. */ -export const ANCHORED_PENDING_ID_PREFIX = 'pending-at:' +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:') || id.startsWith(ANCHORED_PENDING_ID_PREFIX) -} +export const isPendingMessageId = isNativeChatPendingRowId /** True for a pending echo that carries a position of its own. */ export function isAnchoredPendingMessageId(id: string): boolean { 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) + ) +} From 65e6e2b20eead6853765a4b28e211799834cbf58 Mon Sep 17 00:00:00 2001 From: Top Nguyen Date: Wed, 16 Sep 2026 06:32:52 +0700 Subject: [PATCH 5/5] fix(native-chat): hold an anchored echo beside its anchor across timestamp ties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @coderabbitai on #20847: the half-millisecond nudge only worked while the next row sat on a later millisecond. Two transcript rows sharing one millisecond — common enough — put the echo after the row it was sent before, because the tie broke on id. Taking its advice, which is simpler than the nudge it replaces: an anchored echo now carries its anchor's own timestamp, and `compareMessages` returns 0 on a tie involving a `pending-at:` row instead of comparing ids. `sort` is stable, so placement order survives — for the anchor/echo pair and for several echoes on one anchor, without a nudge to tune or an anchor field on the shared message type. Unrelated messages keep id tie-breaking exactly as before. Both conditions are pinned by tests through `orderNativeChatMessages`, and the first fails without the change: expected [ 'u1', 'a1', 'a2', 'pending-at:p1' ] to deeply equal [ 'u1', 'a1', 'pending-at:p1', 'a2' ] 1549 tests in native-chat, 457 in shared/native-chat, typecheck and oxlint clean. Co-Authored-By: Claude Opus 5 --- .../native-chat-pending-anchor.test.ts | 38 +++++++++++++++++++ .../native-chat/native-chat-pending-anchor.ts | 10 ++--- .../native-chat-session-assembler.ts | 6 +++ 3 files changed, 48 insertions(+), 6 deletions(-) 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 index 652a10743a9a..7d1768c55d6a 100644 --- 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 @@ -211,3 +211,41 @@ describe('anchoring does not cost the turn its content or its tiers', () => { ]) }) }) + +// 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 index 3b9ef55c7997..33394b21c862 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending-anchor.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending-anchor.ts @@ -1,10 +1,6 @@ import type { NativeChatMessage } from '../../../../shared/native-chat-types' import { ANCHORED_PENDING_ID_PREFIX, type NativeChatPendingSend } from './native-chat-pending' -/** Places the echo strictly after its anchor without reaching the next row: the - * list re-sorts by timestamp, and transcript rows carry whole milliseconds. */ -const ANCHOR_NUDGE_MS = 0.5 - /** * Render an optimistic echo after the row it was sent against, not at the tail. * @@ -18,7 +14,9 @@ const ANCHOR_NUDGE_MS = 0.5 * 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 takes its anchor's timestamp plus a nudge. + * 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 @@ -52,7 +50,7 @@ export function anchorPendingMessagesToSendBoundary( sharing.push({ ...message, id: `${ANCHORED_PENDING_ID_PREFIX}${message.id.slice('pending:'.length)}`, - timestamp: anchor.timestamp + ANCHOR_NUDGE_MS * (sharing.length + 1) + timestamp: anchor.timestamp }) anchored.set(anchor.id, sharing) } 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 b457dd3d2bc0..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 @@ -126,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 }