History

fix(mobile): restore Android tablet thread controls, clean up header (#5385)

Alex committed 2 months ago

Commit 2d2efff1 parent 9eba1257 files · +73 −175Browse files

Unified · Split

7 files
  1. apps/mobile/src/components/CompactBrandTitle.tsx +4 −1
  2. apps/mobile/src/features/home/AndroidHomeFab.tsx +2 −2
  3. apps/mobile/src/features/home/HomeRouteScreen.tsx +19 −12
  4. apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +21 −12
  5. apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +21 −112
  6. apps/mobile/src/features/threads/sidebar-filter-button.tsx +3 −17
  7. apps/mobile/src/features/threads/sidebar-header-actions.tsx +3 −19
apps/mobile/src/components/CompactBrandTitle.tsx+4−1
@@ -33,6 +33,7 @@ export function brandTitleOffset(nativeLeadingItem: boolean): number { · 32 unmodified lines
3333 */
3434 export function CompactBrandTitle(
3535 props: {
36+ readonly allowFontScaling?: boolean;
3637 readonly nativeLeadingItem?: boolean;
3738 } = {},
3839 ) {
@@ -57,6 +58,7 @@ export function CompactBrandTitle( · 18 unmodified lines
5758 >
5859 <T3Wordmark color={iconColor} height={15} />
5960 <Text
61+ allowFontScaling={props.allowFontScaling}
6062 style={{
6163 color: mutedColor,
6264 fontFamily: "DMSans-Medium",
@@ -75,6 +77,7 @@ export function CompactBrandTitle( · 12 unmodified lines
7577 }}
7678 >
7779 <Text
80+ allowFontScaling={props.allowFontScaling}
7881 style={{
7982 color: mutedColor,
8083 fontFamily: "DMSans-Bold",
@@ -91,7 +94,7 @@ export function CompactBrandTitle( · 10 unmodified lines
9194 }
9295
9396 export function renderCompactBrandTitle() {
94− return <CompactBrandTitle />;
97+ return <CompactBrandTitle allowFontScaling={Platform.OS === "ios"} />;
9598 }
9699
97100 export function renderCompactBrandHeaderItems(): NativeStackHeaderItem[] {
apps/mobile/src/features/home/AndroidHomeFab.tsx+2−2
@@ -6,8 +6,8 @@ import { SymbolView } from "../../components/AppSymbol"; · 5 unmodified lines
66 import { useThemeColor } from "../../lib/useThemeColor";
77
88 /**
9− * Android-only wrapper that overlays a bottom-right new-task FAB on the home
10− * screen. Other platforms render children unchanged.
9+ * Android-only wrapper that overlays a bottom-right new-task FAB on a thread
10+ * list. Other platforms render children unchanged.
1111 */
1212 export function AndroidHomeFabLayout(props: {
1313 readonly onStartNewTask: () => void;
apps/mobile/src/features/home/HomeRouteScreen.tsx+19−12
@@ -2,6 +2,7 @@ import * as Arr from "effect/Array"; · 1 unmodified line
22 import * as Order from "effect/Order";
33 import { useNavigation } from "@react-navigation/native";
44 import { useEffect, useMemo, useState } from "react";
5+import { Platform } from "react-native";
56
67 import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
78 import { useProjects, useThreadShells } from "../../state/entities";
@@ -106,7 +107,11 @@ export function HomeRouteScreen() { · 98 unmodified lines
106107 return (
107108 <>
108109 <NativeStackScreenOptions
109− options={{ title: "", headerTitle: "", unstable_headerLeftItems: () => [] }}
110+ options={
111+ Platform.OS === "android"
112+ ? { headerShown: false }
113+ : { title: "", headerTitle: "", unstable_headerLeftItems: () => [] }
114+ }
110115 />
111116 <WorkspaceSidebarToolbar
112117 afterSidebarButton={
@@ -129,18 +134,20 @@ export function HomeRouteScreen() { · 16 unmodified lines
129134 onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
130135 >
131136 <>
132− {/* Restore the compact title after the split branch blanks the detail
133− header. The brand slot doubles as the connection status surface:
134− while an environment reconnects, the lockup fades to a status label
135− in place (no layout shift in the list below). */}
137+ {/* Restore the header after leaving split view; screen options are
138+ shallow-merged. The brand slot also doubles as the connection
139+ status surface while an environment reconnects. */}
136140 <NativeStackScreenOptions
137− options={getConnectionAwareBrandHeaderOptions({
138− onOpenEnvironments: () =>
139− navigation.navigate("SettingsSheet", {
140− screen: "SettingsContent",
141− params: { screen: "SettingsEnvironments" },
142− }),
143− })}
141+ options={{
142+ ...getConnectionAwareBrandHeaderOptions({
143+ onOpenEnvironments: () =>
144+ navigation.navigate("SettingsSheet", {
145+ screen: "SettingsContent",
146+ params: { screen: "SettingsEnvironments" },
147+ }),
148+ }),
149+ headerShown: true,
150+ }}
144151 />
145152 <HomeHeader
146153 environments={environments}
apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx+21−12
@@ -45,6 +45,7 @@ import { · 44 unmodified lines
4545 parseActiveThreadPath,
4646 useHardwareKeyboardCommand,
4747 } from "../keyboard/hardwareKeyboardCommands";
48+import { AndroidHomeFabLayout } from "../home/AndroidHomeFab";
4849 import { HomeListOptionsProvider } from "../home/home-list-options";
4950 import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar";
5051 import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation";
@@ -435,6 +436,10 @@ function AdaptiveWorkspaceLayoutContent( · 384 unmodified lines
435436 });
436437 }, [navigation]);
437438
439+ const handleStartNewTask = useCallback(() => {
440+ navigation.navigate("NewTaskSheet", { screen: "NewTask" });
441+ }, [navigation]);
442+
438443 // Minted here (root stack navigation) so the sidebar pane stays free of
439444 // navigation hooks — on iOS it renders inside an independent nav tree.
440445 const handleOpenEnvironmentSettings = useCallback(() => {
@@ -528,18 +533,22 @@ function AdaptiveWorkspaceLayoutContent( · 87 unmodified lines
528533 pointerEvents={panes.primarySidebarVisible ? "auto" : "none"}
529534 style={sidebarAnimatedStyle}
530535 >
531− <ThreadNavigationSidebar
532− width={layout.listPaneWidth}
533− visible={panes.primarySidebarVisible}
534− onRequestVisibility={revealPrimarySidebar}
535− selectedThreadKey={selectedThreadKey}
536− onOpenSettings={handleOpenSettings}
537− onOpenEnvironmentSettings={handleOpenEnvironmentSettings}
538− onNewThreadInProject={handleNewThreadInProject}
539− onSelectThread={handleSelectThread}
540− onSearchQueryChange={setPrimarySidebarSearchQuery}
541− searchQuery={primarySidebarSearchQuery}
542− />
536+ <View className="flex-1" style={{ width: layout.listPaneWidth }}>
537+ <AndroidHomeFabLayout onStartNewTask={handleStartNewTask}>
538+ <ThreadNavigationSidebar
539+ width={layout.listPaneWidth}
540+ visible={panes.primarySidebarVisible}
541+ onRequestVisibility={revealPrimarySidebar}
542+ selectedThreadKey={selectedThreadKey}
543+ onOpenSettings={handleOpenSettings}
544+ onOpenEnvironmentSettings={handleOpenEnvironmentSettings}
545+ onNewThreadInProject={handleNewThreadInProject}
546+ onSelectThread={handleSelectThread}
547+ onSearchQueryChange={setPrimarySidebarSearchQuery}
548+ searchQuery={primarySidebarSearchQuery}
549+ />
550+ </AndroidHomeFabLayout>
551+ </View>
543552 </Animated.View>
544553 ) : null}
545554 <View className="flex-1 overflow-hidden bg-screen" collapsable={false}>
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+21−112
@@ -1,4 +1,3 @@
1−import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass";
21 import type {
32 EnvironmentProject,
43 EnvironmentThreadShell,
@@ -14,16 +13,16 @@ import { AsyncResult } from "effect/unstable/reactivity"; · 9 unmodified lines
1413 import type { EnvironmentId } from "@t3tools/contracts";
1514 import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
1615 import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled";
17−import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
18−import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native";
16+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
17+import type { LayoutChangeEvent } from "react-native";
1918 import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native";
2019 import { Gesture, GestureDetector } from "react-native-gesture-handler";
2120 import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
2221 import { useSafeAreaInsets } from "react-native-safe-area-context";
2322 import type { SearchBarCommands } from "react-native-screens";
24−import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
2523
2624 import { AppText as Text } from "../../components/AppText";
25+import { CompactBrandTitle } from "../../components/CompactBrandTitle";
2726 import { ControlPillMenu } from "../../components/ControlPill";
2827 import { SymbolView } from "../../components/AppSymbol";
2928 import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
@@ -40,7 +39,6 @@ import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; · 10 unmodified lines
4039 import { useWorkspaceState } from "../../state/workspace";
4140 import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
4241 import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
43−import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
4442 import {
4543 hasCustomHomeListOptions,
4644 PROJECT_SORT_OPTIONS,
@@ -97,48 +95,7 @@ type SidebarListItem = · 50 unmodified lines
9795 | ThreadListV2ListItem
9896 | { readonly type: "v2-show-more"; readonly key: string; readonly hiddenCount: number };
9997
100−/**
101− * Shared capsule behind the sidebar header buttons — a native liquid-glass
102− * surface on iOS 26+, a tinted pill everywhere else.
103− */
104−function SidebarHeaderButtonGroup(props: {
105− readonly children: ReactNode;
106− readonly colorScheme: "light" | "dark";
107−}) {
108− const fallbackBackground = useThemeColor("--color-glass-surface");
109− const fallbackBorder = useThemeColor("--color-header-border");
110− if (isLiquidGlassSupported) {
111− return (
112− <LiquidGlassView
113− colorScheme={props.colorScheme}
114− effect="regular"
115− interactive
116− style={styles.headerButtonGroup}
117− >
118− {props.children}
119− </LiquidGlassView>
120− );
121− }
122−
123− return (
124− <View
125− style={[
126− styles.headerButtonGroup,
127− { backgroundColor: fallbackBackground, borderColor: fallbackBorder },
128− { borderWidth: StyleSheet.hairlineWidth },
129− ]}
130− >
131− {props.children}
132− </View>
133− );
134−}
135−
13698 const SIDEBAR_STICKY_HEADER_HEIGHT = 106;
137−const SIDEBAR_STICKY_HEADER_FADE_HEIGHT = 44;
138−const SIDEBAR_HEADER_WASH_OPACITY = {
139− dark: [0.22, 0.14, 0.04],
140− light: [0.46, 0.3, 0.08],
141−} as const;
14299
143100 interface ThreadNavigationSidebarProps {
144101 readonly width: number;
@@ -195,16 +152,13 @@ function ThreadNavigationSidebarPane( · 50 unmodified lines
195152 props: ThreadNavigationSidebarProps & { readonly nativeChrome: boolean },
196153 ) {
197154 const insets = useSafeAreaInsets();
198− const { themeAppearance: colorScheme } = useAppearancePreferences();
199155 const projects = useProjects();
200156 const threads = useThreadShells();
201157 const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState();
202158 const { savedConnectionsById } = useSavedRemoteConnections();
203− const [headerIsOverContent, setHeaderIsOverContent] = useState(false);
204159 const searchInputRef = useRef<TextInput>(null);
205160 const searchBarRef = useRef<SearchBarCommands>(null);
206161 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
207− const headerIsOverContentRef = useRef(false);
208162 const sidebarScrollGesture = useMemo(() => Gesture.Native(), []);
209163 const {
210164 archiveThread,
@@ -780,8 +734,6 @@ function ThreadNavigationSidebarPane( · 569 unmodified lines
780734 const borderColor = useThemeColor("--color-border");
781735 const mutedColor = useThemeColor("--color-foreground-muted");
782736 const placeholderColor = useThemeColor("--color-placeholder");
783− const headerFadeColor = String(backgroundColor);
784− const headerWashOpacity = SIDEBAR_HEADER_WASH_OPACITY[colorScheme];
785737 const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState<number | null>(null);
786738 // The sticky header (title row, search field, optional connection status)
787739 // is measured so the list inset always matches its real height — no
@@ -810,19 +762,10 @@ function ThreadNavigationSidebarPane( · 22 unmodified lines
810762 },
811763 [props.onSelectThread],
812764 );
813− const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
814− const next = event.nativeEvent.contentOffset.y > 6;
815− if (headerIsOverContentRef.current === next) {
816− return;
817− }
818− headerIsOverContentRef.current = next;
819− setHeaderIsOverContent(next);
820− }, []);
821765 const handleScrollBeginDrag = useCallback(() => {
822766 openSwipeableRef.current?.close();
823767 }, []);
824768 const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({
825− onScroll: handleScroll,
826769 onScrollBeginDrag: handleScrollBeginDrag,
827770 });
828771 // Project shells load after the first rows draw, so the maps they feed have
@@ -1321,7 +1264,10 @@ function ThreadNavigationSidebarPane( · 492 unmodified lines
13211264 contentContainerStyle={[
13221265 styles.threadListContent,
13231266 {
1324− paddingBottom: 16 + insets.bottom,
1267+ paddingBottom:
1268+ Platform.OS === "android"
1269+ ? Math.max(insets.bottom, 16) + 88 - insets.bottom
1270+ : 16 + insets.bottom,
13251271 paddingTop: topListInset,
13261272 },
13271273 ]}
@@ -1340,65 +1286,34 @@ function ThreadNavigationSidebarPane( · 12 unmodified lines
13401286
13411287 <View
13421288 className="absolute inset-x-0 top-0 z-[4]"
1289+ collapsable={false}
13431290 onLayout={handleStickyHeaderLayout}
1344− pointerEvents="box-none"
1345− style={{ paddingTop: insets.top }}
1291+ pointerEvents="auto"
1292+ style={{
1293+ paddingTop: insets.top,
1294+ backgroundColor,
1295+ }}
13461296 >
1347− <View
1348− className="absolute inset-x-0 top-0"
1349− pointerEvents="none"
1350− accessibilityElementsHidden
1351− importantForAccessibility="no-hide-descendants"
1352− style={{ height: stickyHeaderHeight + SIDEBAR_STICKY_HEADER_FADE_HEIGHT }}
1353− >
1354− <Svg width="100%" height="100%">
1355− <Defs>
1356− <LinearGradient id="sidebar-header-wash" x1="0%" x2="0%" y1="0%" y2="100%">
1357− <Stop
1358− offset="0%"
1359− stopColor={headerFadeColor}
1360− stopOpacity={headerIsOverContent ? headerWashOpacity[0] : 0}
1361− />
1362− <Stop
1363− offset="58%"
1364− stopColor={headerFadeColor}
1365− stopOpacity={headerIsOverContent ? headerWashOpacity[1] : 0}
1366− />
1367− <Stop
1368− offset="88%"
1369− stopColor={headerFadeColor}
1370− stopOpacity={headerIsOverContent ? headerWashOpacity[2] : 0}
1371− />
1372− <Stop offset="100%" stopColor={headerFadeColor} stopOpacity={0} />
1373− </LinearGradient>
1374− </Defs>
1375− <Rect width="100%" height="100%" fill="url(#sidebar-header-wash)" />
1376− </Svg>
1377− </View>
13781297 <View className="h-[50px] flex-row items-end gap-0.5 pr-2 pl-5">
13791298 {/* Title slot doubles as the connection status surface: while an
1380− environment reconnects, "Threads" fades to a status label in
1299+ environment reconnects, the brand fades to a status label in
13811300 place (no layout shift in the list below). */}
13821301 <WorkspaceConnectionTitle
13831302 grow
13841303 onPress={props.onOpenEnvironmentSettings}
13851304 size="pageTitle"
13861305 brand={
1387− <Text className="flex-1 text-[34px] font-t3-bold text-foreground" numberOfLines={1}>
1388− Threads
1389− </Text>
1306+ <View className="h-11 flex-1 justify-center">
1307+ <CompactBrandTitle allowFontScaling={false} />
1308+ </View>
13901309 }
13911310 />
1392− <SidebarHeaderButtonGroup colorScheme={colorScheme}>
1311+ <View className="flex-row items-center gap-2.5">
13931312 <ControlPillMenu actions={listMenuActions} onPressAction={handleListMenuAction}>
1394− <SidebarFilterButton
1395− grouped
1396− accessibilityLabel="Filter and sort threads"
1397− icon={filterIcon}
1398− />
1313+ <SidebarFilterButton accessibilityLabel="Filter and sort threads" icon={filterIcon} />
13991314 </ControlPillMenu>
1400− <SidebarHeaderActions grouped onOpenSettings={props.onOpenSettings} />
1401− </SidebarHeaderButtonGroup>
1315+ <SidebarHeaderActions onOpenSettings={props.onOpenSettings} />
1316+ </View>
14021317 </View>
14031318
14041319 <View className="mx-4 mt-[9px] h-[38px] flex-row items-center gap-1.5 rounded-xl bg-sidebar-search pr-2.5 pl-[11px]">
@@ -1423,12 +1338,6 @@ function ThreadNavigationSidebarPane( · 18 unmodified lines
14231338 }
14241339
14251340 const styles = StyleSheet.create({
1426− headerButtonGroup: {
1427− alignItems: "center",
1428− borderRadius: 22,
1429− flexDirection: "row",
1430− overflow: "hidden",
1431− },
14321341 threadList: {
14331342 flex: 1,
14341343 },
apps/mobile/src/features/threads/sidebar-filter-button.tsx+3−17
@@ -1,5 +1,5 @@
11 import { SymbolView } from "../../components/AppSymbol";
2−import { Pressable, StyleSheet } from "react-native";
2+import { Pressable } from "react-native";
33
44 import { useThemeColor } from "../../lib/useThemeColor";
55
@@ -10,31 +10,17 @@ export type SidebarFilterButtonIcon = · 4 unmodified lines
1010 export function SidebarFilterButton(props: {
1111 readonly accessibilityLabel: string;
1212 readonly icon: SidebarFilterButtonIcon;
13− /** Rendered inside a shared capsule group — no own background/border. */
14− readonly grouped?: boolean;
1513 }) {
1614 const iconColor = useThemeColor("--color-foreground");
17− const pressedBackgroundColor = useThemeColor("--color-subtle");
18− const idleBackgroundColor = useThemeColor("--color-glass-surface");
19− const borderColor = useThemeColor("--color-header-border");
2015
2116 return (
2217 <Pressable
23− className="h-11 w-[50px] cursor-pointer items-center justify-center rounded-[22px]"
18+ className="size-11 cursor-pointer items-center justify-center rounded-full bg-subtle active:opacity-70"
2419 accessibilityLabel={props.accessibilityLabel}
2520 accessibilityRole="button"
2621 hitSlop={4}
27− style={({ pressed }) => [
28− props.grouped
29− ? { backgroundColor: pressed ? pressedBackgroundColor : "transparent", borderWidth: 0 }
30− : {
31− backgroundColor: pressed ? pressedBackgroundColor : idleBackgroundColor,
32− borderColor,
33− borderWidth: StyleSheet.hairlineWidth,
34− },
35− ]}
3622 >
37− <SymbolView name={props.icon} size={20} tintColor={iconColor} type="monochrome" />
23+ <SymbolView name={props.icon} size={16} tintColor={iconColor} type="monochrome" />
3824 </Pressable>
3925 );
4026 }
apps/mobile/src/features/threads/sidebar-header-actions.tsx+3−19
@@ -1,43 +1,28 @@
11 import { SymbolView } from "../../components/AppSymbol";
2−import { Pressable, StyleSheet, View } from "react-native";
2+import { Pressable, View } from "react-native";
33
44 import { useThemeColor } from "../../lib/useThemeColor";
55
66 export interface SidebarHeaderActionsProps {
77 readonly onOpenSettings: () => void;
8− /** Rendered inside a shared capsule group — buttons drop their own chrome. */
9− readonly grouped?: boolean;
108 }
119
1210 function FallbackHeaderButton(props: {
1311 readonly accessibilityLabel: string;
1412 readonly icon: "gearshape" | "square.and.pencil";
15− readonly grouped?: boolean;
1613 readonly onPress: () => void;
1714 }) {
1815 const iconColor = useThemeColor("--color-foreground");
19− const pressedBackgroundColor = useThemeColor("--color-subtle");
20− const idleBackgroundColor = useThemeColor("--color-glass-surface");
21− const borderColor = useThemeColor("--color-header-border");
2216
2317 return (
2418 <Pressable
25− className="h-11 w-[50px] items-center justify-center rounded-[22px]"
19+ className="size-11 items-center justify-center rounded-full bg-subtle active:opacity-70"
2620 accessibilityLabel={props.accessibilityLabel}
2721 accessibilityRole="button"
2822 hitSlop={4}
2923 onPress={props.onPress}
30− style={({ pressed }) => [
31− props.grouped
32− ? { backgroundColor: pressed ? pressedBackgroundColor : "transparent", borderWidth: 0 }
33− : {
34− backgroundColor: pressed ? pressedBackgroundColor : idleBackgroundColor,
35− borderColor,
36− borderWidth: StyleSheet.hairlineWidth,
37− },
38− ]}
3924 >
40− <SymbolView name={props.icon} size={20} tintColor={iconColor} type="monochrome" />
25+ <SymbolView name={props.icon} size={18} tintColor={iconColor} type="monochrome" />
4126 </Pressable>
4227 );
4328 }
@@ -47,7 +32,6 @@ export function SidebarHeaderActions(props: SidebarHeaderActionsProps) { · 3 unmodified lines
4732 <View className="flex-row items-center gap-0.5">
4833 <FallbackHeaderButton
4934 accessibilityLabel="Open settings"
50− grouped={props.grouped}
5135 icon="gearshape"
5236 onPress={props.onOpenSettings}
5337 />