Repository navigation
Conversation
…rator
An optimistic composer echo retires only on an exact normalized-text match, and
the glue path concatenated pending bodies with no separator between them. When
the unsubmitted input line kept that separator, the transcript landed
"tell me a joke continue" rather than "tell me a jokecontinue", nothing matched,
and no other rule retires an echo — so both bubbles stayed cached and, carrying
`sentAt`, sorted past the newest real turn for the rest of the pane's life.
Allow a single separator between glued bodies. Unrelated-prefix protection is
unchanged: the pieces must still consume the row exactly ("hi" ↛ "history").
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lulumeya
force-pushed
the
fix/native-chat-glued-echo-separator
branch
from
August 13, 2026 09:27
f841de6 to
c894277
Compare
Author
|
Closing this. The defect it fixes is real and reproducible, but it is not the cause of the drift I was actually chasing. Root cause turned out to be different: a prompt sent while the agent is mid-turn is queued by Claude Code and never lands in the transcript as a Leaving #14262 open as a standalone report — the separator-glued case still fails on main today. |
8 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
ELI5
When you send a message in the native Chat view, Orca shows it instantly as a "queued" bubble, and that bubble is supposed to disappear the moment the real message appears in the agent's transcript. If you send two messages very quickly, the terminal can merge them into one turn — and Orca already knows how to recognize the merged form. But it only recognized a merge with no space between the texts (
tell me a joke+continue=tell me a jokecontinue). When the merged turn kept a space (tell me a joke continue), nothing recognized it — and since nothing else ever removes a queued bubble, both bubbles sat below the agent's newest reply forever, looking like the conversation had reordered itself. This PR teaches the matcher to accept a single space between the merged pieces.What Changed
The whole fix is one function — everything else is tests:
src/renderer/src/components/native-chat/native-chat-pending-occurrence.tscountLeadingPendingTextsGluedToUserTextwalks the transcript row with a cursor instead of building a concatenated string, skipping one optional space before each subsequent piecesrc/renderer/src/components/native-chat/native-chat-pending.test.tstests/e2e/native-chat-glued-echo-drift-repro.spec.tsBefore — exact concatenation only:
After — cursor walk, one optional separator between bodies:
Note
Unrelated-prefix protection is unchanged: the pieces must still consume the row exactly, so
"hi"still does not match"history". The separator skip is optional, so every row that matched before still matches — the matcher is strictly more permissive, never less. Side benefit: dropping the repeated string concatenation means the walk allocates nothing.Why
Symptom: a prompt the user already sent keeps rendering as a bubble below the agent's newest reply, permanently — the conversation looks reordered and never heals.
flowchart TD A["send #1: 'tell me a joke␣'<br/>(trailing space in draft)"] --> B["queued bubble #1"] A --> C["raw draft → pty<br/>buildNativeChatPasteBytes, no trim"] D["send #2: 'continue'"] --> E["queued bubble #2"] D --> C C --> F["ONE glued transcript user row:<br/>'tell me a joke continue'"] F --> G{"exact normalized match?"} G -- "no" --> H{"glue matcher:<br/>combined += piece"} H -- "'tell me a jokecontinue' ≠ row" --> I["no retirement path left"] I --> J["both bubbles pinned after the newest turn<br/>(timestamp: sentAt) until 8 later sends evict them"]Three facts make the drift permanent:
prunePendingSends/pendingSendsAsMessagesretire an optimistic echo only on a normalized-text match against a real transcript user turn. No timeout, no error, no delivery result retires one.combined += pieceonly recognized"jokecontinue", never"joke continue".pendingSendsAsMessagesstamps echoes withtimestamp: entry.sentAt, so a stale one sorts past every newer real turn untilPENDING_SEND_LIMIT = 8later sends evict it.Why a separator is really there (not a hypothetical)
sendNativeChatMessagewrites the raw draft to the pty (buildNativeChatPasteBytes(text), no trim), while the match key isnormalizeNativeChatPendingText— trim plus whitespace collapse. A trailing space in the draft is therefore invisible to matching but real on the wire: it survives on the unsubmitted input line and becomes exactly the separator the next body glues onto. The send path is1. clear unsubmitted line → 2. write framed body → 3. delayed Enter as a separate write, and glue is an already-observed phenomenon here — the glue helper and its comment exist precisely for it.Not a duplicate. Four open PRs touch pending echoes, each for a different trigger; this PR covers the remaining gap:
/clear, agent restart, resuming a different session)Linked Issue
Fixes #14262
Visual Proof
Before — two stale echoes pinned below the newest reply
Order in the shot:
seed turn…(assistant) →tell me a joke continue(the real glued user turn) →newest real turn from the agent(assistant) →tell me a jokeandcontinuestuck below it.After — only the three real turns remain
Order in the shot:
seed turn…→tell me a joke continue→newest real turn from the agent. No stale bubbles.Testing
native-chat-pending.test.ts— separator-glued prune; trailing-space draft;pendingSendsAsMessagesreturning nothing instead of pinning an echo after the newest turntests/e2e/native-chat-glued-echo-drift-repro.spec.ts— hermetic, follows thenative-chat-first-flush-race.spec.tstechnique (seed the provider session viasetAgentStatus, toggle the pane to chat view, write the transcript JSONL directly; no Claude CLI needed)getByText('tell me a joke', { exact: true })resolved 1 element, expected 0), passes afterpnpm lintpnpm typecheckpnpm buildpnpm test(full suite)main, in files this PR does not touch —src/relay/agent-exec-handler.test.ts(2) andsrc/main/repo-icon-autodetect.test.ts(2). Verified by running both files on a cleanmaincheckout, where they fail identically (4 failed / 20 passed).Platforms actually exercised: macOS only. The change is pure string logic in the renderer with no platform, runtime, or provider branch, so Linux / Windows / SSH / remote behavior is unaffected by construction — but only macOS was tested.
To verify locally: run
native-chat-pending.test.tsand the new e2e spec, or reproduce manually — send a prompt ending in a trailing space, immediately send a second prompt, and watch the queued bubbles retire once the glued turn lands.AI Disclosure
Claude Opus 5 (Claude Code) was used to investigate the bug, write the fix, the unit cases, and the e2e spec.
Review
countLeadingPendingTextsGluedToUserTexthas no mobile caller; grep shows the only call site is in this same desktop file.Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred) — all four run locally; the onlypnpm testfailures are the 4 pre-existing ones described above, reproduced identically on a cleanmain