Skip to content

fix(mobile): Send includes text the iOS composer shows after voice input - #33512

Open
juliusmarminge wants to merge 11 commits into
mainfrom
fix/issue-33497
Open

juliusmarminge wants to merge 11 commits into
mainfrom
fix/issue-33497

Conversation

@juliusmarminge

@juliusmarminge juliusmarminge commented Nov 9, 2026 •

Copy link
Copy Markdown
Member

Problem

On iPad, the composer can show text that Send then leaves out of the message. ThreadComposer drops editor changes while voice input locks the composer (preparing, recording, transcribing, post-processing). If a native edit still reaches JS during that lock, the iOS editor bridge has already acknowledged its revision. It then stamps the draft's older value behind that revision, so the native editor keeps showing the dropped text. On Send, flushPendingInput sees that native is at an already-acknowledged revision and does nothing. The older draft goes out, and the text the user sees is lost.

Reproduced as a unit test of the bridge on main: a dropped edit during the lock, then unlock and flush. The editor shows "Draft plus more" and the draft (what Send reads) stays "Draft".

Change

flushPendingInput in apps/mobile/src/native/T3ComposerEditor.ios.tsx now passes native's text and selection to the parent when all of these hold:

  • native is at the revision the bridge already acknowledged;
  • the bridge stamped the parent's value behind that revision;
  • the two texts differ;
  • the native view the flush read is still the mounted one, so hiding and showing the composer while the read waits never restores into the replacement editor;
  • the parent's draft was not edited, reset or switched while native was being read.

Send then reads the text the editor shows. The restore decision runs in a useEffectEvent, so it uses the latest committed render's callbacks and draft. A parent that locked the composer again during the read (voice input starting while Send waits) drops the restore through its own onChangeText, like any other edit made under the lock.

Android is out of scope. Its flushPendingInput is a no-op, and this report is iPad-only.

Scope and approval

Fixes #33497

Triaged via the automated issue flow.

Verification

  • cd apps/mobile && vp test run src/components/ComposerEditor.ios.test.tsx src/native/composerEditorRevision.test.ts: 29/29 pass (11 bridge, 18 revision).
  • sends text the native editor kept while the composer was locked for voice input fails on main (expected 'Draft' to be 'Draft plus more') and passes here.
  • The test mock now keeps one native handle across renders, as the Expo view does, so the pending-read tests exercise the draft guards instead of a recreated handle.
  • Each of these fails when its protection is removed: does not flush away a parent edit the native editor has not applied yet (stamped-behind check), does not restore dropped text through an editor recreated while a flush waits (view identity), and keeps a parent edit committed while a flush waits on the native editor, does not restore dropped text into a composer locked again while a flush waits and does not restore dropped text after switching to another empty draft while a flush waits (latest-render callbacks). does not restore dropped text into a draft cleared while a flush waits is not tied to one guard: removing the draft-value and reset-generation checks, alone or together, leaves it passing.
  • vp run check:changed: format, lint, knip, typecheck, tests and the React Compiler check pass.
  • No JSX or style changes. The changed behaviour is what Send sends, recorded below on an iPad simulator.

cannot-verify ios-device: the device trigger, meaning what lets a native edit reach JS while the iPad composer is read-only, is not reproduced. On the simulator, voice recording would not start and native typing found no focused input, so no divergence could be produced (attempts recorded below). Candidates are the gap before the readOnly prop reaches the native view, or input that skips shouldChangeTextIn, such as keyboard dictation or Scribble. The fix covers the resulting state however the text got in; the unit red/green transcript is the proof of that state.

Evidence

Full artifact index and complete matrix accounting.

Pooled iPad Pro 13-inch (M5), iPadOS 27.2, simbox-1; portrait × light, 1032 × 1376 logical points, native recordings 2064 × 2752 pixels (2×), Large text-size category, agent-device 0.21.32. Device/OS confirmed through Settings → General → About. Separate Metro bundles and fresh synthetic backends with T3CODE_DEV_SEED=0; same native client fingerprint. No live database was read or copied. Fixture responses are scripted and synthetic.

Same driver on both: empty → focus → type Draft → append \nSecond line for the iPad composer. → Send → exact sent text visible → composer cleared. Approximate playback marks: before ~3.6s keyboard, ~5.4–7.3s short text, ~9–13s multiline, ~14–16s sent/clear; after ~9.8s short text, ~22.8–26s multiline, ~28–29.3s sent/clear. Tool pacing differs; clips show behavior, not latency.

