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,