From aa02e095547511ca937278bffb116da7035ca049 Mon Sep 17 00:00:00 2001 From: Derek Trimm <275381468+derektrimm@users.noreply.github.com> Date: Mon, 14 Sep 2026 09:16:47 -0500 Subject: [PATCH] fix(mobile): reopen the model picker after an iOS sheet swipe The model and effort sheet latched open after an iOS form-sheet swipe dismiss, because the presenting screen often never refocuses. Treat the navigation stack as the source of truth so a later tap can open the picker again. --- .../features/threads/NewTaskDraftScreen.tsx | 44 ++++++++++++++-- .../src/features/threads/ThreadComposer.tsx | 52 +++++++++++++++++-- ...-settings-sheet-presentation-state.test.ts | 27 ++++++++++ ...hread-settings-sheet-presentation-state.ts | 18 +++++++ 4 files changed, 134 insertions(+), 7 deletions(-) create mode 100644 apps/mobile/src/features/threads/thread-settings-sheet-presentation-state.test.ts create mode 100644 apps/mobile/src/features/threads/thread-settings-sheet-presentation-state.ts diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index fddd89d74ea7..ab1a9eca5228 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -75,6 +75,10 @@ import { } from "../voice-input/ComposerDictationControl"; import { useVoiceInputController } from "../voice-input/useVoiceInputController"; import { resolveVoiceComposerPresentation } from "../voice-input/voiceInputPresentation"; +import { + settingsSheetRouteDidDismiss, + stackContainsRouteName, +} from "./thread-settings-sheet-presentation-state"; import { useThreadSettingsSheetPresentation, type NavigationWithFinishTransitioning, @@ -270,6 +274,29 @@ export function NewTaskDraftScreen(props: { }; }, [navigation]); const settingsRoutePresentedRef = useRef(false); + const syncSettingsSheetRoute = useCallback(() => { + const sheetRouteVisible = stackContainsRouteName( + navigation.getState()?.routes, + "ThreadSettings", + ); + if ( + !settingsSheetRouteDidDismiss({ + presented: settingsRoutePresentedRef.current, + sheetRouteVisible, + }) + ) { + return; + } + settingsRoutePresentedRef.current = false; + settingsSheetPresentation.onDismissed(); + }, [navigation, settingsSheetPresentation.onDismissed]); + const openSettings = useCallback(() => { + // A swipe-dismissed iOS form sheet often never blurs this screen, so + // JS can still think the picker is open. Reconcile from the stack + // before the isActive latch can swallow this tap. + syncSettingsSheetRoute(); + settingsSheetPresentation.open(); + }, [settingsSheetPresentation.open, syncSettingsSheetRoute]); useEffect(() => { if (!settingsSheetPresentation.isVisible || settingsRoutePresentedRef.current) { return; @@ -288,15 +315,24 @@ export function NewTaskDraftScreen(props: { settingsSheetPresentation.onDismissed(); }, [settingsSheetPresentation.onDismissed]), ); + useEffect( + () => navigation.addListener("state", syncSettingsSheetRoute), + [navigation, syncSettingsSheetRoute], + ); useEffect( () => // UIKit's completion callback for the sheet dismissal, surfaced by the // native-stack patch. This is when the queued keyboard restore runs. + // Form-sheet swipe dismiss often never blurs this screen, so also + // reconcile from the stack here. (navigation as unknown as NavigationWithFinishTransitioning).addListener( "finishTransitioning", - settingsSheetPresentation.onStackTransitionsFinished, + () => { + settingsSheetPresentation.onStackTransitionsFinished(); + syncSettingsSheetRoute(); + }, ), - [navigation, settingsSheetPresentation.onStackTransitionsFinished], + [navigation, settingsSheetPresentation.onStackTransitionsFinished, syncSettingsSheetRoute], ); const [importingShareKey, setImportingShareKey] = useState(null); const [isCancellingShareImport, setIsCancellingShareImport] = useState(false); @@ -1484,7 +1520,7 @@ export function NewTaskDraftScreen(props: { accessibilityRole="button" className="px-3 py-2" disabled={isComposerInteractionLocked} - onPress={settingsSheetPresentation.open} + onPress={openSettings} > Model unavailable. Open model settings. @@ -1579,7 +1615,7 @@ export function NewTaskDraftScreen(props: { } label={flow.selectedModelOption?.label ?? "Choose model"} maxWidth="100%" - onPress={settingsSheetPresentation.open} + onPress={openSettings} /> {flow.planModeEnabled ? ( diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 680c9cf0babe..72a35aad1a17 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -104,6 +104,10 @@ import { type ExistingThreadSettingsRouteSession, useExistingThreadSettingsRoutePresentation, } from "./ThreadSettingsSheet"; +import { + settingsSheetRouteDidDismiss, + stackContainsRouteName, +} from "./thread-settings-sheet-presentation-state"; import { useThreadSettingsSheetPresentation, type NavigationWithFinishTransitioning, @@ -578,10 +582,42 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer threadProviderGroups, ], ); + const syncSettingsSheetRoute = useCallback(() => { + const sheetRouteVisible = stackContainsRouteName( + navigation.getState()?.routes, + "ThreadSettingsSheet", + ); + if ( + !settingsSheetRouteDidDismiss({ + presented: settingsRoutePresentedRef.current, + sheetRouteVisible, + }) + ) { + return; + } + settingsRoutePresentedRef.current = false; + settingsSheetPresentation.onDismissed(); + settingsRoutePresentation.clear(settingsOwnerId); + }, [ + navigation, + settingsOwnerId, + settingsRoutePresentation.clear, + settingsSheetPresentation.onDismissed, + ]); + const openSettings = useCallback(() => { + // A swipe-dismissed iOS form sheet often never blurs this screen, so + // JS can still think the picker is open. Reconcile from the stack + // before the isActive latch can swallow this tap. + syncSettingsSheetRoute(); settingsRoutePresentation.present(settingsRouteSession); settingsSheetPresentation.open(); - }, [settingsRoutePresentation.present, settingsRouteSession, settingsSheetPresentation.open]); + }, [ + settingsRoutePresentation.present, + settingsRouteSession, + settingsSheetPresentation.open, + syncSettingsSheetRoute, + ]); useEffect(() => { if (settingsSheetPresentation.isActive) { @@ -610,15 +646,25 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }, [settingsOwnerId, settingsRoutePresentation.clear, settingsSheetPresentation.onDismissed]), ); + useEffect( + () => navigation.addListener("state", syncSettingsSheetRoute), + [navigation, syncSettingsSheetRoute], + ); + useEffect( () => // UIKit's completion callback for the sheet dismissal, surfaced by the // native-stack patch. This is when the queued keyboard restore runs. + // Form-sheet swipe dismiss often never blurs this screen, so also + // reconcile from the stack here. (navigation as unknown as NavigationWithFinishTransitioning).addListener( "finishTransitioning", - settingsSheetPresentation.onStackTransitionsFinished, + () => { + settingsSheetPresentation.onStackTransitionsFinished(); + syncSettingsSheetRoute(); + }, ), - [navigation, settingsSheetPresentation.onStackTransitionsFinished], + [navigation, settingsSheetPresentation.onStackTransitionsFinished, syncSettingsSheetRoute], ); return ( diff --git a/apps/mobile/src/features/threads/thread-settings-sheet-presentation-state.test.ts b/apps/mobile/src/features/threads/thread-settings-sheet-presentation-state.test.ts new file mode 100644 index 000000000000..083aa6c24f2c --- /dev/null +++ b/apps/mobile/src/features/threads/thread-settings-sheet-presentation-state.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + settingsSheetRouteDidDismiss, + stackContainsRouteName, +} from "./thread-settings-sheet-presentation-state"; + +describe("settings sheet route presence", () => { + it("finds the picker route on the presenting stack", () => { + expect( + stackContainsRouteName( + [{ name: "Thread" }, { name: "ThreadSettingsSheet" }], + "ThreadSettingsSheet", + ), + ).toBe(true); + expect(stackContainsRouteName([{ name: "Thread" }], "ThreadSettingsSheet")).toBe(false); + expect(stackContainsRouteName(undefined, "ThreadSettingsSheet")).toBe(false); + }); + + it("treats a swipe-dismissed form sheet as closed once the route is gone", () => { + expect(settingsSheetRouteDidDismiss({ presented: true, sheetRouteVisible: false })).toBe(true); + expect(settingsSheetRouteDidDismiss({ presented: true, sheetRouteVisible: true })).toBe(false); + expect(settingsSheetRouteDidDismiss({ presented: false, sheetRouteVisible: false })).toBe( + false, + ); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-settings-sheet-presentation-state.ts b/apps/mobile/src/features/threads/thread-settings-sheet-presentation-state.ts new file mode 100644 index 000000000000..7b9fee339ca7 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-settings-sheet-presentation-state.ts @@ -0,0 +1,18 @@ +/** + * iOS form sheets often keep the presenting screen focused, so a swipe + * dismiss never re-runs useFocusEffect. JS then thinks the picker is still + * open and later model/effort taps no-op. The stack is the source of truth. + */ +export function stackContainsRouteName( + routes: ReadonlyArray<{ readonly name: string }> | undefined, + name: string, +): boolean { + return routes?.some((route) => route.name === name) === true; +} + +export function settingsSheetRouteDidDismiss(input: { + readonly presented: boolean; + readonly sheetRouteVisible: boolean; +}): boolean { + return input.presented && !input.sheetRouteVisible; +}