Repository navigation
fix(native-chat): show Claude prompts sent mid-turn and ! commands where Claude read them - #31204
Conversation
…laude read them Claude records a prompt it takes while busy only as a `queued_command` attachment, and a `!` command as a `<bash-input>` row that chat hid as harness noise, so neither echo ever matched a row and both stayed pinned below every later turn. Decode both as the user row they stand for. The queue entry carries the time it was sent but sits where Claude read it, after the tool result. Desktop orders terminal transcripts by time, so the readers now stamp each row no earlier than the rows above it in the file; otherwise the prompt split tool calls from their results. Fixes #14308, #20846, #25956. Co-authored-by: Paweł Kotowski <p.kotowski89@gmail.com>
📝 Walkthrough
Priority: ➖ Normal Severity of issue fixed: Medium Merge Risk: 🔵 Low · up to
|
There was a problem hiding this comment.
Actionable comments posted: 1
ℹ️ Review info
⚙️ Run configuration
- Configuration used: Repository UI
- Review profile: CHILL
- Plan: Advanced
- Run ID:
02b9a63f-a819-4dd9-8431-0a991ff105c7
📒 Files selected for processing: 12
src/main/native-chat/transcript-file-order.test.tssrc/main/native-chat/transcript-file-order.tssrc/main/native-chat/transcript-incremental-reader.tssrc/main/native-chat/transcript-line-decoders-claude-queued-prompt.test.tssrc/main/native-chat/transcript-line-decoders-claude.tssrc/main/native-chat/transcript-stream-lines.tssrc/main/native-chat/transcript-tail-reader.tssrc/main/native-chat/transcript-turn-lifecycle.test.tssrc/main/native-chat/transcript-turn-lifecycle.tssrc/main/native-chat/transcript-watch-engine.tssrc/shared/claude-bash-input.test.tssrc/shared/claude-bash-input.ts
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 9 remain after this review.
| import type { NativeChatMessage } from '../../shared/native-chat-types' | ||
|
|
||
| /** Desktop orders terminal transcript rows by time, but the file holds the true order: | ||
| * Claude stamps a prompt it took mid-turn with when it was queued, then writes it | ||
| * where it read it. So a row never sorts above the rows before it. `latest` is the | ||
| * newest time above this row; returns the row and the newest time including it. */ | ||
| export function inTranscriptFileOrder( | ||
| message: NativeChatMessage, | ||
| latest: number | ||
| ): { message: NativeChatMessage; latest: number } { | ||
| if (message.timestamp === null || message.timestamp >= latest) { | ||
| return { message, latest: Math.max(latest, message.timestamp ?? latest) } | ||
| } | ||
| // Why: one past, not equal: equal times fall back to id order, which can still put it first. | ||
| const timestamp = latest + 1 | ||
| return { message: { ...message, timestamp }, latest: timestamp } | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
sed -n '1,100p' src/main/native-chat/transcript-file-order.ts
rg -n 'sort\\(|timestamp.*id|compare.*[Mm]essage|latestTimestamp|inTranscriptFileOrder' src/main/native-chat src/shared | head -130Repository: stablyai/orca
Length of output: 1188
🏁 Script executed:
set -o pipefail
printf '%s\n' '--- helper usages ---'
rg -n -F -- 'inTranscriptFileOrder' src || test "$?" -eq 1
printf '%s\n' '--- native-chat sort-related references ---'
rg -n -F --glob '*.{ts,tsx}' -e '.sort(' -e 'sort(' -e 'timestamp' -e 'id' src/main/native-chat src/shared || test "$?" -eq 1
printf '%s\n' '--- changed-file diff against requested base ---'
git diff --no-ext-diff --unified=80 6ac9458d0b8beffc4d7d82e9f0e4538cd3a476d1 32ed0204fe106daa3e266b2dbe11cbf3da89d385 -- src/main/native-chat/transcript-file-order.tsRepository: stablyai/orca
Length of output: 45666
🏁 Script executed:
set -o pipefail
printf '%s\n' '--- caller blocks ---'
sed -n '105,155p' src/main/native-chat/transcript-incremental-reader.ts
sed -n '135,180p' src/main/native-chat/transcript-tail-reader.ts
sed -n '1,60p' src/main/native-chat/transcript-stream-lines.ts
printf '%s\n' '--- focused sort calls in transcript-related source ---'
rg -n -F --glob '*.{ts,tsx}' -e 'messages.sort(' -e 'rows.sort(' -e 'transcript.sort(' -e 'sort((a, b)' -e 'sort((left, right)' src/main src/renderer src/shared || test "$?" -eq 1
printf '%s\n' '--- timestamp comparator candidates ---'
rg -n --glob '*.{ts,tsx}' 'timestamp[[:space:]]*[-+]|[.]timestamp[[:space:]]*[<>]|compare.*timestamp|timestamp.*compare|id.*localeCompare|localeCompare.*id' src/main src/renderer src/shared || test "$?" -eq 1
printf '%s\n' '--- realistic equal-time fixture ---'
sed -n '1,125p' src/main/native-chat/transcript-line-decoders-claude-queued-prompt.test.tsRepository: stablyai/orca
Length of output: 41839
🏁 Script executed:
set -o pipefail
printf '%s\n' '--- native-chat renderer files ---'
rg --files src/renderer/src | rg '/native-chat/|native-chat'
printf '%s\n' '--- native-chat renderer ordering references ---'
rg -n --glob '*.{ts,tsx}' -e 'NativeChatMessage' -e 'messages' -e 'timestamp' -e 'sort' -e 'toSorted' -e 'toReversed' src/renderer/src/components/native-chat src/renderer/src/store src/shared/native-chat* src/shared/agent-session* || test "$?" -eq 1
printf '%s\n' '--- transcript consumer references repository-wide ---'
rg -n --glob '*.{ts,tsx}' -e 'readNativeChatTranscript' -e 'decodeTranscriptStream' -e 'transcriptMessages' -e 'NativeChatMessage\\[\\]' -e 'nativeChat.*messages' src || test "$?" -eq 1Repository: stablyai/orca
Length of output: 45667
🏁 Script executed:
set -o pipefail
printf '%s\n' '--- message-list projection ---'
nl -ba src/renderer/src/components/native-chat/native-chat-message-list-projection.ts
printf '%s\n' '--- transcript items ---'
nl -ba src/renderer/src/components/native-chat/NativeChatTranscriptItems.tsx
printf '%s\n' '--- message list ordering-relevant blocks ---'
rg -n -F --glob '*.tsx' -e 'NativeChatTranscriptItems' -e 'messages.map' -e 'messageList' -e 'sort' -e 'timestamp' src/renderer/src/components/native-chat/NativeChatMessageList.tsx src/renderer/src/components/native-chat/NativeChatView.tsx src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx || test "$?" -eq 1
printf '%s\n' '--- file-order tests ---'
nl -ba src/main/native-chat/transcript-file-order.test.tsRepository: stablyai/orca
Length of output: 13950
🏁 Script executed:
set -o pipefail
printf '%s\n' '--- compareMessages definition and callers ---'
rg -n -F -- 'compareMessages' src/renderer/src/components/native-chat src/shared src/main || test "$?" -eq 1
printf '%s\n' '--- native-chat session assembler relevant block ---'
rg -n -F --glob '*.ts' -e 'function compareMessages' -e 'const compareMessages' -e 'export.*compareMessages' src/renderer/src/components/native-chat src/shared || test "$?" -eq 1
printf '%s\n' '--- shared transcript projection ---'
nl -ba src/shared/native-chat-transcript-projection.tsRepository: stablyai/orca
Length of output: 12237
🏁 Script executed:
nl -ba src/renderer/src/components/native-chat/native-chat-session-assembler.ts | sed -n '90,125p'
printf '%s\n' '--- nearby comparator tests ---'
rg -n -F --glob '*.{ts,tsx}' -e 'compareMessages(' -e 'same timestamp' -e 'equal timestamp' -e 'timestamp.*id' src/renderer/src/components/native-chat src/shared/native-chat* src/main/native-chat || test "$?" -eq 1Repository: stablyai/orca
Length of output: 3006
🏁 Script executed:
nl -ba src/main/native-chat/transcript-turn-lifecycle.test.ts | sed -n '55,190p'
printf '%s\n' '--- repeated timestamp records in native-chat tests ---'
rg -n -B3 -A3 -F -- '2026-07-16T23:45:37.608Z' src/main/native-chat || test "$?" -eq 1Repository: stablyai/orca
Length of output: 12141
Make equal transcript timestamps strictly increasing.
inTranscriptFileOrder leaves a row unchanged when its timestamp equals latest. The renderer then sorts transcript rows by timestamp and uses the semantic row ID as the tie-breaker. A later file row with a lower ID can therefore render before the earlier row.
Change the comparison to > so equal timestamps receive latest + 1.
Suggested fix
- if (message.timestamp === null || message.timestamp >= latest) {
+ if (message.timestamp === null || message.timestamp > latest) {
ELI5
In a Claude chat, two kinds of messages got stuck at the very bottom: a message you send while Claude is still working, and a
!shell command. Every later reply showed up above them, so the conversation read out of order. Now they show up where Claude actually read them.What Changed
Before: a message sent while Claude was busy, or a
!command, stayed pinned below the newest reply until you restarted. The terminal showed the right order; the chat did not.After: the message appears at the point Claude took it in, and its temporary "just sent" bubble goes away like any other message's.
Mechanism: chat shows a temporary bubble when you send, and removes it once Claude's transcript has a matching user message. In two cases it never had one:
queued_commandrecord. The Claude transcript reader dropped those records. It now reads one as a user message when you typed it (commandMode: "prompt"), and still drops the ones you didn't type: background-task notices and messages from other agents. Older Claude versions leave out who sent it, and those entries were always typed by the user.!command. Claude records! git statusas<bash-input> git status</bash-input>, which chat hid as Claude's own bookkeeping. The reader now turns that row back into the line you typed. Command output stays hidden as before, and the row still doesn't mark the turn as working, the same as today.Ordering. Claude stamps a queued message with the time you sent it, but writes it into the file after the tool result it was read with. Desktop chat orders Claude's rows by time, so in more than half of the real cases the message landed between a tool call and its result. The call then showed no output, and the output became a stray row under your message. The transcript readers now give each row a time no earlier than the rows above it in the file (one millisecond past them when it would be earlier). Mobile already keeps file order and gets the same rows.
Why
The fix sits where the information was lost: the host's transcript reader. Once the user row exists, the existing bubble matching removes the bubble with no renderer changes, and desktop, mobile and
worker-readall see the message.Considered and decided against:
This rebuilds #26582 by @pawel-kotowski on current main, with the ordering fix added. It also covers the open decoder PR #14309 (and the closed #16121 and #19428).
Linked Issue
Fixes #14308
Fixes #20846
Fixes #25956
Fixes #22086
Fixes #18494
#22086 and #18494 are the same mid-turn case. In #18494 the stuck bubble ("ten en cuenta que hora son las 12 y 1") is a message Claude read during a tool call and answered, which the resumed terminal shows. #22086 happens only when sending while Claude works, and not every time, because a message still waiting when the turn ends does get a normal user record.
#29212 (mobile) is a different bug. There the phone kept only the newest 40 rows during a turn, which #28413 already fixed (desktop 1.4.228 and later serve that page).
Visual Proof
Claude Code 2.1.304 terminal chat on macOS, hidden dev builds of main and this branch.
A message sent while Claude runs a 35s command ("Also say banana at the end."):
On main the message is stuck below Claude's answer to it. After, it sits where Claude read it, and the command keeps its output.
A
!command, then one more message:On main both the queued message and
! echo hello-bangstay pinned at the bottom, and Claude's reply to the!is merged into the first turn ("Worked for 1m 56s"). After, each sits in order with its own reply.While Claude is still working after reading a second message that asks for another command:
The first turn folds as soon as Claude reads the new message, and the work that follows shows under it, as in the terminal.
Known and left for a follow-up: until the turn ends, the newest turn's live "Working for" counts from the start of the whole run, so it reads 31s here. It settles once a later turn arrives. Main also miscounts in this case: it gives the pinned bubble its own "Worked for 39s".
Testing
transcript-file-order.test.tsruns a real-shaped mid-turn sequence through the full read, the tail read and a live append after the first snapshot, then through the chat projection. It checks that the message sits after the tool run and that the call keeps its result. All three cases fail without the reader change, and the append case also fails without the snapshot-to-append handoff.Decoder tests use the record shapes Claude Code 2.1.292 and 2.1.304 write: a typed message, an older entry with no sender, a message with content blocks, background-task notices, messages from other agents, and a malformed entry. The
!tests cover a command with and without a space after!.Replayed all 507 Claude sessions on my machine through the real reader and chat projection. Main: 12 tool results without their call (all predate this work). This branch: still 12, with 64 messages sent mid-turn now shown. The decoder change alone would have added 35 more.
Measured against 152,727 real Claude rows and 46,404 Codex rows: the new ordering moves 77 Claude rows (the 66 mid-turn messages plus 11 tool results already stamped earlier than the row above them) and no Codex rows.
Drove the real Claude Code 2.1.304 CLI to capture the
!rows. It writes<bash-input> echo hello-bang</bash-input>(space kept) and<bash-input>echo nospace</bash-input>.pnpm lint,pnpm tc,check:code-quality:changed,check:react-doctor:changed, and the native-chat suites (815 files, 8,815 tests) pass.Tested on macOS.
I manually tested these changes locally
Automated tests added/updated, or explained why not below
Review
Agent skill upstream boundary
Notes
attachmentline the reader already parsed.Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)