authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-18 23:58:18-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-18 23:58:18-07:00
log29a23fdf1481485593d7d90e195b7c8bf2a705a8
treede38807a6b63ea7b761333eba8701112f501867c
parent803f94e787faef498edecba5dbd07493ac8bc817
signature Signed by PGP key B5690EEEBB952194

fix(mobile): adapt workspace navigation and expand controls (#12551)


12 files changed, 109 insertions(+), 124 deletions(-)

apps/mobile/src/Stack.tsx+38-23
......@@ -11,7 +11,14 @@ import {
1111 type NativeStackNavigationOptions,
1212} from "@react-navigation/native-stack";
1313import { useEffect, useRef } from "react";
14import { Platform, Pressable, ScrollView, StyleSheet, View } from "react-native";
14import {
15 Platform,
16 Pressable,
17 ScrollView,
18 StyleSheet,
19 View,
20 useWindowDimensions,
21} from "react-native";
1522import { useResolveClassNames } from "uniwind";
1623
1724import { AppText as Text } from "./components/AppText";
......@@ -93,6 +100,7 @@ import {
93100 transitionIncomingSharePresentation,
94101} from "./features/sharing/incoming-share-presentation";
95102import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass";
103import { deriveLayout } from "./lib/layout";
96104import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader";
97105import { FORM_SHEET_PRESENTATION_OPTIONS } from "./native/sheet-surface";
98106import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain";
......@@ -339,9 +347,8 @@ const SettingsSheetStack = createNativeStackNavigator({
339347// the same deep-link URLs the nested config produced.
340348const THREAD_LINKING_PREFIX = "threads/:environmentId/:threadId";
341349
342// New-task / add-project flow: nested navigator inside the formSheet (Settings-sheet
343// pattern — a plain formSheet screen cannot render a stack header; the header and
344// in-sheet pushes come from this nested stack).
350// New-task / add-project flow: the nested navigator owns the header and pushes
351// whether the flow opens in the workspace or in a compact form sheet.
345352const NewTaskSheetStack = createNativeStackNavigator({
346353 initialRouteName: "NewTask",
347354 screenOptions: {
......@@ -570,7 +577,7 @@ function NotFoundScreen() {
570577 );
571578}
572579
573export const RootStack = createNativeStackNavigator({
580const RootStackConfig = createNativeStackNavigator({
574581 initialRouteName: "Home",
575582 layout: RootStackLayout,
576583 screenOptions: {
......@@ -698,15 +705,6 @@ export const RootStack = createNativeStackNavigator({
698705 options: {
699706 gestureEnabled: true,
700707 headerShown: false,
701 // Android pushes settings as a regular full page with an in-screen
702 // back header; iOS keeps the detented form sheet.
703 ...(Platform.OS === "android"
704 ? { presentation: "card" as const }
705 : {
706 ...FORM_SHEET_PRESENTATION_OPTIONS,
707 sheetAllowedDetents: [0.92],
708 sheetGrabberVisible: true,
709 }),
710708 },
711709 }),
712710 SettingsLegal: createNativeStackScreen({
......@@ -770,15 +768,6 @@ export const RootStack = createNativeStackNavigator({
770768 options: {
771769 gestureEnabled: true,
772770 headerShown: false,
773 // Android pushes the flow as a regular full page — the draft should
774 // read like a thread that just doesn't exist yet; iOS keeps the sheet.
775 ...(Platform.OS === "android"
776 ? { presentation: "card" as const }
777 : {
778 ...FORM_SHEET_PRESENTATION_OPTIONS,
779 sheetAllowedDetents: [0.92],
780 sheetGrabberVisible: true,
781 }),
782771 },
783772 }),
784773 NotFound: createNativeStackScreen({
......@@ -787,6 +776,32 @@ export const RootStack = createNativeStackNavigator({
787776 }),
788777 },
789778});
779
780export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navigator }) {
781 const { width, height } = useWindowDimensions();
782 const usesWorkspaceFlowScreens =
783 Platform.OS === "android" || deriveLayout({ width, height }).usesSplitView;
784
785 return (
786 <Navigator
787 screenOptions={({ route }) => {
788 if (route.name !== "SettingsSheet" && route.name !== "NewTaskSheet") {
789 return {};
790 }
791
792 // Follow the workspace viewport as it resizes; compact iOS keeps sheets.
793 return usesWorkspaceFlowScreens
794 ? { presentation: "card" }
795 : {
796 ...FORM_SHEET_PRESENTATION_OPTIONS,
797 sheetAllowedDetents: [0.92],
798 sheetGrabberVisible: true,
799 };
800 }}
801 />
802 );
803});
804
790805type RootStackType = typeof RootStack;
791806
792807const navigationPathConfig = {
apps/mobile/src/components/AndroidScreenHeader.tsx+1-7
......@@ -25,13 +25,7 @@ export function AndroidHeaderIconButton(props: {
2525 readonly disabled?: boolean;
2626 readonly selected?: boolean;
2727}) {
28 return (
29 <MaterialIconButton
30 {...props}
31 tintColorClassName="accent-header-foreground"
32 variant={props.selected ? "tonal" : "standard"}
33 />
34 );
28 return <MaterialIconButton {...props} tintColorClassName="accent-header-foreground" />;
3529}
3630
3731export function AndroidScreenHeader(props: {
apps/mobile/src/components/AppSymbol.tsx+2-2
......@@ -19,7 +19,7 @@ import IconArrowUp from "@tabler/icons-react-native/IconArrowUp";
1919import IconArrowUpCircle from "@tabler/icons-react-native/IconArrowUpCircle";
2020import IconArrowUpRight from "@tabler/icons-react-native/IconArrowUpRight";
2121import IconArrowUpRightCircle from "@tabler/icons-react-native/IconArrowUpRightCircle";
22import IconArrowsMaximize from "@tabler/icons-react-native/IconArrowsMaximize";
22import IconArrowsDiagonal2 from "@tabler/icons-react-native/IconArrowsDiagonal2";
2323import IconArrowsMinimize from "@tabler/icons-react-native/IconArrowsMinimize";
2424import IconBellRinging from "@tabler/icons-react-native/IconBellRinging";
2525import IconBolt from "@tabler/icons-react-native/IconBolt";
......@@ -122,7 +122,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = {
122122 "arrow.turn.left.up": IconArrowBackUp,
123123 "arrow.up": IconArrowUp,
124124 "arrow.up.circle": IconArrowUpCircle,
125 "arrow.up.left.and.arrow.down.right": IconArrowsMaximize,
125 "arrow.up.left.and.arrow.down.right": IconArrowsDiagonal2,
126126 "arrow.down.right.and.arrow.up.left": IconArrowsMinimize,
127127 "arrow.up.right": IconArrowUpRight,
128128 "arrow.up.right.circle": IconArrowUpRightCircle,
apps/mobile/src/components/ScreenHeader.tsx+17-13
......@@ -103,7 +103,7 @@ export function ScreenHeader(props: ScreenHeaderProps) {
103103 ...props.options,
104104 }}
105105 />
106 {props.sidebar !== false && layout.usesSplitView ? (
106 {layout.usesSplitView && (props.sidebar !== false || props.backInSplitView) ? (
107107 <NativeHeaderToolbar placement="left">
108108 {props.backInSplitView && (props.backInSplitView.onPress || props.onBack) ? (
109109 <ScreenHeaderButton
......@@ -111,18 +111,22 @@ export function ScreenHeader(props: ScreenHeaderProps) {
111111 onPress={props.backInSplitView.onPress ?? props.onBack}
112112 />
113113 ) : null}
114 <ScreenHeaderButton
115 accessibilityLabel={
116 panes.primarySidebarVisible ? `Maximize ${props.title.toLowerCase()}` : "Show threads"
117 }
118 icon={
119 panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left"
120 }
121 onPress={togglePrimarySidebar}
122 separateBackground={
123 props.backInSplitView ? props.backInSplitView.separateBackground : true
124 }
125 />
114 {props.sidebar !== false ? (
115 <ScreenHeaderButton
116 accessibilityLabel={
117 panes.primarySidebarVisible
118 ? `Maximize ${props.title.toLowerCase()}`
119 : "Show threads"
120 }
121 icon={
122 panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left"
123 }
124 onPress={togglePrimarySidebar}
125 separateBackground={
126 props.backInSplitView ? props.backInSplitView.separateBackground : true
127 }
128 />
129 ) : null}
126130 </NativeHeaderToolbar>
127131 ) : null}
128132 {(props.actions?.length ||
apps/mobile/src/features/home/HomeRouteScreen.tsx+4-10
......@@ -11,10 +11,6 @@ import { useWorkspaceState } from "../../state/workspace";
1111import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
1212import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
1313import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail";
14import {
15 AndroidWorkspaceSidebarButton,
16 WorkspaceSidebarToolbar,
17} from "../layout/workspace-sidebar-toolbar";
1814import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
1915import { checkForAppUpdateOnLaunch, startAppUpdateForegroundRecheck } from "../updates/app-updates";
2016import { AndroidHomeFabLayout } from "./AndroidHomeFab";
......@@ -121,18 +117,16 @@ export function HomeRouteScreen() {
121117 : { title: "", headerTitle: "", unstable_headerLeftItems: () => [] }
122118 }
123119 />
124 <WorkspaceSidebarToolbar
125 afterSidebarButton={
120 {Platform.OS === "ios" ? (
121 <NativeHeaderToolbar placement="left">
126122 <NativeHeaderToolbar.Button
127123 accessibilityLabel="New task"
128124 icon="square.and.pencil"
129125 onPress={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
130126 />
131 }
132 />
133 {Platform.OS === "android" ? (
134 <AndroidScreenHeader title="Threads" leading={<AndroidWorkspaceSidebarButton />} />
127 </NativeHeaderToolbar>
135128 ) : null}
129 {Platform.OS === "android" ? <AndroidScreenHeader title="Threads" /> : null}
136130 <WorkspaceEmptyDetail
137131 onAddConnection={
138132 Platform.OS === "android" && !catalogState.hasConnections
apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx+14-14
......@@ -238,6 +238,7 @@ function AdaptiveWorkspaceLayoutContent(
238238 const navigation = useNavigation();
239239 const activeRoleOwner = useRef<symbol | null>(null);
240240 const [primarySidebarPreferredVisible, setPrimarySidebarPreferredVisible] = useState(true);
241 const showPrimarySidebar = pathname === "/" || primarySidebarPreferredVisible;
241242 const [supplementaryPanePreferredVisible, setSupplementaryPanePreferredVisible] = useState(true);
242243 const [supplementaryPanePreferredWidth, setSupplementaryPanePreferredWidth] = useState<
243244 number | null
......@@ -262,17 +263,9 @@ function AdaptiveWorkspaceLayoutContent(
262263 viewportWidth: width,
263264 preferredWidth: fileInspectorPreferredWidth ?? undefined,
264265 reservedLeadingWidth:
265 shouldRenderPrimarySidebar && primarySidebarPreferredVisible
266 ? (layout.listPaneWidth ?? 0)
267 : 0,
266 shouldRenderPrimarySidebar && showPrimarySidebar ? (layout.listPaneWidth ?? 0) : 0,
268267 }),
269 [
270 fileInspectorPreferredWidth,
271 layout,
272 primarySidebarPreferredVisible,
273 shouldRenderPrimarySidebar,
274 width,
275 ],
268 [fileInspectorPreferredWidth, layout, showPrimarySidebar, shouldRenderPrimarySidebar, width],
276269 );
277270 const auxiliaryPaneRole: WorkspaceAuxiliaryPaneRole =
278271 focusedAuxiliaryPaneRole ?? (/\/files(?:\/|$)/.test(pathname) ? "inspector" : "supplementary");
......@@ -289,7 +282,7 @@ function AdaptiveWorkspaceLayoutContent(
289282 deriveWorkspacePaneLayout({
290283 layout,
291284 viewportWidth: width,
292 primarySidebarPreferredVisible,
285 primarySidebarPreferredVisible: showPrimarySidebar,
293286 auxiliaryPanePreferredVisible,
294287 auxiliaryPaneRole,
295288 auxiliaryPanePreferredWidth: auxiliaryPanePreferredWidth ?? undefined,
......@@ -299,7 +292,7 @@ function AdaptiveWorkspaceLayoutContent(
299292 auxiliaryPaneRole,
300293 auxiliaryPanePreferredWidth,
301294 layout,
302 primarySidebarPreferredVisible,
295 showPrimarySidebar,
303296 width,
304297 ],
305298 );
......@@ -357,13 +350,16 @@ function AdaptiveWorkspaceLayoutContent(
357350 };
358351 }, []);
359352 const togglePrimarySidebar = useCallback(() => {
353 if (pathname === "/") {
354 return;
355 }
360356 if (!panes.primarySidebarVisible && panes.primarySidebarSuppressedByAuxiliary) {
361357 setFileInspectorPreferredVisible(false);
362358 setPrimarySidebarPreferredVisible(true);
363359 return;
364360 }
365361 setPrimarySidebarPreferredVisible((current) => !current);
366 }, [panes.primarySidebarSuppressedByAuxiliary, panes.primarySidebarVisible]);
362 }, [panes.primarySidebarSuppressedByAuxiliary, panes.primarySidebarVisible, pathname]);
367363 const revealPrimarySidebar = useCallback(() => {
368364 if (panes.primarySidebarSuppressedByAuxiliary) {
369365 setFileInspectorPreferredVisible(false);
......@@ -374,7 +370,11 @@ function AdaptiveWorkspaceLayoutContent(
374370 togglePrimarySidebar();
375371 return true;
376372 }, [togglePrimarySidebar]);
377 useHardwareKeyboardCommand("toggleSidebar", handleToggleSidebarCommand);
373 const sidebarCommands = useMemo(
374 () => (pathname === "/" ? [] : (["toggleSidebar"] as const)),
375 [pathname],
376 );
377 useHardwareKeyboardCommand(sidebarCommands, handleToggleSidebarCommand);
378378 const showAuxiliaryPane = useCallback((role: WorkspaceAuxiliaryPaneRole) => {
379379 if (role === "inspector") {
380380 setFocusedAuxiliaryPaneRole("inspector");
apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx+2-34
......@@ -1,5 +1,3 @@
1import { NativeHeaderToolbar } from "../../native/StackHeader";
2import type { ReactNode } from "react";
31import { Platform } from "react-native";
42
53import { AndroidHeaderIconButton } from "../../components/AndroidScreenHeader";
......@@ -12,39 +10,9 @@ export function AndroidWorkspaceSidebarButton() {
1210
1311 return (
1412 <AndroidHeaderIconButton
15 accessibilityLabel={
16 panes.primarySidebarVisible ? "Hide thread sidebar" : "Show thread sidebar"
17 }
18 icon="sidebar.left"
19 selected={panes.primarySidebarVisible}
13 accessibilityLabel={panes.primarySidebarVisible ? "Maximize content" : "Show thread sidebar"}
14 icon={panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left"}
2015 onPress={togglePrimarySidebar}
2116 />
2217 );
2318}
24
25export function WorkspaceSidebarToolbar(
26 props: {
27 readonly children?: ReactNode;
28 readonly afterSidebarButton?: ReactNode;
29 } = {},
30) {
31 const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();
32
33 if (Platform.OS === "android" || !layout.usesSplitView) {
34 return null;
35 }
36
37 return (
38 <NativeHeaderToolbar placement="left">
39 {props.children}
40 <NativeHeaderToolbar.Button
41 accessibilityLabel={
42 panes.primarySidebarVisible ? "Maximize content" : "Show thread sidebar"
43 }
44 icon={panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left"}
45 onPress={togglePrimarySidebar}
46 />
47 {props.afterSidebarButton}
48 </NativeHeaderToolbar>
49 );
50}
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+13-2
......@@ -6,7 +6,8 @@ import { deriveProjectGroupLabel } from "@t3tools/client-runtime/state/project-g
66import { useSafeAreaInsets } from "react-native-safe-area-context";
77
88import { hasCloudPublicConfig } from "../cloud/publicConfig";
9import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
9import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
10import { NativeHeaderToolbar } from "../../native/StackHeader";
1011import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
1112import { SettingsRow } from "./components/SettingsRow";
1213import { SettingsSection } from "./components/SettingsSection";
......@@ -18,6 +19,8 @@ import {
1819import { useSettingsEnvironmentFilter } from "./settings-environment-filter";
1920
2021export function SettingsRouteScreen() {
22 const navigation = useNavigation();
23 const { layout } = useAdaptiveWorkspaceLayout();
2124 const content = hasCloudPublicConfig() ? (
2225 <ConfiguredSettingsRouteScreen />
2326 ) : (
......@@ -26,7 +29,15 @@ export function SettingsRouteScreen() {
2629
2730 return (
2831 <>
29 <WorkspaceSidebarToolbar />
32 {Platform.OS === "ios" && layout.usesSplitView ? (
33 <NativeHeaderToolbar placement="left">
34 <NativeHeaderToolbar.Button
35 accessibilityLabel="Go back"
36 icon="chevron.left"
37 onPress={() => navigation.goBack()}
38 />
39 </NativeHeaderToolbar>
40 ) : null}
3041 <SettingsEnvironmentFilterHeader closeSettings />
3142 {Platform.OS === "android" ? (
3243 <SettingsScreen title="Settings" trailing={<AndroidSettingsEnvironmentFilter />}>
apps/mobile/src/features/settings/components/SettingsEnvironmentFilterHeader.tsx+5-1
......@@ -5,10 +5,13 @@ import { ControlPillMenu } from "../../../components/ControlPill";
55import { SymbolView } from "../../../components/AppSymbol";
66import { NativeStackScreenOptions } from "../../../native/StackHeader";
77import { withNativeGlassHeaderItem } from "../../layout/native-glass-header-items";
8import { useAdaptiveWorkspaceLayout } from "../../layout/AdaptiveWorkspaceLayout";
89import { useSettingsEnvironmentFilter } from "../settings-environment-filter";
910
1011export function SettingsEnvironmentFilterHeader(props: { readonly closeSettings?: boolean }) {
1112 const navigation = useNavigation();
13 const { layout } = useAdaptiveWorkspaceLayout();
14 const closeSettings = props.closeSettings === true && !layout.usesSplitView;
1215 const {
1316 availableTargets,
1417 selectedTargets,
......@@ -26,6 +29,7 @@ export function SettingsEnvironmentFilterHeader(props: { readonly closeSettings?
2629 ? "line.3.horizontal.decrease"
2730 : "line.3.horizontal.decrease.circle.fill";
2831 const filterVersion = JSON.stringify({
32 closeSettings,
2933 selection: selectedIds === null ? null : [...selectedIds].sort(),
3034 targets: availableTargets.map((entry) => [entry.environmentId, entry.label, entry.displayUrl]),
3135 project: selectedProjectKey,
......@@ -94,7 +98,7 @@ export function SettingsEnvironmentFilterHeader(props: { readonly closeSettings?
9498 ],
9599 },
96100 }),
97 ...(props.closeSettings
101 ...(closeSettings
98102 ? [
99103 withNativeGlassHeaderItem({
100104 accessibilityLabel: "Close settings",
apps/mobile/src/features/settings/components/SettingsScreen.tsx+1-1
......@@ -23,7 +23,7 @@ export function SettingsScreen(
2323 title={props.title}
2424 actions={props.actions}
2525 trailing={Platform.OS === "android" ? props.trailing : undefined}
26 sidebar={Platform.OS === "android" && !props.formSheet}
26 sidebar={false}
2727 onBack={() => navigation.goBack()}
2828 hideBottomBorder={!props.formSheet}
2929 />
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+2-3
......@@ -100,9 +100,8 @@ function TerminalHeader(props: {
100100 subtitle={props.subtitle}
101101 onBack={props.onCloseTerminal}
102102 backInSplitView={{
103 accessibilityLabel: "Close terminal",
104 icon: "xmark",
105 separateBackground: true,
103 accessibilityLabel: "Back to chat",
104 icon: "chevron.left",
106105 }}
107106 menus={
108107 props.isEnvironmentReady
apps/mobile/src/features/threads/NewTaskRouteScreen.tsx+10-14
......@@ -98,28 +98,24 @@ function NewTaskHeader(props: {
9898 title={props.title}
9999 subtitle={props.subtitle ?? undefined}
100100 sidebar={false}
101 backInSplitView={{
102 accessibilityLabel: "Go back",
103 icon: "chevron.left",
104 }}
105 options={{ headerBackVisible: !layout.usesSplitView }}
101106 hideBottomBorder
102107 onBack={() => navigation.goBack()}
103 actions={[
104 ...(Platform.OS === "ios" && layout.usesSplitView
105 ? [
106 {
107 accessibilityLabel: "Close new task",
108 icon: "xmark" as const,
109 onPress: () => navigation.goBack(),
110 },
111 ]
112 : []),
113 ...(props.canAddProject
108 actions={
109 props.canAddProject
114110 ? [
115111 {
116112 accessibilityLabel: "Add project",
117 icon: "plus" as const,
113 icon: "plus",
118114 onPress: () => navigation.dispatch(StackActions.push("AddProject")),
119115 },
120116 ]
121 : []),
122 ]}
117 : []
118 }
123119 search={{
124120 value: props.searchText,
125121 onChangeText: props.onSearchTextChange,