Skip to content

[Bug]: iPad: composer shows empty after answering a question, draft only reappears after switching threads #14053

Closed

Description

@scriptease

Before submitting

  • 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

  1. iPad app 1.3.0, open a thread with a running agent.
  2. Start typing a long follow-up message in the composer.
  3. While typing, the agent calls the ask-question tool. The question card replaces the composer.
  4. 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.

Relation to existing issues

composed with Claude Opus 5.5 in Claude Code

Activity

  1. juliusmarminge commented on Sep 28, 2026

    @juliusmarminge
    Member

    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:

                  {/* 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. */}
                  <View
                    style={
                      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:

      func setControlledDocumentJson(_ documentJson: String) {
        guard let document = decode(ComposerControlledDocumentPayload.self, from: documentJson),
              document.mostRecentEventCount >= nativeEventCount else {
          return
        }
        if document.isNativeEcho && textView.serializedText() != document.value {
          return
        }

    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.

  2. added
    bugSomething is broken or behaving incorrectly.
    acceptedfeature request accepted
    via-triageFiled through npx t3 triage
    on Sep 28, 2026
  3. jlipworth commented on Oct 8, 2026

    @jlipworth

    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)

    1. In a running thread where you have already typed or sent something, wait for a question card.
    2. Answer it and tap Submit answers. The turn keeps running.
    3. Type a follow-up in the composer.
    4. Actual: the button stays Stop and never turns into Queue / Steer.
    5. 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:

    1. 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).
    2. 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.
    3. 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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    acceptedfeature request acceptedbugSomething is broken or behaving incorrectly.via-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions