Skip to content

fix(native-chat): place mid-turn and ! prompts where they were sent - #26582

Closed
pawel-kotowski wants to merge 1 commit into
stablyai:mainfrom
pawel-kotowski:fix/native-chat-mid-turn-order
Closed

pawel-kotowski wants to merge 1 commit into
stablyai:mainfrom
pawel-kotowski:fix/native-chat-mid-turn-order

Conversation

@pawel-kotowski

@pawel-kotowski pawel-kotowski commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

ELI5

If you send Claude a message while it is still working, or run a ! shell command from the chat box, the chat view shows your message stuck at the very bottom. Every later reply appears above it, so the conversation reads out of order. This PR makes those messages show up where you actually sent them.

What Changed

Before: a message sent mid-turn, or a ! command, stays pinned under the newest reply forever (until restart). The terminal pane shows the right order; chat does not.

After: the message appears in the conversation at the point Claude took it, and the temporary "just sent" bubble disappears like it does for any other message.

Mechanism: chat shows a temporary bubble when you send, then removes it once a matching user message shows up in Claude's transcript. In two cases no matching message ever showed up:

  1. Mid-turn prompts. Claude Code absorbs these into the running turn and writes no type:"user" record. The only record is a type:"attachment" with attachment.type === "queued_command", and the Claude decoder dropped every non-user/assistant record. The decoder now reads a queued_command attachment with commandMode: "prompt" as a user message at that position in the file. It keeps dropping the ones the user did not type: task notifications (commandMode: "task-notification") and peer-agent messages (origin.kind: "peer"). Older Claude versions omit origin, so a missing origin counts as human.
  2. ! commands. Claude records ! git status as <bash-input> git status</bash-input>, which chat filters out as harness noise. The decoder now turns a user row that is exactly one <bash-input> envelope back into the line as typed (! git status), which matches the bubble. The turn-lifecycle decoder ignores these rows, so a shell command never flips the agent to "working". Command output (<bash-stdout>) stays hidden as before.

The turn-lifecycle decoder already skips non-user records, so the new attachment rows do not change working/done state.

Why

The fix sits where the data is lost: the transcript reader. Once the user row exists, the existing bubble-matching code removes the bubble with no renderer changes, and desktop, mobile, and worker-read all see the prompt.

I considered and decided against:

I checked my own ~/.claude/projects transcripts (about 1,250 queued_command records): a human prompt-mode entry never also gets a type:"user" record, so this cannot double a message.

Linked Issue

Fixes #14308
Fixes #20846
Fixes #25956
Related: #22086, #18494

Visual Proof

Before: the screenshots in #14308, #20846, #22086 and #25956 show the pinned bubble. After: replayed a real session transcript (Claude Code 2.1.292) in which two mid-turn prompts were stuck at the bottom. With this change both decode in place and their pending bubbles are pruned. A before/after recording will be attached in a comment.

Testing

  • New decoder tests in transcript-line-decoders-claude-queued-prompt.test.ts use the attachment shape Claude Code 2.1.292 writes: human prompt, legacy entry with no origin, content-block prompt, task notifications, peer messages, a malformed entry, and other attachment types.

  • claude-bash-input.test.ts covers the ! round trip, including the space after ! and envelopes with surrounding text.

  • transcript-turn-lifecycle.test.ts: neither a ! row nor a queued prompt marks the turn as working.

  • pnpm tc passes. Vitest on src/main/native-chat/transcript*, src/shared/native-chat*, src/shared/harness-injected* and src/renderer/src/components/native-chat/: 4,531 tests pass. check:code-quality:changed passes.

  • Replayed a real Claude Code 2.1.292 transcript (macOS) through the decoder and the pending-bubble pruning.

  • I manually tested these changes locally

  • Automated tests added/updated, or explained why not below

AI Disclosure

Written with Claude Code (Claude Opus 5.5), reviewed by me.

Review

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

  • Cross-platform: string parsing of transcript lines only, no paths or platform APIs.
  • SSH and mixed versions: the host publishes extra ordinary user messages. Older clients render them like any other user message, and nothing on the wire changes.
  • Mobile: receives the same rows. Its existing re-anchoring of unmatched bubbles still applies to anything else.
  • Performance: one extra field check per attachment line, which the decoder already parsed.

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

🤖 Generated with Claude Code

Claude records a prompt sent while it is busy only as a `queued_command`
attachment, and a `!` shell command as a `<bash-input>` row that chat hides
as harness noise. Neither gave the optimistic echo a row to match, so the
echo stayed pinned below every later turn. Decode both as the user row they
stand for; keep `!` commands out of the turn lifecycle so they never mark
the agent working.

Fixes stablyai#14308, stablyai#20846, stablyai#25956.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

📝 Walkthrough

Walkthrough

