Repository navigation
fix(mobile-native-chat): retire pending bubbles glued into one transcript row (mobile half of #14262) - #14665
Conversation
…ript row Mobile's native chat retires an optimistic pending bubble only when a transcript user turn matches its normalized text at the expected ordinal. When two rapid sends collapse into a single glued user row neither key matches, so both bubbles pin below every newer reply for the rest of the session — mobile has a parallel implementation with no glue handling at all. Trim the send body once at the send seam so the bytes the host writes verbatim and the reconciliation key describe the same message on every send path, then add a bounded glue matcher: a greedy cursor walk that may only consider transcript turns strictly AFTER each send's captured tail, so an older turn that happens to read like the concatenation can never retire a newer queued send. Refs #14262
📝 WalkthroughWalkthroughNative chat now uses centralized pending-message retirement. The logic matches normalized text turns, detects multiple sends merged into one transcript row, respects captured baseline tails, and handles image previews separately. Draft reconciliation calls the shared helper. Send paths trim trailing whitespace once while preserving leading whitespace and interior newlines, then reuse the normalized text across sending, origin capture, optimistic updates, and command recording. Tests cover reconciliation and text normalization behavior. 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 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 |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
mobile/src/session/mobile-native-chat-pending-retirement.ts (1)
11-22: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueShorten the matcher comments.
Keep each comment to one concise statement of the non-obvious constraint. The multi-line narratives explain implementation detail that the identifiers and tests already show.
As per coding guidelines: “Comments must be concise, non-obvious, and brief—prefer one line; do not explain obvious behavior or walk through code.”
Also applies to: 40-41, 69-74, 105-106
Source: Coding guidelines
mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts (1)
1-2: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winUse a maintained React test renderer.
react-test-rendereris deprecated in React 19. Replace this new usage with the repository-supported Testing Library hook test approach after confirming its available test utilities.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 0dacd532-82d6-4902-84dd-af21b5eee7bd
📒 Files selected for processing (6)
mobile/src/session/mobile-native-chat-pending-retirement.test.tsmobile/src/session/mobile-native-chat-pending-retirement.tsmobile/src/session/use-mobile-native-chat-drafts-glued-pending.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
| rawText: string, | ||
| images: string[] | undefined, | ||
| syncComposer: boolean, | ||
| recordControlSend: boolean, | ||
| sharedDeadline?: number | ||
| ): Promise<MobileNativeChatSendOutcome> => { | ||
| // Why: the host writes these bytes verbatim, so surrounding whitespace lands | ||
| // on the agent's TUI input line while every reconciliation key is | ||
| // normalized. A rapid follow-up send then glues onto that residue and the | ||
| // submitted turn matches neither pending key. Trim once here so the wire | ||
| // text and the match key describe the same message for every send path. | ||
| const text = rawText.trim() |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
rg -n -C 6 "typeMobileNativeChatCommandWithOutcome|dispatchCommand" mobile/src/sessionRepository: stablyai/orca
Length of output: 46705
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- hook implementation ---'
sed -n '140,330p' mobile/src/session/use-mobile-native-chat-message-send.ts
printf '%s\n' '--- command helper implementation ---'
sed -n '1,180p' mobile/src/session/mobile-native-chat-send.ts
printf '%s\n' '--- hook tests around dispatch and whitespace ---'
sed -n '1,360p' mobile/src/session/use-mobile-native-chat-message-send.test.ts
printf '%s\n' '--- command helper tests ---'
sed -n '296,380p' mobile/src/session/mobile-native-chat-send.test.tsRepository: stablyai/orca
Length of output: 29855
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- sendMessage normalization and call sites ---'
sed -n '70,145p' mobile/src/session/use-mobile-native-chat-message-send.ts
rg -n -C 5 "function typeAgentTuiCommand|const typeAgentTuiCommand|export .*typeAgentTuiCommand" src mobile
printf '%s\n' '--- command typing implementation ---'
rg -l "typeAgentTuiCommand" src mobile | while read -r file; do
case "$file" in
*.ts|*.tsx) sed -n '1,180p' "$file" ;;
esac
done
printf '%s\n' '--- whitespace-related tests and dispatch callers ---'
rg -n -C 4 "dispatchCommand\\(['\"]|rawText\\.trim|trim\\(\\)" mobile/src/session src/shared | head -240Repository: stablyai/orca
Length of output: 39024
🏁 Script executed:
#!/bin/bash
set -e
python3 - <<'PY'
from pathlib import Path
import re
hook = Path("mobile/src/session/use-mobile-native-chat-message-send.ts").read_text()
send_start = hook.index("const sendMessage = useCallback(")
dispatch_start = hook.index("const dispatchCommand = useCallback(")
send_block = hook[send_start:dispatch_start]
dispatch_block = hook[dispatch_start:]
assert "const text = rawText.trim()" in send_block
assert re.search(r"command:\s*text\b", dispatch_block), "dispatchCommand no longer forwards raw text"
assert "const text = rawText.trim()" not in dispatch_block
print("sendMessage normalizes rawText:", True)
print("Codex dispatchCommand forwards its input unchanged:", True)
print("Direct Codex whitespace input remains distinct:", " /model ".strip() != " /model ")
PY
printf '%s\n' '--- every dispatchCommand caller ---'
rg -n -C 3 "dispatchCommand" mobile src --glob '*.{ts,tsx}'Repository: stablyai/orca
Length of output: 50369
Normalize Codex commands before typed dispatch.
The Codex branch of dispatchCommand passes whitespace unchanged to typeMobileNativeChatCommandWithOutcome, unlike sendMessage. Trim the command before this call and add a regression test for dispatchCommand(' /model ').
There was a problem hiding this comment.
Actionable comments posted: 1
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: a5cd87fb-d4e5-4e2c-a156-547ed02f8fb9
📒 Files selected for processing (5)
mobile/src/session/mobile-native-chat-pending-retirement.test.tsmobile/src/session/mobile-native-chat-pending-retirement.tsmobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.tsmobile/src/session/use-mobile-native-chat-message-send.test.tsmobile/src/session/use-mobile-native-chat-message-send.ts
🚧 Files skipped from review as they are similar to previous changes (3)
- mobile/src/session/use-mobile-native-chat-message-send.ts
- mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts
- mobile/src/session/use-mobile-native-chat-message-send.test.ts
| const landed = | ||
| item.text.trim() === '' | ||
| ? countImageSourceTurnsAfter(messages, item.baselineTailMessageId) >= | ||
| item.expectedOccurrence | ||
| : (landedCounts.get(normalizeReconcileText(item.text)) ?? 0) >= item.expectedOccurrence |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Apply the baseline-tail bound to ordinary text landing.
Lines 125-129 count matching text across the complete transcript. They do not use baselineTailMessageId. A matching row that existed before the send can satisfy expectedOccurrence and retire a newer pending item.
Count normalized text turns only after each item's captured tail. This must match the post-tail rule already used for image-only sends. Add a regression test with an older identical transcript row and a newer pending send.
Mobile emulator QA (PR #14665)Platform: iOS Simulator · iPhone 17 Pro · iOS 26.5 Paired the simulator to a live desktop host and opened native chat (Claude Code tab). Sent two user turns ( The exact sub-500ms glue race was not hit. Host glue needs the second send body to land in the ~500ms body→Enter gap; Screenshots are Simulator window captures. Inspected before upload; no pairing codes, QR payloads, or device tokens. 1. App loaded (Welcome back) 2. Paired host workspace list 3. Native chat composer before send 4. After two sequential sends 5. Settled transcript — two rows, no stale pending 6. Later turns — still no pending leftovers at the bottom Screenshots were not committed. |
|
@brennanb2025 We reverted #14665 in #14819 because it introduced a launch-blocking regression: a rejected mobile send can restore a trimmed composer and drop draft text, and loading-time glued sends can stay queued. Please reland with a fix that keeps glue-retirement: https://linear.app/stably/issue/STA-4482/reland-14665-retire-glued-mobile-pending-bubbles-after-revert-14819 |
Committed by the coordinator during teardown so the lane's in-flight work is not lost when the pipeline moves off openclaw. Snapshotted mid-edit; NOT verified and NOT proven RED->GREEN. Tests for the #14665 reland that must also fix the two regressions #14819 reverted it for: a rejected send must restore the ORIGINAL composer contents (not a trimmed one), and loading-time glued sends must not stay queued forever.
…E/Node-24 baselines Ancestry is the wrong test for whether #14665 is live on main: the commit is an ancestor but its effect was reverted. Record the file-existence check instead so the next reader is not misled into re-applying it.
…ane never reported Recovered from the revert diff and the code the lane left behind: the exact mechanism of both regressions #14819 cited, and the lane's replacement of glueBaselineTrusted with a baseline-rebase. Labelled as reconstruction, not as the lane's own account.
…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.
…two revert causes (STA-4482, STA-4492) (#14936) * fix(mobile-native-chat): reland glued pending retirement without the 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. * 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.
…ript row (mobile half of stablyai#14262) (stablyai#14665) * fix(mobile-native-chat): retire pending bubbles glued into one transcript row Mobile's native chat retires an optimistic pending bubble only when a transcript user turn matches its normalized text at the expected ordinal. When two rapid sends collapse into a single glued user row neither key matches, so both bubbles pin below every newer reply for the rest of the session — mobile has a parallel implementation with no glue handling at all. Trim the send body once at the send seam so the bytes the host writes verbatim and the reconciliation key describe the same message on every send path, then add a bounded glue matcher: a greedy cursor walk that may only consider transcript turns strictly AFTER each send's captured tail, so an older turn that happens to read like the concatenation can never retire a newer queued send. Refs stablyai#14262 * fix(mobile-native-chat): harden glued pending retirement * fix(mobile-native-chat): preserve pending image previews * fix(mobile-native-chat): bound glue to loaded transcripts
…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 chat sends can appear in the transcript as one user row, such as
run the testsplusagainbecomingrun the tests again. Mobile previously waited for two separate rows, so both optimistic “queued” bubbles could remain below later replies forever. This change recognizes a safely bounded glued row and retires only the pending sends it proves the row represents.What Changed
Mobile only. This is the mobile half of #14262; the desktop/renderer implementation is handled separately.
Normalize trailing whitespace at the message-send seam (
use-mobile-native-chat-message-send.ts).sendMessageappliestrimEnd()before capture, classification, transport, and optimistic-echo creation. Leading whitespace and interior newlines remain unchanged, so leading-space prose is not converted into a slash command. This covers composer, question-answer, and image-message callers routed throughsendMessage; the separate CodexdispatchCommandpath is unchanged.Bounded glue matching (new
mobile-native-chat-pending-retirement.ts). One transcript user turn retires a run of at least two consecutive text-only pending sends only when it exactly spells their normalized concatenation, with an optional single-space boundary. Every candidate row must be strictly after every matched send’s captured transcript tail, and the baseline transcript snapshot must have finished loading before the send.Preserve original adjacency and image-preview state. Exact landings, image echoes, empty text, unresolved tails, and baselines captured against a loading placeholder transcript are barriers. Matching advances only the leading unresolved candidate in each barrier-separated run. It never searches past an unresolved earlier text send and never drops an image echo before its phone-local preview is rebound to an authoritative row.
Keep retirement rules together. The existing count/ordinal exact-match behavior moved into the same module, and hook-level regressions exercise the rendered pending list through the real draft hook and render-data builder.
The glue pass performs at most
O(transcript rows × pending entries)segment inspections. It adds no timers, polling, RPCs, persisted state, or retained resources.Why
Pending bubbles previously retired only when the transcript contained their exact normalized text at the expected occurrence. If two sends landed as one user row, neither exact key appeared and both bubbles remained cached.
The host writes a send body and its Enter as separate PTY writes with a 500 ms beat between them. Rapid successive sends can still be observed by the TUI as one input row even though the mobile composer awaits each send call. Trimming trailing whitespace removes the separator controlled by the client, while glue reconciliation handles coalesced rows that still reach the transcript.
The transcript-tail bound is essential: an older row that merely looks like a new pair of sends must never retire real pending messages. Tests cover an already-existing glued-looking row, a historical row that spells two later sends, and history arriving after a send was captured against a loading placeholder transcript.
No timeout fallback was added. A slow transcript echo is valid over relay or SSH, and silently removing the only local evidence of a send could prompt a duplicate resend.
Linked Issue
Refs #14262 for the mobile client.
src/rendereris untouched.Visual Proof
Native mobile QA screenshots are attached in the evidence comment. On an iPhone 17 Pro simulator running iOS 26.5, two sequential send pairs settled without stale pending bubbles. The exact sub-500 ms glue race could not be reproduced because
orca emulatorrequired 4.85 seconds between sends; deterministic tests cover that race and its false-positive bounds.Testing
Run from
mobile/unless noted:Results at reviewed HEAD:
git diff --check: clean.Not rerun after the final review adjustment: the whole mobile suite. Not run locally per the brief: full
tsc/tsc -b.Review
rows × pending; all Maps, arrays, and Sets are invocation-local.mobile/src/session/; no desktop renderer implementation.Checklist
AI Disclosure
Authored and reviewed with AI assistance.
Author