| author | |
| committer | |
| log | 859304b7808ab9a4be87b1bddcd07c6485bf9c4f |
| tree | 784652183f5d4f840a653bfe2b2d5887d3be9193 |
| parent | 96f43708a0ec97e9da89ab1e165039f50f09e037 |
| signature | Signed by PGP key B5690EEEBB952194 |
6 files changed, 84 insertions(+), 22 deletions(-)
apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx+17-2| ... | ... | @@ -22,7 +22,12 @@ import { |
| 22 | 22 | type ReactNode, |
| 23 | 23 | } from "react"; |
| 24 | 24 | import { useWindowDimensions, View } from "react-native"; |
| 25 | import Animated, { useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated"; | |
| 25 | import Animated, { | |
| 26 | useAnimatedStyle, | |
| 27 | useDerivedValue, | |
| 28 | useSharedValue, | |
| 29 | withTiming, | |
| 30 | } from "react-native-reanimated"; | |
| 26 | 31 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 27 | 32 | |
| 28 | 33 | import { |
| ... | ... | @@ -51,6 +56,7 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe |
| 51 | 56 | import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; |
| 52 | 57 | import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; |
| 53 | 58 | import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; |
| 59 | import { WorkspaceContentWidthContext } from "./workspace-content-width"; | |
| 54 | 60 | |
| 55 | 61 | interface AdaptiveWorkspaceContextValue { |
| 56 | 62 | readonly layout: Layout; |
| ... | ... | @@ -505,6 +511,10 @@ function AdaptiveWorkspaceLayoutContent( |
| 505 | 511 | const contentSettledWidth = layout.usesSplitView |
| 506 | 512 | ? Math.max(0, panes.contentPaneWidth - inspectorColumnTargetWidth) |
| 507 | 513 | : null; |
| 514 | const renderedInspectorWidth = useSharedValue(inspectorColumnTargetWidth); | |
| 515 | const renderedContentWidth = useDerivedValue(() => | |
| 516 | Math.max(0, width - renderedSidebarWidth.value - renderedInspectorWidth.value), | |
| 517 | ); | |
| 508 | 518 | |
| 509 | 519 | const handleSelectThread = useCallback( |
| 510 | 520 | (thread: EnvironmentThreadShell) => { |
| ... | ... | @@ -583,10 +593,15 @@ function AdaptiveWorkspaceLayoutContent( |
| 583 | 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 | 601 | </View> |
| 588 | 602 | </View> |
| 589 | 603 | <WorkspaceInspectorPane |
| 604 | renderedInspectorWidth={renderedInspectorWidth} | |
| 590 | 605 | active={workspaceInspector?.active ?? false} |
| 591 | 606 | panes={panes} |
| 592 | 607 | renderInspector={workspaceInspector?.render} |
apps/mobile/src/features/layout/workspace-content-width.ts created+10| ... | ... | @@ -0,0 +1,10 @@ |
| 1 | import { createContext, use } from "react"; | |
| 2 | import 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. | |
| 6 | export const WorkspaceContentWidthContext = createContext<SharedValue<number> | null>(null); | |
| 7 | ||
| 8 | export 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 | 4 | useAnimatedStyle, |
| 5 | 5 | useSharedValue, |
| 6 | 6 | withTiming, |
| 7 | type SharedValue, | |
| 7 | 8 | } from "react-native-reanimated"; |
| 8 | 9 | |
| 9 | 10 | import { constrainAuxiliaryPaneWidth, type WorkspacePaneLayout } from "../../lib/layout"; |
| ... | ... | @@ -22,6 +23,7 @@ import { WorkspacePaneDivider } from "./workspace-pane-divider"; |
| 22 | 23 | * module stays import-cycle-free with AdaptiveWorkspaceLayout. |
| 23 | 24 | */ |
| 24 | 25 | export function WorkspaceInspectorPane(props: { |
| 26 | readonly renderedInspectorWidth: SharedValue<number>; | |
| 25 | 27 | /** |
| 26 | 28 | * When false the pane animates closed but keeps its content mounted for the |
| 27 | 29 | * exit transition (a route that lost focus). `onClosed` fires once the |
| ... | ... | @@ -45,7 +47,7 @@ export function WorkspaceInspectorPane(props: { |
| 45 | 47 | // inspector at its final position so route replacement never replays an |
| 46 | 48 | // entering transition. Only visibility and explicit resizing change it. |
| 47 | 49 | const inspectorProgress = useSharedValue(inspectorVisible ? 1 : 0); |
| 48 | const renderedInspectorWidth = useSharedValue(inspectorVisible ? (inspectorWidth ?? 0) : 0); | |
| 50 | const { renderedInspectorWidth } = props; | |
| 49 | 51 | // The content keeps its own width so the reveal (outer width) clips a |
| 50 | 52 | // fully-laid-out pane instead of reflowing text every frame. When the OPEN |
| 51 | 53 | // 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 | 37 | import Animated, { |
| 38 | 38 | FadeIn, |
| 39 | 39 | FadeOut, |
| 40 | LinearTransition, | |
| 40 | type LayoutAnimationFunction, | |
| 41 | 41 | ReduceMotion, |
| 42 | 42 | useAnimatedStyle, |
| 43 | 43 | useSharedValue, |
| ... | ... | @@ -153,10 +153,32 @@ export interface ThreadComposerProps { |
| 153 | 153 | // running alongside that translate reads as jitter. Snapping the layout and |
| 154 | 154 | // letting the keyboard-synced slide be the only motion looks native there. |
| 155 | 155 | export 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. | |
| 159 | const 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 | }; | |
| 156 | 180 | export 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; | |
| 160 | 182 | |
| 161 | 183 | const COMPOSER_ATTACHMENT_ENTERING = |
| 162 | 184 | Platform.OS === "android" |
| ... | ... | @@ -351,7 +373,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 351 | 373 | ); |
| 352 | 374 | const isVoiceInputPresented = voicePresentation.statusLabel !== null; |
| 353 | 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 | 377 | const showsCompactDictation = isVoiceInputPresented && !isExpanded; |
| 356 | 378 | const isToolbarVisible = isExpanded || isVoiceInputPresented; |
| 357 | 379 | const attachmentBlockReason = composerAttachmentUploadBlockReason({ |
| ... | ... | @@ -407,11 +429,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 407 | 429 | |
| 408 | 430 | const handleBlur = useCallback(() => { |
| 409 | 431 | setIsFocused(false); |
| 410 | if (!settingsSheetPresentation.isActive) { | |
| 432 | if (!settingsSheetPresentation.keepsComposerExpanded) { | |
| 411 | 433 | onExpandedChange?.(false); |
| 412 | 434 | } |
| 413 | 435 | onEditorFocusChange?.(false); |
| 414 | }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.isActive]); | |
| 436 | }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.keepsComposerExpanded]); | |
| 415 | 437 | const handleSend = useCallback(async () => { |
| 416 | 438 | // Typed out in full rather than picked from the menu. Attachments mean the |
| 417 | 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 | 58 | FadeOut, |
| 59 | 59 | ReduceMotion, |
| 60 | 60 | useAnimatedReaction, |
| 61 | useAnimatedStyle, | |
| 61 | 62 | useSharedValue, |
| 62 | 63 | withTiming, |
| 63 | 64 | } from "react-native-reanimated"; |
| 64 | 65 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 66 | import { useWorkspaceContentWidth } from "../layout/workspace-content-width"; | |
| 65 | 67 | |
| 66 | 68 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 67 | 69 | import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits"; |
| ... | ... | @@ -647,6 +649,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 647 | 649 | const layoutVariant = props.layoutVariant ?? "compact"; |
| 648 | 650 | const isSplitLayout = layoutVariant === "split"; |
| 649 | 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 | 658 | const selectedInstanceId = props.selectedThread.modelSelection.instanceId; |
| 651 | 659 | useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); |
| 652 | 660 | const selectedProviderSkills = useMemo(() => { |
| ... | ... | @@ -915,7 +923,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 915 | 923 | <Animated.View |
| 916 | 924 | layout={COMPOSER_LAYOUT_TRANSITION} |
| 917 | 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 | 928 | {/* No paddingTop here: the overlay's measured height becomes the |
| 921 | 929 | 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 | 3 | |
| 4 | 4 | import type { ComposerEditorHandle } from "../../components/ComposerEditor"; |
| 5 | 5 | |
| 6 | type PresentationPhase = "closed" | "opening" | "visible"; | |
| 6 | type PresentationPhase = "closed" | "opening" | "visible" | "restoring"; | |
| 7 | 7 | |
| 8 | 8 | /** |
| 9 | 9 | * The navigator-level UIKit completion event added by the repo's |
| ... | ... | @@ -91,7 +91,8 @@ export function useThreadSettingsSheetPresentation(input: { |
| 91 | 91 | focusRestoreIdRef.current += 1; |
| 92 | 92 | clearDismissRestoreTimer(); |
| 93 | 93 | restorePendingRef.current = false; |
| 94 | restoreFocusAfterDismissRef.current = input.isEditorFocused || KeyboardController.isVisible(); | |
| 94 | restoreFocusAfterDismissRef.current = | |
| 95 | phase === "restoring" || input.isEditorFocused || KeyboardController.isVisible(); | |
| 95 | 96 | setPhase("opening"); |
| 96 | 97 | |
| 97 | 98 | const openingId = openingIdRef.current + 1; |
| ... | ... | @@ -109,7 +110,7 @@ export function useThreadSettingsSheetPresentation(input: { |
| 109 | 110 | } |
| 110 | 111 | setPhase("visible"); |
| 111 | 112 | }); |
| 112 | }, [clearDismissRestoreTimer, input.editorRef, input.isEditorFocused]); | |
| 113 | }, [clearDismissRestoreTimer, input.editorRef, input.isEditorFocused, phase]); | |
| 113 | 114 | |
| 114 | 115 | const restoreEditorFocus = useCallback(() => { |
| 115 | 116 | const focusRestoreId = focusRestoreIdRef.current + 1; |
| ... | ... | @@ -120,12 +121,11 @@ export function useThreadSettingsSheetPresentation(input: { |
| 120 | 121 | // normally succeeds; the retries are insurance against UIKit briefly |
| 121 | 122 | // refusing first-responder status right at the transition boundary. |
| 122 | 123 | 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"); | |
| 129 | 129 | return; |
| 130 | 130 | } |
| 131 | 131 | |
| ... | ... | @@ -157,11 +157,15 @@ export function useThreadSettingsSheetPresentation(input: { |
| 157 | 157 | */ |
| 158 | 158 | const onDismissed = useCallback(() => { |
| 159 | 159 | isActiveRef.current = false; |
| 160 | setPhase("closed"); | |
| 161 | 160 | |
| 162 | 161 | if (!restoreFocusAfterDismissRef.current) { |
| 162 | setPhase("closed"); | |
| 163 | 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 | 169 | restoreFocusAfterDismissRef.current = false; |
| 166 | 170 | restorePendingRef.current = true; |
| 167 | 171 | clearDismissRestoreTimer(); |
| ... | ... | @@ -185,7 +189,8 @@ export function useThreadSettingsSheetPresentation(input: { |
| 185 | 189 | }, [runPendingDismissalRestore]); |
| 186 | 190 | |
| 187 | 191 | return { |
| 188 | isActive: phase !== "closed", | |
| 192 | isActive: phase === "opening" || phase === "visible", | |
| 193 | keepsComposerExpanded: phase !== "closed", | |
| 189 | 194 | isVisible: phase === "visible", |
| 190 | 195 | open, |
| 191 | 196 | onDismissed, |