Skip to content

fix(mobile-native-chat): retire pending bubbles glued into one transcript row (mobile half of #14262) - #14665

Merged
brennanb2025 merged 4 commits into
mainfrom
brennanb2025/fix-14262-mobile-glued-pending
Aug 15, 2026
Merged

brennanb2025 merged 4 commits into
mainfrom
brennanb2025/fix-14262-mobile-glued-pending

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor

ELI5

On mobile, two fast chat sends can appear in the transcript as one user row, such as run the tests plus again becoming run 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.

  1. Normalize trailing whitespace at the message-send seam (use-mobile-native-chat-message-send.ts). sendMessage applies trimEnd() 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 through sendMessage; the separate Codex dispatchCommand path is unchanged.

  2. 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.

  3. 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.

  4. 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/renderer is 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 emulator required 4.85 seconds between sends; deterministic tests cover that race and its false-positive bounds.

BEFORE                                  AFTER
[assistant] ready                       [assistant] ready
[user] run the tests again              [user] run the tests again
[assistant] newest reply                [assistant] newest reply
[user] run the tests   ← stale
[user] again           ← stale

Testing

Run from mobile/ unless noted:

pnpm exec vitest run --config vitest.config.ts src/session/mobile-native-chat-pending-retirement.test.ts \
  src/session/use-mobile-native-chat-drafts-glued-pending.test.ts \
  src/session/use-mobile-native-chat-drafts.test.ts \
  src/session/use-mobile-native-chat-message-send.test.ts
pnpm exec vitest run --config vitest.config.ts src/session
pnpm exec oxlint --config .oxlintrc.json src/session/mobile-native-chat-pending-retirement.ts \
  src/session/mobile-native-chat-pending-retirement.test.ts
pnpm exec oxfmt --check src/session/mobile-native-chat-pending-retirement.ts \
  src/session/mobile-native-chat-pending-retirement.test.ts
PATH=/opt/homebrew/opt/node@24/bin:$PATH node config/scripts/check-changed-code-quality.mjs -- origin/main

Results at reviewed HEAD:

  • Targeted suites: 4 files / 82 tests passed.
  • Mobile session suites: 129 files / 1,151 tests passed.
  • Focused Oxlint, Oxfmt, and git diff --check: clean.
  • Node 24 code quality, type-aware quality, and React Doctor: 0 findings across 8 changed files.

Not rerun after the final review adjustment: the whole mobile suite. Not run locally per the brief: full tsc / tsc -b.

  • Manual native mobile QA — ordinary retirement verified; exact glue race blocked by emulator action latency
  • Automated regressions added

Review

  • Security / supply chain — no dependencies, new IO, network sinks, secrets, shell execution, or persisted state.
  • Backward compatibility / data loss — exact ordinal retirement keeps its prior semantics. Glue matching requires full-row consumption and at least two adjacent text sends; barriers prevent retirement across exact landings, unresolved image previews, or history that was not loaded when the send baseline was captured.
  • Performance / resources — deterministic coverage bounds segment inspections to rows × pending; all Maps, arrays, and Sets are invocation-local.
  • Cross-platform / remote — pure Expo client string logic with no platform, path, provider, local-host, SSH, relay, or wire-protocol branch.
  • Scope — eight files under mobile/src/session/; no desktop renderer implementation.

Checklist

  • Focused mobile-only fix
  • Root cause and false-positive bounds documented
  • Automated correctness and performance regressions
  • Cross-platform, remote, folder-workspace, and wire impact reviewed
  • Native screenshots attached; exact race limitation disclosed

AI Disclosure

Authored and reviewed with AI assistance.

Author

  • X / Twitter: @BrennanKB5

…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
@coderabbitai

coderabbitai Bot commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Native 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)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the mobile fix for pending bubbles that are glued into one transcript row.
Description check ✅ Passed The description covers the change, rationale, linked issue, visual proof, testing, review considerations, checklist, and AI disclosure.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
mobile/src/session/mobile-native-chat-pending-retirement.ts (1)

11-22: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Shorten 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 win