R01 — native send/reset, complete Before After Diff0 DiffT
iPad portrait × light Recording
bbdc7dca32
Recording
bb8005d7d7
0 threshold, ×32
bbdc7dca32 → bb8005d7d7
max channel >8/255
bbdc7dca32 → bb8005d7d7

Before bbdc7dca32: exact sent multiline text and cleared composer; After bb8005d7d7: same result

R03 — cannot-verify ios-device. On both commits, with microphone permission and the same installed 44 MB transcription package: focus Draft, tap Start dictation, immediately attempt native type ' plus more'. Both return TEXT_INPUT_NOT_FOCUSED and show “Could not start voice recording.” Dismiss the error and Send: both send exactly Draft and clear. No native suffix was accepted or dropped. R04 shows error/unlock; Preparing is visible behind the base setup permission alert. Steady Recording, Transcribing, Cancel and Finish remain unverified because recording could not start. Exact attempts, setup and results.

Approximate active-attempt playback marks: base ~3.9s Draft, ~5.9–10s error, ~11.8s dismissed, ~13.7s sent, ~17.6s settled; head ~4.8s Draft, ~6.4–11.2s error, ~12.8s dismissed, ~14.4s sent/cleared. Simulator video timestamps differ from the command wall-clock log.

R03/R04 — attempted; no native race reproduction Before After Diff0 DiffT
iPad portrait × light Recording
bbdc7dca32
Recording
bb8005d7d7
0 threshold, ×32
bbdc7dca32 → bb8005d7d7
max channel >8/255
bbdc7dca32 → bb8005d7d7

Unit harness only: Red/green transcript · Reproduction commands. Base bbdc7dca32 with head’s test file: 10 pass, 1 fails, specifically “sends text the native editor kept while the composer was locked for voice input” (expected Draft plus more, received Draft; exit 1). Head bb8005d7d7: 11/11 bridge tests pass (exit 0), plus 18/18 revision-helper tests pass. Native view/events are mocked; this does not establish the real iPad trigger or invoke the actual Send handler.

What else changed: actual status/message timestamps and their glyph spacing; faint R01 composer-shadow raster differences at low channel values. The clock was not frozen; pool commands expose no clock override. No new content/layout difference was observed. All four final frames passed two pixel-identical screenshots separated by at least 500 ms. R01: 10,420/1,420,032 pixels differ at zero threshold, 781 above 8/255; active voice attempt: 2,952 at zero, 2,690 above 8/255. “Nothing else changed” is not claimed.

Sheets: R01 Before · After; installed-package R03/R04 attempt Before · After. Actions: R01 before · R01 after · Voice before · Voice after.

Fixture and original drivers: setup · RPC seed · scripted responder · R01 driver · voice driver. Drivers were downloaded unchanged; round runner only relocates scratch/worktree paths and SHA labels. Full reproduction recipe · Checkout/bundle provenance · Original driver checksums.

AI-assisted; verified with the tests and tools listed above.

A native edit that reaches JS while the composer is locked for voice input
is dropped from the draft but still acknowledged, so the native view keeps
showing it and the flush on Send cannot deliver it. These tests reproduce
#33497: the sent draft differs from the visible text.
While the composer is locked for voice input it drops onChangeText, but the
iOS bridge has already acknowledged that native revision, so the parent's
older value is stamped behind native and the view keeps showing the dropped
text. Send's flush saw native at the acknowledged revision and ignored it,
sending the stale draft.

flushPendingInput now delivers native's text and selection when native is at
the acknowledged revision, the bridge stamped the parent's value behind it,
and the texts differ. A parent edit native has not applied yet is stamped at
the acknowledged revision, so a flush never reverts it.
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:S 10-29 changed lines (additions + deletions). labels Nov 9, 2026
macroscopeapp[bot]
macroscopeapp Bot previously approved these changes Nov 9, 2026
@macroscopeapp

macroscopeapp Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Approved at c2dfac1

Macroscope's review found this PR approvable — The iOS Send path now recovers visible native text that the parent draft missed during voice input. Revision and draft-identity guards constrain recovery so concurrent edits, resets and draft switches are preserved.

No code changes detected at 93193b5. Prior analysis still applies.

You can add or adjust custom eligibility rules. Learn more.

@coderabbitai

coderabbitai Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Explain Change →

Important

Review skipped

Review was skipped as selected files did not have any reviewable changes.

Run configuration

Commits

