authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-10 14:03:00-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-10 14:03:00-07:00
log859304b7808ab9a4be87b1bddcd07c6485bf9c4f
tree784652183f5d4f840a653bfe2b2d5887d3be9193
parent96f43708a0ec97e9da89ab1e165039f50f09e037
signature Signed by PGP key B5690EEEBB952194

fix(mobile): keep composer transitions aligned (#11127)


6 files changed, 84 insertions(+), 22 deletions(-)

apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx+17-2
......@@ -22,7 +22,12 @@ import {
2222 type ReactNode,
2323} from "react";
2424import { useWindowDimensions, View } from "react-native";
25import Animated, { useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated";
25import Animated, {
26 useAnimatedStyle,
27 useDerivedValue,
28 useSharedValue,
29 withTiming,
30} from "react-native-reanimated";
2631import { AsyncResult } from "effect/unstable/reactivity";
2732
2833import {
......@@ -51,6 +56,7 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe
5156import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar";
5257import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation";
5358import { WorkspaceInspectorPane } from "./workspace-inspector-pane";
59import { WorkspaceContentWidthContext } from "./workspace-content-width";
5460
5561interface AdaptiveWorkspaceContextValue {
5662 readonly layout: Layout;
......@@ -505,6 +511,10 @@ function AdaptiveWorkspaceLayoutContent(
505511 const contentSettledWidth = layout.usesSplitView
506512 ? Math.max(0, panes.contentPaneWidth - inspectorColumnTargetWidth)
507513 : null;
514 const renderedInspectorWidth = useSharedValue(inspectorColumnTargetWidth);
515 const renderedContentWidth = useDerivedValue(() =>
516 Math.max(0, width - renderedSidebarWidth.value - renderedInspectorWidth.value),
517 );
508518
509519 const handleSelectThread = useCallback(
510520 (thread: EnvironmentThreadShell) => {
......@@ -583,10 +593,15 @@ function AdaptiveWorkspaceLayoutContent(
583593 contentSettledWidth !== null ? { flex: 1, width: contentSettledWidth } : { flex: 1 }
584594 }
585595 >
586 {props.children}
596 <WorkspaceContentWidthContext
597 value={layout.usesSplitView ? renderedContentWidth : null}
598 >
599 {props.children}
600 </WorkspaceContentWidthContext>
587601 </View>
588602 </View>
589603 <WorkspaceInspectorPane
604 renderedInspectorWidth={renderedInspectorWidth}
590605 active={workspaceInspector?.active ?? false}
591606 panes={panes}
592607 renderInspector={workspaceInspector?.render}
apps/mobile/src/features/layout/workspace-content-width.ts created+10
......@@ -0,0 +1,10 @@
1import { createContext, use } from "react";
2import type { SharedValue } from "react-native-reanimated";
3
4// Floating controls follow the visible pane while the expensive navigator/feed
5// stays laid out at its settled width during sidebar and inspector transitions.
6export const WorkspaceContentWidthContext = createContext<SharedValue<number> | null>(null);
7
8export function useWorkspaceContentWidth() {
9 return use(WorkspaceContentWidthContext);
10}
apps/mobile/src/features/layout/workspace-inspector-pane.tsx+3-1
......@@ -4,6 +4,7 @@ import Animated, {
44 useAnimatedStyle,
55 useSharedValue,
66 withTiming,
7 type SharedValue,
78} from "react-native-reanimated";
89
910import { constrainAuxiliaryPaneWidth, type WorkspacePaneLayout } from "../../lib/layout";
......@@ -22,6 +23,7 @@ import { WorkspacePaneDivider } from "./workspace-pane-divider";
2223 * module stays import-cycle-free with AdaptiveWorkspaceLayout.
2324 */
2425export function WorkspaceInspectorPane(props: {
26 readonly renderedInspectorWidth: SharedValue<number>;
2527 /**
2628 * When false the pane animates closed but keeps its content mounted for the
2729 * exit transition (a route that lost focus). `onClosed` fires once the
......@@ -45,7 +47,7 @@ export function WorkspaceInspectorPane(props: {
4547 // inspector at its final position so route replacement never replays an
4648 // entering transition. Only visibility and explicit resizing change it.
4749 const inspectorProgress = useSharedValue(inspectorVisible ? 1 : 0);
48 const renderedInspectorWidth = useSharedValue(inspectorVisible ? (inspectorWidth ?? 0) : 0);
50 const { renderedInspectorWidth } = props;
4951 // The content keeps its own width so the reveal (outer width) clips a
5052 // fully-laid-out pane instead of reflowing text every frame. When the OPEN
5153 // pane's target width changes (e.g. the sidebar toggles and reserves
apps/mobile/src/features/threads/ThreadComposer.tsx+29-7
......@@ -37,7 +37,7 @@ import {
3737import Animated, {
3838 FadeIn,
3939 FadeOut,
40 LinearTransition,
40 type LayoutAnimationFunction,
4141 ReduceMotion,
4242 useAnimatedStyle,
4343 useSharedValue,
......@@ -153,10 +153,32 @@ export interface ThreadComposerProps {
153153// running alongside that translate reads as jitter. Snapping the layout and
154154// letting the keyboard-synced slide be the only motion looks native there.
155155export const COMPOSER_TRANSITION_DURATION_MS = 220;
156// Side panes already animate the dock's width. Nested horizontal layout
157// transitions would leave the surface trailing its toolbar's new position.
158// Keep the vertical pill/card morph while horizontal layout follows the dock.
159const composerHeightTransition: LayoutAnimationFunction = (values) => {
160 "worklet";
161 const timing = {
162 duration: COMPOSER_TRANSITION_DURATION_MS,
163 reduceMotion: ReduceMotion.System,
164 };
165 return {
166 initialValues: {
167 originX: values.targetOriginX,
168 originY: values.currentOriginY,
169 width: values.targetWidth,
170 height: values.currentHeight,
171 },
172 animations: {
173 originX: values.targetOriginX,
174 originY: withTiming(values.targetOriginY, timing),
175 width: values.targetWidth,
176 height: withTiming(values.targetHeight, timing),
177 },
178 };
179};
156180export const COMPOSER_LAYOUT_TRANSITION =
157 Platform.OS === "android"
158 ? undefined
159 : LinearTransition.duration(COMPOSER_TRANSITION_DURATION_MS).reduceMotion(ReduceMotion.System);
181 Platform.OS === "android" ? undefined : composerHeightTransition;
160182
161183const COMPOSER_ATTACHMENT_ENTERING =
162184 Platform.OS === "android"
......@@ -351,7 +373,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
351373 );
352374 const isVoiceInputPresented = voicePresentation.statusLabel !== null;
353375 // An open draft stays visible; only a collapsed composer becomes a voice strip.
354 const isExpanded = isFocused || settingsSheetPresentation.isActive;
376 const isExpanded = isFocused || settingsSheetPresentation.keepsComposerExpanded;
355377 const showsCompactDictation = isVoiceInputPresented && !isExpanded;
356378 const isToolbarVisible = isExpanded || isVoiceInputPresented;
357379 const attachmentBlockReason = composerAttachmentUploadBlockReason({
......@@ -407,11 +429,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
407429
408430 const handleBlur = useCallback(() => {
409431 setIsFocused(false);
410 if (!settingsSheetPresentation.isActive) {
432 if (!settingsSheetPresentation.keepsComposerExpanded) {
411433 onExpandedChange?.(false);
412434 }
413435 onEditorFocusChange?.(false);
414 }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.isActive]);
436 }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.keepsComposerExpanded]);
415437 const handleSend = useCallback(async () => {
416438 // Typed out in full rather than picked from the menu. Attachments mean the
417439 // user is sending a prompt, so those go through as usual.
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+9-1
......@@ -58,10 +58,12 @@ import Animated, {
5858 FadeOut,
5959 ReduceMotion,
6060 useAnimatedReaction,
61 useAnimatedStyle,
6162 useSharedValue,
6263 withTiming,
6364} from "react-native-reanimated";
6465import { useSafeAreaInsets } from "react-native-safe-area-context";
66import { useWorkspaceContentWidth } from "../layout/workspace-content-width";
6567
6668import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
6769import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits";
......@@ -647,6 +649,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
647649 const layoutVariant = props.layoutVariant ?? "compact";
648650 const isSplitLayout = layoutVariant === "split";
649651 const contentMaxWidth = isSplitLayout ? CHAT_CONTENT_MAX_WIDTH : undefined;
652 const workspaceContentWidth = useWorkspaceContentWidth();
653 const composerWidthStyle = useAnimatedStyle(() =>
654 isSplitLayout && workspaceContentWidth !== null
655 ? { width: workspaceContentWidth.value, right: undefined }
656 : { width: undefined, right: 0 },
657 );
650658 const selectedInstanceId = props.selectedThread.modelSelection.instanceId;
651659 useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed);
652660 const selectedProviderSkills = useMemo(() => {
......@@ -915,7 +923,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
915923 <Animated.View
916924 layout={COMPOSER_LAYOUT_TRANSITION}
917925 pointerEvents="box-none"
918 style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}
926 style={[{ position: "absolute", bottom: 0, left: 0, right: 0 }, composerWidthStyle]}
919927 >
920928 {/* No paddingTop here: the overlay's measured height becomes the
921929 list's bottom inset, so any padding above the pill/composer
apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts+16-11
......@@ -3,7 +3,7 @@ import { KeyboardController } from "react-native-keyboard-controller";
33
44import type { ComposerEditorHandle } from "../../components/ComposerEditor";
55
6type PresentationPhase = "closed" | "opening" | "visible";
6type PresentationPhase = "closed" | "opening" | "visible" | "restoring";
77
88/**
99 * The navigator-level UIKit completion event added by the repo's
......@@ -91,7 +91,8 @@ export function useThreadSettingsSheetPresentation(input: {
9191 focusRestoreIdRef.current += 1;
9292 clearDismissRestoreTimer();
9393 restorePendingRef.current = false;
94 restoreFocusAfterDismissRef.current = input.isEditorFocused || KeyboardController.isVisible();
94 restoreFocusAfterDismissRef.current =
95 phase === "restoring" || input.isEditorFocused || KeyboardController.isVisible();
9596 setPhase("opening");
9697
9798 const openingId = openingIdRef.current + 1;
......@@ -109,7 +110,7 @@ export function useThreadSettingsSheetPresentation(input: {
109110 }
110111 setPhase("visible");
111112 });
112 }, [clearDismissRestoreTimer, input.editorRef, input.isEditorFocused]);
113 }, [clearDismissRestoreTimer, input.editorRef, input.isEditorFocused, phase]);
113114
114115 const restoreEditorFocus = useCallback(() => {
115116 const focusRestoreId = focusRestoreIdRef.current + 1;
......@@ -120,12 +121,11 @@ export function useThreadSettingsSheetPresentation(input: {
120121 // normally succeeds; the retries are insurance against UIKit briefly
121122 // refusing first-responder status right at the transition boundary.
122123 const restoreFocus = () => {
123 if (
124 !isMountedRef.current ||
125 focusRestoreIdRef.current !== focusRestoreId ||
126 isEditorFocusedRef.current ||
127 attemptsRemaining <= 0
128 ) {
124 if (!isMountedRef.current || focusRestoreIdRef.current !== focusRestoreId) {
125 return;
126 }
127 if (isEditorFocusedRef.current || attemptsRemaining <= 0) {
128 setPhase("closed");
129129 return;
130130 }
131131
......@@ -157,11 +157,15 @@ export function useThreadSettingsSheetPresentation(input: {
157157 */
158158 const onDismissed = useCallback(() => {
159159 isActiveRef.current = false;
160 setPhase("closed");
161160
162161 if (!restoreFocusAfterDismissRef.current) {
162 setPhase("closed");
163163 return;
164164 }
165 // Keep the card expanded across the handoff back to its editor. With a
166 // hardware keyboard there is no software-keyboard travel to hide a collapse
167 // while the sheet dismissal and focus restoration finish.
168 setPhase("restoring");
165169 restoreFocusAfterDismissRef.current = false;
166170 restorePendingRef.current = true;
167171 clearDismissRestoreTimer();
......@@ -185,7 +189,8 @@ export function useThreadSettingsSheetPresentation(input: {
185189 }, [runPendingDismissalRestore]);
186190
187191 return {
188 isActive: phase !== "closed",
192 isActive: phase === "opening" || phase === "visible",
193 keepsComposerExpanded: phase !== "closed",
189194 isVisible: phase === "visible",
190195 open,
191196 onDismissed,