Use a maintained React test renderer.

react-test-renderer is 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

📥 Commits

Reviewing files that changed from the base of the PR and between 73bbe20 and 286bbdf.

📒 Files selected for processing (6)
  • mobile/src/session/mobile-native-chat-pending-retirement.test.ts
  • mobile/src/session/mobile-native-chat-pending-retirement.ts
  • mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts
  • mobile/src/session/use-mobile-native-chat-drafts.ts
  • mobile/src/session/use-mobile-native-chat-message-send.test.ts
  • mobile/src/session/use-mobile-native-chat-message-send.ts

Comment on lines +88 to +99
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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

rg -n -C 6 "typeMobileNativeChatCommandWithOutcome|dispatchCommand" mobile/src/session

Repository: 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.ts

Repository: 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 -240

Repository: 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 ').

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a5cd87fb-d4e5-4e2c-a156-547ed02f8fb9

📥 Commits

Reviewing files that changed from the base of the PR and between 286bbdf and c8fb01c.

📒 Files selected for processing (5)
  • mobile/src/session/mobile-native-chat-pending-retirement.test.ts
  • mobile/src/session/mobile-native-chat-pending-retirement.ts
  • mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts
  • mobile/src/session/use-mobile-native-chat-message-send.test.ts
  • mobile/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

Comment on lines +125 to +129
const landed =
item.text.trim() === ''
? countImageSourceTurnsAfter(messages, item.baselineTailMessageId) >=
item.expectedOccurrence
: (landedCounts.get(normalizeReconcileText(item.text)) ?? 0) >= item.expectedOccurrence

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Mobile emulator QA (PR #14665)

Platform: iOS Simulator · iPhone 17 Pro · iOS 26.5
Worktree: fix-14262-mobile-glued-pending @ 286bbdf892 (matches this PR)
Metro: this worktree’s mobile/ on LAN port 8082 (8081 belonged to another checkout)

Paired the simulator to a live desktop host and opened native chat (Claude Code tab). Sent two user turns (run the tests then again, later hi / yo). Both pairs landed as separate white user bubbles; after the transcript updated there were no grey leftover pending/queued bubbles pinned under later replies.

The exact sub-500ms glue race was not hit. Host glue needs the second send body to land in the ~500ms body→Enter gap; orca emulator tap/type each took ~1.2–2.2s (timed send1→send2 4850ms). That is a control-surface latency blocker, not a product skip. Android/adb was not available.

Screenshots are Simulator window captures. Inspected before upload; no pairing codes, QR payloads, or device tokens.

1. App loaded (Welcome back)

01-app-loaded-welcome.png

2. Paired host workspace list

02-paired-host62.png

3. Native chat composer before send

03-native-chat-before-send.png

4. After two sequential sends

04-after-two-sends.png

5. Settled transcript — two rows, no stale pending

05-settled-two-rows.png

6. Later turns — still no pending leftovers at the bottom

06-after-second-pair.png

Screenshots were not committed.

@AmethystLiang

Copy link
Copy Markdown
Contributor

@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

brennanb2025 added a commit that referenced this pull request Aug 16, 2026
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.
brennanb2025 added a commit that referenced this pull request Aug 16, 2026
…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.
brennanb2025 added a commit that referenced this pull request Aug 16, 2026
…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.
brennanb2025 added a commit that referenced this pull request Aug 17, 2026
…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.
brennanb2025 added a commit that referenced this pull request Aug 17, 2026
…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.
paidaxingyo666 pushed a commit to paidaxingyo666/Manta that referenced this pull request Aug 21, 2026
…two revert causes (STA-4482, STA-4492) (stablyai#14936)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

One inspection budget now covers the whole slide instead. The first attempt
spans the entire run and always fits, so a genuine glue is never truncated;
only a run of identical prefix-matching sends can exhaust the budget, which is
exactly the case that should be cheap. The in-flight attempt may overshoot the
remainder — that is what makes the guarantee hold — so the budget test asserts
the real ceiling. Re-fuzzed at 250k seeds with runs past the budget.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants