Repository navigation
fix(mobile-native-chat): reland glued pending retirement without the two revert causes (STA-4482, STA-4492) - #14936
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
🚧 Files skipped from review as they are similar to previous changes (3)
Included review availability: Your plan includes up to 10 reviews per rolling hour; 1 remains after this review. 📝 WalkthroughWalkthroughThe 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)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Mobile emulator QA (PR #14936) — launch verified, glue flow BLOCKEDPlatform: iOS Simulator · iPhone 17 Pro · iOS 26.5 What passedThe 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". Because #14819 reverted #14665 for a launch-blocking regression, this is the directly relevant check for the reland, and it passes. What is blocked, and why no substitute was usedExercising the glued-send retirement itself could not be done through
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. |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
mobile/src/session/use-mobile-native-chat-controller.ts (1)
114-115: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDistinguish the two transcript-settled definitions in this file.
Line 115 defines settled as
status === 'ready'. Line 141 definesnativeChatTranscriptSettledasstatus === '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
nativeChatTranscriptSettledhere, which would silently resolve baselines against a failed read.Add a short comment stating why baseline resolution requires
readyonly, 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.tsmobile/src/session/MobileNativeChatComposer.tsxmobile/src/session/mobile-native-chat-pending-baseline.test.tsmobile/src/session/mobile-native-chat-pending-baseline.tsmobile/src/session/mobile-native-chat-pending-echo.tsmobile/src/session/mobile-native-chat-pending-retirement.test.tsmobile/src/session/mobile-native-chat-pending-retirement.tsmobile/src/session/use-mobile-native-chat-controller.test.tsmobile/src/session/use-mobile-native-chat-controller.tsmobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.tsmobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.tsmobile/src/session/use-mobile-native-chat-drafts.test.tsmobile/src/session/use-mobile-native-chat-drafts.tsmobile/src/session/use-mobile-native-chat-message-send.test.tsmobile/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.
Review loopRan the full review loop on this branch. Readiness checklist at both ends, independent review rounds, 1. One of the two documented revert causes does not reproduceCause (a) — "a rejected send restored a trimmed composer and dropped draft text" — is a phantom, and this So whatever else motivated #14819, it was not draft loss. The PR body now says that plainly instead of The 2. Two defects the reland itself introduced — both fixed hereP1 — the rebase stranded a send on its own echo, and took the feature down with it. This is worse than the #14665 it relands — Fix: recover only the tail. The ordinal was already counted against an empty transcript, which is exactly P2 — a failed read produced a null tail that let any row claim a send. The two fixes are coupled and had to land together: tightening the predicate alone would have stranded those 3. EvidenceNon-vacuity — seven mutations, every failure behavioural, none a compile or missing-export failure.
Glue matcher fuzzed for soundness. 250,000 seeded cases, ~20,000 producing a real retirement, over Gates. Mobile session suite 131 files / 1174 tests green. One flake seen and diagnosed, unrelated: 4. Prior artRe-derived rather than re-read. Mature clients reconcile an optimistic send to its server row by a Two findings bear directly on the P1 above. Precedent preserves unmatched local rows across bootstrap and Verdict: aligned, standard, senior-level. Every divergence traces to the transport. 5. Mobile QA — exercised, and it turned up something biggerThe 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 Two further blockers on a Claude Code host: mobile text sends are 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
7. Desktop visual proofThis change has no rendered surface in the Electron app. Every changed file is under |
Addendum — rounds 4 and 5The 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:
Corrected totals at HEAD: seven commits; eleven mutations → RED, all behavioural, none a compile failure (the four above plus The pattern is the finding. 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.
e305972 to
4402da0
Compare
|
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.
Review loop — final: CLEAN after eight roundsRound 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:
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
What I would want a merger to weigh
|
…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.
…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.
…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.
…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.

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:
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 overvalue.trimEnd(), and that line predates #14665 by ten days (#12366, 2026-08-04).trimEnd(trimEnd(x)) === trimEnd(x), so whatrestoreRejectedDraftput back was byte-identical to whatmainrestores today, on every path. The non-composer callers passsyncComposer: falseand 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/textsplit 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 duplicatetrimEnd(below). The one genuine behavior change #14665 made here wasrecordCommand(text)in place ofrecordCommand(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: falseon 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/rendereris untouched.The draft and the payload are separate values, and the split now reaches production (
use-mobile-native-chat-message-send.ts,MobileNativeChatComposer.tsx).draftTextis what the user typed and is whatclearDraftForSend/restoreRejectedDraftreceive; only the transportedtextistrimEnd()ed. The composer used to pre-trim withonSend(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 passesvaluethrough and the seam owns the trim, which also stops it happening twice.A baseline captured before the history was known is a placeholder, not a disqualification (new
mobile-native-chat-pending-baseline.ts).baselineResolved: falsemarks such a send;rebaseMobileNativeChatPendingBaselinesgives 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 —
createNativeChatTranscriptRetentionkeeps 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, soturn.index <= segment.tailrejected every turn and the pair stayed queued for the session, blocking every later pair in its run. A captured tail is now never moved.baselineResolvedmeans "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, sincemainhas no glue matching at all.transcriptSettledis 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 ofbaselineTailMessageId.Retirement rules live in one module (new
mobile-native-chat-pending-retirement.ts). Exact/ordinal retirement and glue matching now shareretireLandedMobileNativeChatPending, which returns the input array identity when nothing retires — the drafts hook'snext === currentearly-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.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.tailrejects 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.landedCountslooseness).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_SPANkeeps 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.tsand the symbolglueBaselineTrustedwere all deleted by #14819 (verified: 0 hits onorigin/main). It was filed against spane570cad..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'smobile/on an isolated port 8083; paired to an isolatedorca serve --mobile-pairingbuilt from this worktree, never the primary Orca app.Which code the phone ran, checked before each arm:
entry.bundlereturns HTTP 200 at 27.7 MB (not the ~7 KB error JSON) and containsrebaseMobileNativeChatPendingBaselines,selectGluedPendingIds,matchGluedRun,retireLandedMobileNativeChatPendingandbaselineResolved.BEFORE —
selectGluedPendingIdsarmed to return ∅. The glued rowEcho three Fourand the agent's reply are both in the transcript, and both grey bubbles stay pinned beneath them (pending=2 [Echo three| Four]):AFTER — same live pane, same pending pair, only the function restored. Both clear within ~12 s (
pending=0 []):Two pending bubbles immediately after the two sends, and the live transcript against a real Claude Code session:
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 wholeterminal.sendRPC — 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 printedCtrl+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/:From the repo root:
PATH=/opt/homebrew/opt/node@24/bin:$PATH node config/scripts/check-changed-code-quality.mjs -- origin/mainResults at this HEAD:
tsc --noEmit: clean. Notemobile/tsconfig.jsonexcludes**/*.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 (MobileNativeChatSendOutcomeused but never imported — present identically onorigin/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.use-mobile-native-chat-drafts.tssits at 288 effective lines against the 300-linemax-linescap. 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; nevergit stash), one property at a time. Every failure below is behavioural — none is a compile or missing-export failure:clearDraftForSend/restoreRejectedDraftfed the trimmed valueconst rebased = current— never rebaserebaseMobileNativeChatPendingBaselinesbaselineResolved: !transcriptLoading— the loose predicateselectGluedPendingIdsreturns ∅turn.index <= segment.tailguardretireLandedMobileNativeChatPendingreturns[...current]The send-seam mutation fails with the symptom the revert named, verbatim:
The untrusted-flag mutation fails as bubbles that never clear, including the neighbour-barrier face:
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.
Review
docs/reference/remote-wire-compatibility.md. No wire change.baselineResolved,baselineTailMessageIdandexpectedOccurrenceare client-local React state inpendingBySession; 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 existingterminal.sendparams.text, with no new field and no new opcode, and interpreted identically by hosts of any version.restoreRejectedDraftonly 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'smobile-native-chat-rejected-draft-merge.retireLandedMobileNativeChatPendingpreserves 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.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.mobile/src/session/. Nomax-linescap was raised and nomax-linesdisable 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:
mobile/src/session/use-mobile-native-chat-drafts.ts. The hunks do not overlap (this PR: imports,captureSendOrigin, the retirement effect; fix(terminal): serialize agent-prompt submits per PTY so overlapping sends stop gluing #14980: one import andrestoreRejectedDraft), but the file has 12 effective lines of headroom under the 300-linemax-linescap, so whichever merges second is tight.Checklist
tsconfig.jsonexcluding themAI Disclosure
Authored and reviewed with AI assistance.
Author