Skip to content

fix(native-chat): retire glued echoes when the input line kept a separator - #14263

Closed
lulumeya wants to merge 1 commit into
stablyai:mainfrom
lulumeya:fix/native-chat-glued-echo-separator
Closed

lulumeya wants to merge 1 commit into
stablyai:mainfrom
lulumeya:fix/native-chat-glued-echo-separator

Conversation

@lulumeya

@lulumeya lulumeya commented Aug 13, 2026 •

Copy link
Copy Markdown

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:

File Change
src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts countLeadingPendingTextsGluedToUserText walks the transcript row with a cursor instead of building a concatenated string, skipping one optional space before each subsequent piece
src/renderer/src/components/native-chat/native-chat-pending.test.ts 3 regression cases (see Testing)
tests/e2e/native-chat-glued-echo-drift-repro.spec.ts New hermetic e2e repro of the drift — failed before the fix, passes after

Before — exact concatenation only:

let combined = ''
for (let index = 0; index < pendingTexts.length; index += 1) {
  const piece = pendingTexts[index]
  if (!piece) {
    return 0
  }
  combined += piece
  if (combined === userText) {
    return index + 1
  }
  if (!userText.startsWith(combined)) {
    return 0
  }
}
return 0

After — cursor walk, one optional separator between bodies:

let cursor = 0
for (let index = 0; index < pendingTexts.length; index += 1) {
  const piece = pendingTexts[index]
  if (!piece) {
    return 0
  }
  // Why: an unsubmitted input line can keep the separator that sat between the
  // two bodies, so the glued row reads "joke continue" and never matched.
  if (cursor > 0 && userText.startsWith(' ', cursor)) {
    cursor += 1
  }
  if (!userText.startsWith(piece, cursor)) {
    return 0
  }
  cursor += piece.length
  if (cursor === userText.length) {
    return index + 1
  }
}
return 0

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"]
Loading

Three facts make the drift permanent:

  1. Retirement is match-or-nothing. prunePendingSends / pendingSendsAsMessages retire an optimistic echo only on a normalized-text match against a real transcript user turn. No timeout, no error, no delivery result retires one.
  2. The glue matcher missed the separator form. Rapid sends landing as one glued row is already handled — but combined += piece only recognized "jokecontinue", never "joke continue".
  3. A missed echo pins itself. pendingSendsAsMessages stamps echoes with timestamp: entry.sentAt, so a stale one sorts past every newer real turn until PENDING_SEND_LIMIT = 8 later sends evict it.
Why a separator is really there (not a hypothetical)

sendNativeChatMessage writes the raw draft to the pty (buildNativeChatPasteBytes(text), no trim), while the match key is normalizeNativeChatPendingText — 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 is 1. 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:

PR Trigger it covers
#11509 Conversation replaced out from under the echo (/clear, agent restart, resuming a different session)
#13513 A cancelled send never releases the occurrence slot it reserved
#12643 Delivery itself unconfirmed (8s timeout, recovery card)
#13613 Renderer vs host clock domains
this PR Delivery succeeded and the turn is in the transcript — but the glued row carries a separator

Linked Issue

Fixes #14262

Visual Proof

Before — two stale echoes pinned below the newest reply Before — two stale queued bubbles 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 joke and continue stuck below it.

After — only the three real turns remain 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

Gate Result
3 new unit cases in native-chat-pending.test.ts — separator-glued prune; trailing-space draft; pendingSendsAsMessages returning nothing instead of pinning an echo after the newest turn ✅ file passes 49/49
All native-chat renderer tests ✅ 74 files, 748 tests pass
New e2e regression spec tests/e2e/native-chat-glued-echo-drift-repro.spec.ts — hermetic, follows the native-chat-first-flush-race.spec.ts technique (seed the provider session via setAgentStatus, toggle the pane to chat view, write the transcript JSONL directly; no Claude CLI needed) ✅ failed before the fix (getByText('tell me a joke', { exact: true }) resolved 1 element, expected 0), passes after
pnpm lint ✅ exit 0
pnpm typecheck ✅ exit 0
pnpm build ✅ exit 0
pnpm test (full suite) ✅ 51,081 passed / 114 skipped. 4 failures are pre-existing on main, in files this PR does not touch — src/relay/agent-exec-handler.test.ts (2) and src/main/repo-icon-autodetect.test.ts (2). Verified by running both files on a clean main checkout, 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.ts and 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.

  • I manually tested these changes locally
  • Automated tests added/updated, or explained why not below

AI Disclosure

Claude Opus 5 (Claude Code) was used to investigate the bug, write the fix, the unit cases, and the e2e spec.

Review

Axis Assessment
Security No new input surface — a string comparison over text the renderer already holds.
Cross-platform (macOS / Linux / Windows) No platform branch; pure string logic. Only macOS exercised (stated above).
Remote / SSH Matching runs in the renderer against a transcript it already received; no wire or protocol change.
Mobile Unaffected — countLeadingPendingTextsGluedToUserText has no mobile caller; grep shows the only call site is in this same desktop file.
Agent / integration compatibility Provider-neutral; no agent- or provider-specific branch touched.
Backwards compatibility Strictly more permissive matcher: the separator skip is optional, so every previously matching glued row still matches.
Performance The cursor walk drops the per-iteration string concatenation and allocates nothing.

Checklist

  • This PR is small and focused
  • I explained what changed and why (including ELI5)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred) — all four run locally; the only pnpm test failures are the 4 pre-existing ones described above, reproduced identically on a clean main

…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
lulumeya force-pushed the fix/native-chat-glued-echo-separator branch from f841de6 to c894277 Compare August 13, 2026 09:27
@lulumeya

Copy link
Copy Markdown
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 type: "user" turn — it exists only as queue-operation and attachment.type: "queued_command" records, which decodeClaudeTranscriptLine drops. The optimistic echo therefore has nothing to match against, pins below the newest reply, and is silently evicted once PENDING_SEND_LIMIT is exceeded. I'll open that separately.

Leaving #14262 open as a standalone report — the separator-glued case still fails on main today.

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]: Native chat stale "queued" bubbles pin below the newest reply after a separator-glued rapid send

1 participant