Skip to content

fix(mobile-native-chat): reland glued pending retirement without the two revert causes (STA-4482, STA-4492) - #14936

Merged
brennanb2025 merged 11 commits into
mainfrom
brennanb2025/sta-4482-mobile-glue-retirement-reland
Aug 17, 2026
Merged

brennanb2025 merged 11 commits into
mainfrom
brennanb2025/sta-4482-mobile-glue-retirement-reland

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Aug 16, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 6 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​705 $\color{#cf222e}{\Huge{\mathbf{−}}}$​7 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​698
Prod 6 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​241 $\color{#cf222e}{\Huge{\mathbf{−}}}$​32 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​209

ELI5

On mobile, two fast sends can land in the transcript as one merged user row (run the tests + again → run the tests again). #14665 taught mobile to recognize that and clear both grey "queued" bubbles. It was reverted in #14819 because it also broke two things. This brings the feature back with both of those causes fixed — not re-applied and hoped for.

Why #14665 was reverted

#14819's body says only "a launch-blocking regression". The specific causes are named by the maintainer on #14665 and in STA-4482:

a rejected mobile send can restore a trimmed composer and drop draft text, and loading-time glued sends can stay queued.

Cause 1 — a rejected send restored less than the user typed. This one does not reproduce, and the PR previously claimed otherwise. The review loop traced it: #14665 (68ca17e46c) touched 8 files, none of them the composer or the drafts hook's clear/restore callbacks, and it did not reassign a parameter — it renamed one, const text = rawText.trimEnd(). The only production caller, MobileNativeChatComposer.handleSend, was already handing over value.trimEnd(), and that line predates #14665 by ten days (#12366, 2026-08-04). trimEnd(trimEnd(x)) === trimEnd(x), so what restoreRejectedDraft put back was byte-identical to what main restores today, on every path. The non-composer callers pass syncComposer: false and never touch the draft at all.

So whatever else motivated #14819, it was not draft loss, and this PR should not be read as having fixed it. The draftText / text split is still worth keeping — it is what makes the seam's contract real rather than accidental — and the review loop made it actually load-bearing by removing the composer's duplicate trimEnd (below). The one genuine behavior change #14665 made here was recordCommand(text) in place of recordCommand(text.trim()), leaking leading whitespace into the session-option catalog key; this reland restores the .trim().

Cause 2 — hydration-time sends were stranded forever. #14665 stamped glueBaselineTrusted: false on any send captured while the transcript was still loading, and nothing ever cleared it. Such a send could never glue, and because the matcher treats a non-representable entry as a barrier, it also blocked its neighbours from ever gluing. That is STA-4492, and it is real.

What Changed

Mobile only. src/renderer is untouched.

  1. The draft and the payload are separate values, and the split now reaches production (use-mobile-native-chat-message-send.ts, MobileNativeChatComposer.tsx). draftText is what the user typed and is what clearDraftForSend / restoreRejectedDraft receive; only the transported text is trimEnd()ed. The composer used to pre-trim with onSend(value.trimEnd()), so the raw draft never reached the seam and the split had nothing to restore — a rejected multi-line prompt still came back without its trailing blank lines. The composer now passes value through and the seam owns the trim, which also stops it happening twice.

  2. A baseline captured before the history was known is a placeholder, not a disqualification (new mobile-native-chat-pending-baseline.ts). baselineResolved: false marks such a send; rebaseMobileNativeChatPendingBaselines gives it the boundary it never had on the first settled read. The drafts hook rebases before retiring, because a send has to own a real boundary before any row can be judged against it.

    The ordinal is deliberately not recounted against that read. The read can already carry the send's own echo — a re-subscribe after a tab switch or reconnect returns whatever exists now — and nothing local separates that echo from an identical older prompt: the transport writes keystrokes into a TUI and carries no message id, and row timestamps come from the host while the send time comes from the phone. Recounting put the ordinal one past anything the transcript could supply. The review loop reproduced the result: a permanently stuck "Queued" bubble, which also sat as a live segment at the head of its run so no later pair could glue either, and whose inflated ordinal was carried onto the next send of the same text by earlierOutstanding. That is the STA-4492 symptom, reached through a different door — worse than the fix(mobile-native-chat): retire pending bubbles glued into one transcript row (mobile half of #14262) #14665 it relands, whose count pass never consulted the untrusted flag at all. The ordinal was already counted against an empty transcript, which is exactly right for "no history was known"; only a missing tail needed recovering.

    And only a missing one. An unsettled read still shows this session's own retained history — createNativeChatTranscriptRetention keeps the conversation on screen across a reconnect or a failed read rather than blanking it — so a send made across one already owns a correct boundary. An earlier revision of this fix overwrote it with the tail of the read that followed, which sits at or after the pair's own glued row, so turn.index <= segment.tail rejected every turn and the pair stayed queued for the session, blocking every later pair in its run. A captured tail is now never moved.

  3. baselineResolved means "captured against a settled read", not merely "not loading" (use-mobile-native-chat-drafts.ts, use-mobile-native-chat-controller.ts). A read that failed hands back an empty list, which is indistinguishable from an empty conversation — and an empty conversation legitimately yields a null tail, which the glue matcher reads as "any row may be my echo". Two sends issued in that window were therefore retired by an arbitrary old row once the successful read finally arrived. That false positive is new in this PR, since main has no glue matching at all. transcriptSettled is a required prop, so no caller can silently fall back to the gate it replaced.

    Only a text-bearing send is given a boundary. The glue matcher is the sole consumer a supplied tail helps; every other one is harmed by it. An image echo reconciles by counting turns after its tail and has no other retirement path — landedImagePendingIds, from the preview pass, is the only one — so a tail taken from a read that already carries its own echo excludes the very row it is waiting for: a permanent "Queued" photo bubble, and a transcript row rendering as bare [Image: source: …] marker text with no photo. A captioned one is worse still, binding by an ordinal counted over the whole transcript, so a tail without a matching recount leaves it claiming nothing at all. Both were reproduced; the rule now covers all three consumers of baselineTailMessageId.

  4. Retirement rules live in one module (new mobile-native-chat-pending-retirement.ts). Exact/ordinal retirement and glue matching now share retireLandedMobileNativeChatPending, which returns the input array identity when nothing retires — the drafts hook's next === current early-out depends on that, and a new array every pass would loop the effect. fix(mobile-native-chat): retire pending bubbles glued into one transcript row (mobile half of #14262) #14665 returned [...current] here and relied on a length comparison; the identity contract is now pinned by a test.

  5. The bound is preserved. One transcript user turn retires a run of 2+ adjacent text-only pending sends only when it exactly spells their normalized concatenation with at most a single-space boundary, and turn.index <= segment.tail rejects any row a send predates. Exact landings, image echoes, empty text and unresolved tails remain barriers.

Known bounds, and why they are now actually bounded

Three cases do not retire. All are pre-existing in outcome and match main; none is a regression.

  • A caption-less or captioned photo sent before any read settled can bind to an older photo turn — caption-less to any photo in the read, captioned to an older row carrying the same caption. Its ordinal was counted against an empty read and no local signal separates its own echo from the older row. Fixing it needs a tail that excludes older image turns without excluding the send's own echo, which is a separate change.
  • A pair of sends issued when the transcript was genuinely unknown — a cold start with no retained history, so both captured a null tail — whose glued row is already in the first settled read. The tail supplied to them sits at or after that row, and the matcher refuses any row a send predates. Relaxing that is exactly the false positive (FP1/FP2) this design exists to prevent.
  • A send the count pass claims against an older identical row, orphaning the rest of its glued run (the pre-existing absolute-landedCounts looseness).
  • New, and the honest cost of the cursor slide: an unrelated transcript row that happens to spell the concatenation of two or more later outstanding sends can claim them, moving the cursor past an earlier pair whose own echo has not landed yet — stranding that pair. It needs an exact text coincidence and the row must land after those sends' tails but before the earlier pair's echo, so it is far rarer than the frozen-head bug the slide fixes; the trade is net positive, but it is a new failure mode rather than a pre-existing one. The same case has a second face: a glue that needs a slide to be found can have its budget spent by earlier attempts, if and only if the leading segments prefix-match the row deeply — a heterogeneous run costs one inspection per failed attempt and never comes close. Both faces leave a lingering bubble; neither can cause a wrong retirement.

What changed: none of these can take the feature down with them any more. The match cursor used to advance only on a hit, so any one of the above, sitting at the head of a run, froze every later rapid pair permanently — the earlier revision of this PR disclosed these as bounded when they were not. The cursor now slides past a head that cannot match, staying monotonic so no later turn can claim a send an earlier one took. MAX_GLUE_SPAN keeps that search linear in the run length; the budget test asserts the bound rather than the looser one the slide would otherwise have broken.

The whole condition is removed at its source by serializing agent-prompt submits per PTY (#14980), so the glue never forms.

Linked Issue

Refs STA-4482 (reland) and STA-4492 (hydration barrier). Original PR #14665, revert #14819. Fixes the user-visible symptom tracked as STA-4506.

Why one PR and not two

STA-4492 does not reproduce on current origin/main — mobile-native-chat-pending-retirement.ts, use-mobile-native-chat-drafts-glued-pending.test.ts and the symbol glueBaselineTrusted were all deleted by #14819 (verified: 0 hits on origin/main). It was filed against span e570cad..d2ffe1f, which still carried #14665 and not the revert. So STA-4492 is not a standalone defect on main; it is one of the two conditions this reland has to satisfy, in code that only exists inside the reland. Shipping them separately is not possible — the "fix" would have no code to apply to.

The desktop half (STA-4477, same family of bug in the renderer) is deliberately a separate PR, since this one is a reland of an already-once-reverted change and must stay independently revertable without dragging a live P1 desktop fix out with it.

Visual Proof

Mobile emulator QA — glued-send retirement EXERCISED on device, with a live A/B. iOS Simulator (iPhone 17 Pro, iOS 26.5), driven through orca emulator; Metro from this worktree's mobile/ on an isolated port 8083; paired to an isolated orca serve --mobile-pairing built from this worktree, never the primary Orca app.

Which code the phone ran, checked before each arm: entry.bundle returns HTTP 200 at 27.7 MB (not the ~7 KB error JSON) and contains rebaseMobileNativeChatPendingBaselines, selectGluedPendingIds, matchGluedRun, retireLandedMobileNativeChatPending and baselineResolved.

BEFORE — selectGluedPendingIds armed to return ∅. The glued row Echo three Four and the agent's reply are both in the transcript, and both grey bubbles stay pinned beneath them (pending=2 [Echo three| Four]):

BEFORE

AFTER — same live pane, same pending pair, only the function restored. Both clear within ~12 s (pending=0 []):

AFTER

Two pending bubbles immediately after the two sends, and the live transcript against a real Claude Code session:

pending
transcript

The QA pending=… text in the composer is a temporary probe so the pending set was observed rather than inferred. All QA instruments were reverted afterwards; the tree carries none of them.

How the glued row was produced — and what that says about the bug

The two sends were 13 s apart, not raced. The glue was produced deliberately, by an env-gated host instrument that dropped the Ctrl+U clear prefix and withheld the Enter so successive sends shared one TUI input line. That was not a shortcut around a timing problem; the timing approach is impossible, and the reason matters:

Two rapid mobile composer sends cannot interleave today. Every composer send runs through sendNativeChat, which takes the per-terminal write lock (mobile-native-chat-terminal-write-lock.ts) and holds it across the whole terminal.send RPC — and that RPC only resolves after the host's body → 500 ms sleep → Enter. A second send arriving in the gap is rejected, not glued. That lock landed in #12502 on 2026-08-04, ten days before the mobile half of #14262. Two further blockers on a Claude Code host: mobile text sends are \x15-prefixed, and its TUI honors Ctrl+U as kill-line (verified live — it printed Ctrl+Y to paste deleted text), so an interleaved body would erase the first rather than glue to it; and it does not merge queued prompts (two prompts queued during a long turn landed as two separate user turns).

So on a current mobile client against a Claude Code host, the glued-row precondition does not arise from the composer race this PR describes. The retirement logic is verified correct against a glued row, but the producer of such a row is not the mobile composer. Rows glued by some other producer — a concurrent client, a host path that bypasses the lock, or an agent TUI that does not honor Ctrl+U — remain the case this defends. Reviewers should weigh that when deciding whether this ships now or waits behind the host-side serialization in #14980.

Not covered: the hydration and image-attachment paths were not exercised on device; both are covered by tests only.

Testing

Run from mobile/:

pnpm exec vitest run --config vitest.config.ts src/session
pnpm exec tsc --noEmit
pnpm exec oxlint --config .oxlintrc.json src/session/mobile-native-chat-pending-*.ts src/session/use-mobile-native-chat-*.ts
pnpm exec oxfmt --check src/session/mobile-native-chat-pending-*.ts src/session/use-mobile-native-chat-*.ts

From the repo root:

PATH=/opt/homebrew/opt/node@24/bin:$PATH node config/scripts/check-changed-code-quality.mjs -- origin/main

Results at this HEAD:

  • Mobile session suites: 131 files / 1173 tests passed (mobile's own vitest config, explicitly).
  • tsc --noEmit: clean. Note mobile/tsconfig.json excludes **/*.test.ts, so test type errors ship silently; this branch was additionally typechecked with that exclusion removed. That surfaced 4 pre-existing errors in a file this PR touches (MobileNativeChatSendOutcome used but never imported — present identically on origin/main). Fixed here with a type-only import, called out explicitly rather than left behind: repo-wide mobile test-included errors go 363 → 359, and 0 remain in any file this PR touches.
  • Changed-code quality, type-aware quality, React Doctor: 0 new findings across every changed file.
  • Oxlint and oxfmt: clean. The lint gate was confirmed live (a clean oxlint run prints nothing, which is indistinguishable from a dead one) by observing a real violation fire, then clearing it.
  • use-mobile-native-chat-drafts.ts sits at 288 effective lines against the 300-line max-lines cap. No cap was raised and no disable added — but see the note on fix(terminal): serialize agent-prompt submits per PTY so overlapping sends stop gluing #14980 below, because that file is shared.

Red-first / non-vacuity. Production code was mutated with tests kept (git checkout HEAD -- <path> to restore; never git stash), one property at a time. Every failure below is behavioural — none is a compile or missing-export failure:

Mutation Reproduces Tests RED
clearDraftForSend / restoreRejectedDraft fed the trimmed value a naive re-apply of #14665's send seam 2
const rebased = current — never rebase a naive re-apply of #14665's untrusted flag / STA-4492 3
restore the ordinal recount in rebaseMobileNativeChatPendingBaselines the stuck bubble the review loop found 6
baselineResolved: !transcriptLoading — the loose predicate an old row claiming sends issued before any read settled 1
selectGluedPendingIds returns ∅ the feature itself 14
drop the turn.index <= segment.tail guard the false-positive bound 6
retireLandedMobileNativeChatPending returns [...current] the render-safety contract 1
keep a null-tailed image echo unpinned an old image turn claiming a fresh photo 1
unfiltered image-preview pass an unbounded send claiming an image turn already in the read 1
move a tail the send already captured a reconnect pair stranded, blocking its whole run 3
supply a tail to an image echo a photo bubble stuck for the session, photo lost 3

The send-seam mutation fails with the symptom the revert named, verbatim:

FAIL … > restores the untrimmed draft when the launch-draft pre-clear is rejected
AssertionError: expected 'ship it' to be 'ship it   '

The untrusted-flag mutation fails as bubbles that never clear, including the neighbour-barrier face:

FAIL … > retires both bubbles once their glued row lands after hydration
AssertionError: expected [ { id: 'pending-1', …(4) }, …(1) ] to deeply equal []
FAIL … > stops barring a later pair from gluing once its own echo lands

Glue matcher fuzzed for soundness. 250,000 seeded cases — ~20,000 of them producing a real retirement — over transcripts built to contain the exact concatenation of a random slice of the pending queue, with randomized separators and adversarial baseline placement. Zero violations of: never retires an image echo, never retires an unresolved baseline, never retires a lone send as glue, and every retired run exactly spells a user row landing strictly after every member's own baseline tail. Kept out of the suite deliberately — it is a 10-second check and the FP1/FP2 fixtures already pin the bound.

Two suites currently pin the same fixture shape in opposite directions, and that is deliberate. "An older identical prompt arrives with the read" and "my own echo arrives with the read" are byte-identical states; the tests now assert the claiming direction, with the reasoning in the fixture comments.

  • Automated regressions added for the real revert cause and for both defects the review loop found
  • Manual native mobile QA of the glue flow — see Visual Proof

Review

  • Security / supply chain — no dependencies, new IO, network sinks, secrets, shell execution, or persisted state.
  • Remote wire compatibility — checked against docs/reference/remote-wire-compatibility.md. No wire change. baselineResolved, baselineTailMessageId and expectedOccurrence are client-local React state in pendingBySession; they are not RPC params, not stream frames, and not content a host publishes, so Rules 1–3 are not engaged and no capability negotiation is needed. The one client→host difference is that trailing whitespace is no longer written onto the agent's input line — carried by the existing terminal.send params.text, with no new field and no new opcode, and interpreted identically by hosts of any version.
  • Backward compatibility / data loss — pending entries are in-memory only, so there is no persisted shape to migrate. The rejection path now restores the draft byte-for-byte rather than trailing-trimmed. Glue retirement still requires full-row consumption and 2+ adjacent text sends. One remaining draft-loss hole is deliberately not closed here: restoreRejectedDraft only restores into an empty composer, so text typed while a rejection is in flight still discards the sent draft — that is fix(terminal): serialize agent-prompt submits per PTY so overlapping sends stop gluing #14980's mobile-native-chat-rejected-draft-merge.
  • Render safety — retireLandedMobileNativeChatPending preserves array identity on the no-op path, so the drafts effect settles instead of re-entering; the rebase pass converges in one extra tick and is then idempotent.
  • Performance — bounded by transcript rows × pending entries; all Maps, Sets and arrays are invocation-local. No timers, polling, RPCs, or retained resources. No timeout-based bubble drop was added: a slow echo is legitimate over relay/SSH, and silently removing the only local record of a send invites a duplicate resend.
  • Cross-platform — pure Expo client string logic; no platform, path, provider, SSH, or relay branch. iOS/Android identical.
  • Scope — mobile only, all under mobile/src/session/. No max-lines cap was raised and no max-lines disable was added.

Relationship to #14980

#14980 fixes the root cause this PR reconciles downstream — a per-PTY send lock, so overlapping submits never glue in the first place — plus a deterministic fake-PTY interleaving test. Nothing here duplicates it. Two merge notes:

Checklist

  • Each documented revert cause traced to source — one reproduced and fixed, one shown not to reproduce and the claim withdrawn
  • PR states plainly what did and did not cause the revert, and what remains unexplained
  • Two defects the reland itself introduced found by the review loop, reproduced red-first, and fixed
  • Intended glue-retirement behavior and its false-positive bounds preserved, pinned, and fuzzed
  • Mobile suite run explicitly against mobile's own vitest config
  • Mobile test files typechecked despite tsconfig.json excluding them
  • Wire, cross-platform, folder-workspace and data-loss impact reviewed
  • Mobile QA limitation disclosed rather than substituted

AI Disclosure

Authored and reviewed with AI assistance.

Author

  • X / Twitter: @BrennanKB5

@coderabbitai

coderabbitai Bot commented Aug 16, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 60dec4bb-c053-4a6c-a71a-31f0c05d14ee

📥 Commits

Reviewing files that changed from the base of the PR and between 4402da0 and 947ccc3.

📒 Files selected for processing (3)
  • mobile/src/session/mobile-native-chat-pending-baseline.test.ts
  • mobile/src/session/mobile-native-chat-pending-baseline.ts
  • mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • mobile/src/session/mobile-native-chat-pending-baseline.ts
  • mobile/src/session/mobile-native-chat-pending-baseline.test.ts
  • mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts

Included review availability: Your plan includes up to 10 reviews per rolling hour; 1 remains after this review.


📝 Walkthrough

Walkthrough

The change preserves trailing whitespace in composer callbacks while trimming it from transmitted messages. Rejected sends restore the original draft. Mobile native-chat pending messages now record whether their transcript baseline is settled. Unresolved baselines can be rebased against settled transcript tails. Centralized logic retires exact landed messages, glued transcript turns, and eligible image echoes. The controller and draft-hook tests cover hydration, reconnect, settlement, and pending-message edge cases.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 32.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the mobile glued pending retirement reland and the two addressed revert causes.
Description check ✅ Passed The description thoroughly documents the changes, rationale, testing, visual proof, limitations, linked issues, and compatibility considerations.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

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.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Mobile emulator QA (PR #14936) — launch verified, glue flow BLOCKED

Platform: iOS Simulator · iPhone 17 Pro · iOS 26.5
Worktree: mac-glue-cluster @ this PR's HEAD, rebased onto bf6dc6fcba
Metro: this worktree's mobile/ on isolated LAN port 8083 (8081/8082 belonged to other checkouts)
Control surface: orca emulator (attach / ax); Computer Use used only for the Simulator window capture, never to tap, type, scroll, or change focus. Device was attached by this run and shut down afterwards.

What passed

The dev build of this branch bundles and the app renders: "Welcome back" with the desktop list, no redbox and no "could not connect to development server". orca emulator ax returns a live, correctly-labelled tree (Open Host 63, Can't connect — check Tailscale, Actions for Host 63, …), so JS from this branch is executing and the UI is interactive.

Because #14819 reverted #14665 for a launch-blocking regression, this is the directly relevant check for the reland, and it passes.

01-mobile-app-loaded-reland.png

What is blocked, and why no substitute was used

Exercising the glued-send retirement itself could not be done through orca emulator, for two independent reasons:

  1. No live paired host. Every previously-paired host on this simulator is disconnected. Establishing one would require driving the user's live Orca desktop UI, or standing up a competing headless Orca runtime on the user's primary machine — neither is in bounds for this run.
  2. The race is finer than the control surface. The host writes a send's body, sleeps 500 ms, then writes the \r; gluing needs the second body inside that window. orca emulator tap/type actions cost ~1.2–2.2 s each — fix(mobile-native-chat): retire pending bubbles glued into one transcript row (mobile half of #14262) #14665's own QA run measured 4850 ms between two sends. That is ~10× too slow to ever produce the condition, so this is a control-surface limit rather than a skipped step.

Per the mobile QA rules this is reported as blocked rather than replaced with another interaction method (no Expo Web, no Electron render, no OS-level input). The race and its false-positive bounds are covered deterministically instead — including the exact hydration ordering that caused the revert; see the non-vacuity table in the PR description.

Screenshot is a Simulator window capture, inspected before upload: no pairing codes, QR payloads, or device tokens. Not committed.

@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

🧹 Nitpick comments (1)
mobile/src/session/use-mobile-native-chat-controller.ts (1)

114-115: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Distinguish the two transcript-settled definitions in this file.

Line 115 defines settled as status === 'ready'. Line 141 defines nativeChatTranscriptSettled as status === 'ready' || (status === 'error' && messages.length > 0). The two names are nearly identical but the predicates differ.

The narrower predicate at line 115 is the safe direction for baseline resolution: an unresolved baseline keeps a bubble queued instead of retiring it against rows the send never saw. The risk is maintenance. A later reader can reasonably assume the two flags are interchangeable and pass nativeChatTranscriptSettled here, which would silently resolve baselines against a failed read.

Add a short comment stating why baseline resolution requires ready only, or name the value to reflect the stricter rule.

♻️ Proposed clarification
     transcriptLoading: nativeChatSession.transcriptLoading,
-    transcriptSettled: nativeChatSession.status === 'ready'
+    // Stricter than `nativeChatTranscriptSettled` below: a baseline may only
+    // resolve against a read that succeeded, never against a stale error read.
+    transcriptSettled: nativeChatSession.status === 'ready'
   })

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f984aa32-2199-4c4b-83db-7762714cb916

📥 Commits

Reviewing files that changed from the base of the PR and between bf6dc6f and ac539105df732914c83e30c53b4a2307fb95f398.

📒 Files selected for processing (15)
  • mobile/src/session/MobileNativeChatComposer.test.ts
  • mobile/src/session/MobileNativeChatComposer.tsx
  • mobile/src/session/mobile-native-chat-pending-baseline.test.ts
  • mobile/src/session/mobile-native-chat-pending-baseline.ts
  • mobile/src/session/mobile-native-chat-pending-echo.ts
  • mobile/src/session/mobile-native-chat-pending-retirement.test.ts
  • mobile/src/session/mobile-native-chat-pending-retirement.ts
  • mobile/src/session/use-mobile-native-chat-controller.test.ts
  • mobile/src/session/use-mobile-native-chat-controller.ts
  • mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts
  • mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts
  • mobile/src/session/use-mobile-native-chat-drafts.test.ts
  • mobile/src/session/use-mobile-native-chat-drafts.ts
  • mobile/src/session/use-mobile-native-chat-message-send.test.ts
  • mobile/src/session/use-mobile-native-chat-message-send.ts

Included review availability: Your plan includes up to 10 reviews per rolling hour; 0 remain after this review.

Comment thread mobile/src/session/mobile-native-chat-pending-retirement.ts
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review loop

Ran the full review loop on this branch. Readiness checklist at both ends, independent review rounds,
prior-art re-derivation, and a real attempt at the mobile QA gap. Four commits landed on top of the PR head.

1. One of the two documented revert causes does not reproduce

Cause (a) — "a rejected send restored a trimmed composer and dropped draft text" — is a phantom, and this
PR claimed to have fixed it.
Traced to source: #14665 (68ca17e46c) touched 8 files, none of them the
composer or the drafts hook's clear/restore callbacks, and it did not reassign a parameter — it renamed
one, const text = rawText.trimEnd(). The only production caller, MobileNativeChatComposer.handleSend,
was already handing over value.trimEnd(), and that line predates #14665 by ten days (#12366, 2026-08-04).
trimEnd(trimEnd(x)) === trimEnd(x), so what restoreRejectedDraft put back was byte-identical to what
main restores today. The non-composer callers pass syncComposer: false and never touch the draft.

So whatever else motivated #14819, it was not draft loss. The PR body now says that plainly instead of
claiming a fix. Treat the cause-(a) half of the revert as still unexplained.

The draftText / text split is worth keeping anyway, and the loop made it actually load-bearing: the
composer now passes value through instead of pre-trimming, so the seam owns the trim and a rejection
restores the draft byte-for-byte rather than by accident.

2. Two defects the reland itself introduced — both fixed here

P1 — the rebase stranded a send on its own echo, and took the feature down with it.
rebaseMobileNativeChatPendingBaselines recounted expectedOccurrence against the first settled read. That
read can already carry the send's own echo — a re-subscribe after a tab switch or reconnect returns whatever
exists now — so the ordinal landed one past anything the transcript could supply. Reproduced at the hook
level. The bubble never cleared; it also stayed a live segment at the head of its run, so the cursor never
advanced and no later pair could glue either (the STA-4492 symptom); and appendMobileNativeChatPending's
earlierOutstanding carried the inflation onto the next send of the same text, so it cascaded on continue,
y, run the tests.

This is worse than the #14665 it relands — git show 68ca17e46c:…/mobile-native-chat-pending-retirement.ts
shows #14665's count pass never consulted the untrusted flag at all; the flag gated only glue matching. The
|| !item.baselineResolved suppression in the count pass is new.

Fix: recover only the tail. The ordinal was already counted against an empty transcript, which is exactly
right for "no history was known". A caption-less image echo keeps its captured tail, since it reconciles by
counting image turns after it — pinning that tail onto the read would throw away an echo the read carries.

P2 — a failed read produced a null tail that let any row claim a send. baselineResolved was
!transcriptLoading, but transcriptLoading is status === 'loading' only. A read that ERRORED hands back
an empty list, indistinguishable from an empty conversation — and an empty conversation legitimately yields a
null tail, which mobile-native-chat-pending-retirement.ts maps to -1, i.e. "any row may be my echo". Two
sends issued in that window were retired by an arbitrary old row once the successful read arrived. That is
the FP2 case reached through a tail the guard cannot see, and it is new in this PR — main has no glue
matching at all. Fix: baselineResolved now means "captured against a settled read", threaded as
transcriptSettled.

The two fixes are coupled and had to land together: tightening the predicate alone would have stranded those
sends deterministically under the old rebase.

3. Evidence

Non-vacuity — seven mutations, every failure behavioural, none a compile or missing-export failure.
Production mutated with tests kept, restored with git checkout HEAD -- <path>:

Mutation Reproduces RED
clearDraftForSend/restoreRejectedDraft fed the trimmed value a naive re-apply of #14665's send seam 2
const rebased = current — never rebase a naive re-apply of #14665's untrusted flag / STA-4492 3
restore the ordinal recount the stuck bubble, its run-blocking, and its cascade 6
baselineResolved: !transcriptLoading an old row claiming sends issued before any read settled 1
selectGluedPendingIds returns ∅ the feature itself 14
drop the turn.index <= segment.tail guard the false-positive bound 6
retireLandedMobileNativeChatPending returns [...current] the render-safety contract 1

Glue matcher fuzzed for soundness. 250,000 seeded cases, ~20,000 producing a real retirement, over
transcripts built to contain the exact concatenation of a random slice of the pending queue with randomized
separators and adversarial baseline placement. Zero violations of: never retires an image echo, never retires
an unresolved baseline, never retires a lone send as glue, and every retired run exactly spells a user row
landing strictly after every member's own baseline tail. Kept out of the suite — a 10 s check, and the
FP1/FP2 fixtures already pin the bound.

Gates. Mobile session suite 131 files / 1174 tests green. tsc --noEmit clean, and again with
**/*.test.ts un-excluded — 0 errors in every file this PR touches. oxlint and oxfmt clean, with the gate
confirmed live by observing a real violation fire rather than trusting silence. Changed-code quality,
type-aware quality and React Doctor: 0 new findings. use-mobile-native-chat-drafts.ts at 288 effective
lines against the 300-line max-lines cap; no cap raised, no disable added.

One flake seen and diagnosed, unrelated: mobile-image-base64-accumulator times out at 5 s on a 256 KB loop
under CPU contention. Not in this diff, passes on a quiet machine, and present on an unrelated branch too.

4. Prior art

Re-derived rather than re-read. Mature clients reconcile an optimistic send to its server row by a
client-generated id that round-trips, with an explicit two-flag state machine separating "RPC settled"
from "provider acknowledged"; content matching survives only as a dated compatibility path for records that
predate the id field, and identity matching is deliberately confined to one module rather than reimplemented
per consumer. Orca cannot carry an id here — terminal.send writes raw keystrokes into a TUI's stdin — so a
text + ordinal + boundary matcher is the correct last resort, and consolidating it into one module matches
precedent.

Two findings bear directly on the P1 above. Precedent preserves unmatched local rows across bootstrap and
same-epoch resets "because a provider may never echo them", dropping them only on a known epoch change — so
the never-drop bias here is right, and the hydration ambiguity is the known cost of having no continuity
anchor. And precedent appends unmatched local rows after the canonical tail rather than interleaving by
timestamp, because timestamps come from different machines — which independently rules out the
timestamp-based discriminator that would otherwise be the obvious fix. Identity-preserving reducer output
(changed ? next : previous) is the standard idiom, not a local trick.

Verdict: aligned, standard, senior-level. Every divergence traces to the transport.

5. Mobile QA — exercised, and it turned up something bigger

The glued-send retirement is now verified on device with a live A/B (see the PR body). The finding worth reviewer attention is how the glued row had to be produced.

The two sends were 13 s apart. The glue was created deliberately, by an env-gated host instrument, because the timing approach is not merely impractical — it is impossible. Every mobile composer send runs through sendNativeChat, which takes the per-terminal write lock (mobile-native-chat-terminal-write-lock.ts: acquired at use-mobile-native-chat-image-attachments.ts:230) and holds it in a finally spanning the entire terminal.send RPC — and that RPC resolves only after the host's body → 500 ms sleep → Enter. A second send arriving inside the gap is rejected, not glued. That lock landed in #12502 on 2026-08-04, ten days before the mobile half of #14262.

Two further blockers on a Claude Code host: mobile text sends are \x15-prefixed (mobile-native-chat-send.ts) and its TUI honors Ctrl+U as kill-line — verified live, it printed Ctrl+Y to paste deleted text — so an interleaved second body would erase the first rather than glue to it; and it does not merge queued prompts (two queued during a long turn landed as two separate user turns).

So the composer race this PR describes does not appear reachable on a current mobile client against a Claude Code host. The retirement logic is verified correct against a glued row; what is not established is a live producer of such a row on that path. Rows glued by another producer — a concurrent client, a host path that bypasses the client-local lock, or an agent TUI that does not honor Ctrl+U — remain the case this defends, and the host-side serialization in #14980 removes the condition outright.

I am flagging rather than concluding: this bears on whether the PR ships now or waits behind #14980, and that is the owner's call. It does not affect the STA-4492 half, which is a real correctness fix independent of how the row got glued.

6. Follow-ups worth their own tickets

  • Bound or make actionable an unreconciled pending entry. Every reference implementation either expires an optimistic entry (a ~30 s TTL is the common shape) or gives it an affordance — tap to edit, swipe to delete. An inert grey chip that can persist indefinitely is the one configuration none of them ships. Deliberately not added here: silently dropping the only local record of a send invites a duplicate resend, and an affordance is a separate design change.
  • Replace the count pass with an earliest-unclaimed claim pass. Precedent assigns an incoming row to the earliest unreconciled local candidate rather than counting occurrences. That would also fix the pre-existing pagination hazard, where loadEarlier prepending older identical prompts can satisfy an outstanding ordinal. A redesign of the exact-retirement path, so it belongs in its own PR with its own tests.
  • Host-side serialization (fix(terminal): serialize agent-prompt submits per PTY so overlapping sends stop gluing #14980) is the structural fix, and the reference exposed to the same body → delay → Enter hazard solves it exactly that way rather than reconciling the merge downstream.

7. Desktop visual proof

This change has no rendered surface in the Electron app. Every changed file is under
mobile/src/session/; src/renderer, src/main and src/shared are untouched, so there is nothing a
desktop screenshot could show. The rendered surface is Orca Mobile.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Addendum — rounds 4 and 5

The comment above was written mid-loop and is stale in two places. Correcting rather than leaving it:

Round 4 found that my own round-2 fix introduced a fresh permanent-stuck-bubble bug, and a second one on the image path. Both reproduced with probes, both fixed:

  • A captured boundary was being thrown away. I had written that an unsettled read "hands back an empty list". That is false — createNativeChatTranscriptRetention deliberately returns this session's retained history for an unsettled same-identity read, so the conversation does not blank on a reconnect. A pair sent across a reconnect therefore already owns a correct tail, and the rebase overwrote it with the tail of the read that followed, which sits at or after the pair's own glued row. turn.index <= segment.tail then rejected every turn: two permanent bubbles for a message that did land, and every later pair in that run blocked for the session. Probed — tails m2,m2 → m3,m3, glue []. Fixed by pinning only a send that captured no tail at all.
  • A captioned image echo became permanently unbindable. It binds its preview by an ordinal counted over the whole transcript while the tail is only a rejection guard, so supplying a tail without recounting left it matching nothing. Probed — pinned=[] vs unpinned=[…]. Fixed by leaving captioned image echoes alone entirely.

Corrected totals at HEAD: seven commits; eleven mutations → RED, all behavioural, none a compile failure (the four above plus unfiltered image-preview pass → 1, move a captured tail → 3); mobile session suite 1179/1179; use-mobile-native-chat-drafts.ts at 291/300 effective lines.

The pattern is the finding. rebaseMobileNativeChatPendingBaselines was rewritten three times across four rounds, and each rewrite fixed a real permanent-stuck-bubble bug while introducing another. Not carelessness — each was verified red-then-green — but a signal that one field, baselineTailMessageId, feeds three consumers with different tail semantics (selectGluedPendingIds treats it as a glue boundary, countImageSourceTurnsAfter as a counting origin, findLandedImagePreviewEchoes as a rejection guard against an absolute ordinal), and local reasoning about one keeps breaking the others. That is the same conclusion the prior-art pass reached from the other direction: no reference reconciles by text ordinal at all, because they all carry a client message id.

Worth weighing alongside the mobile-QA finding above when deciding whether this ships now or behind #14980.

…two revert causes

Relands #14665 (reverted by #14819). #14665 retired mobile pending bubbles when
two fast sends landed as one transcript row, but shipped two regressions; both
are fixed here rather than re-applied and hoped for.

1. A rejected send restored a TRIMMED composer. #14665 reassigned `text` to
   `text.trimEnd()` at the top of `sendMessage` and then used that one value for
   both the bytes on the wire and the composer restore, so a rejection put back
   less than the user typed. The draft and the payload are now separate values:
   `draftText` is what the user typed and is what `clearDraftForSend` /
   `restoreRejectedDraft` see; only the transported `text` is trimmed.

2. Sends issued during hydration were stranded forever. #14665 persisted
   `glueBaselineTrusted: false` on any send captured while the transcript was
   still loading and never cleared it, so that send could never retire and stood
   as a permanent glue barrier for its neighbours. A hydration-time baseline is
   now a placeholder (`baselineResolved: false`) that the first authoritative
   read rebases onto real rows, ordinals included, instead of a permanent
   disqualification. That is STA-4492.

The intended behavior is unchanged: one transcript user turn retires a run of
2+ adjacent text-only pending sends only when it exactly spells their normalized
concatenation, every send is bounded by its OWN transcript tail, and exact
landings, image echoes and unresolved tails stay barriers.

No wire change: `baselineResolved` and the baseline tail are client-local React
state in `pendingBySession` and are never exchanged with a host. The only
client->host difference is trailing whitespace no longer being written onto the
agent's input line, over the existing `terminal.send` params.

Refs STA-4482, STA-4492. Original PR #14665, revert #14819.
The composer sent `value.trimEnd()`, so the raw draft never reached
`sendMessage` and a rejected send still handed back a trimmed composer —
the split of `draftText` from the transported `text` had nothing to
restore. Pass the draft through; the seam already owns the wire trim.

Also pins the array-identity contract of
`retireLandedMobileNativeChatPending`: the drafts effect early-outs on
`next === current`, and nothing tested it.
…n its own echo

Rebasing recounted the send's ordinal against the first authoritative read.
That read can already carry the send's own echo — a re-subscribe after a tab
switch or reconnect returns whatever exists now — so the ordinal landed one
past anything the transcript could supply. The bubble never cleared, it stayed
a live segment at the head of its run so no later pair could glue either, and
`earlierOutstanding` carried the inflation onto the next send of the same text.
Only the tail needs recovering; the ordinal was already counted against an
empty transcript, which is right for "no history was known". A caption-less
image echo keeps its captured tail, since it counts turns after it.

`baselineResolved` also has to mean "captured against a settled read", not
merely "not loading": a read that failed hands back an empty list that reads as
an empty conversation, and the null tail then let any row the successful read
finally brought glue-retire those sends.
…ts run glue-capable

A held send sits as a live segment at the head of its run, so the cursor can
never reach a later pair — the stuck bubble takes the whole feature down with
it. Goes red against the ordinal recount.
… require the settled flag

A caption-less image echo keeps its captured tail because it counts image turns
after it — but a send issued before any history was known captured null, which
counts from the top of the transcript. An old image turn then claimed the send
and bound the user's fresh photo to it, leaving the just-sent turn with no
preview. A null tail is not a boundary worth preserving, so pin those too.

`transcriptSettled` was optional and defaulted to the gate it replaced, so any
caller that omitted it silently got the pre-fix behaviour. Required now, and
threaded through every harness.
…n already in the read

The image-preview pass runs before the rebase, so a send captured with no
boundary matched any image turn the settled read carried — binding the user's
freshly attached photo to an old one and retiring the bubble through
landedImagePendingIds, which short-circuits the retirement path entirely.
Pinning the tail in the rebase could not help: the claim was already made.
Such an entry now waits one tick and claims against a real tail.
An unsettled read still shows this session's own retained history — a reconnect
or a failed read keeps the conversation on screen rather than blanking it — so
sends made across one already own a correct tail. The rebase overwrote it with
the tail of the read that followed, which sits at or after their own glued row,
so `turn.index <= segment.tail` rejected every turn and the pair stayed queued
for the session, blocking every later pair in the run. Pin only a send that
captured no tail at all.

A captioned image echo is now left alone entirely: it binds its preview by an
ordinal counted over the whole transcript, so supplying a tail without
recounting left it matching nothing, forever.
@brennanb2025
brennanb2025 force-pushed the brennanb2025/sta-4482-mobile-glue-retirement-reland branch from e305972 to 4402da0 Compare August 17, 2026 04:25
@coderabbitai

coderabbitai Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

An image echo reconciles by counting turns AFTER its tail and has no other
retirement path, so the tail supplied from a read that already carried its own
echo excluded the very row it was waiting for: the "Queued" photo bubble stuck
for the life of the session and the transcript row rendered as bare marker text
with no photo. A regression against main, and against the earlier revision of
this fix that pinned only captioned echoes.

The glue matcher is the only consumer a supplied tail helps. Everything that
reconciles relative to its own tail keeps whatever it captured.
…the session

The match cursor only advanced on a hit, so a head that could never match —
a pair whose glued row arrived with the read, or a send the count pass claimed
against an older row — froze the run behind it and every later rapid pair
became permanently unretirable. Two cases previously disclosed as bounded were
not bounded at all. Slide past a non-matching head, keeping the cursor
monotonic so a later turn can never take a send an earlier one claimed.

The slide widens the search, so a span cap keeps the work linear in the run
length instead of quadratic; the existing budget test now asserts that bound
rather than the old one it silently broke. Re-fuzzed at 250k seeds: the
boundary guarantee still holds.

Also corrects a comment that claimed the preview-pass filter made a photo claim
against a real tail. It does not — an image echo keeps whatever tail it
captured, so a caption-less photo can still bind to an older photo turn, as on
main.
Capping each match attempt at 8 segments did not truncate a longer glue, it
rejected it outright: a row spelling 9+ sends exhausted the loop without
reaching the end of the text and returned zero, so none of the nine retired —
and each stuck send then inflated `earlierOutstanding` for the next send of the
same text. Nothing bounds how many sends pile onto the agent's input line;
accumulation ends when the agent accepts input again, not at any fixed count.

One inspection budget now covers the whole slide instead. The first attempt
spans the entire run and always fits, so a genuine glue is never truncated;
only a run of identical prefix-matching sends can exhaust the budget, which is
exactly the case that should be cheap. The in-flight attempt may overshoot the
remainder — that is what makes the guarantee hold — so the budget test asserts
the real ceiling. Re-fuzzed at 250k seeds with runs past the budget.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review loop — final: CLEAN after eight rounds

Round 8 returned CLEAN with no changes required, which is the loop's stopping condition. Ten commits landed on the PR head across rounds 1–7.

Every one of the first seven rounds found a real defect, and four of them were introduced by the previous round's fix to the same function. That is the most useful thing this loop produced, so it goes first:

Round Defect Introduced by
1 Hydration rebase recounted the ordinal and stranded a send on its own echo; also blocked its whole run and cascaded via earlierOutstanding the reland
1 baselineResolved meant "not loading", so a failed read's null tail let any row claim a send the reland
2 An image echo with a null tail bound a fresh photo to an old turn pre-existing, surfaced
4 The rebase overwrote a tail the send had genuinely captured across a reconnect — stranded the pair, blocked the run round 2's fix
4 A captioned image echo became permanently unbindable round 3's fix
5 A caption-less image echo was pinned past its own echo — stuck bubble and lost photo round 2's fix
6 One unmatchable send froze the match cursor, disabling glue for the whole session the reland
7 The span cap added to bound round 6's fix rejected any glue of 9+ sends outright round 6's fix

rebaseMobileNativeChatPendingBaselines was rewritten four times. Each rewrite was verified red-then-green with a probe, so this is not carelessness — it is that one field, baselineTailMessageId, feeds three matchers with three different meanings (glue boundary / counting origin / rejection guard against an absolute ordinal), and reasoning locally about one kept breaking the others. The prior-art pass reached the same conclusion from the other side: no reference client reconciles by text ordinal at all — the strongest text matcher anywhere is bare presence, used only as a dated compatibility path, because they all carry a client-generated message id that round-trips. Orca cannot, because the transport is keystrokes into a TUI.

Two claims previously on this PR were wrong and are corrected in the body: that an unsettled read "hands back an empty list" (it returns this session's retained history — that error caused the round-4 defect), and that the non-retiring cases were bounded (any one of them at a run's head disabled the feature for the session until round 6).

Final state

  • 15 mutations RED, all behavioural, none a compile failure.
  • selectGluedPendingIds fuzzed at 250,000 seeds after the final change, with >5,000 samples whose runs exceed the slide budget: never retires an image echo, an unresolved baseline, or a lone send, and every retired run exactly spells a user row landing strictly after every member's tail. Zero violations.
  • Mobile session suite 1202/1202; tsc --noEmit clean and again with **/*.test.ts un-excluded (no new errors in any PR file); oxlint/oxfmt clean with the gate proven live; changed-code quality, type-aware quality and React Doctor all 0 new findings; the drafts hook at 291/300 effective lines with no cap raised and no disable added.
  • Mobile QA exercised on device with a real before/after A/B (above), which also established that two rapid mobile composer sends cannot interleave at all — the per-terminal write lock spans the whole terminal.send RPC and landed ten days before the mobile half.

What I would want a merger to weigh

  1. The composer race this PR describes does not appear reachable on a current mobile client against a Claude Code host. The retirement logic is verified correct against a glued row; the producer of such a row on that path is not established. fix(terminal): serialize agent-prompt submits per PTY so overlapping sends stop gluing #14980 removes the condition at its source.
  2. Eight rounds to reach clean, with four self-inflicted defects, in a change that has already been reverted twice. The code is now the most-tested it has ever been, and I would still merge it behind fix(terminal): serialize agent-prompt submits per PTY so overlapping sends stop gluing #14980 rather than ahead of it.
  3. Three follow-ups worth their own tickets: bound or give an affordance to an unreconciled pending entry (every reference does one or the other); replace the count pass with an earliest-unclaimed claim pass (also fixes the pagination looseness); and host-side serialization as the structural fix.

@brennanb2025
brennanb2025 merged commit c7995a6 into main Aug 17, 2026
46 checks passed
brennanb2025 added a commit that referenced this pull request Aug 17, 2026
…cused

The PR had been CONFLICTING, which stops GitHub building refs/pull/N/merge and
so stops every `pull_request` run: pushes were producing zero CI runs while
`gh pr view` kept serving the last pre-conflict rollup as green.

main #14936 relanded mobile pending retirement into two new modules
(mobile-native-chat-pending-retirement.ts, mobile-native-chat-pending-baseline.ts)
built around marker-stripped reconcile keys. Taking that side verbatim would
have merged this branch's literal-marker echo identity into dead code, so the
identity rule is re-homed onto the new call sites:

- retirement keys pending text with nativeChatUserTextMatchText (verbatim when
  the send carried no image) and counts landed rows with userTextOccurrenceCounts,
  which already excludes image-bearing rows
- the glue matcher keys transcript rows the same way and skips image rows
- baseline rebase detects an image-only send with the same rule

The auto-merge also dropped draft-reconcile's normalizeReconcileText re-export
while both new modules still imported it; those call sites now use the
image-aware helpers directly.

captureSendOrigin keeps this branch's (text, images) signature on top of main's
transcriptSettled/baselineResolved boundary work. The literal-marker echo
harness passes transcriptSettled: true — mobile tsc excludes test files, so the
hook's new required arg could not surface as a type error.
brennanb2025 added a commit that referenced this pull request Aug 17, 2026
…s type

The local handler was annotated `Promise<ReadTranscriptResult>`, which has no
`hasMore`. The field reached the renderer anyway — a returned call result is not
a fresh object literal, so excess-property checking never looked at it — but the
type said otherwise, and nothing type-links this result to the preload shape that
now reads it. Any later refactor that built the result inline would have dropped
the flag silently, and the renderer would have fallen back to inferring paging
from the row count: the exact defect the flag exists to close.

Covered end-to-end from a real on-disk transcript, including the case the count
inference cannot express — a window that is exactly full reports `hasMore: false`.

Also pins, at the call sites a pure-function test cannot reach:

- the window-head marker rule through the live session hook, so a call site that
  stops forwarding the host's paging answer fails rather than silently reverting
  to "older history always exists"
- the two rules re-homed onto #14936's modules: a marker-only send that carried
  no attachment still earns a real boundary, and a photo turn whose caption
  happens to spell a run cannot retire the typed sends around it

Mutating each re-homed line turns 3, 3 and 4 tests red respectively, so the merge
landed live code rather than dead code.
paidaxingyo666 pushed a commit to paidaxingyo666/Manta that referenced this pull request Aug 21, 2026
…two revert causes (STA-4482, STA-4492) (stablyai#14936)

* fix(mobile-native-chat): reland glued pending retirement without the two revert causes

Relands stablyai#14665 (reverted by stablyai#14819). stablyai#14665 retired mobile pending bubbles when
two fast sends landed as one transcript row, but shipped two regressions; both
are fixed here rather than re-applied and hoped for.

1. A rejected send restored a TRIMMED composer. stablyai#14665 reassigned `text` to
   `text.trimEnd()` at the top of `sendMessage` and then used that one value for
   both the bytes on the wire and the composer restore, so a rejection put back
   less than the user typed. The draft and the payload are now separate values:
   `draftText` is what the user typed and is what `clearDraftForSend` /
   `restoreRejectedDraft` see; only the transported `text` is trimmed.

2. Sends issued during hydration were stranded forever. stablyai#14665 persisted
   `glueBaselineTrusted: false` on any send captured while the transcript was
   still loading and never cleared it, so that send could never retire and stood
   as a permanent glue barrier for its neighbours. A hydration-time baseline is
   now a placeholder (`baselineResolved: false`) that the first authoritative
   read rebases onto real rows, ordinals included, instead of a permanent
   disqualification. That is STA-4492.

The intended behavior is unchanged: one transcript user turn retires a run of
2+ adjacent text-only pending sends only when it exactly spells their normalized
concatenation, every send is bounded by its OWN transcript tail, and exact
landings, image echoes and unresolved tails stay barriers.

No wire change: `baselineResolved` and the baseline tail are client-local React
state in `pendingBySession` and are never exchanged with a host. The only
client->host difference is trailing whitespace no longer being written onto the
agent's input line, over the existing `terminal.send` params.

Refs STA-4482, STA-4492. Original PR stablyai#14665, revert stablyai#14819.

* fix(mobile-native-chat): let the untrimmed draft reach the send seam

The composer sent `value.trimEnd()`, so the raw draft never reached
`sendMessage` and a rejected send still handed back a trimmed composer —
the split of `draftText` from the transported `text` had nothing to
restore. Pass the draft through; the seam already owns the wire trim.

Also pins the array-identity contract of
`retireLandedMobileNativeChatPending`: the drafts effect early-outs on
`next === current`, and nothing tested it.

* docs(mobile-native-chat): name the hydration rebase's residual ambiguity

* fix(mobile-native-chat): stop the hydration rebase stranding a send on its own echo

Rebasing recounted the send's ordinal against the first authoritative read.
That read can already carry the send's own echo — a re-subscribe after a tab
switch or reconnect returns whatever exists now — so the ordinal landed one
past anything the transcript could supply. The bubble never cleared, it stayed
a live segment at the head of its run so no later pair could glue either, and
`earlierOutstanding` carried the inflation onto the next send of the same text.
Only the tail needs recovering; the ordinal was already counted against an
empty transcript, which is right for "no history was known". A caption-less
image echo keeps its captured tail, since it counts turns after it.

`baselineResolved` also has to mean "captured against a settled read", not
merely "not loading": a read that failed hands back an empty list that reads as
an empty conversation, and the null tail then let any row the successful read
finally brought glue-retire those sends.

* test(mobile-native-chat): pin that a resolved hydration send leaves its run glue-capable

A held send sits as a live segment at the head of its run, so the cursor can
never reach a later pair — the stuck bubble takes the whole feature down with
it. Goes red against the ordinal recount.

* fix(mobile-native-chat): pin an image echo that captured no tail, and require the settled flag

A caption-less image echo keeps its captured tail because it counts image turns
after it — but a send issued before any history was known captured null, which
counts from the top of the transcript. An old image turn then claimed the send
and bound the user's fresh photo to it, leaving the just-sent turn with no
preview. A null tail is not a boundary worth preserving, so pin those too.

`transcriptSettled` was optional and defaulted to the gate it replaced, so any
caller that omitted it silently got the pre-fix behaviour. Required now, and
threaded through every harness.

* fix(mobile-native-chat): stop an unbounded send claiming an image turn already in the read

The image-preview pass runs before the rebase, so a send captured with no
boundary matched any image turn the settled read carried — binding the user's
freshly attached photo to an old one and retiring the bubble through
landedImagePendingIds, which short-circuits the retirement path entirely.
Pinning the tail in the rebase could not help: the claim was already made.
Such an entry now waits one tick and claims against a real tail.

* fix(mobile-native-chat): never move a boundary the send already captured

An unsettled read still shows this session's own retained history — a reconnect
or a failed read keeps the conversation on screen rather than blanking it — so
sends made across one already own a correct tail. The rebase overwrote it with
the tail of the read that followed, which sits at or after their own glued row,
so `turn.index <= segment.tail` rejected every turn and the pair stayed queued
for the session, blocking every later pair in the run. Pin only a send that
captured no tail at all.

A captioned image echo is now left alone entirely: it binds its preview by an
ordinal counted over the whole transcript, so supplying a tail without
recounting left it matching nothing, forever.

* fix(mobile-native-chat): supply a boundary only to a text-bearing send

An image echo reconciles by counting turns AFTER its tail and has no other
retirement path, so the tail supplied from a read that already carried its own
echo excluded the very row it was waiting for: the "Queued" photo bubble stuck
for the life of the session and the transcript row rendered as bare marker text
with no photo. A regression against main, and against the earlier revision of
this fix that pinned only captioned echoes.

The glue matcher is the only consumer a supplied tail helps. Everything that
reconciles relative to its own tail keeps whatever it captured.

* fix(mobile-native-chat): stop one unmatchable send freezing glue for the session

The match cursor only advanced on a hit, so a head that could never match —
a pair whose glued row arrived with the read, or a send the count pass claimed
against an older row — froze the run behind it and every later rapid pair
became permanently unretirable. Two cases previously disclosed as bounded were
not bounded at all. Slide past a non-matching head, keeping the cursor
monotonic so a later turn can never take a send an earlier one claimed.

The slide widens the search, so a span cap keeps the work linear in the run
length instead of quadratic; the existing budget test now asserts that bound
rather than the old one it silently broke. Re-fuzzed at 250k seeds: the
boundary guarantee still holds.

Also corrects a comment that claimed the preview-pass filter made a photo claim
against a real tail. It does not — an image echo keeps whatever tail it
captured, so a caption-less photo can still bind to an older photo turn, as on
main.

* fix(mobile-native-chat): stop the span cap stranding a long glued run

Capping each match attempt at 8 segments did not truncate a longer glue, it
rejected it outright: a row spelling 9+ sends exhausted the loop without
reaching the end of the text and returned zero, so none of the nine retired —
and each stuck send then inflated `earlierOutstanding` for the next send of the
same text. Nothing bounds how many sends pile onto the agent's input line;
accumulation ends when the agent accepts input again, not at any fixed count.

One inspection budget now covers the whole slide instead. The first attempt
spans the entire run and always fits, so a genuine glue is never truncated;
only a run of identical prefix-matching sends can exhaust the budget, which is
exactly the case that should be cheap. The in-flight attempt may overshoot the
remainder — that is what makes the guarantee hold — so the budget test asserts
the real ceiling. Re-fuzzed at 250k seeds with runs past the budget.
enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
…two revert causes (STA-4482, STA-4492) (stablyai#14936)

* fix(mobile-native-chat): reland glued pending retirement without the two revert causes

Relands stablyai#14665 (reverted by stablyai#14819). stablyai#14665 retired mobile pending bubbles when
two fast sends landed as one transcript row, but shipped two regressions; both
are fixed here rather than re-applied and hoped for.

1. A rejected send restored a TRIMMED composer. stablyai#14665 reassigned `text` to
   `text.trimEnd()` at the top of `sendMessage` and then used that one value for
   both the bytes on the wire and the composer restore, so a rejection put back
   less than the user typed. The draft and the payload are now separate values:
   `draftText` is what the user typed and is what `clearDraftForSend` /
   `restoreRejectedDraft` see; only the transported `text` is trimmed.

2. Sends issued during hydration were stranded forever. stablyai#14665 persisted
   `glueBaselineTrusted: false` on any send captured while the transcript was
   still loading and never cleared it, so that send could never retire and stood
   as a permanent glue barrier for its neighbours. A hydration-time baseline is
   now a placeholder (`baselineResolved: false`) that the first authoritative
   read rebases onto real rows, ordinals included, instead of a permanent
   disqualification. That is STA-4492.

The intended behavior is unchanged: one transcript user turn retires a run of
2+ adjacent text-only pending sends only when it exactly spells their normalized
concatenation, every send is bounded by its OWN transcript tail, and exact
landings, image echoes and unresolved tails stay barriers.

No wire change: `baselineResolved` and the baseline tail are client-local React
state in `pendingBySession` and are never exchanged with a host. The only
client->host difference is trailing whitespace no longer being written onto the
agent's input line, over the existing `terminal.send` params.

Refs STA-4482, STA-4492. Original PR stablyai#14665, revert stablyai#14819.

* fix(mobile-native-chat): let the untrimmed draft reach the send seam

The composer sent `value.trimEnd()`, so the raw draft never reached
`sendMessage` and a rejected send still handed back a trimmed composer —
the split of `draftText` from the transported `text` had nothing to
restore. Pass the draft through; the seam already owns the wire trim.

Also pins the array-identity contract of
`retireLandedMobileNativeChatPending`: the drafts effect early-outs on
`next === current`, and nothing tested it.

* docs(mobile-native-chat): name the hydration rebase's residual ambiguity

* fix(mobile-native-chat): stop the hydration rebase stranding a send on its own echo

Rebasing recounted the send's ordinal against the first authoritative read.
That read can already carry the send's own echo — a re-subscribe after a tab
switch or reconnect returns whatever exists now — so the ordinal landed one
past anything the transcript could supply. The bubble never cleared, it stayed
a live segment at the head of its run so no later pair could glue either, and
`earlierOutstanding` carried the inflation onto the next send of the same text.
Only the tail needs recovering; the ordinal was already counted against an
empty transcript, which is right for "no history was known". A caption-less
image echo keeps its captured tail, since it counts turns after it.

`baselineResolved` also has to mean "captured against a settled read", not
merely "not loading": a read that failed hands back an empty list that reads as
an empty conversation, and the null tail then let any row the successful read
finally brought glue-retire those sends.

* test(mobile-native-chat): pin that a resolved hydration send leaves its run glue-capable

A held send sits as a live segment at the head of its run, so the cursor can
never reach a later pair — the stuck bubble takes the whole feature down with
it. Goes red against the ordinal recount.

* fix(mobile-native-chat): pin an image echo that captured no tail, and require the settled flag

A caption-less image echo keeps its captured tail because it counts image turns
after it — but a send issued before any history was known captured null, which
counts from the top of the transcript. An old image turn then claimed the send
and bound the user's fresh photo to it, leaving the just-sent turn with no
preview. A null tail is not a boundary worth preserving, so pin those too.

`transcriptSettled` was optional and defaulted to the gate it replaced, so any
caller that omitted it silently got the pre-fix behaviour. Required now, and
threaded through every harness.

* fix(mobile-native-chat): stop an unbounded send claiming an image turn already in the read

The image-preview pass runs before the rebase, so a send captured with no
boundary matched any image turn the settled read carried — binding the user's
freshly attached photo to an old one and retiring the bubble through
landedImagePendingIds, which short-circuits the retirement path entirely.
Pinning the tail in the rebase could not help: the claim was already made.
Such an entry now waits one tick and claims against a real tail.

* fix(mobile-native-chat): never move a boundary the send already captured

An unsettled read still shows this session's own retained history — a reconnect
or a failed read keeps the conversation on screen rather than blanking it — so
sends made across one already own a correct tail. The rebase overwrote it with
the tail of the read that followed, which sits at or after their own glued row,
so `turn.index <= segment.tail` rejected every turn and the pair stayed queued
for the session, blocking every later pair in the run. Pin only a send that
captured no tail at all.

A captioned image echo is now left alone entirely: it binds its preview by an
ordinal counted over the whole transcript, so supplying a tail without
recounting left it matching nothing, forever.

* fix(mobile-native-chat): supply a boundary only to a text-bearing send

An image echo reconciles by counting turns AFTER its tail and has no other
retirement path, so the tail supplied from a read that already carried its own
echo excluded the very row it was waiting for: the "Queued" photo bubble stuck
for the life of the session and the transcript row rendered as bare marker text
with no photo. A regression against main, and against the earlier revision of
this fix that pinned only captioned echoes.

The glue matcher is the only consumer a supplied tail helps. Everything that
reconciles relative to its own tail keeps whatever it captured.

* fix(mobile-native-chat): stop one unmatchable send freezing glue for the session

The match cursor only advanced on a hit, so a head that could never match —
a pair whose glued row arrived with the read, or a send the count pass claimed
against an older row — froze the run behind it and every later rapid pair
became permanently unretirable. Two cases previously disclosed as bounded were
not bounded at all. Slide past a non-matching head, keeping the cursor
monotonic so a later turn can never take a send an earlier one claimed.

The slide widens the search, so a span cap keeps the work linear in the run
length instead of quadratic; the existing budget test now asserts that bound
rather than the old one it silently broke. Re-fuzzed at 250k seeds: the
boundary guarantee still holds.

Also corrects a comment that claimed the preview-pass filter made a photo claim
against a real tail. It does not — an image echo keeps whatever tail it
captured, so a caption-less photo can still bind to an older photo turn, as on
main.

* fix(mobile-native-chat): stop the span cap stranding a long glued run

Capping each match attempt at 8 segments did not truncate a longer glue, it
rejected it outright: a row spelling 9+ sends exhausted the loop without
reaching the end of the text and returned zero, so none of the nine retired —
and each stuck send then inflated `earlierOutstanding` for the next send of the
same text. Nothing bounds how many sends pile onto the agent's input line;
accumulation ends when the agent accepts input again, not at any fixed count.

One inspection budget now covers the whole slide instead. The first attempt
spans the entire run and always fits, so a genuine glue is never truncated;
only a run of identical prefix-matching sends can exhaust the budget, which is
exactly the case that should be cheap. The in-flight attempt may overshoot the
remainder — that is what makes the guarantee hold — so the budget test asserts
the real ceiling. Re-fuzzed at 250k seeds with runs past the budget.
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.

1 participant