You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[Bug]: iPad: composer shows empty after answering a question, draft only reappears after switching threads #14053
I searched existing issues and did not find a duplicate.
I included enough detail to reproduce or investigate the problem.
Area
apps/mobile
Steps to reproduce
iPad app 1.3.0, open a thread with a running agent.
Start typing a long follow-up message in the composer.
While typing, the agent calls the ask-question tool. The question card replaces the composer.
Answer the question and submit.
Expected behavior
The composer comes back showing the partially typed message.
Actual behavior
The composer comes back empty. The text is not lost: switch to another thread and back, and the message is shown again. So the draft is still stored, but the composer on the current thread does not re-render it after the question card closes.
Impact
Major degradation or frequent failure
Version or commit
iPad app 1.3.0
Environment
iPadOS, T3 Code mobile 1.3.0, provider Claude Code
Workaround
Switch to another thread and back. The draft reappears.
Confirmed from current main (d15210cd). Still present in the 1.3.1 mobile app; the 1.3.1 bump does not touch this path, so updating from 1.3.0 will not fix it. Not a duplicate of #12569 (web: clicking an option deletes text that was bound as the question answer) or #13856 (web: a typed question answer is dropped on New thread). Here the thread draft stays stored and only the visible composer is empty. Checked from source only; not reproduced on a device.
What is wrong
After the question card closes, the composer for this thread paints empty even though the draft is still in the composer draft store. Switching threads remounts the editor, and the same draft shows up again.
Cause
While a question is pending, the composer stays mounted and is hidden with display: "none" so the draft is not thrown away:
{/* Hidden (not unmounted) while a user-input request owns the composer slot, so composer drafts and editor state survive. A rejected creation has no thread to send to; the failure card owns the slot instead. */}<Viewstyle={activeUserInputRequestId!==null||props.creationState?.kind==="failed"
? {display: "none"}
: undefined}
That hide is what makes the draft survive. It also leaves the native editor's revision history in place. The stored draft is still classified as a native echo of the last keystroke (T3ComposerEditor.ios.tsx, the isNativeEcho payload). The iOS editor then refuses to write that echo back if the text view no longer matches:
Closing the question re-renders the same draft. That render is an echo, so the guard returns and the empty text view stays empty. A thread switch mounts a new editor. Snapshot history starts empty, so the first payload is not an echo and the stored draft is applied. That matches "the text is not lost; it reappears after switching threads."
The same hide block is in the 1.3.0 commit (76cc9b08f1); the Swift guard is unchanged since then. The hide is shared with iPhone. The echo drop is iOS. Android has the same drop (T3ComposerEditorView.kt around line 200), but this report is iPad only.
The echo guard should stay for live typing. The hole is the hidden composer: when it is shown again, a draft that no longer matches the text view has to be applied instead of dropped as an echo.
Also on iPhone (Opus 5.5, Claude Code). The empty composer is the visible half. The worse half: anything you type after answering is never stored, so the button stays on Stop, and the next question wipes the text.
Repro (iPhone)
In a running thread where you have already typed or sent something, wait for a question card.
Answer it and tap Submit answers. The turn keeps running.
Type a follow-up in the composer.
Actual: the button stays Stop and never turns into Queue / Steer.
If the agent asks another question, the composer comes back with none of that text.
Workaround: copy the text, go back to the thread list, reopen the thread, paste. The button switches to Queue as usual.
Expected: after answering, typing switches the button to Queue / Steer like any other mid-turn follow-up, and the text survives the next question.
Cause (main 04ad17425a)
The triage above is right that the composer is hidden instead of unmounted. What it misses is that on iOS, Fabric does not keep display: none views mounted. The native editor is destroyed when the question appears and a new one is created when the question closes. The JS component above it stays mounted.
ThreadDetailScreen.tsx:1336 hides the composer slot with display: "none".
RN 0.88 Fabric marks display: none nodes Trait::Hidden and leaves them out of the mount tree (ReactCommon/react/renderer/mounting/internal/sliceChildShadowNodeViewPairs.cpp:65; on Android only when useTraitHiddenOnAndroid is on, which defaults to false). Expo views opt out of recycling (ExpoFabricView.shouldBeRecycled() -> false), so the composer comes back as a brand-new T3ComposerEditorView.
The new native view starts from scratch, but JS keeps the old view's state:
Empty composer (this issue). JS's snapshot history still says the draft is an echo, and the new view's text view is empty, so the view ignores the draft (T3ComposerEditorView.swift:647).
Keystrokes dropped (stuck Stop). The new view numbers its events from 0 (T3ComposerEditorView.swift:466). JS still holds the old view's count in mostRecentEventCountRef (T3ComposerEditor.ios.tsx:138). acknowledgeComposerNativeEvent rejects any incomingEventCount < mostRecentEventCount (composerEditorRevision.ts:16), so every change and selection event returns early at T3ComposerEditor.ios.tsx:306 and never reaches onChangeText. draftMessage never updates, hasContent stays false, and showStopAction stays true (ThreadComposer.tsx:402-412). It only recovers after you have produced more native events than the old view did, and every keystroke fires both a change and a selection event.
Text lost. Text you typed was never written to the draft store. The next question destroys the native view again, so that text is gone.
Reopening the thread remounts ThreadComposer, which resets the JS revision state. That is why the workaround works.
Suggested fix
Reset the JS revision state whenever the native editor is recreated, not only when the thread changes. Either of these would do it:
JS: key the ComposerEditor on whether the slot is hidden (or on a counter that increments when the slot is shown again). The refs in T3ComposerEditor.ios.tsx then reset alongside the native view. The draft lives in the store, so nothing is lost. Closed fix(mobile): show the composer draft after answering a question #14058 took roughly this approach.
Native: have the first setControlledDocumentJson a fresh view receives set nativeEventCount = max(nativeEventCount, document.mostRecentEventCount) and apply the document even when isNativeEcho is set. The native view then continues numbering from where JS expects and paints the stored draft.
The fix should target the Fabric unmount, not only the echo guard. Otherwise the composer would show the draft again, but new typing would still be dropped.
Activity
juliusmarminge commentedon Sep 28, 2026
Triage
Confirmed from current
main(d15210cd). Still present in the 1.3.1 mobile app; the 1.3.1 bump does not touch this path, so updating from 1.3.0 will not fix it. Not a duplicate of #12569 (web: clicking an option deletes text that was bound as the question answer) or #13856 (web: a typed question answer is dropped on New thread). Here the thread draft stays stored and only the visible composer is empty. Checked from source only; not reproduced on a device.What is wrong
After the question card closes, the composer for this thread paints empty even though the draft is still in the composer draft store. Switching threads remounts the editor, and the same draft shows up again.
Cause
While a question is pending, the composer stays mounted and is hidden with
display: "none"so the draft is not thrown away:That hide is what makes the draft survive. It also leaves the native editor's revision history in place. The stored draft is still classified as a native echo of the last keystroke (
T3ComposerEditor.ios.tsx, theisNativeEchopayload). The iOS editor then refuses to write that echo back if the text view no longer matches:Closing the question re-renders the same draft. That render is an echo, so the guard returns and the empty text view stays empty. A thread switch mounts a new editor. Snapshot history starts empty, so the first payload is not an echo and the stored draft is applied. That matches "the text is not lost; it reappears after switching threads."
The same hide block is in the 1.3.0 commit (
76cc9b08f1); the Swift guard is unchanged since then. The hide is shared with iPhone. The echo drop is iOS. Android has the same drop (T3ComposerEditorView.ktaround line 200), but this report is iPad only.The echo guard should stay for live typing. The hole is the hidden composer: when it is shown again, a draft that no longer matches the text view has to be applied instead of dropped as an echo.
jlipworth commentedon Oct 8, 2026
Also on iPhone (Opus 5.5, Claude Code). The empty composer is the visible half. The worse half: anything you type after answering is never stored, so the button stays on Stop, and the next question wipes the text.
Repro (iPhone)
Workaround: copy the text, go back to the thread list, reopen the thread, paste. The button switches to Queue as usual.
Expected: after answering, typing switches the button to Queue / Steer like any other mid-turn follow-up, and the text survives the next question.
Cause (main
04ad17425a)The triage above is right that the composer is hidden instead of unmounted. What it misses is that on iOS, Fabric does not keep
display: noneviews mounted. The native editor is destroyed when the question appears and a new one is created when the question closes. The JS component above it stays mounted.ThreadDetailScreen.tsx:1336hides the composer slot withdisplay: "none".display: nonenodesTrait::Hiddenand leaves them out of the mount tree (ReactCommon/react/renderer/mounting/internal/sliceChildShadowNodeViewPairs.cpp:65; on Android only whenuseTraitHiddenOnAndroidis on, which defaults to false). Expo views opt out of recycling (ExpoFabricView.shouldBeRecycled() -> false), so the composer comes back as a brand-newT3ComposerEditorView.The new native view starts from scratch, but JS keeps the old view's state:
T3ComposerEditorView.swift:647).T3ComposerEditorView.swift:466). JS still holds the old view's count inmostRecentEventCountRef(T3ComposerEditor.ios.tsx:138).acknowledgeComposerNativeEventrejects anyincomingEventCount < mostRecentEventCount(composerEditorRevision.ts:16), so every change and selection event returns early atT3ComposerEditor.ios.tsx:306and never reachesonChangeText.draftMessagenever updates,hasContentstays false, andshowStopActionstays true (ThreadComposer.tsx:402-412). It only recovers after you have produced more native events than the old view did, and every keystroke fires both a change and a selection event.Reopening the thread remounts
ThreadComposer, which resets the JS revision state. That is why the workaround works.Suggested fix
Reset the JS revision state whenever the native editor is recreated, not only when the thread changes. Either of these would do it:
ComposerEditoron whether the slot is hidden (or on a counter that increments when the slot is shown again). The refs inT3ComposerEditor.ios.tsxthen reset alongside the native view. The draft lives in the store, so nothing is lost. Closed fix(mobile): show the composer draft after answering a question #14058 took roughly this approach.setControlledDocumentJsona fresh view receives setnativeEventCount = max(nativeEventCount, document.mostRecentEventCount)and apply the document even whenisNativeEchois set. The native view then continues numbering from where JS expects and paints the stored draft.The fix should target the Fabric unmount, not only the echo guard. Otherwise the composer would show the draft again, but new typing would still be dropped.