Claude transcript decoding now converts eligible queued human prompts into user rows and unwraps Bash input blocks as ! commands. Turn lifecycle decoding excludes typed Bash command rows from working-turn detection. Tests cover queued prompt filtering, Bash input conversion, and lifecycle behavior.


Priority: ➖ Normal

Severity of issue fixed: Medium

Merge Risk: 🔵 Low · up to 6dc00

Some shell-command messages may appear incorrectly or be treated as working turns. These limited cases warrant a fix or explicit acceptance but do not appear to block merging.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 55.56% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 9 functions across 6 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes satisfy the coding objectives for [#14308], [#20846], and [#25956]. transcript-line-decoders-claude.ts converts human queued_command prompt attachments into user records at transcript …
Out of Scope Changes check ✅ Passed The changed production files implement the linked transcript-decoding and lifecycle behavior. The added tests directly cover those changes. No unrelated production behavior or unrelated files are show…
Title check ✅ Passed The title clearly and concisely describes the primary change: positioning mid-turn and shell-command prompts at their transcript location.
Description check ✅ Passed The description is complete and directly matches the implementation. It covers the user impact, mechanism, rationale, linked issues, testing, compatibility, and checklist status. Visual proof is refer…

  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 31e11c3e-874f-4858-9c17-03ac758d2eac
📥 Commits

Reviewing files that changed from the base of the PR and between 89ba968 and 6dc0099.

📒 Files selected for processing (6)
  • src/main/native-chat/transcript-line-decoders-claude-queued-prompt.test.ts
  • src/main/native-chat/transcript-line-decoders-claude.ts
  • src/main/native-chat/transcript-turn-lifecycle.test.ts
  • src/main/native-chat/transcript-turn-lifecycle.ts
  • src/shared/claude-bash-input.test.ts
  • src/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.

// hidden as noise and the echo stayed pinned at the tail (#25956).
blocks:
role === 'user'
? blocks.map((block) => unwrapClaudeBashInputBlock(unwrapClaudePastedContentBlock(block)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Require a single Bash-input block before converting the user row.

If a user row contains a <bash-input> text block and another text block, this map converts the first block to a ! command. isClaudeBashInputRecord does not recognize that multi-block row and emits a working turn. Apply the single-envelope condition to the complete row so display and lifecycle decoding use the same boundary.

* stripped. Returns the line as typed, or null when the text is anything else. */
export function claudeBashInputAsTypedCommand(text: string): string | null {
const command = BASH_INPUT.exec(text)?.[1]
return command === undefined || command.includes('<bash-input>') ? null : `!${command}`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Allow literal <bash-input> text inside a shell command.

If the user types !echo '<bash-input>', the transcript contains <bash-input>echo '<bash-input>'</bash-input>. The substring check returns null, although the row has one outer envelope. The decoder leaves the command as markup, and the lifecycle decoder treats it as a working turn. Reject additional envelopes rather than every occurrence of the opening tag.

@pawel-kotowski
pawel-kotowski force-pushed the fix/native-chat-mid-turn-order branch from 6dc0099 to 9d9f101 Compare October 12, 2026 11:18
@ilyakaznacheev

Copy link
Copy Markdown

Have the same issue, waiting for it to be merged 😊

@sojiro-o

Copy link
Copy Markdown
Contributor

Tested this PR on Windows + WSL2 (Orca 1.4.227, Claude Code 2.1.302). It fixes #20846 there.

Real transcript. A message sent while Claude was working left only a type:"attachment" row with attachment.type:"queued_command", commandMode:"prompt", origin.kind:"human". It had no type:"user" row.

  • On main, the Claude decoder outputs nothing for that row, so the sent bubble stays at the bottom.
  • With this PR, the same row decodes as a user message between the tool result and the final answer. readNativeChatTranscript gives the same order.

In the app. I fed a transcript with the same shape into a background e2e build.

  • Without the decoder change, the mid-turn message sat under the final answer.
  • With it, the message appeared once, at the point it was sent.

Other checks

  • The three test files in this PR pass (28 tests).
  • git merge-tree against current main shows no conflicts, though the branch is about 1,800 commits behind.
  • In 38 human queued_command rows across 12 local sessions (Claude Code 2.1.283–2.1.302), none also had a matching type:"user" row. So the new decoding doesn't create duplicates.

One thing the tests don't cover. Claude stamps the queued_command row with the time the message was sent. That time is earlier than the rows written just before it in the file. So in chat the message lands where it was sent, not where its row sits in the file. That matches the PR's goal, but a test with that time inversion would lock it in.

@AmoabaKelvin

Copy link
Copy Markdown
Collaborator

Thanks @pawel-kotowski, the decoder change here was the right call. We rebuilt it on current main in #31204, with you as co-author. That PR also fixes the ordering: Claude stamps a mid-turn prompt with the time it was sent but writes it after the tool result it was read with, and desktop orders by time, so the prompt split tool calls from their results in about half of real sessions. Closing in favour of #31204.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

4 participants