From 52e01cf6f9a5bf1a3b12bdf055fde714407c7c5c Mon Sep 17 00:00:00 2001 From: macodev00 <273427913+macodev00@users.noreply.github.com> Date: Mon, 28 Sep 2026 06:47:32 +0000 Subject: [PATCH] fix(mobile): show the composer draft after answering a question An ask-question card hides the composer without unmounting it, so the stored draft comes back as a native echo and the text view stays empty. Rebind the editor when the card closes so that draft is painted again. Fixes #14053 --- .../src/features/threads/ThreadComposer.tsx | 16 +++++++++ .../features/threads/ThreadDetailScreen.tsx | 25 ++++++++------ .../src/native/composerEditorRevision.test.ts | 33 +++++++++++++++++++ .../src/native/composerEditorRevision.ts | 33 +++++++++++++++++++ .../src/native/useComposerDraftRebind.ts | 25 ++++++++++++++ 5 files changed, 122 insertions(+), 10 deletions(-) create mode 100644 apps/mobile/src/native/useComposerDraftRebind.ts diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index d131dfaa51f1..24b4c64e7fa3 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -69,6 +69,7 @@ import { import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; import { GlassSurface } from "../../components/GlassSurface"; import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; +import { useComposerDraftRebind } from "../../native/useComposerDraftRebind"; import { fileRoutePathSegments } from "../files/filePath"; import { ComposerActionButton, @@ -155,6 +156,12 @@ export interface ThreadComposerProps { readonly onExpandedChange?: (expanded: boolean) => void; /** Fires on editor focus/blur; hosts use it to vet stale keyboard state. */ readonly onEditorFocusChange?: (focused: boolean) => void; + /** + * True while an ask-question card (or a failed-creation card) owns the + * composer slot. Flipping this back to false remounts the editor so the + * stored draft is shown again. + */ + readonly composerConcealed: boolean; } /** @@ -267,7 +274,12 @@ export function ComposerSurface(props: { ); } +/** + * Composer for an existing thread. When `composerConcealed` flips from true + * back to false, the editor remounts and paints the stored draft. + */ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { + const draftRebindGeneration = useComposerDraftRebind(props.composerConcealed); const project = useProject(scopeProjectRef(props.environmentId, props.selectedThread.projectId)); const { themeVariables: materialTheme } = useAppearancePreferences(); const composerPanel = materialTheme["--color-composer-panel"]; @@ -737,7 +749,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer className={isExpanded ? "px-[14px]" : "min-w-0 flex-1 px-[4px]"} layout={COMPOSER_LAYOUT_TRANSITION} > + {/* Remount after the ask-question card closes. The hidden editor's + revision history would treat the stored draft as a native echo + and leave the text view empty. */} { diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 14beefd6d178..b1d2a022e751 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -264,6 +264,11 @@ const USER_INPUT_TOGGLE_TIMING = { easing: Easing.out(Easing.cubic), }; +/** + * Thread transcript and composer. An ask-question card hides the composer + * without clearing its draft, and the composer rebinds that draft when the + * card closes. + */ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) { const navigation = useNavigation(); const deviceState = useEnvironmentQuery( @@ -441,6 +446,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const [collapsedUserInputRequestId, setCollapsedUserInputRequestId] = useState(null); const activeUserInputRequestId = props.activePendingUserInput?.requestId ?? null; + // Same condition that hides the composer. Closing the card (or the failure + // card) flips this off so the editor rebinds the stored draft in place. + const composerConcealed = + activeUserInputRequestId !== null || props.creationState?.kind === "failed"; // The open /usage-limits panel for this thread, model and turn. Only the open // moment is stored: the rows read live provider data, so a redeemed reset // credit or refreshed probe shows through. Anything that spends quota closes @@ -1061,17 +1070,13 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread {/* 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. */} - + composer slot, so the stored draft survives. Closing the card + rebinds the editor so that draft is shown again. A rejected + creation has no thread to send to; the failure card owns the + slot instead. */} + { }); }); +describe("nextComposerDraftRebind", () => { + const draft = "long follow-up typed before the question"; + const selection = { start: draft.length, end: draft.length }; + const typedSnapshots = [{ eventCount: 6, value: draft, selection }]; + + it("keeps the editor mounted while the question card opens and stays open", () => { + const opened = nextComposerDraftRebind({ generation: 2, concealed: false }, true); + expect(opened).toEqual({ generation: 2, concealed: true }); + const stillOpen = nextComposerDraftRebind(opened, true); + expect(stillOpen).toBe(opened); + // The stored draft is still an echo of the hidden editor, and this did not clear it. + expect(isComposerNativeEcho(draft, selection, 6, typedSnapshots)).toBe(true); + }); + + it("rebinds when the question card closes so a fresh editor paints the stored draft", () => { + const closed = nextComposerDraftRebind({ generation: 2, concealed: true }, false); + expect(closed).toEqual({ generation: 3, concealed: false }); + + // The bumped generation remounts the editor. A new mount has no snapshot + // history, so the stored draft is a controlled document rather than an echo + // the text view would skip. + const freshSnapshots: typeof typedSnapshots = []; + expect(isComposerNativeEcho(draft, selection, 0, freshSnapshots)).toBe(false); + expect(resolveComposerControlledEventCount(draft, selection, 0, freshSnapshots)).toBe(0); + }); + + it("does not rebind when the composer was already visible", () => { + const visible = { generation: 4, concealed: false }; + expect(nextComposerDraftRebind(visible, false)).toBe(visible); + }); +}); + describe("typing immediately before an intercepted paste", () => { it("keeps the pre-paste React value behind the native paste revision", () => { const snapshots = [ diff --git a/apps/mobile/src/native/composerEditorRevision.ts b/apps/mobile/src/native/composerEditorRevision.ts index 45d68ac1b652..8f91067a6835 100644 --- a/apps/mobile/src/native/composerEditorRevision.ts +++ b/apps/mobile/src/native/composerEditorRevision.ts @@ -103,6 +103,39 @@ export function assumeComposerControlledState( ]; } +export interface ComposerDraftRebind { + /** Increments when a concealed composer is shown again. */ + readonly generation: number; + /** True while an ask-question card owns the composer slot. */ + readonly concealed: boolean; +} + +/** + * Decide whether the composer editor should rebind its stored draft. + * + * An ask-question card hides the composer without unmounting it, so the draft + * stays stored and the last keystroke stays classified as a native echo. On + * iOS that echo is dropped when the text view no longer matches, and the + * composer comes back empty. A thread switch mounts a fresh editor, which + * paints the same draft. `generation` advances only when the card closes so + * the editor can do that fresh mount in place. + * + * Opening the card, or leaving it open, keeps the current generation. The + * draft text is not an input, so this cannot clear it. + */ +export function nextComposerDraftRebind( + rebind: ComposerDraftRebind, + concealed: boolean, +): ComposerDraftRebind { + if (rebind.concealed === concealed) { + return rebind; + } + if (rebind.concealed && !concealed) { + return { generation: rebind.generation + 1, concealed }; + } + return { generation: rebind.generation, concealed }; +} + export function pruneAcknowledgedComposerNativeEvents( snapshots: ReadonlyArray, acknowledgedEventCount: number, diff --git a/apps/mobile/src/native/useComposerDraftRebind.ts b/apps/mobile/src/native/useComposerDraftRebind.ts new file mode 100644 index 000000000000..7d5826ed1fd8 --- /dev/null +++ b/apps/mobile/src/native/useComposerDraftRebind.ts @@ -0,0 +1,25 @@ +import { useState } from "react"; + +import { nextComposerDraftRebind, type ComposerDraftRebind } from "./composerEditorRevision"; + +/** + * Bind generation for a composer hidden by an ask-question card. + * + * The generation stays put while the card opens and while it is open, so the + * mounted editor and its stored draft are left alone. It advances when the + * card closes. The composer uses that value as the editor key, which mounts a + * fresh text view that paints the stored draft instead of skipping it as a + * native echo. + */ +export function useComposerDraftRebind(concealed: boolean): number { + const [rebind, setRebind] = useState(() => ({ + generation: 0, + concealed, + })); + if (rebind.concealed === concealed) { + return rebind.generation; + } + const next = nextComposerDraftRebind(rebind, concealed); + setRebind(next); + return next.generation; +}