authorgravatar for me@pixp.ccAlex <me@pixp.cc> 2026-08-24 02:52:26-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-24 02:52:26-07:00
log2d2efff28c4fc47745538570fcb7dd07272e3e4b
tree3476de409e230919ecd0d972a77f0bac6801c671
parent9eba1252c1f8264b6951068d2e2396363412299c
signature Signed by PGP key B5690EEEBB952194

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


7 files changed, 73 insertions(+), 175 deletions(-)

apps/mobile/src/components/CompactBrandTitle.tsx+4-1
......@@ -33,6 +33,7 @@ export function brandTitleOffset(nativeLeadingItem: boolean): number {
3333 */
3434export function CompactBrandTitle(
3535 props: {
36 readonly allowFontScaling?: boolean;
3637 readonly nativeLeadingItem?: boolean;
3738 } = {},
3839) {
......@@ -57,6 +58,7 @@ export function CompactBrandTitle(
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(
7577 }}
7678 >
7779 <Text
80 allowFontScaling={props.allowFontScaling}
7881 style={{
7982 color: mutedColor,
8083 fontFamily: "DMSans-Bold",
......@@ -91,7 +94,7 @@ export function CompactBrandTitle(
9194}
9295
9396export function renderCompactBrandTitle() {
94 return <CompactBrandTitle />;
97 return <CompactBrandTitle allowFontScaling={Platform.OS === "ios"} />;
9598}
9699
97100export function renderCompactBrandHeaderItems(): NativeStackHeaderItem[] {
apps/mobile/src/features/home/AndroidHomeFab.tsx+2-2
......@@ -6,8 +6,8 @@ import { SymbolView } from "../../components/AppSymbol";
66import { 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 */
1212export 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";
22import * as Order from "effect/Order";
33import { useNavigation } from "@react-navigation/native";
44import { useEffect, useMemo, useState } from "react";
5import { Platform } from "react-native";
56
67import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
78import { useProjects, useThreadShells } from "../../state/entities";
......@@ -106,7 +107,11 @@ export function HomeRouteScreen() {
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() {
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 {
4545 parseActiveThreadPath,
4646 useHardwareKeyboardCommand,
4747} from "../keyboard/hardwareKeyboardCommands";
48import { AndroidHomeFabLayout } from "../home/AndroidHomeFab";
4849import { HomeListOptionsProvider } from "../home/home-list-options";
4950import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar";
5051import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation";
......@@ -435,6 +436,10 @@ function AdaptiveWorkspaceLayoutContent(
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(
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 @@
1import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass";
21import type {
32 EnvironmentProject,
43 EnvironmentThreadShell,
......@@ -14,16 +13,16 @@ import { AsyncResult } from "effect/unstable/reactivity";
1413import type { EnvironmentId } from "@t3tools/contracts";
1514import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
1615import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled";
17import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
18import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native";
16import { useCallback, useEffect, useMemo, useRef, useState } from "react";
17import type { LayoutChangeEvent } from "react-native";
1918import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native";
2019import { Gesture, GestureDetector } from "react-native-gesture-handler";
2120import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
2221import { useSafeAreaInsets } from "react-native-safe-area-context";
2322import type { SearchBarCommands } from "react-native-screens";
24import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
2523
2624import { AppText as Text } from "../../components/AppText";
25import { CompactBrandTitle } from "../../components/CompactBrandTitle";
2726import { ControlPillMenu } from "../../components/ControlPill";
2827import { SymbolView } from "../../components/AppSymbol";
2928import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
......@@ -40,7 +39,6 @@ import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
4039import { useWorkspaceState } from "../../state/workspace";
4140import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
4241import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
43import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
4442import {
4543 hasCustomHomeListOptions,
4644 PROJECT_SORT_OPTIONS,
......@@ -97,48 +95,7 @@ type SidebarListItem =
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 */
104function 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
13698const SIDEBAR_STICKY_HEADER_HEIGHT = 106;
137const SIDEBAR_STICKY_HEADER_FADE_HEIGHT = 44;
138const SIDEBAR_HEADER_WASH_OPACITY = {
139 dark: [0.22, 0.14, 0.04],
140 light: [0.46, 0.3, 0.08],
141} as const;
14299
143100interface ThreadNavigationSidebarProps {
144101 readonly width: number;
......@@ -195,16 +152,13 @@ function ThreadNavigationSidebarPane(
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(
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(
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(
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(
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(
14231338}
14241339
14251340const 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 @@
11import { SymbolView } from "../../components/AppSymbol";
2import { Pressable, StyleSheet } from "react-native";
2import { Pressable } from "react-native";
33
44import { useThemeColor } from "../../lib/useThemeColor";
55
......@@ -10,31 +10,17 @@ export type SidebarFilterButtonIcon =
1010export 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 @@
11import { SymbolView } from "../../components/AppSymbol";
2import { Pressable, StyleSheet, View } from "react-native";
2import { Pressable, View } from "react-native";
33
44import { useThemeColor } from "../../lib/useThemeColor";
55
66export interface SidebarHeaderActionsProps {
77 readonly onOpenSettings: () => void;
8 /** Rendered inside a shared capsule group — buttons drop their own chrome. */
9 readonly grouped?: boolean;
108}
119
1210function 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) {
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 />