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 {...@@ -22,7 +22,12 @@ import {
22 type ReactNode,22 type ReactNode,
23} from "react";23} from "react";
24import { useWindowDimensions, View } from "react-native";24import { 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";
26import { AsyncResult } from "effect/unstable/reactivity";31import { AsyncResult } from "effect/unstable/reactivity";
2732
28import {33import {
...@@ -51,6 +56,7 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe...@@ -51,6 +56,7 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe
51import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar";56import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar";
52import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation";57import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation";
53import { WorkspaceInspectorPane } from "./workspace-inspector-pane";58import { WorkspaceInspectorPane } from "./workspace-inspector-pane";
59import { WorkspaceContentWidthContext } from "./workspace-content-width";
5460
55interface AdaptiveWorkspaceContextValue {61interface AdaptiveWorkspaceContextValue {
56 readonly layout: Layout;62 readonly layout: Layout;
...@@ -505,6 +511,10 @@ function AdaptiveWorkspaceLayoutContent(...@@ -505,6 +511,10 @@ function AdaptiveWorkspaceLayoutContent(
505 const contentSettledWidth = layout.usesSplitView511 const contentSettledWidth = layout.usesSplitView
506 ? Math.max(0, panes.contentPaneWidth - inspectorColumnTargetWidth)512 ? Math.max(0, panes.contentPaneWidth - inspectorColumnTargetWidth)
507 : null;513 : null;
514 const renderedInspectorWidth = useSharedValue(inspectorColumnTargetWidth);
515 const renderedContentWidth = useDerivedValue(() =>
516 Math.max(0, width - renderedSidebarWidth.value - renderedInspectorWidth.value),
517 );
508518
509 const handleSelectThread = useCallback(519 const handleSelectThread = useCallback(
510 (thread: EnvironmentThreadShell) => {520 (thread: EnvironmentThreadShell) => {
...@@ -583,10 +593,15 @@ function AdaptiveWorkspaceLayoutContent(...@@ -583,10 +593,15 @@ function AdaptiveWorkspaceLayoutContent(
583 contentSettledWidth !== null ? { flex: 1, width: contentSettledWidth } : { flex: 1 }593 contentSettledWidth !== null ? { flex: 1, width: contentSettledWidth } : { flex: 1 }
584 }594 }
585 >595 >
586 {props.children}596 <WorkspaceContentWidthContext
597 value={layout.usesSplitView ? renderedContentWidth : null}
598 >
599 {props.children}
600 </WorkspaceContentWidthContext>
587 </View>601 </View>
588 </View>602 </View>
589 <WorkspaceInspectorPane603 <WorkspaceInspectorPane
604 renderedInspectorWidth={renderedInspectorWidth}
590 active={workspaceInspector?.active ?? false}605 active={workspaceInspector?.active ?? false}
591 panes={panes}606 panes={panes}
592 renderInspector={workspaceInspector?.render}607 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, {...@@ -4,6 +4,7 @@ import Animated, {
4 useAnimatedStyle,4 useAnimatedStyle,
5 useSharedValue,5 useSharedValue,
6 withTiming,6 withTiming,
7 type SharedValue,
7} from "react-native-reanimated";8} from "react-native-reanimated";
89
9import { constrainAuxiliaryPaneWidth, type WorkspacePaneLayout } from "../../lib/layout";10import { constrainAuxiliaryPaneWidth, type WorkspacePaneLayout } from "../../lib/layout";
...@@ -22,6 +23,7 @@ import { WorkspacePaneDivider } from "./workspace-pane-divider";...@@ -22,6 +23,7 @@ import { WorkspacePaneDivider } from "./workspace-pane-divider";
22 * module stays import-cycle-free with AdaptiveWorkspaceLayout.23 * module stays import-cycle-free with AdaptiveWorkspaceLayout.
23 */24 */
24export function WorkspaceInspectorPane(props: {25export function WorkspaceInspectorPane(props: {
26 readonly renderedInspectorWidth: SharedValue<number>;
25 /**27 /**
26 * When false the pane animates closed but keeps its content mounted for the28 * When false the pane animates closed but keeps its content mounted for the
27 * exit transition (a route that lost focus). `onClosed` fires once the29 * exit transition (a route that lost focus). `onClosed` fires once the
...@@ -45,7 +47,7 @@ export function WorkspaceInspectorPane(props: {...@@ -45,7 +47,7 @@ export function WorkspaceInspectorPane(props: {
45 // inspector at its final position so route replacement never replays an47 // inspector at its final position so route replacement never replays an
46 // entering transition. Only visibility and explicit resizing change it.48 // entering transition. Only visibility and explicit resizing change it.
47 const inspectorProgress = useSharedValue(inspectorVisible ? 1 : 0);49 const inspectorProgress = useSharedValue(inspectorVisible ? 1 : 0);
48 const renderedInspectorWidth = useSharedValue(inspectorVisible ? (inspectorWidth ?? 0) : 0);50 const { renderedInspectorWidth } = props;
49 // The content keeps its own width so the reveal (outer width) clips a51 // The content keeps its own width so the reveal (outer width) clips a
50 // fully-laid-out pane instead of reflowing text every frame. When the OPEN52 // fully-laid-out pane instead of reflowing text every frame. When the OPEN
51 // pane's target width changes (e.g. the sidebar toggles and reserves53 // 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 {...@@ -37,7 +37,7 @@ import {
37import Animated, {37import Animated, {
38 FadeIn,38 FadeIn,
39 FadeOut,39 FadeOut,
40 LinearTransition,40 type LayoutAnimationFunction,
41 ReduceMotion,41 ReduceMotion,
42 useAnimatedStyle,42 useAnimatedStyle,
43 useSharedValue,43 useSharedValue,
...@@ -153,10 +153,32 @@ export interface ThreadComposerProps {...@@ -153,10 +153,32 @@ export interface ThreadComposerProps {
153// running alongside that translate reads as jitter. Snapping the layout and153// running alongside that translate reads as jitter. Snapping the layout and
154// letting the keyboard-synced slide be the only motion looks native there.154// letting the keyboard-synced slide be the only motion looks native there.
155export const COMPOSER_TRANSITION_DURATION_MS = 220;155export 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};
156export const COMPOSER_LAYOUT_TRANSITION =180export const COMPOSER_LAYOUT_TRANSITION =
157 Platform.OS === "android"181 Platform.OS === "android" ? undefined : composerHeightTransition;
158 ? undefined
159 : LinearTransition.duration(COMPOSER_TRANSITION_DURATION_MS).reduceMotion(ReduceMotion.System);
160182
161const COMPOSER_ATTACHMENT_ENTERING =183const COMPOSER_ATTACHMENT_ENTERING =
162 Platform.OS === "android"184 Platform.OS === "android"
...@@ -351,7 +373,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -351,7 +373,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
351 );373 );
352 const isVoiceInputPresented = voicePresentation.statusLabel !== null;374 const isVoiceInputPresented = voicePresentation.statusLabel !== null;
353 // An open draft stays visible; only a collapsed composer becomes a voice strip.375 // 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;
355 const showsCompactDictation = isVoiceInputPresented && !isExpanded;377 const showsCompactDictation = isVoiceInputPresented && !isExpanded;
356 const isToolbarVisible = isExpanded || isVoiceInputPresented;378 const isToolbarVisible = isExpanded || isVoiceInputPresented;
357 const attachmentBlockReason = composerAttachmentUploadBlockReason({379 const attachmentBlockReason = composerAttachmentUploadBlockReason({
...@@ -407,11 +429,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -407,11 +429,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
407429
408 const handleBlur = useCallback(() => {430 const handleBlur = useCallback(() => {
409 setIsFocused(false);431 setIsFocused(false);
410 if (!settingsSheetPresentation.isActive) {432 if (!settingsSheetPresentation.keepsComposerExpanded) {
411 onExpandedChange?.(false);433 onExpandedChange?.(false);
412 }434 }
413 onEditorFocusChange?.(false);435 onEditorFocusChange?.(false);
414 }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.isActive]);436 }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.keepsComposerExpanded]);
415 const handleSend = useCallback(async () => {437 const handleSend = useCallback(async () => {
416 // Typed out in full rather than picked from the menu. Attachments mean the438 // Typed out in full rather than picked from the menu. Attachments mean the
417 // user is sending a prompt, so those go through as usual.439 // 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, {...@@ -58,10 +58,12 @@ import Animated, {
58 FadeOut,58 FadeOut,
59 ReduceMotion,59 ReduceMotion,
60 useAnimatedReaction,60 useAnimatedReaction,
61 useAnimatedStyle,
61 useSharedValue,62 useSharedValue,
62 withTiming,63 withTiming,
63} from "react-native-reanimated";64} from "react-native-reanimated";
64import { useSafeAreaInsets } from "react-native-safe-area-context";65import { useSafeAreaInsets } from "react-native-safe-area-context";
66import { useWorkspaceContentWidth } from "../layout/workspace-content-width";
6567
66import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";68import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
67import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits";69import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits";
...@@ -647,6 +649,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -647,6 +649,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
647 const layoutVariant = props.layoutVariant ?? "compact";649 const layoutVariant = props.layoutVariant ?? "compact";
648 const isSplitLayout = layoutVariant === "split";650 const isSplitLayout = layoutVariant === "split";
649 const contentMaxWidth = isSplitLayout ? CHAT_CONTENT_MAX_WIDTH : undefined;651 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 );
650 const selectedInstanceId = props.selectedThread.modelSelection.instanceId;658 const selectedInstanceId = props.selectedThread.modelSelection.instanceId;
651 useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed);659 useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed);
652 const selectedProviderSkills = useMemo(() => {660 const selectedProviderSkills = useMemo(() => {
...@@ -915,7 +923,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -915,7 +923,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
915 <Animated.View923 <Animated.View
916 layout={COMPOSER_LAYOUT_TRANSITION}924 layout={COMPOSER_LAYOUT_TRANSITION}
917 pointerEvents="box-none"925 pointerEvents="box-none"
918 style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}926 style={[{ position: "absolute", bottom: 0, left: 0, right: 0 }, composerWidthStyle]}
919 >927 >
920 {/* No paddingTop here: the overlay's measured height becomes the928 {/* No paddingTop here: the overlay's measured height becomes the
921 list's bottom inset, so any padding above the pill/composer929 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";...@@ -3,7 +3,7 @@ import { KeyboardController } from "react-native-keyboard-controller";
33
4import type { ComposerEditorHandle } from "../../components/ComposerEditor";4import type { ComposerEditorHandle } from "../../components/ComposerEditor";
55
6type PresentationPhase = "closed" | "opening" | "visible";6type PresentationPhase = "closed" | "opening" | "visible" | "restoring";
77
8/**8/**
9 * The navigator-level UIKit completion event added by the repo's9 * The navigator-level UIKit completion event added by the repo's
...@@ -91,7 +91,8 @@ export function useThreadSettingsSheetPresentation(input: {...@@ -91,7 +91,8 @@ export function useThreadSettingsSheetPresentation(input: {
91 focusRestoreIdRef.current += 1;91 focusRestoreIdRef.current += 1;
92 clearDismissRestoreTimer();92 clearDismissRestoreTimer();
93 restorePendingRef.current = false;93 restorePendingRef.current = false;
94 restoreFocusAfterDismissRef.current = input.isEditorFocused || KeyboardController.isVisible();94 restoreFocusAfterDismissRef.current =
95 phase === "restoring" || input.isEditorFocused || KeyboardController.isVisible();
95 setPhase("opening");96 setPhase("opening");
9697
97 const openingId = openingIdRef.current + 1;98 const openingId = openingIdRef.current + 1;
...@@ -109,7 +110,7 @@ export function useThreadSettingsSheetPresentation(input: {...@@ -109,7 +110,7 @@ export function useThreadSettingsSheetPresentation(input: {
109 }110 }
110 setPhase("visible");111 setPhase("visible");
111 });112 });
112 }, [clearDismissRestoreTimer, input.editorRef, input.isEditorFocused]);113 }, [clearDismissRestoreTimer, input.editorRef, input.isEditorFocused, phase]);
113114
114 const restoreEditorFocus = useCallback(() => {115 const restoreEditorFocus = useCallback(() => {
115 const focusRestoreId = focusRestoreIdRef.current + 1;116 const focusRestoreId = focusRestoreIdRef.current + 1;
...@@ -120,12 +121,11 @@ export function useThreadSettingsSheetPresentation(input: {...@@ -120,12 +121,11 @@ export function useThreadSettingsSheetPresentation(input: {
120 // normally succeeds; the retries are insurance against UIKit briefly121 // normally succeeds; the retries are insurance against UIKit briefly
121 // refusing first-responder status right at the transition boundary.122 // refusing first-responder status right at the transition boundary.
122 const restoreFocus = () => {123 const restoreFocus = () => {
123 if (124 if (!isMountedRef.current || focusRestoreIdRef.current !== focusRestoreId) {
124 !isMountedRef.current ||125 return;
125 focusRestoreIdRef.current !== focusRestoreId ||126 }
126 isEditorFocusedRef.current ||127 if (isEditorFocusedRef.current || attemptsRemaining <= 0) {
127 attemptsRemaining <= 0128 setPhase("closed");
128 ) {
129 return;129 return;
130 }130 }
131131
...@@ -157,11 +157,15 @@ export function useThreadSettingsSheetPresentation(input: {...@@ -157,11 +157,15 @@ export function useThreadSettingsSheetPresentation(input: {
157 */157 */
158 const onDismissed = useCallback(() => {158 const onDismissed = useCallback(() => {
159 isActiveRef.current = false;159 isActiveRef.current = false;
160 setPhase("closed");
161160
162 if (!restoreFocusAfterDismissRef.current) {161 if (!restoreFocusAfterDismissRef.current) {
162 setPhase("closed");
163 return;163 return;
164 }164 }
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");
165 restoreFocusAfterDismissRef.current = false;169 restoreFocusAfterDismissRef.current = false;
166 restorePendingRef.current = true;170 restorePendingRef.current = true;
167 clearDismissRestoreTimer();171 clearDismissRestoreTimer();
...@@ -185,7 +189,8 @@ export function useThreadSettingsSheetPresentation(input: {...@@ -185,7 +189,8 @@ export function useThreadSettingsSheetPresentation(input: {
185 }, [runPendingDismissalRestore]);189 }, [runPendingDismissalRestore]);
186190
187 return {191 return {
188 isActive: phase !== "closed",192 isActive: phase === "opening" || phase === "visible",
193 keepsComposerExpanded: phase !== "closed",
189 isVisible: phase === "visible",194 isVisible: phase === "visible",
190 open,195 open,
191 onDismissed,196 onDismissed,