Skip to content

fix(chat): decode Claude pastes and report terminal delivery uncertainty - #23788

Merged
brennanb2025 merged 17 commits into
mainfrom
brennanb2025/chat-echo-identity
Sep 30, 2026
Merged

brennanb2025 merged 17 commits into
mainfrom
brennanb2025/chat-echo-identity

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 8 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​415 $\color{#cf222e}{\Huge{\mathbf{−}}}$​4 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​411
Prod 20 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​478 $\color{#cf222e}{\Huge{\mathbf{−}}}$​141 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​337

What this fixes

In the Chat view of a Claude agent running in a terminal pane (desktop, paired web, and mobile, all of which read the same host transcript), a multi-line prompt sent from the chat composer left a second copy of itself pinned below Claude's reply for the rest of the session (#23450, STA-8729).

Who hits it: Claude Code versions that save pasted text inside a <pasted_content id="…"> envelope in the session transcript. In QA this envelope appeared only when Claude's own server-side paste feature flag was on for the account; with it off, Claude saved the plain text and there was no duplicate. So some users see this on every multi-line send, others never.

Also fixed on desktop: a composer send whose terminal write was refused, or whose write acknowledgment was lost, used to look like an ordinary pending message forever. It now says "Message not sent" or, after 20 seconds, "Delivery unconfirmed — check chat before retrying", keeps the text selectable, and offers Dismiss. Nothing is ever resent automatically.

Not affected: structured (native) chat sending, the plain terminal view, and non-Claude agents' sends. Mobile's existing delivery notices are unchanged; mobile only gains the transcript fix. Prompts typed while Claude is mid-turn behave exactly as before (see Known weaknesses).

ELI5

When you paste several lines into the chat box and send, Orca shows your message right away as a "pending" bubble and removes that bubble once it sees the same text in Claude's saved conversation. Claude sometimes saves pasted text wrapped in a label, like putting it in an envelope. Orca compared the envelope to your message, never found a match, and so the pending bubble never went away. Now Orca opens the envelope before comparing.

What Changed

Before: the host's Claude transcript reader passed Claude's user text through as written, so a pasted prompt arrived as <pasted_content id="7e64">\n…\n</pasted_content id="7e64">. The chat showed that raw text as the user's message, and the pending echo (which holds the plain draft) never matched it.

After: the host's Claude transcript reader removes a whole-message paste envelope from user text only (src/shared/claude-pasted-content.ts, called from transcript-line-decoders-claude.ts). It unwraps only when the envelope is the entire message, the opening and closing ids match, and the body does not contain another tag with the same id. Surrounding prose, mismatched ids, several envelopes in one message, and assistant text that quotes an envelope are left untouched. The shared comparison used by desktop and mobile to retire pending echoes also unwraps, so a new client talking to an older host (which still sends the raw envelope) still matches. No wire format, journal schema, or persisted field changes.

Before: a Claude chat send wrote its bytes into the terminal fire-and-forget, so a refused or lost write left nothing to react to.

After: a Claude chat send uses the verified terminal write. A write the host refuses marks the pending bubble "Message not sent". A write whose acknowledgment is lost (the remote call failed, so we cannot tell whether the bytes arrived) still sends Enter without re-sending the text, and starts a 20-second hold; if Claude's transcript still has no matching message by then, the bubble reads "Delivery unconfirmed — check chat before retrying". A message that lands later still retires the bubble. Sending the same text again replaces the failed bubble, and Stop no longer deletes a failed bubble (it holds the only copy of the text). Claude chat sends with attached images use the same verified path; slash commands and non-Claude agents keep their existing write path.

Why this design

The first version of this PR marked a send "unconfirmed" when the agent's turn ended and the transcript still had no matching message. Review showed that rule is wrong in the common case: when you send while Claude is working, Claude saves the prompt only as a queued-command record, which Orca's transcript reader does not surface yet (open PR #16121), so a prompt Claude actually answered would have been flagged, inviting a duplicate resend. It also never fired for an idle Claude that lost the prompt. A fixed wall-clock timer on every send has the same false-flag problem. So this PR reports only outcomes the transport itself proves: a refused write, or a lost acknowledgment. That is the same rule the mobile app already applies on main, and it needs no agent-status subscription.

Unwrapping happens in the host decoder so every reader (desktop, paired web, mobile, history import) sees the text the user actually sent; the shared comparison also unwraps so mixed versions keep working.

Alternatives rejected: stripping the envelope only at comparison time (the bubble would still show raw tags); inferring non-delivery from turn end (false flags on queued prompts, above); a flat timer (same); automatic resend (can duplicate a prompt Claude did receive).

Known weaknesses

  • A prompt typed while Claude is mid-turn that Claude folds into the running turn still stays pending forever, as on main; admitting Claude's queued-command records is fix(native-chat): preserve queued Claude prompts #16121.
  • Keystrokes swallowed by a Claude dialog (for example the /model picker), or typed into a shell after Claude exits, still look pending, as on main. A follow-up can use the host's existing "no agent running" send guard to refuse those sends outright.
  • A failed bubble sorts below later turns until dismissed (pending bubbles always sort last; main showed the same bubble there, without a notice).
  • On desktop a failed send stays as a bubble with the text and Dismiss; mobile keeps its existing behaviour of restoring the text to the composer with a banner.
  • The session scanner's titles and search still show raw paste tags (not changed here).
  • Imported legacy structured-chat history now stores the unwrapped text (an improvement, noted because structured chat is otherwise untouched).
  • When a remote host's calls time out, a Claude chat send can hold the pane's send queue for up to about 30 seconds before the next send starts; order is preserved.

Linked Issue

Fixes #23450 (STA-8729). Partially addresses STA-2985 (refused or lost writes now say so; sends the agent silently ignores still look pending) and STA-6980. Helps wrapped large-prompt reports such as STA-8201. No closure claim for STA-2989, STA-4146, STA-7617, STA-8118 or STA-3690.

Supersedes the transcript-unwrap fix proposed in #23469; its author is credited with Co-authored-by: Harshul Rathod <harshulrathod1640@gmail.com>.

Visual Proof

Live QA ran on a separate macOS machine (not the author's), with the real Claude Code 2.1.284 in a terminal pane viewed through Chat, against the base commit and this branch. Claude's paste envelope was switched on in the test machine's isolated Claude settings only.

Before: after Claude replies, the transcript bubble shows the raw closing tag and a second pending copy of the prompt stays pinned below the reply.

Before: raw pasted_content tag in the user bubble and a duplicate pending bubble pinned below Claude's reply

After: one user bubble, no tags, Claude's reply below it.

After: one clean user bubble followed by Claude's reply

The two notices cannot be triggered on a local pane without faking a transport failure, so they were captured with a test-only fault switch that was never committed.

A write whose acknowledgment was lost and whose text never reached Claude: "Delivery unconfirmed" appeared 20.0 seconds after the send, with Dismiss.

Delivery unconfirmed with Dismiss, 20 seconds after a lost write acknowledgment

A refused write: "Message not sent" with Dismiss; nothing reached Claude.

Message not sent with Dismiss after a refused write

Resending the same text after a refusal leaves exactly one bubble once Claude answers, and Stop keeps a failed bubble instead of deleting its text.

Resend after a refusal leaves one bubble and Claude's reply

After Stop, a refused send keeps its Message not sent notice below Conversation interrupted

Testing

  • I manually tested these changes (live Electron QA on a separate macOS machine, real Claude, before and after)
  • Automated tests added/updated

Live QA results (base vs this branch, real Claude 2.1.284, macOS): multi-line paste with the envelope on: base 3/3 duplicate pinned bubble, branch 3/3 one bubble; with the envelope off both behave correctly (plain text is recorded). Prompt sent while Claude was working: no notice in 800 samples over the whole turn (it still stays pending on both, see Known weaknesses). Prompt swallowed by Claude's /model picker: pending with no notice on both. Lost acknowledgment where the text did land: Enter still submitted, Claude ran the prompt exactly once, bubble retired, no notice. Toggling to the terminal view and back duplicates nothing. Mobile live QA was blocked by test-machine setup (simulator licence and pairing), so mobile is covered by unit tests only.

Automated: 19 named desktop, main and shared test files (262 tests) and the mobile echo-retirement tests pass. Disabling the envelope unwrap or the lost-acknowledgment hold turns the new tests red. Web typecheck passes; the node typecheck's only error is main's missing codex-structured-journal-provider-verdicts module, which also turns this PR's CI typecheck, static analysis and persistence jobs red until main is fixed. Changed-file oxlint (default, type-aware, native plugins), the changed-code quality gate, React Doctor on changed lines, and the localization checks pass. Not run: SSH, Windows and Linux (the change has no path, shell or platform-specific code; the unwrap accepts CRLF).

Review

Overlap with other open PRs: #16121 also edits transcript-line-decoders-claude.ts (this PR adds one import and maps returned user blocks; its queued-record admission hunks are untouched) and native-chat-pending.ts. #13613 also edits native-chat-pending.ts; this PR only adds the delivery fields and the resend-replaces-failed rule. #20847 overlaps the pending import lines.

brennanb2025 and others added 13 commits September 28, 2026 21:20
Keep queued prompts pending while the existing agent status reports work, and check fresh history after a later idle fact. Preserve draft text and distinguish write rejection from unconfirmed delivery.

Co-authored-by: Harshul Rathod <harshulrathod1640@gmail.com>
…rer tests out of main

The observed-send path imported the clear helpers from native-chat-runtime-send,
which imports it back. Move the input-clear layer into its own module both use.
The Claude paste decoder test imported the renderer pending module from
src/main, which the node typecheck project cannot see; the echo-retirement
assertions now live in a renderer test.
…edgment was lost

A remote write whose acknowledgment is lost (timeout, dropped link) is not a
refusal, but the observed path stopped there and never sent Enter, leaving a
body that did land sitting unsubmitted in Claude's input line until the next
send's clear wiped it. Continue to the next write without re-sending the bytes,
as the unobserved path always did.
…ing every row

The delivery notices were merged into a new Map on every render, which
invalidated the transcript row context and re-rendered every memoized row on
each stream update. The pending hook also wrote a fresh array on every status
ping and prune pass even when nothing changed, and the phone mapped its pending
list on every render, rebuilding the chat list data. Memoize the merged
notices, skip no-op pending writes, and memoize the phone's rendered pending
list. The phone also skips a transcript read when no send is due.
…le Claude never starts

Two gaps in when terminal Chat calls a Claude send "Delivery unconfirmed":

A prompt sent while Claude is mid-turn is queued, and Claude folds it into the
running turn as a queued-command record. The transcript reader drops those
records, so once the turn ended the prompt Claude did run read as unconfirmed,
inviting a duplicate resend. A send made while the agent is busy is now never
checked; it keeps the pending behaviour it had before.

A prompt sent to an idle Claude that never starts a turn (Claude exited to the
shell, or the paste went nowhere) left the status at the same idle fact
forever, so the check never ran and the bubble stayed pending. An idle agent
starts a turn on a delivered prompt at once, so a send whose idle status is
unchanged after the existing 20 s bound is now checked against a fresh
transcript read.
The Dismiss action's translate key was missing from en.json, which fails the
localization catalog and extraction gates. The desktop "Message not sent" and
"Delivery unconfirmed" notices were hard-coded English; route them through
translate with the same wording.
…ender

Moving the acknowledgment-loss hold into its own hook made its render-time ref
writes new lines, which the React Doctor changed-lines gate blocks. Held sends
report after commit, so syncing those refs in a layout effect keeps them
current where they are read.
The delivery rule inferred "Delivery unconfirmed" from "the turn ended and the
transcript has no matching row". Claude records a prompt sent mid-turn only as a
queued-command attachment, which the transcript reader drops, so that rule
flagged prompts Claude had answered. It also never fired for an idle Claude that
lost the write, because no newer turn arrives.

Keep only facts the transport reports:
- a refused write reads "Message not sent", keeps its text, and can be dismissed;
- a lost write acknowledgment holds the echo for 20 s, the phone's existing
  rule, then reads "Delivery unconfirmed" unless its row has landed.
An ordinary send, including one Claude queues mid-turn, stays pending as before.

Remove the agent-status subscription, the status-epoch origin, the fresh
500-row transcript read, the confirmed state and the no-status clock. The phone
already implements this rule, so its changes revert to main; only a test for
old-host paste envelopes remains.
Add the Dismiss, "Message not sent" and "Delivery unconfirmed" strings to the
es, fr, ja, ko and zh catalogs, reusing each catalog's existing Dismiss wording.
A "Message not sent" or "Delivery unconfirmed" echo kept its transcript
occurrence, so resending the same text numbered the resend as the second
copy: the one landed row retired the failed echo and pinned the resend
below the reply forever. Appending a send now drops a failed echo with the
same content first.
The envelope parser refused any body containing a pasted_content tag, so a
pasted prompt that itself quotes one (a transcript excerpt, or code that
handles these tags) kept its wrapper and its echo stayed pinned below the
reply. Claude's per-paste id exists to disambiguate exactly that; only a
same-id tag inside the body is now ambiguous. Wrappers without an id keep
the strict rule.
Only a Claude chat send (text or images) reports a refused or unacknowledged
write to its pending echo; other agents and slash commands keep the
unobserved write path exactly as before.
Stop cleared every optimistic echo, including a "Message not sent" or
"Delivery unconfirmed" bubble whose send had already settled. Stop cannot
affect that send, and the bubble is the only place its text stays copyable,
so it now survives until the user dismisses or resends it. Also moves the
observed-send import below the file header comment.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review status (head 72b1ede)

Verdict: code is ready for a maintainer's look; the PR stays a draft. CI is red only because origin/main is missing src/main/codex/codex-structured-journal-provider-verdicts.ts (deleted by #23682 while still imported); typecheck, static analysis, persistence and verify all fail on that one import, plus one runner mobile-install error. Rerun CI once main is fixed.

What changed during review (all pushed as new commits):

  • The first "Delivery unconfirmed" rule (turn ended and no matching transcript row) was removed. It would have flagged prompts Claude actually answered when they were sent mid-turn, because Claude saves those only as queued-command records that Orca's transcript reader does not read yet (fix(native-chat): preserve queued Claude prompts #16121). Now only transport-proven outcomes show a notice: a refused write reads "Message not sent", and a lost write acknowledgment reads "Delivery unconfirmed" after 20 seconds. Mobile keeps main's existing delivery behaviour.
  • A lost write acknowledgment now still sends Enter (the first version left the prompt unsubmitted in Claude's input line).
  • Resending the same text after a failure no longer leaves the resend pinned forever; Stop no longer deletes a failed bubble and its text.
  • A paste whose text itself quotes paste tags now unwraps correctly.
  • Fixed CI blockers from the first commit: a main-process test importing a renderer file, an import cycle, and missing translation keys (all six catalogs now have them).
  • Render churn fixed: the notice map and pending list no longer change identity on every streamed update.

Live QA (separate macOS machine, real Claude Code 2.1.284, base vs this branch; screenshots are in the description):

  • Multi-line paste with Claude's paste envelope on: base 3/3 duplicate pinned bubble with raw tags, branch 3/3 one clean bubble. With the envelope off (Claude's default on that account) both are correct.
  • Prompt sent while Claude was working: no notice in 800 samples; it still stays pending on both base and branch (pre-existing, fix(native-chat): preserve queued Claude prompts #16121).
  • Prompt swallowed by Claude's /model picker: pending, no notice, on both (pre-existing; follow-up below).
  • Both notices, Dismiss, resend-after-failure and Stop were captured with a test-only fault switch that was never committed; the 20-second hold measured 20.0 seconds.
  • Mobile live QA was blocked by the test machine's setup (simulator licence needs admin rights; Android pairing cannot be typed through the emulator tool). Mobile is covered by unit tests only.

Checks: 262 named desktop/main/shared tests and the mobile echo tests pass; disabling the unwrap or the 20-second hold turns the new tests red; web typecheck passes; changed-file lint, quality, React Doctor and localization gates pass. Not run: SSH, Windows, Linux.

Known gaps, not caused by this PR: queued mid-turn prompts never retire (#16121); sends swallowed by a Claude dialog or typed into a shell after Claude exits stay pending (follow-up: use the host's existing "no agent running" send guard so those sends are refused outright); session titles and search still show raw paste tags; after Stop the pane's sidebar status stayed "working" in QA.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

CI is green on 8a4859c (current origin/main merged in; all checks pass, none pending) and this PR is ready for Brennan's review; it stays a draft until then.

@brennanb2025
brennanb2025 marked this pull request as ready for review September 30, 2026 01:51
@greptile-apps

greptile-apps Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 1/5

[Medium risk] Adds Claude paste decoding and delivery status tracking to chat.

The PR does not appear safe to merge while four previously reported send and transcript defects remain unresolved.

Findings

  1. P1 Local refusals remain pending ▶
  2. P1 Stop discards uncertain drafts ▶
  3. P1 Partial writes appear rejected ▶
  4. P1 Literal paste tags disappear ▶

Summary

The PR unwraps whole-message Claude paste envelopes so transcript rows can retire matching pending echoes. It also adds verified writes and desktop notices for refused or unacknowledged Claude terminal sends, without automatic retries.

Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Claude Chat send] --> B[Verified terminal writes]
  B -->|Accepted| C[Await transcript row]
  B -->|Refused| D[Message not sent]
  B -->|Acknowledgment lost| E[20-second hold]
  E -->|No matching row| F[Delivery unconfirmed]
  C --> G[Retire pending echo]
  E -->|Matching row| G
Loading

Reviews (2) · Last reviewed commit: "Merge remote-tracking branch 'origin/mai..."

if (isCancelled()) {
return
}
void sendRuntimePtyInputVerified(settings, ptyId, write.data, 'driving')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Local refusals remain pending

On a local Claude pane, sendRuntimePtyInputVerified returns true even when pty.writeAccepted refuses the write: it makes an unchecked fallback write. If the PTY has closed or refuses input, this code still proceeds to Enter and leaves the bubble pending instead of showing “Message not sent.”

Comment on lines +84 to +85
const clear = useCallback(
() => save((entries) => entries.filter((entry) => entry.delivery)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Stop discards uncertain drafts

If the write acknowledgment is lost and the user presses Stop before the 20-second hold expires, holdUnconfirmed has set a timestamp but not delivery. This clear filter removes the bubble. If the bytes never landed, the draft disappears from chat without an uncertainty notice.

Comment on lines +42 to +45
if (!accepted) {
options.onWriteRejected?.()
markSubmitted()
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Partial writes appear rejected

A remote pane can accept the message body but refuse the separate Enter. This branch then reports “Message not sent” and releases the queue without clearing the body already left in Claude’s input line. A later manual Enter can submit it, while retrying can create a duplicate. An image send can likewise leave earlier accepted attachment writes behind when a later write is refused.

Comment on lines +5 to +11
const trimmed = text.trim()
const open = /^<pasted_content(?: id="([^"<>\r\n]{1,64})")?>\r?\n/.exec(trimmed)
if (!open) {
return text
}
const close = open[1] ? `</pasted_content id="${open[1]}">` : '</pasted_content>'
if (!trimmed.endsWith(close)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Literal paste tags disappear

If a user sends a whole-message <pasted_content id="a"> envelope as literal text and Claude records it verbatim, this parser treats it as a generated wrapper and removes the tags. The transcript and imported history then show different text from what the user sent.

@coderabbitai

coderabbitai Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: e38d33a1-8415-4d14-8b47-33b71738a5c4

📥 Commits

Reviewing files that changed from the base of the PR and between 8a4859c and 34f0636.

📒 Files selected for processing (11)
  • src/renderer/src/components/native-chat/NativeChatComposer.tsx
  • src/renderer/src/components/native-chat/NativeChatResolvedView.tsx
  • src/renderer/src/components/native-chat/native-chat-composer-types.ts
  • src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.test.tsx
  • src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts
  • src/renderer/src/i18n/locales/en.json
  • src/renderer/src/i18n/locales/es.json
  • src/renderer/src/i18n/locales/fr.json
  • src/renderer/src/i18n/locales/ja.json
  • src/renderer/src/i18n/locales/ko.json
  • src/renderer/src/i18n/locales/zh.json
🚧 Files skipped from review as they are similar to previous changes (6)
  • src/renderer/src/i18n/locales/fr.json
  • src/renderer/src/i18n/locales/ko.json
  • src/renderer/src/i18n/locales/zh.json
  • src/renderer/src/i18n/locales/en.json
  • src/renderer/src/i18n/locales/ja.json
  • src/renderer/src/i18n/locales/es.json

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

The change unwraps valid Claude pasted-content envelopes in decoded user messages and text normalization. It adds an observed PTY write path that reports rejected writes and lost acknowledgments. A pending-delivery hook tracks optimistic sends, updates their delivery status, and supplies dismissible notices to the chat view.

Fixed issue severity: Medium

Priority: ➖ Normal

Severity of issue fixed: Medium

Merge Risk: 🔵 Low · up to 34f06

Most chat sends are unaffected, but a prompt containing a nested Claude paste envelope can leave a duplicate optimistic message in the conversation. This is a narrow issue; the image-send delivery notice path works.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 34f06

The changes remain narrowly scoped to chat delivery and transcript handling, with no expanded access identified. A nested pasted-text edge case can prevent a delivered prompt from being reconciled correctly and make manual retry decisions less reliable. Security coverage remains incomplete.

Retained concerns

  • Low · reliability · inferred: Paste normalization is not idempotent across pending and transcript consumers. A prompt containing valid differently identified nested envelopes can have a pending key that retains an inner envelope while its transcript key becomes the inner body. The delivered prompt can consequently remain pending, or become unconfirmed after a lost acknowledgment. This weakens manual retry safety for actions performed under the agent's existing authority, although the PR does not automatically resend the prompt.
Security review details

Security Blast Radius

  • inferred — The traced write scope remains the selected pane's PTY and its owning runtime environment. The PR changes delivery observation, not destination selection or the agent's downstream privileges. Resources reachable under those existing privileges were not inventoried.

Security Findings and Attack Paths

  • inferred — User-controlled nested envelope text can produce inconsistent delivery identity after transcript decoding. If its write acknowledgment is also lost, a delivered prompt can retain an unconfirmed notice and influence a manual retry. This is a bounded recovery concern, not a verified privilege-escalation or cross-tenant attack path; the new observed-send sequence does not automatically replay uncertain bytes.

Trust Boundaries and Controls

  • observed — Composer text still passes through the existing terminal-paste byte builder. Observed writes use driving input authority and the existing size-limited runtime writer. Verified and unverified writers resolve the same environment and terminal handle; neither delivery callbacks nor transcript normalization select a new target.

Resilience and Maintainability Implications

  • observed — Delivery is not transactional across a multi-write prompt: refusal stops later writes without undoing earlier accepted bytes, while lost acknowledgment permits later sequence bytes without replaying the uncertain write. The unchanged local/SSH verified writer also falls back to fire-and-forget delivery when writeAccepted returns false, so its true result is not uniformly proof of accepted delivery. These existing transport limitations bound what the new notices can guarantee.

Hardening Proposals

  • proposed — Separate raw-provider envelope decoding from canonical message identity, or make identity normalization idempotent with explicit representation provenance. Pending drafts and decoded transcript rows should retain equivalent meaning without recursively interpreting literal nested tags. This would strengthen reconciliation and manual retry safety across client versions.
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR adds changes that are not connected to [#23450]. The observed PTY-write path, write rejection and acknowledgment callbacks, 20-second unconfirmed-delivery hold, delivery notices, dismiss action… Remove the terminal delivery-outcome changes from this PR, or link them to a separate active issue and submit them in a separate PR. Keep the Claude pasted-content decoder, normalization, pending-echo, and related test changes for [#23450].
Docstring Coverage ⚠️ Warning Docstring coverage is 38.10% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 21 functions across 22 files. (6 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes both primary changes: Claude paste decoding and terminal delivery uncertainty reporting.
Description check ✅ Passed The description is detailed and covers the required ELI5, user-facing changes, rationale, linked issue, visual proof, testing, known weaknesses, and review context. It omits some template sections, in…
Linked Issues check ✅ Passed The PR meets the coding requirements in directly linked issue [#23450]. unwrapClaudePastedContent removes only a complete, unambiguous Claude <pasted_content> envelope. `decodeClaudeTranscriptLine…
Full details: Out of Scope Changes check

Explanation

The PR adds changes that are not connected to [#23450]. The observed PTY-write path, write rejection and acknowledgment callbacks, 20-second unconfirmed-delivery hold, delivery notices, dismiss actions, resend behavior, and related localization and tests address terminal delivery outcomes. Issue [#23450] addresses Claude pasted-content decoding and pending-echo matching only.

Full details: Docstring Coverage

Explanation

Docstring coverage is 38.10% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 21 functions across 22 files. (6 skipped: 6 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🛠️ Fix failing CI checks 💡
  • Commit to this branch
  • Create a new PR

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: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: cef99bd7-6629-43d8-96e2-2215d768a94e

📥 Commits

Reviewing files that changed from the base of the PR and between 59b746f and 8a4859c.

📒 Files selected for processing (28)
  • mobile/src/session/mobile-native-chat-pending-pasted-content.test.ts
  • src/main/native-chat/transcript-line-decoders-claude-pasted-content.test.ts
  • src/main/native-chat/transcript-line-decoders-claude.ts
  • src/renderer/src/components/native-chat/NativeChatComposer.tsx
  • src/renderer/src/components/native-chat/NativeChatMessageRow.tsx
  • src/renderer/src/components/native-chat/NativeChatResolvedView.tsx
  • src/renderer/src/components/native-chat/native-chat-composer-types.ts
  • src/renderer/src/components/native-chat/native-chat-input-clear.ts
  • src/renderer/src/components/native-chat/native-chat-observed-send.test.ts
  • src/renderer/src/components/native-chat/native-chat-observed-send.ts
  • src/renderer/src/components/native-chat/native-chat-pending-pasted-content.test.ts
  • src/renderer/src/components/native-chat/native-chat-pending.ts
  • src/renderer/src/components/native-chat/native-chat-runtime-image-send.ts
  • src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts
  • src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts
  • src/renderer/src/components/native-chat/native-chat-runtime-send.ts
  • src/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsx
  • src/renderer/src/components/native-chat/use-native-chat-pending-delivery.ts
  • src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.test.tsx
  • src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts
  • src/renderer/src/i18n/locales/en.json
  • src/renderer/src/i18n/locales/es.json
  • src/renderer/src/i18n/locales/fr.json
  • src/renderer/src/i18n/locales/ja.json
  • src/renderer/src/i18n/locales/ko.json
  • src/renderer/src/i18n/locales/zh.json
  • src/shared/claude-pasted-content.ts
  • src/shared/native-chat-image-transcript-markers.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 4 remain after this review.

message.blocks
.filter(isTextBlock)
.map((block) => block.text)
.map((block) => unwrapClaudePastedContent(block.text))

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

🔎 Supported by static analysis

🏁 Script executed:

cat src/shared/claude-pasted-content.ts
sed -n '60,100p' src/shared/native-chat-image-transcript-markers.ts
sed -n '110,140p' src/main/native-chat/transcript-line-decoders-claude.ts
sed -n '1,240p' src/renderer/src/components/native-chat/native-chat-pending.ts

Repository: stablyai/orca

Length of output: 13831


🏁 Script executed:

set -eu
printf '%s\n' '--- changed hunks ---'
git diff --unified=30 59b746ff3c7f8de85bd202ffa9aab487be97861f 8a4859c9ab7dc16ebcffb877c4768d3421753e6e -- src/shared/native-chat-image-transcript-markers.ts src/main/native-chat/transcript-line-decoders-claude.ts

printf '%s\n' '--- symbol references ---'
rg -n -C 3 'normalizeNativeChatUserText|normalizedNativeChatUserMessageText|unwrapClaudePastedContent(Block)?|decode.*Claude|Claude.*decode|prunePendingSends|pendingSendsAsMessages' src test tests 2>/dev/null || true

printf '%s\n' '--- candidate files ---'
git ls-files | rg 'native-chat|claude-pasted|transcript-line-decoders|pending' | head -120

Repository: stablyai/orca

Length of output: 41437


🏁 Script executed:

set -eu
printf '%s\n' '--- exact references ---'
rg -n -C 5 'normalizedNativeChatUserMessageText|normalizeNativeChatUserText|unwrapClaudePastedContent|pendingSendsAsMessages|prunePendingSends' src mobile -g '*.ts' -g '*.tsx'

printf '%s\n' '--- relevant test files ---'
rg -l 'pasted_content|pending.*echo|pending.*send|normalizeNativeChatUser' src mobile -g '*test.ts' -g '*test.tsx' | sort

printf '%s\n' '--- Claude decoder guards and callers ---'
sed -n '1,155p' src/main/native-chat/transcript-line-decoders-claude.ts
rg -n -C 5 'decodeClaudeTranscriptLine' src mobile -g '*.ts' -g '*.tsx'

Repository: stablyai/orca

Length of output: 42309


🏁 Script executed:

set -eu
printf '%s\n' '--- normalization imports and key construction ---'
rg -n -C 8 'native-chat-image-transcript-markers|normalizedNativeChatUserMessageText|nativeChatPending(ContentKey|MatchKey)|function nativeChatPending' src/renderer mobile src/shared -g '*.ts' -g '*.tsx'

printf '%s\n' '--- pending occurrence outline ---'
ast-grep outline src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts --view compact 2>/dev/null || true
sed -n '1,260p' src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts

printf '%s\n' '--- pasted-content tests ---'
cat src/main/native-chat/transcript-line-decoders-claude-pasted-content.test.ts

printf '%s\n' '--- pending pasted-content tests ---'
cat mobile/src/session/mobile-native-chat-pending-pasted-content.test.ts 2>/dev/null || true
rg -l 'pasted_content' src/renderer mobile -g '*test.ts' -g '*test.tsx' | xargs -r -n1 sh -c 'echo "--- $0"; sed -n "1,260p" "$0"'

Repository: stablyai/orca

Length of output: 42537


Remove the second envelope unwrap during payload normalization.

When Claude emits distinct nested envelopes, the decoder removes the outer transport envelope. Line 83 removes the next envelope from the decoded block, but pending text normalization removes only one envelope. The desktop and mobile comparison keys can therefore differ, leaving a duplicate pending echo visible.

Keep the decoder's single transport unwrap. Do not unwrap each text block again during payload normalization.

🐛 Suggested fix
     message.blocks
       .filter(isTextBlock)
-      .map((block) => unwrapClaudePastedContent(block.text))
+      .map((block) => block.text)
       .join(' ')

Add a regression that compares pending text with the actual Claude decoder output for three nested envelopes with distinct IDs.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.map((block) => unwrapClaudePastedContent(block.text))
.map((block) => block.text)

@pullfrog pullfrog 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.

✅ No new issues found.

Reviewed changes

Full read of all 28 changed files, plus independent verification of the two highest-risk areas. The PR makes two changes to the terminal-backed Chat view (desktop, paired web, mobile share the host transcript):

  • Decode Claude paste envelopes. New src/shared/claude-pasted-content.ts unwraps a whole-message <pasted_content id="…"> envelope; wired into the host decoder (user role only) and the shared pending-echo comparison key, so a new host sends decoded text and an old host's raw envelope still matches. Parser only unwraps whole-block text with matching ids and no same-id tag; prose, mismatched ids, multiple envelopes, and assistant text are preserved.
  • Report terminal delivery uncertainty. NativeChatSendOptions gains onWriteRejected/onWriteUnconfirmed; Claude chat sends (text and image) use the verified write path, marking an echo rejected on a refused write and holding unconfirmed for 20s on a lost acknowledgment (Enter still written, bytes never re-sent). New use-native-chat-pending-delivery owns the pending cache, notices, hold timer, and Stop semantics (failed bubbles kept). native-chat-input-clear.ts is a mechanical extraction of the clear helpers.

Verification notes. I confirmed the new tests can fail: the paste tests assert exact decoded text and would keep the raw envelope without the unwrap, and native-chat-observed-send.test.ts pins the exact verified-write sequence per outcome. I checked the moved clear helpers have no stale importers, and the mixed-version paths resolve (old host + new client unwraps for match; new host + old client compares plain text). Two areas I probed and cleared: a Ctrl+U/ANSI prefix before the envelope (mobile type-sends raw text, so no envelope; desktop sends the bracketed body after the clear, and live QA confirms the unwrap fires), and the appendPendingSendCache resend filter only replacing a copy already carrying delivery (the duplicate-before-failure stuck-bubble end state matches main, so not a regression). The double unwrap in normalizedNativeChatUserMessageText is required for multi-block messages and only diverges for contrived nested same-shaped envelopes.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

…o-identity

# Conflicts:
#	src/renderer/src/i18n/locales/en.json
#	src/renderer/src/i18n/locales/fr.json
#	src/renderer/src/i18n/locales/ja.json
#	src/renderer/src/i18n/locales/ko.json
#	src/renderer/src/i18n/locales/zh.json

@pullfrog pullfrog 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.

✅ No new issues found.

Reviewed changes

Delta against the prior pullfrog review (8a4859c → 34f0636): a merge of origin/main (bringing in #23731 and its shared composer refactor), plus the author's conflict resolution. No functional or behavioral change to this PR's decoding or delivery logic.

  • Consolidated the two composer props onOptimisticSendRejected / onOptimisticSendUnconfirmed into a single optimisticSendOutcome: { reject, holdUnconfirmed } object (NativeChatOptimisticSendOutcome). Semantically identical: onWriteRejected → reject(pendingId), onWriteUnconfirmed → holdUnconfirmed(pendingId); NativeChatResolvedView passes optimisticSendOutcome={delivery}. No stale references to the old names remain.
  • Resolved the i18n merge conflicts (en/fr/ja/ko/zh; es auto-merged) by keeping both main's queuedMessages keys and this PR's dismissDeliveryNotice / messageNotSent / deliveryUnconfirmed in every locale. Diffed against the merged main tip: only the three delivery strings were added, nothing dropped.
  • Verified the core PR files (claude-pasted-content.ts, native-chat-image-transcript-markers.ts, native-chat-observed-send.ts, native-chat-pending.ts, native-chat-input-clear.ts, use-native-chat-pending-delivery.ts, native-chat-runtime-send.ts) are byte-identical to the previously reviewed version.
  • Focused tests re-run green: use-native-chat-pty-composer-send.test.tsx (4), use-native-chat-pending-delivery.test.tsx (9), native-chat-observed-send.test.ts (3).

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@brennanb2025
brennanb2025 merged commit 707d3dc into main Sep 30, 2026
59 of 61 checks passed
@zpyoung zpyoung mentioned this pull request Oct 12, 2026
6 of 9 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Chat view never retires the echo of a multi-line prompt because Claude Code records pastes inside <pasted_content> tags

1 participant