Reviewing files that changed from the base of the PR and between bb8005d and 93193b5.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review

No actionable comments were generated in the recent review.

Walkthrough

The iOS composer now checks captured native state against the current draft, view, and reset generation before restoring text during a flush. Tests cover parent edits and composer state changes while a native read is pending.

Changes: iOS Composer Flush Reconciliation

Layer / File(s) Summary
Guarded native flush
apps/mobile/src/native/T3ComposerEditor.ios.tsx
The flush captures the view and rendered draft before reading native state. It restores eligible native text and selection only when the revision, draft, view, and reset-generation checks pass. Otherwise, it follows the existing eligible-state application path.
Flush race tests
apps/mobile/src/components/ComposerEditor.ios.test.tsx
The test mock can pause a read after capturing native state. Tests cover locked input, parent edits, draft clearing or switching, and editor recreation during a pending flush.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix · Severity of issue fixed: Medium

Possibly related issues

  • pingdotgg/t3code#7390: Both address divergence between iOS native editor text and the parent draft; this change does not show the thread-switch replacement failure as fixed.
  • pingdotgg/t3code#15129: The flush path can pass native text to the parent when the draft is stale, addressing the reported omission of manual edits to a voice transcript.

Possibly related PRs

  • pingdotgg/t3code#26452: It introduced the readState bridge and flushPendingInput() path extended here.
  • pingdotgg/t3code#32621: Both changes handle native editor state that races with send or draft clearing.
  • pingdotgg/t3code#30308: This flush checks the resetGeneration state that the PR added to native events and controlled documents.

Suggested labels: 📱 Native Change

Suggested reviewers: saphid

Merge Risk: Minimal · previous review c2dfa

The change makes the iOS composer send the text it shows after voice input, and it avoids overwriting newer edits. No actionable merge-blocking risk remains. The exact iPad trigger has not been reproduced on a device.

Pre-merge checks: Passed 4

