Skip to content

[Bug]: chat view renders a mid-turn queued message after the reply instead of where it was sent #20846

Description

@topnguyen

Operating system

Windows

Orca version

1.4.203 (also seen on 1.4.197)

Details

Short summary: a message typed while the agent is still working renders at the bottom of the chat view, below the whole reply it triggered — so the answer appears above the question still sitting underneath it. The terminal pane shows the same conversation in the correct order.

What happened?

Claude Code absorbs a mid-turn send into the running turn and writes no type:"user" row for it. The optimistic echo therefore has no transcript row to match — ever — and stays pinned at the tail of the list while the rest of the reply lands above it.

Captured from ~/.claude/projects/<encoded-cwd>/<session>.jsonl, line numbers as they appear in the file:

  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","content":"check status, second time",
                                                   "reason":"absorbed_mid_turn"}
 33  {"type":"attachment","attachment":{"type":"queued_command",
                                        "prompt":"check status, second time"}}
 41  {"type":"assistant", ...}   "Working tree is clean and in sync with origin..."
 61  {"type":"assistant", ...}   "It's 14:20 UTC - both armed probes have fired..."
 68  {"type":"assistant", ...}   "The discriminating test has fired..."

The echo belongs at line 33. The chat view renders it after line 68.

order shown
Terminal pane (correct) msg 1 → tools → msg 2 → more tools → final answer
Chat view (wrong) msg 1 → tools → final answer → msg 2

How can we reproduce it?

  1. Open an agent pane running Claude Code and send a prompt that takes ~30s of tool calls.
  2. While it is still working, send a second, clearly different message.
  3. Compare the terminal pane with the chat view.

Anything else that might help:

Mobile already fixes exactly this, and its test says so in as many words (mobile/src/session/mobile-native-chat-render-data.test.ts):

Claude consumes a mid-turn send through a queued_command attachment and writes no type:"user" record for it, so that echo has no row to match - ever. What made the conversation read as scrambled was not the unmatched echo itself but where it rendered: appended after every turn, so it re-read below each new one.

Mobile anchors on baselineTailMessageId. Desktop records the same boundary as NativeChatPendingSend.afterMessageId but only uses it for matching — pendingSendsAsMessages deliberately timestamps echoes to sort to the end:

the send time as the timestamp so they sort to the end (most recent) of the list

A second path reaches the same symptom: a message sent to a pane whose agent session is not yet registered is never delivered and likewise produces no user row, so its echo dangles at the tail too.

Workaround: wait for the turn to finish before sending the next message.

A PR follows.

