From 859304b7808ab9a4be87b1bddcd07c6485bf9c4f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 10 Sep 2026 14:03:00 -0700 Subject: [PATCH] fix(mobile): keep composer transitions aligned (#11127) --- .../layout/AdaptiveWorkspaceLayout.tsx | 19 ++++++++-- .../layout/workspace-content-width.ts | 10 ++++++ .../layout/workspace-inspector-pane.tsx | 4 ++- .../src/features/threads/ThreadComposer.tsx | 36 +++++++++++++++---- .../features/threads/ThreadDetailScreen.tsx | 10 +++++- .../use-thread-settings-sheet-presentation.ts | 27 ++++++++------ 6 files changed, 84 insertions(+), 22 deletions(-) create mode 100644 apps/mobile/src/features/layout/workspace-content-width.ts diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 4d9968cca097c1ee2f050afa49b663a8a764c7fd..c030e49c2b771f5c979dd07cdc466241550c056b 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -22,7 +22,12 @@ import { type ReactNode, } from "react"; import { useWindowDimensions, View } from "react-native"; -import Animated, { useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated"; +import Animated, { + useAnimatedStyle, + useDerivedValue, + useSharedValue, + withTiming, +} from "react-native-reanimated"; import { AsyncResult } from "effect/unstable/reactivity"; import { @@ -51,6 +56,7 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; +import { WorkspaceContentWidthContext } from "./workspace-content-width"; interface AdaptiveWorkspaceContextValue { readonly layout: Layout; @@ -505,6 +511,10 @@ function AdaptiveWorkspaceLayoutContent( const contentSettledWidth = layout.usesSplitView ? Math.max(0, panes.contentPaneWidth - inspectorColumnTargetWidth) : null; + const renderedInspectorWidth = useSharedValue(inspectorColumnTargetWidth); + const renderedContentWidth = useDerivedValue(() => + Math.max(0, width - renderedSidebarWidth.value - renderedInspectorWidth.value), + ); const handleSelectThread = useCallback( (thread: EnvironmentThreadShell) => { @@ -583,10 +593,15 @@ function AdaptiveWorkspaceLayoutContent( contentSettledWidth !== null ? { flex: 1, width: contentSettledWidth } : { flex: 1 } } > - {props.children} + + {props.children} + | null>(null); + +export function useWorkspaceContentWidth() { + return use(WorkspaceContentWidthContext); +} diff --git a/apps/mobile/src/features/layout/workspace-inspector-pane.tsx b/apps/mobile/src/features/layout/workspace-inspector-pane.tsx index 9825ed7fdf5b7682fe2f471febf0e7909f458146..7c6574e22e6f226067b7d8e42a73efd9ec54104a 100644 --- a/apps/mobile/src/features/layout/workspace-inspector-pane.tsx +++ b/apps/mobile/src/features/layout/workspace-inspector-pane.tsx @@ -4,6 +4,7 @@ import Animated, { useAnimatedStyle, useSharedValue, withTiming, + type SharedValue, } from "react-native-reanimated"; import { constrainAuxiliaryPaneWidth, type WorkspacePaneLayout } from "../../lib/layout"; @@ -22,6 +23,7 @@ import { WorkspacePaneDivider } from "./workspace-pane-divider"; * module stays import-cycle-free with AdaptiveWorkspaceLayout. */ export function WorkspaceInspectorPane(props: { + readonly renderedInspectorWidth: SharedValue; /** * When false the pane animates closed but keeps its content mounted for the * exit transition (a route that lost focus). `onClosed` fires once the @@ -45,7 +47,7 @@ export function WorkspaceInspectorPane(props: { // inspector at its final position so route replacement never replays an // entering transition. Only visibility and explicit resizing change it. const inspectorProgress = useSharedValue(inspectorVisible ? 1 : 0); - const renderedInspectorWidth = useSharedValue(inspectorVisible ? (inspectorWidth ?? 0) : 0); + const { renderedInspectorWidth } = props; // The content keeps its own width so the reveal (outer width) clips a // fully-laid-out pane instead of reflowing text every frame. When the OPEN // pane's target width changes (e.g. the sidebar toggles and reserves diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 7e5de3f3b4e56d6fc05bd10d4e91dbcdfc960a4f..7ea20e49ce523344d85d0952511096e1107d17b8 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -37,7 +37,7 @@ import { import Animated, { FadeIn, FadeOut, - LinearTransition, + type LayoutAnimationFunction, ReduceMotion, useAnimatedStyle, useSharedValue, @@ -153,10 +153,32 @@ export interface ThreadComposerProps { // running alongside that translate reads as jitter. Snapping the layout and // letting the keyboard-synced slide be the only motion looks native there. export const COMPOSER_TRANSITION_DURATION_MS = 220; +// Side panes already animate the dock's width. Nested horizontal layout +// transitions would leave the surface trailing its toolbar's new position. +// Keep the vertical pill/card morph while horizontal layout follows the dock. +const composerHeightTransition: LayoutAnimationFunction = (values) => { + "worklet"; + const timing = { + duration: COMPOSER_TRANSITION_DURATION_MS, + reduceMotion: ReduceMotion.System, + }; + return { + initialValues: { + originX: values.targetOriginX, + originY: values.currentOriginY, + width: values.targetWidth, + height: values.currentHeight, + }, + animations: { + originX: values.targetOriginX, + originY: withTiming(values.targetOriginY, timing), + width: values.targetWidth, + height: withTiming(values.targetHeight, timing), + }, + }; +}; export const COMPOSER_LAYOUT_TRANSITION = - Platform.OS === "android" - ? undefined - : LinearTransition.duration(COMPOSER_TRANSITION_DURATION_MS).reduceMotion(ReduceMotion.System); + Platform.OS === "android" ? undefined : composerHeightTransition; const COMPOSER_ATTACHMENT_ENTERING = Platform.OS === "android" @@ -351,7 +373,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ); const isVoiceInputPresented = voicePresentation.statusLabel !== null; // An open draft stays visible; only a collapsed composer becomes a voice strip. - const isExpanded = isFocused || settingsSheetPresentation.isActive; + const isExpanded = isFocused || settingsSheetPresentation.keepsComposerExpanded; const showsCompactDictation = isVoiceInputPresented && !isExpanded; const isToolbarVisible = isExpanded || isVoiceInputPresented; const attachmentBlockReason = composerAttachmentUploadBlockReason({ @@ -407,11 +429,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const handleBlur = useCallback(() => { setIsFocused(false); - if (!settingsSheetPresentation.isActive) { + if (!settingsSheetPresentation.keepsComposerExpanded) { onExpandedChange?.(false); } onEditorFocusChange?.(false); - }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.isActive]); + }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.keepsComposerExpanded]); const handleSend = useCallback(async () => { // Typed out in full rather than picked from the menu. Attachments mean the // user is sending a prompt, so those go through as usual. diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 42bb4df1495a733855935e9d3a09c76d29a9115c..55cd9e9a6c83cc4aad237c0d888f7db9dee064cd 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -58,10 +58,12 @@ import Animated, { FadeOut, ReduceMotion, useAnimatedReaction, + useAnimatedStyle, useSharedValue, withTiming, } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useWorkspaceContentWidth } from "../layout/workspace-content-width"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits"; @@ -647,6 +649,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const layoutVariant = props.layoutVariant ?? "compact"; const isSplitLayout = layoutVariant === "split"; const contentMaxWidth = isSplitLayout ? CHAT_CONTENT_MAX_WIDTH : undefined; + const workspaceContentWidth = useWorkspaceContentWidth(); + const composerWidthStyle = useAnimatedStyle(() => + isSplitLayout && workspaceContentWidth !== null + ? { width: workspaceContentWidth.value, right: undefined } + : { width: undefined, right: 0 }, + ); const selectedInstanceId = props.selectedThread.modelSelection.instanceId; useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); const selectedProviderSkills = useMemo(() => { @@ -915,7 +923,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread {/* No paddingTop here: the overlay's measured height becomes the list's bottom inset, so any padding above the pill/composer diff --git a/apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts b/apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts index b5b4914ad11ecf8c108f2765cedf0ac9d0ea7045..805b9acf95dc96b28866c1b876dd51b2c1e4fb6e 100644 --- a/apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts +++ b/apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts @@ -3,7 +3,7 @@ import { KeyboardController } from "react-native-keyboard-controller"; import type { ComposerEditorHandle } from "../../components/ComposerEditor"; -type PresentationPhase = "closed" | "opening" | "visible"; +type PresentationPhase = "closed" | "opening" | "visible" | "restoring"; /** * The navigator-level UIKit completion event added by the repo's @@ -91,7 +91,8 @@ export function useThreadSettingsSheetPresentation(input: { focusRestoreIdRef.current += 1; clearDismissRestoreTimer(); restorePendingRef.current = false; - restoreFocusAfterDismissRef.current = input.isEditorFocused || KeyboardController.isVisible(); + restoreFocusAfterDismissRef.current = + phase === "restoring" || input.isEditorFocused || KeyboardController.isVisible(); setPhase("opening"); const openingId = openingIdRef.current + 1; @@ -109,7 +110,7 @@ export function useThreadSettingsSheetPresentation(input: { } setPhase("visible"); }); - }, [clearDismissRestoreTimer, input.editorRef, input.isEditorFocused]); + }, [clearDismissRestoreTimer, input.editorRef, input.isEditorFocused, phase]); const restoreEditorFocus = useCallback(() => { const focusRestoreId = focusRestoreIdRef.current + 1; @@ -120,12 +121,11 @@ export function useThreadSettingsSheetPresentation(input: { // normally succeeds; the retries are insurance against UIKit briefly // refusing first-responder status right at the transition boundary. const restoreFocus = () => { - if ( - !isMountedRef.current || - focusRestoreIdRef.current !== focusRestoreId || - isEditorFocusedRef.current || - attemptsRemaining <= 0 - ) { + if (!isMountedRef.current || focusRestoreIdRef.current !== focusRestoreId) { + return; + } + if (isEditorFocusedRef.current || attemptsRemaining <= 0) { + setPhase("closed"); return; } @@ -157,11 +157,15 @@ export function useThreadSettingsSheetPresentation(input: { */ const onDismissed = useCallback(() => { isActiveRef.current = false; - setPhase("closed"); if (!restoreFocusAfterDismissRef.current) { + setPhase("closed"); return; } + // Keep the card expanded across the handoff back to its editor. With a + // hardware keyboard there is no software-keyboard travel to hide a collapse + // while the sheet dismissal and focus restoration finish. + setPhase("restoring"); restoreFocusAfterDismissRef.current = false; restorePendingRef.current = true; clearDismissRestoreTimer(); @@ -185,7 +189,8 @@ export function useThreadSettingsSheetPresentation(input: { }, [runPendingDismissalRestore]); return { - isActive: phase !== "closed", + isActive: phase === "opening" || phase === "visible", + keepsComposerExpanded: phase !== "closed", isVisible: phase === "visible", open, onDismissed, -- 2.54.0