Check name Status Explanation
Title check Passed The title clearly identifies the iOS composer fix: Send now includes text still shown after voice input.
Linked Issues check Passed Issue [#33497] requires Send to include text visible in the iPad composer. T3ComposerEditor.ios.tsx restores native text and selection when the parent draft was stamped behind the acknowledged nativ…
Out of Scope Changes check Passed The changes affect the iOS editor flush path and its regression tests. Both support issue [#33497]. No unrelated changes appear in the reviewed diff. Android remains unchanged.
Description check Passed The description covers the problem, change, scope, and verification. It links the triaged issue and reports detailed tests, device attempts, evidence, and verification limits.
Full details: Linked Issues check

Explanation

Issue [#33497] requires Send to include text visible in the iPad composer. T3ComposerEditor.ios.tsx restores native text and selection when the parent draft was stamped behind the acknowledged native revision. It skips restoration after a parent edit, reset, draft switch, or editor replacement. The iOS tests cover the dropped-edit case and these race guards. The exact device trigger remains unverified, but the issue does not require identifying it.

Finishing Touches

Generate docstrings

  • Commit to this branch
  • Create a new PR
Generate unit tests (beta)

  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Recent review info

Run configuration

Commits

Reviewing files that changed from the base of the PR and between b05a945 and c2dfac1.

Files selected for processing: 2

  • apps/mobile/src/components/ComposerEditor.ios.test.tsx
  • apps/mobile/src/native/T3ComposerEditor.ios.tsx

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.

@juliusmarminge juliusmarminge added bugfix Fix PR opened by an issue lead; enters the PR loop pr:gate-failed A land gate failed labels Nov 9, 2026
@shivamhwp

Copy link
Copy Markdown
Collaborator

[GPT-6.1 Sol][Codex] on behalf of shivam.

Handoff from the parallel investigation, testing main at 6b02cbff3a0cd9a0f2c97007562a1ce74fd35aa0 on shiv, not this PR's head:

  • The existing iOS composer bridge, revision, voice-input session and shared controller suites passed: 4 files, 84 tests. Native Swift composer, image-drop eligibility and provider-paste checks also passed.
  • No device reproduction completed. The iPad 10th generation simulator on iOS 27.0 was leased, but the matching client build exited 65 during ExpoWidgets bundling: Unable to resolve ".../apps/mobile/src/widgets/AgentActivityWidget.tsx". The client was never installed or launched. Android tablet and hardware keyboard testing did not start.
  • No screenshots or recordings were collected. Real dictation interruption, subsequent edits and submission remain unverified by this investigation. No code changes or pushes were made; the test setup and device lease are cleaned up.

@juliusmarminge juliusmarminge added pr:in-progress A lead is working on this PR and removed pr:gate-failed A land gate failed labels Nov 9, 2026
…te the draft

flushPendingInput restores text the parent dropped under a lock. When the
composer was hidden and shown while the native read was pending, the old
editor instance's guards still matched its own last render, so the late
read wrote stale text over what was typed into the recreated editor.

Pin the native view the flush read from and skip the restore unless it is
still mounted.
…omposer callbacks

The restore branch of flushPendingInput called the onChangeText captured
before its native read, so a composer locked again during the read had
the dropped text written into its draft. The restore decision now runs as
an effect event against the latest committed render, and the test mock
keeps one native handle across renders like Expo does.
@github-actions github-actions Bot added size:M 30-99 changed lines (additions + deletions). and removed size:S 10-29 changed lines (additions + deletions). labels Nov 10, 2026
@macroscopeapp
macroscopeapp Bot dismissed their stale review November 10, 2026 00:07

Dismissing prior approval to re-evaluate c2dfac1

@juliusmarminge juliusmarminge added pr:needs-verification Reviewed and green; needs a run on a desktop, Android or iOS device and captures posted and removed pr:in-progress A lead is working on this PR labels Nov 10, 2026
@arun279

arun279 commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

Some observations from a simulator run that may help with the "cannot-verify ios-device" note. Setup: iPhone 17 Pro simulator, iOS 27.0, main at ca796d521, development client with temporary logging in the editor view and in ThreadComposer. The simulator has no on-device transcriber, so a stand-in transcriber was used; recording was real.

A native edit does reach JS while the composer is locked. Type thank yo on the on-screen keyboard and wait for the grey inline u, then tap the mic. The composer locks and hides the editor, the editor resigns, and on resign iOS sends four events (the prediction is cleared, then committed). The bridge acknowledges them and ThreadComposer drops all four (log lines shortened):

10:29:01.709 JS phase=preparing locked=true draft="Thank you" sel=8-8
10:29:01.738 native change ec=338 len=8 value="Thank yo"
10:29:01.739 native change ec=341 len=9 value="Thank you"
10:29:01.763 native blur (end editing)
10:29:01.840 JS onChangeText locked=true len=8   (3 times, then len=9)

The same steps with a tap outside the editor instead of the mic send the same events with the lock off, and they are accepted. A pending autocorrection behaves differently: iOS applies it as the mic tap lands, before the lock (5 of 5 runs).

Left alone, this never left the box and the draft disagreeing on main. The hidden editor's native view is destroyed while dictation is shown, and the one mounted afterwards shows the JS draft (#30139). Across the run (cancel in each phase, a finished transcript, errors, backgrounding, leaving the thread) the box and the draft matched every time.

The symptom in #33497 matches a build without #30139. With #30139 reverted on the same simulator, text typed after a cancelled dictation showed in the composer, never reached the draft, and Send sent the older draft. App Store 2.2.0 (build 202) was cut before #30139, and the issue does not give a version.

Forcing a different edit into that window does break main. With a debugger I inserted one character inside the editor's resign, after the lock (two runs). The bridge acknowledged it and ThreadComposer dropped it. After the cancel the new view showed the draft without it, and the next typed character appeared in the composer but never reached the draft: the new view continues from the revision the draft was stamped with, and JS had already acknowledged one more. Send then left that character out. A second keystroke repaired it.

So the state this PR guards against is reachable on main, but I only got there by forcing the edit. Left alone, the events iOS sent in that window ended on the same text as the draft.

Written by Claude Fable 5.2 (Claude Code, run from T3 Code) on behalf of Arun.

Copy link
Copy Markdown
Member Author

The automated pipeline stopped on this pull request and will not merge it:

the capture step could not show the problem on the base commit, so there is nothing to prove fixed.

On both commits: open the seeded thread, focus the composer, type 'Draft', tap the keyboard microphone, refocus the composer, append ' plus more', and tap Send. The microphone dismissed the keyboard without starting transcription. Both commits submitted 'Draft plus more', matching the visible composer. This was an unsuccessful reproduction attempt.

It stays open for a person to finish or close.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugfix Fix PR opened by an issue lead; enters the PR loop pr:needs-verification Reviewed and green; needs a run on a desktop, Android or iOS device and captures posted size:M 30-99 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: iPad composer shows text added after transcription stops, but submission omits it

3 participants