Activity

  1. AmethystLiang commented on Sep 15, 2026

    @AmethystLiang
    Contributor

    thanks for reporting. looking into it

  2. added theissue type on Sep 19, 2026
  3. lurunzi commented on Sep 22, 2026

    @lurunzi
    Contributor

    Independent Windows report with Claude Code 2.1.278: messages sent during an active reply were received by Claude, but remained at the bottom of Orca Chat as pending echoes. Read-only inspection found queue-operation.remove with reason: absorbed_mid_turn, followed by a persisted attachment with this sanitized shape:

    {"type":"attachment","uuid":"synthetic-message","timestamp":"2026-09-22T19:37:59.013Z","attachment":{"type":"queued_command","prompt":"Check the result while you continue.","commandMode":"prompt","origin":{"kind":"human"},"humanTurn":true}}

    The shared Claude decoder drops this record on inspected main 18696bd875. A narrowly scoped personal-fork fix decodes this positively identified human prompt as an ordinary transcript user message, letting existing pending reconciliation remove its echo without resending or introducing a turn boundary:

    • Implementation and regression: lurunzi@eadc907
    • The 15-case regression covers pending reconciliation, unchanged lifecycle, repeated/multiline input, and rejection of queue-only, non-human and unsupported records. It passed locally on Windows; no installed-app or live-SSH validation is claimed.

    This is evidence for the persisted-human-attachment case, not a complete fix for every case in this issue. The narrow filter intentionally excludes older records without origin.kind / humanTurn; it does not solve general file-order versus timestamp ordering or prompts that never reached the process. Existing PRs #14309, #16121, #19428 and #20847 already address overlapping scopes, so I am adding the evidence here rather than opening another competing PR. No user transcript, paths, project names or images are attached.

  4. topnguyen commented on Sep 23, 2026

    @topnguyen
    Author

    Thanks — this is the piece I was missing. queue-operation.remove with reason: absorbed_mid_turn followed by the synthetic-message / queued_command attachment explains why there is no row to match: the shared decoder on main still drops it. I can confirm independently that src/main/native-chat/transcript-line-decoders-claude.ts has had no commits on main since this issue was filed, so nothing has landed for it yet.

    Worth stating plainly for anyone triaging: our two fixes sit at different layers and compose rather than compete.

    The second case survives the first by your own scoping: records without origin.kind / humanTurn, and prompts that never reached the process, still leave an echo with nothing to reconcile against. Held at the tail it re-reads below every turn of the reply it triggered, which is the symptom in the title here.

    #19428 already carries a staff version of the admission path and has been sitting in draft since 8 Sep, so your evidence is probably best aimed there rather than at a fifth PR — I have linked it from that thread.

  5. hishiki1029 commented on Oct 11, 2026

    @hishiki1029

    Also reproduced on macOS (Orca 1.4.224, Claude Code agent).

    Two additional observations:

    • The duplicated bubble persists after closing and reopening the chat.
    • An older message from earlier in the same session (also sent mid-turn) re-appeared at the bottom together with the latest one.

    The terminal pane shows the correct order, same as described above.

  6. wutonglab commented on Oct 17, 2026

    @wutonglab

    Still reproducible on Orca 1.4.224 (Linux server + Orca mobile) with Claude Code terminal sessions, and the cause is entirely on the Orca side, so here is a minimal fix that we have tested locally.

    Data. One long Claude session has 408 queue-operation remove reason:absorbed_mid_turn records and 406 attachment.type:"queued_command" records, and none of them has a type:"user" row with the same text. Of those attachments, 53 are human prompts (commandMode:"prompt", origin.kind:"human"); the rest are task notifications.

    Mechanism (1.4.224 bundle).

    1. The Claude line decoder (transcript-line-decoders-claude.ts, used by nativeChat.subscribe) returns null for every record whose type is not user/assistant, so the queued_command attachment never reaches clients.
    2. Mobile keeps the optimistic echo pending until a transcript role:user row with the same normalized text appears. It never appears, and there is no timeout.
    3. The pending echo is anchored after baselineTailMessageId. Once the 40-row subscription window slides past that anchor, the echo falls into the unanchored bucket and is appended after everything, including the streaming row, which is the "stuck at the bottom" symptom.

    The echo lives only in in-memory React state, so reloading the app clears it. After that, the absorbed prompt is simply missing from chat. A next normal turn, the end of the turn, or toggling the chat view does not clear it. A related side effect is that prompts typed mid-turn in the desktop terminal never show up in chat on any device.

    Fix. In the Claude decoder, map type:"attachment" with attachment.type === "queued_command", commandMode === "prompt", and origin either absent or {kind:"human"} (and not sidechain or meta) to a role:"user" text message. Use the attachment's uuid as the id and keep its parentUuid and timestamp. Keep dropping task notifications, peer messages, and other attachments. The row lands at the transcript position where Claude actually absorbed the prompt, and the existing text-match logic then retires the optimistic echo. Turn-state detection is unaffected because it only looks at user/assistant records. Happy to open a PR with tests if that direction works for you.

  7. wutonglab commented on Oct 18, 2026

    @wutonglab

    Two more findings on the phone chat send path, reproduced on Orca 1.4.224 (Linux host, Claude Code 2.1.302). I reproduced them at the host RPC level: I replayed the exact terminal.send calls the 1.4.224 mobile client makes for a plain Claude message (first {text:"\x15", enter:false}, then {text, enter:true} with no agentPrompt). I did not drive the phone UI itself.

    Path. These calls skip the desktop agent-prompt branch, because the terminal.send handler (src/main/runtime/rpc/methods/terminal/terminal-send-method.ts) only takes that branch when agentPrompt && client.type === 'desktop'. They go to sendTerminal → writeActionWithinLimit (src/main/runtime/runtime-terminal-writer.ts), which writes the text, waits about 500 ms, then writes a separate \r. That path has no permission/selector guard, no render gate and no turn_started check, unlike writeTerminalAgentPrompt (src/main/runtime/terminal/input/terminal-agent-prompt.ts).

    1. An unsent draft in the agent's input box gets submitted with the message.
    The mobile client clears the input with a single Ctrl+U (clearMobileNativeChatInput writing AGENT_TUI_CLEAR_INPUT_LINE). Ctrl+U only deletes from the cursor back to the start of the current logical line.

    • Single-line draft, cursor at the end: cleared. Submitted "MOBILE-A: say hi", as expected.
    • Two-line draft draft line one\ndraft line two: the transcript user row is "draft line one\nMOBILE-B: say hi".
    • Single-line draft please reply with the word, cursor moved 4 to the left: the transcript user row is "MOBILE-C: say hiword".

    Expected: either exactly the sent text is submitted, or the send is refused with "input has unsent text". Actual: leftover draft text is merged into the prompt and silently changes what the agent receives, and the rest of the draft is lost. Not verified on the phone: whether the optimistic bubble retires when the row is draft + message rather than the message alone.

    2. A message sent while Claude shows a selector answers the selector.
    Steps: get Claude to call AskUserQuestion (options Red/Green/Blue, Red highlighted), then replay the mobile send "MOBILE-D: what is 2+2".
    Actual: the selector closes with → Red. The transcript has a tool_result of "Red", and the message text appears nowhere. terminal.send returned accepted: true, so the phone would show it as sent.
    Contrast: with the same selector open, orca terminal send --text … --enter, which is the desktop agentPrompt path, is refused with agent_prompt_blocked, and the selector stays untouched.
    Since #25761 the phone disables Send while a prompt card is shown. The host still accepts these writes, though, so a send gets through whenever the card is not detected yet or Send has been re-enabled with the collapse chevron.

    Suggested fixes

    • Host: for agent terminals, guard the plain text+enter path the same way as the agent-prompt path. Refuse with a refusedReason while the agent is in permission/question state (assertAgentPromptPermissionSafe). Alternatively, let mobile clients use the agentPrompt branch, which also gives the render gate and turn_started confirmation, so the phone and desktop share one submit path.
    • Draft: before writing the body, check that the rendered input line is empty (terminal.read --screen already exposes it as draft). If it is not empty, either clear all of it (the confirmCleared + widened burst used for launch drafts, applied to any draft, plus moving the cursor to the end first) or refuse the send and show "Message not sent – input has unsent text".
    • Selector: while the host sees a selector or permission prompt, return a refusal the phone can show, instead of accepted: true.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions