Repository navigation
fix(chat): decode Claude pastes and report terminal delivery uncertainty - #23788
Conversation
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.
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 What changed during review (all pushed as new commits):
Live QA (separate macOS machine, real Claude Code 2.1.284, base vs this branch; screenshots are in the description):
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. |
…o-identity # Conflicts: # src/renderer/src/components/native-chat/NativeChatResolvedView.tsx
|
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. |
|
| if (isCancelled()) { | ||
| return | ||
| } | ||
| void sendRuntimePtyInputVerified(settings, ptyId, write.data, 'driving') |
There was a problem hiding this comment.
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.”
| const clear = useCallback( | ||
| () => save((entries) => entries.filter((entry) => entry.delivery)), |
There was a problem hiding this comment.
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.
| if (!accepted) { | ||
| options.onWriteRejected?.() | ||
| markSubmitted() | ||
| return |
There was a problem hiding this comment.
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.
| 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)) { |
There was a problem hiding this comment.
|
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 configurationConfiguration used: Repository UI Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (11)
🚧 Files skipped from review as they are similar to previous changes (6)
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 7 remain after this review. 📝 WalkthroughWalkthroughThe 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 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 ReviewSecurity architecture risk: 🔵 Low · up to 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
Security review detailsSecurity Blast Radius
Security Findings and Attack Paths
Trust Boundaries and Controls
Resilience and Maintainability Implications
Hardening Proposals
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
Full details: Out of Scope Changes checkExplanation The PR adds changes that are not connected to [ Full details: Docstring CoverageExplanation 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.)
✨ Finishing Touches 💡 2📝 Generate docstrings 💡
🛠️ Fix failing CI checks 💡
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. Comment |
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: cef99bd7-6629-43d8-96e2-2215d768a94e
📒 Files selected for processing (28)
mobile/src/session/mobile-native-chat-pending-pasted-content.test.tssrc/main/native-chat/transcript-line-decoders-claude-pasted-content.test.tssrc/main/native-chat/transcript-line-decoders-claude.tssrc/renderer/src/components/native-chat/NativeChatComposer.tsxsrc/renderer/src/components/native-chat/NativeChatMessageRow.tsxsrc/renderer/src/components/native-chat/NativeChatResolvedView.tsxsrc/renderer/src/components/native-chat/native-chat-composer-types.tssrc/renderer/src/components/native-chat/native-chat-input-clear.tssrc/renderer/src/components/native-chat/native-chat-observed-send.test.tssrc/renderer/src/components/native-chat/native-chat-observed-send.tssrc/renderer/src/components/native-chat/native-chat-pending-pasted-content.test.tssrc/renderer/src/components/native-chat/native-chat-pending.tssrc/renderer/src/components/native-chat/native-chat-runtime-image-send.tssrc/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.tssrc/renderer/src/components/native-chat/native-chat-runtime-send.test.tssrc/renderer/src/components/native-chat/native-chat-runtime-send.tssrc/renderer/src/components/native-chat/use-native-chat-pending-delivery.test.tsxsrc/renderer/src/components/native-chat/use-native-chat-pending-delivery.tssrc/renderer/src/components/native-chat/use-native-chat-pty-composer-send.test.tsxsrc/renderer/src/components/native-chat/use-native-chat-pty-composer-send.tssrc/renderer/src/i18n/locales/en.jsonsrc/renderer/src/i18n/locales/es.jsonsrc/renderer/src/i18n/locales/fr.jsonsrc/renderer/src/i18n/locales/ja.jsonsrc/renderer/src/i18n/locales/ko.jsonsrc/renderer/src/i18n/locales/zh.jsonsrc/shared/claude-pasted-content.tssrc/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)) |
There was a problem hiding this comment.
🎯 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.tsRepository: 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 -120Repository: 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.
| .map((block) => unwrapClaudePastedContent(block.text)) | |
| .map((block) => block.text) |
There was a problem hiding this comment.
✅ 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.tsunwraps 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.
NativeChatSendOptionsgainsonWriteRejected/onWriteUnconfirmed; Claude chat sends (text and image) use the verified write path, marking an echorejectedon a refused write and holdingunconfirmedfor 20s on a lost acknowledgment (Enter still written, bytes never re-sent). Newuse-native-chat-pending-deliveryowns the pending cache, notices, hold timer, and Stop semantics (failed bubbles kept).native-chat-input-clear.tsis 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.
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
There was a problem hiding this comment.
✅ 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/onOptimisticSendUnconfirmedinto a singleoptimisticSendOutcome: { reject, holdUnconfirmed }object (NativeChatOptimisticSendOutcome). Semantically identical:onWriteRejected→reject(pendingId),onWriteUnconfirmed→holdUnconfirmed(pendingId);NativeChatResolvedViewpassesoptimisticSendOutcome={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
queuedMessageskeys and this PR'sdismissDeliveryNotice/messageNotSent/deliveryUnconfirmedin 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).
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

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 fromtranscript-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
/modelpicker), 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.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.
After: one user bubble, no tags, Claude's reply below it.
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.
A refused write: "Message not sent" with Dismiss; nothing reached Claude.
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.
Testing
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
/modelpicker: 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-verdictsmodule, 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) andnative-chat-pending.ts. #13613 also editsnative-chat-pending.ts; this PR only adds the delivery fields and the resend-replaces-failed rule. #20847 overlaps the pending import lines.