import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass";import type { EnvironmentProject, EnvironmentThreadShell,} from "@t3tools/client-runtime/state/shell";import { LegendList } from "@legendapp/list/react-native";import type { MenuAction } from "@react-native-menu/menu";import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native";import { Platform, StyleSheet, TextInput, View, useColorScheme } from "react-native";import { Gesture, GestureDetector } from "react-native-gesture-handler";import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";import { useSafeAreaInsets } from "react-native-safe-area-context";import type { SearchBarCommands } from "react-native-screens";import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; import { AppText as Text } from "../../components/AppText";import { ControlPillMenu } from "../../components/ControlPill";import { SymbolView } from "../../components/AppSymbol";import { NativeStackScreenOptions } from "../../native/StackHeader";import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";import { useThemeColor } from "../../lib/useThemeColor";import { useProjects, useThreadShells } from "../../state/entities";import { usePendingNewTasks } from "../../state/use-pending-new-tasks";import { useWorkspaceState } from "../../state/workspace";import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";import { hasCustomHomeListOptions, PROJECT_GROUPING_OPTIONS, PROJECT_SORT_OPTIONS, THREAD_SORT_OPTIONS, useHomeListOptions,} from "../home/home-list-options";import { buildHomeListFilterMenu } from "../home/home-list-filter-menu";import { buildHomeListLayout, DEFAULT_GROUP_DISPLAY_STATE, homeListItemsAreEqual, nextGroupDisplayState, type HomeGroupDisplayAction, type HomeGroupDisplayState, type HomeListItem,} from "../home/homeListItems";import { buildHomeThreadGroups } from "../home/homeThreadList";import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions";import { usePendingTaskListActions } from "../home/usePendingTaskListActions";import { useThreadListActions } from "../home/useThreadListActions";import { WorkspaceConnectionStatus } from "../home/WorkspaceConnectionStatus";import { shouldShowWorkspaceConnectionStatus } from "../home/workspace-connection-status";import { SidebarHeaderActions } from "./sidebar-header-actions";import { SidebarFilterButton } from "./sidebar-filter-button";import { createSidebarHeaderItems } from "./sidebar-native-header-items";import { SidebarNavigationShell } from "./sidebar-navigation-shell";import { PendingTaskListRow, ThreadListGroupHeader, ThreadListRow, ThreadListShowMoreRow,} from "./thread-list-items"; /** * Shared capsule behind the sidebar header buttons — a native liquid-glass * surface on iOS 26+, a tinted pill everywhere else. */function SidebarHeaderButtonGroup(props: { readonly children: ReactNode; readonly colorScheme: "light" | "dark";}) { if (isLiquidGlassSupported) { return ( <LiquidGlassView colorScheme={props.colorScheme} effect="regular" interactive style={styles.headerButtonGroup} > {props.children} </LiquidGlassView> ); } return ( <View style={[ styles.headerButtonGroup, props.colorScheme === "dark" ? { backgroundColor: "rgba(118,118,128,0.24)", borderColor: "rgba(255,255,255,0.08)" } : { backgroundColor: "rgba(255,255,255,0.72)", borderColor: "rgba(0,0,0,0.08)" }, { borderWidth: StyleSheet.hairlineWidth }, ]} > {props.children} </View> );} const SIDEBAR_STICKY_HEADER_HEIGHT = 106;const SIDEBAR_STICKY_HEADER_FADE_HEIGHT = 44;const SIDEBAR_HEADER_WASH_OPACITY = { dark: [0.22, 0.14, 0.04], light: [0.46, 0.3, 0.08],} as const; interface ThreadNavigationSidebarProps { readonly width: number; readonly visible: boolean; readonly selectedThreadKey: string | null; readonly onOpenSettings: () => void; readonly onOpenEnvironmentSettings: () => void; readonly onNewThreadInProject: (project: EnvironmentProject) => void; readonly onSearchQueryChange: (query: string) => void; readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onRequestVisibility: () => void; readonly searchQuery: string;} /** * iPad/large-width sidebar column. * * On iOS the pane is hosted inside its own navigation-inert single-screen * native stack (SidebarNavigationShell) so the header is a real * UINavigationBar: large title, native bar-button items, and a * UISearchController search field — the same chrome a UISplitViewController * column gets. Other platforms keep the custom header chrome. */export function ThreadNavigationSidebar(props: ThreadNavigationSidebarProps) { if (Platform.OS !== "ios") { return <ThreadNavigationSidebarPane {...props} nativeChrome={false} />; } return <NativeSidebarContainer {...props} />;} function NativeSidebarContainer(props: ThreadNavigationSidebarProps) { const backgroundColor = useThemeColor("--color-drawer"); const borderColor = useThemeColor("--color-border"); return ( <View testID="thread-navigation-sidebar" className="flex-1" style={{ width: props.width, backgroundColor, borderRightColor: borderColor, borderRightWidth: StyleSheet.hairlineWidth, }} > <SidebarNavigationShell> <ThreadNavigationSidebarPane {...props} nativeChrome /> </SidebarNavigationShell> </View> );} function ThreadNavigationSidebarPane( props: ThreadNavigationSidebarProps & { readonly nativeChrome: boolean },) { const insets = useSafeAreaInsets(); const colorScheme = useColorScheme() === "dark" ? "dark" : "light"; const projects = useProjects(); const threads = useThreadShells(); const { state: catalogState } = useWorkspaceState(); const { savedConnectionsById } = useSavedRemoteConnections(); const [headerIsOverContent, setHeaderIsOverContent] = useState(false); const searchInputRef = useRef<TextInput>(null); const searchBarRef = useRef<SearchBarCommands>(null); const openSwipeableRef = useRef<SwipeableMethods | null>(null); const headerIsOverContentRef = useRef(false); const sidebarScrollGesture = useMemo(() => Gesture.Native(), []); const { archiveThread, confirmDeleteThread } = useThreadListActions(); const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const environments = useMemo( () => Object.values(savedConnectionsById) .map((connection) => ({ environmentId: connection.environmentId, label: connection.environmentLabel, })) .sort((left, right) => left.label.localeCompare(right.label)), [savedConnectionsById], ); const availableEnvironmentIds = useMemo( () => new Set(environments.map((environment) => environment.environmentId)), [environments], ); const { options, setSelectedEnvironmentId, setProjectGroupingMode, setProjectSortOrder, setThreadSortOrder, } = useHomeListOptions(availableEnvironmentIds); const groups = useMemo( () => buildHomeThreadGroups({ projects, threads, pendingTasks, environmentId: options.selectedEnvironmentId, searchQuery: props.searchQuery, projectSortOrder: options.projectSortOrder, threadSortOrder: options.threadSortOrder, projectGroupingMode: options.projectGroupingMode, }), [options, pendingTasks, projects, props.searchQuery, threads], ); const [groupDisplayStates, setGroupDisplayStates] = useState< ReadonlyMap<string, HomeGroupDisplayState> >(() => new Map()); const updateGroupDisplay = useCallback((key: string, action: HomeGroupDisplayAction) => { setGroupDisplayStates((previous) => { const next = new Map(previous); next.set( key, nextGroupDisplayState(previous.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action), ); return next; }); }, []); const hasSearchQuery = props.searchQuery.trim().length > 0; const listLayout = useMemo( () => buildHomeListLayout({ groups, displayStates: groupDisplayStates, showAllThreads: hasSearchQuery, }), [groups, groupDisplayStates, hasSearchQuery], ); const projectCwdByKey = useMemo(() => { const map = new Map<string, string>(); for (const project of projects) { map.set(scopedProjectKey(project.environmentId, project.id), project.workspaceRoot); } return map; }, [projects]); const showsConnectionStatus = shouldShowWorkspaceConnectionStatus(catalogState); const listMenuActions = useMemo<MenuAction[]>( () => [ { id: "environment", title: "Environment", subactions: [ { id: "environment:all", title: "All environments", subtitle: "Show threads from every environment", state: options.selectedEnvironmentId === null ? "on" : "off", }, ...environments.map((environment) => ({ id: `environment:${environment.environmentId}`, title: environment.label, state: options.selectedEnvironmentId === environment.environmentId ? ("on" as const) : ("off" as const), })), ], }, { id: "project-sort", title: "Sort projects", subactions: PROJECT_SORT_OPTIONS.map((option) => ({ id: `project-sort:${option.value}`, title: option.label, state: options.projectSortOrder === option.value ? "on" : "off", })), }, { id: "thread-sort", title: "Sort threads", subactions: THREAD_SORT_OPTIONS.map((option) => ({ id: `thread-sort:${option.value}`, title: option.label, state: options.threadSortOrder === option.value ? "on" : "off", })), }, { id: "project-grouping", title: "Group projects", subactions: PROJECT_GROUPING_OPTIONS.map((option) => ({ id: `project-grouping:${option.value}`, title: option.label, subtitle: option.subtitle, state: options.projectGroupingMode === option.value ? "on" : "off", })), }, ], [environments, options], ); const handleListMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { const event = nativeEvent.event; if (event === "environment:all") { setSelectedEnvironmentId(null); return; } if (event.startsWith("environment:")) { const environment = environments.find( (candidate) => String(candidate.environmentId) === event.slice("environment:".length), ); if (environment) setSelectedEnvironmentId(environment.environmentId); return; } const projectSort = PROJECT_SORT_OPTIONS.find( (option) => `project-sort:${option.value}` === event, ); if (projectSort) { setProjectSortOrder(projectSort.value); return; } const threadSort = THREAD_SORT_OPTIONS.find( (option) => `thread-sort:${option.value}` === event, ); if (threadSort) { setThreadSortOrder(threadSort.value); return; } const grouping = PROJECT_GROUPING_OPTIONS.find( (option) => `project-grouping:${option.value}` === event, ); if (grouping) setProjectGroupingMode(grouping.value); }, [ environments, setProjectGroupingMode, setProjectSortOrder, setSelectedEnvironmentId, setThreadSortOrder, ], ); const backgroundColor = useThemeColor("--color-drawer"); const borderColor = useThemeColor("--color-border"); const mutedColor = useThemeColor("--color-foreground-muted"); const placeholderColor = useThemeColor("--color-placeholder"); const headerFadeColor = String(backgroundColor); const headerWashOpacity = SIDEBAR_HEADER_WASH_OPACITY[colorScheme]; const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState<number | null>(null); // The sticky header (title row, search field, optional connection status) // is measured so the list inset always matches its real height — no // hardcoded per-variant constants. const stickyHeaderHeight = measuredHeaderHeight ?? insets.top + SIDEBAR_STICKY_HEADER_HEIGHT; const topListInset = stickyHeaderHeight + 6; const handleStickyHeaderLayout = useCallback((event: LayoutChangeEvent) => { const nextHeight = Math.round(event.nativeEvent.layout.height); setMeasuredHeaderHeight((current) => (current === nextHeight ? current : nextHeight)); }, []); const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { if (openSwipeableRef.current !== methods) { openSwipeableRef.current?.close(); openSwipeableRef.current = methods; } }, []); const handleSwipeableClose = useCallback((methods: SwipeableMethods) => { if (openSwipeableRef.current === methods) { openSwipeableRef.current = null; } }, []); const handleSelectThread = useCallback( (thread: EnvironmentThreadShell) => { props.onSelectThread(thread); openSwipeableRef.current?.close(); }, [props.onSelectThread], ); const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => { const next = event.nativeEvent.contentOffset.y > 6; if (headerIsOverContentRef.current === next) { return; } headerIsOverContentRef.current = next; setHeaderIsOverContent(next); }, []); const handleScrollBeginDrag = useCallback(() => { openSwipeableRef.current?.close(); }, []); const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({ onScroll: handleScroll, onScrollBeginDrag: handleScrollBeginDrag, }); const listExtraData = props.selectedThreadKey ?? ""; const focusSearch = useCallback(() => { const focus = () => { if (props.nativeChrome) { searchBarRef.current?.focus(); return; } searchInputRef.current?.focus(); }; if (!props.visible) { props.onRequestVisibility(); setTimeout(focus, 240); } else { focus(); } return true; }, [props.nativeChrome, props.onRequestVisibility, props.visible]); useHardwareKeyboardCommand("focusSearch", focusSearch); const renderListItem = useCallback( ({ item }: { readonly item: HomeListItem }) => { switch (item.type) { case "header": return ( <ThreadListGroupHeader variant="sidebar" collapsed={item.collapsed} isFirst={item.isFirst} groupKey={item.group.key} onGroupAction={updateGroupDisplay} // Same gating as the compact Home list: aggregated groups have no // single target project, and pending-project groups hold a // placeholder shell rather than a real project. newThreadTarget={item.group.newThreadTarget} onNewThread={props.onNewThreadInProject} project={item.group.representative} threadCount={item.group.threads.length + item.group.pendingTasks.length} title={item.group.title} /> ); case "pending-task": return ( <PendingTaskListRow variant="sidebar" pendingTask={item.pendingTask} environmentLabel={ savedConnectionsById[item.pendingTask.message.environmentId]?.environmentLabel ?? null } isLast={item.isLast} onSelectPendingTask={openPendingTask} onDeletePendingTask={confirmDeletePendingTask} /> ); case "thread": { const thread = item.thread; return ( <ThreadListRow variant="sidebar" thread={thread} environmentLabel={ savedConnectionsById[thread.environmentId]?.environmentLabel ?? null } projectCwd={ projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null } isLast={item.isLast} selected={ scopedThreadKey(thread.environmentId, thread.id) === props.selectedThreadKey } fullSwipeWidth={props.width - 20} onArchiveThread={archiveThread} onDeleteThread={confirmDeleteThread} onSelectThread={handleSelectThread} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} simultaneousSwipeGesture={sidebarScrollGesture} /> ); } case "show-more": return ( <ThreadListShowMoreRow variant="sidebar" hiddenCount={item.hiddenCount} canShowLess={item.canShowLess} groupKey={item.groupKey} onGroupAction={updateGroupDisplay} /> ); } }, [ archiveThread, confirmDeletePendingTask, confirmDeleteThread, handleSelectThread, handleSwipeableClose, handleSwipeableWillOpen, openPendingTask, projectCwdByKey, props.onNewThreadInProject, props.selectedThreadKey, props.width, savedConnectionsById, sidebarScrollGesture, updateGroupDisplay, ], ); const filterIcon = hasCustomHomeListOptions(options) ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle"; const filterMenu = useMemo( () => buildHomeListFilterMenu({ environments, selectedEnvironmentId: options.selectedEnvironmentId,Lines 1–500 of 750. Load more
import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; import type { EnvironmentProject, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import { LegendList } from "@legendapp/list/react-native"; import type { MenuAction } from "@react-native-menu/menu"; import { useCallback, useMemo, useRef, useState, type ReactNode } from "react"; import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native"; import { Platform, StyleSheet, TextInput, View, useColorScheme } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import type { SearchBarCommands } from "react-native-screens"; import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; import { AppText as Text } from "../../components/AppText"; import { ControlPillMenu } from "../../components/ControlPill"; import { SymbolView } from "../../components/AppSymbol"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useThemeColor } from "../../lib/useThemeColor"; import { useProjects, useThreadShells } from "../../state/entities"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { hasCustomHomeListOptions, PROJECT_GROUPING_OPTIONS, PROJECT_SORT_OPTIONS, THREAD_SORT_OPTIONS, useHomeListOptions, } from "../home/home-list-options"; import { buildHomeListFilterMenu } from "../home/home-list-filter-menu"; import { buildHomeListLayout, DEFAULT_GROUP_DISPLAY_STATE, homeListItemsAreEqual, nextGroupDisplayState, type HomeGroupDisplayAction, type HomeGroupDisplayState, type HomeListItem, } from "../home/homeListItems"; import { buildHomeThreadGroups } from "../home/homeThreadList"; import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions"; import { usePendingTaskListActions } from "../home/usePendingTaskListActions"; import { useThreadListActions } from "../home/useThreadListActions"; import { WorkspaceConnectionStatus } from "../home/WorkspaceConnectionStatus"; import { shouldShowWorkspaceConnectionStatus } from "../home/workspace-connection-status"; import { SidebarHeaderActions } from "./sidebar-header-actions"; import { SidebarFilterButton } from "./sidebar-filter-button"; import { createSidebarHeaderItems } from "./sidebar-native-header-items"; import { SidebarNavigationShell } from "./sidebar-navigation-shell"; import { PendingTaskListRow, ThreadListGroupHeader, ThreadListRow, ThreadListShowMoreRow, } from "./thread-list-items"; /** * Shared capsule behind the sidebar header buttons — a native liquid-glass * surface on iOS 26+, a tinted pill everywhere else. */ function SidebarHeaderButtonGroup(props: { readonly children: ReactNode; readonly colorScheme: "light" | "dark"; }) { if (isLiquidGlassSupported) { return ( <LiquidGlassView colorScheme={props.colorScheme} effect="regular" interactive style={styles.headerButtonGroup} > {props.children} </LiquidGlassView> ); } return ( <View style={[ styles.headerButtonGroup, props.colorScheme === "dark" ? { backgroundColor: "rgba(118,118,128,0.24)", borderColor: "rgba(255,255,255,0.08)" } : { backgroundColor: "rgba(255,255,255,0.72)", borderColor: "rgba(0,0,0,0.08)" }, { borderWidth: StyleSheet.hairlineWidth }, ]} > {props.children} </View> ); } const SIDEBAR_STICKY_HEADER_HEIGHT = 106; const SIDEBAR_STICKY_HEADER_FADE_HEIGHT = 44; const SIDEBAR_HEADER_WASH_OPACITY = { dark: [0.22, 0.14, 0.04], light: [0.46, 0.3, 0.08], } as const; interface ThreadNavigationSidebarProps { readonly width: number; readonly visible: boolean; readonly selectedThreadKey: string | null; readonly onOpenSettings: () => void; readonly onOpenEnvironmentSettings: () => void; readonly onNewThreadInProject: (project: EnvironmentProject) => void; readonly onSearchQueryChange: (query: string) => void; readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onRequestVisibility: () => void; readonly searchQuery: string; } /** * iPad/large-width sidebar column. * * On iOS the pane is hosted inside its own navigation-inert single-screen * native stack (SidebarNavigationShell) so the header is a real * UINavigationBar: large title, native bar-button items, and a * UISearchController search field — the same chrome a UISplitViewController * column gets. Other platforms keep the custom header chrome. */ export function ThreadNavigationSidebar(props: ThreadNavigationSidebarProps) { if (Platform.OS !== "ios") { return <ThreadNavigationSidebarPane {...props} nativeChrome={false} />; } return <NativeSidebarContainer {...props} />; } function NativeSidebarContainer(props: ThreadNavigationSidebarProps) { const backgroundColor = useThemeColor("--color-drawer"); const borderColor = useThemeColor("--color-border"); return ( <View testID="thread-navigation-sidebar" className="flex-1" style={{ width: props.width, backgroundColor, borderRightColor: borderColor, borderRightWidth: StyleSheet.hairlineWidth, }} > <SidebarNavigationShell> <ThreadNavigationSidebarPane {...props} nativeChrome /> </SidebarNavigationShell> </View> ); } function ThreadNavigationSidebarPane( props: ThreadNavigationSidebarProps & { readonly nativeChrome: boolean }, ) { const insets = useSafeAreaInsets(); const colorScheme = useColorScheme() === "dark" ? "dark" : "light"; const projects = useProjects(); const threads = useThreadShells(); const { state: catalogState } = useWorkspaceState(); const { savedConnectionsById } = useSavedRemoteConnections(); const [headerIsOverContent, setHeaderIsOverContent] = useState(false); const searchInputRef = useRef<TextInput>(null); const searchBarRef = useRef<SearchBarCommands>(null); const openSwipeableRef = useRef<SwipeableMethods | null>(null); const headerIsOverContentRef = useRef(false); const sidebarScrollGesture = useMemo(() => Gesture.Native(), []); const { archiveThread, confirmDeleteThread } = useThreadListActions(); const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const environments = useMemo( () => Object.values(savedConnectionsById) .map((connection) => ({ environmentId: connection.environmentId, label: connection.environmentLabel, })) .sort((left, right) => left.label.localeCompare(right.label)), [savedConnectionsById], ); const availableEnvironmentIds = useMemo( () => new Set(environments.map((environment) => environment.environmentId)), [environments], ); const { options, setSelectedEnvironmentId, setProjectGroupingMode, setProjectSortOrder, setThreadSortOrder, } = useHomeListOptions(availableEnvironmentIds); const groups = useMemo( () => buildHomeThreadGroups({ projects, threads, pendingTasks, environmentId: options.selectedEnvironmentId, searchQuery: props.searchQuery, projectSortOrder: options.projectSortOrder, threadSortOrder: options.threadSortOrder, projectGroupingMode: options.projectGroupingMode, }), [options, pendingTasks, projects, props.searchQuery, threads], ); const [groupDisplayStates, setGroupDisplayStates] = useState< ReadonlyMap<string, HomeGroupDisplayState> >(() => new Map()); const updateGroupDisplay = useCallback((key: string, action: HomeGroupDisplayAction) => { setGroupDisplayStates((previous) => { const next = new Map(previous); next.set( key, nextGroupDisplayState(previous.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action), ); return next; }); }, []); const hasSearchQuery = props.searchQuery.trim().length > 0; const listLayout = useMemo( () => buildHomeListLayout({ groups, displayStates: groupDisplayStates, showAllThreads: hasSearchQuery, }), [groups, groupDisplayStates, hasSearchQuery], ); const projectCwdByKey = useMemo(() => { const map = new Map<string, string>(); for (const project of projects) { map.set(scopedProjectKey(project.environmentId, project.id), project.workspaceRoot); } return map; }, [projects]); const showsConnectionStatus = shouldShowWorkspaceConnectionStatus(catalogState); const listMenuActions = useMemo<MenuAction[]>( () => [ { id: "environment", title: "Environment", subactions: [ { id: "environment:all", title: "All environments", subtitle: "Show threads from every environment", state: options.selectedEnvironmentId === null ? "on" : "off", }, ...environments.map((environment) => ({ id: `environment:${environment.environmentId}`, title: environment.label, state: options.selectedEnvironmentId === environment.environmentId ? ("on" as const) : ("off" as const), })), ], }, { id: "project-sort", title: "Sort projects", subactions: PROJECT_SORT_OPTIONS.map((option) => ({ id: `project-sort:${option.value}`, title: option.label, state: options.projectSortOrder === option.value ? "on" : "off", })), }, { id: "thread-sort", title: "Sort threads", subactions: THREAD_SORT_OPTIONS.map((option) => ({ id: `thread-sort:${option.value}`, title: option.label, state: options.threadSortOrder === option.value ? "on" : "off", })), }, { id: "project-grouping", title: "Group projects", subactions: PROJECT_GROUPING_OPTIONS.map((option) => ({ id: `project-grouping:${option.value}`, title: option.label, subtitle: option.subtitle, state: options.projectGroupingMode === option.value ? "on" : "off", })), }, ], [environments, options], ); const handleListMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { const event = nativeEvent.event; if (event === "environment:all") { setSelectedEnvironmentId(null); return; } if (event.startsWith("environment:")) { const environment = environments.find( (candidate) => String(candidate.environmentId) === event.slice("environment:".length), ); if (environment) setSelectedEnvironmentId(environment.environmentId); return; } const projectSort = PROJECT_SORT_OPTIONS.find( (option) => `project-sort:${option.value}` === event, ); if (projectSort) { setProjectSortOrder(projectSort.value); return; } const threadSort = THREAD_SORT_OPTIONS.find( (option) => `thread-sort:${option.value}` === event, ); if (threadSort) { setThreadSortOrder(threadSort.value); return; } const grouping = PROJECT_GROUPING_OPTIONS.find( (option) => `project-grouping:${option.value}` === event, ); if (grouping) setProjectGroupingMode(grouping.value); }, [ environments, setProjectGroupingMode, setProjectSortOrder, setSelectedEnvironmentId, setThreadSortOrder, ], ); const backgroundColor = useThemeColor("--color-drawer"); const borderColor = useThemeColor("--color-border"); const mutedColor = useThemeColor("--color-foreground-muted"); const placeholderColor = useThemeColor("--color-placeholder"); const headerFadeColor = String(backgroundColor); const headerWashOpacity = SIDEBAR_HEADER_WASH_OPACITY[colorScheme]; const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState<number | null>(null); // The sticky header (title row, search field, optional connection status) // is measured so the list inset always matches its real height — no // hardcoded per-variant constants. const stickyHeaderHeight = measuredHeaderHeight ?? insets.top + SIDEBAR_STICKY_HEADER_HEIGHT; const topListInset = stickyHeaderHeight + 6; const handleStickyHeaderLayout = useCallback((event: LayoutChangeEvent) => { const nextHeight = Math.round(event.nativeEvent.layout.height); setMeasuredHeaderHeight((current) => (current === nextHeight ? current : nextHeight)); }, []); const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { if (openSwipeableRef.current !== methods) { openSwipeableRef.current?.close(); openSwipeableRef.current = methods; } }, []); const handleSwipeableClose = useCallback((methods: SwipeableMethods) => { if (openSwipeableRef.current === methods) { openSwipeableRef.current = null; } }, []); const handleSelectThread = useCallback( (thread: EnvironmentThreadShell) => { props.onSelectThread(thread); openSwipeableRef.current?.close(); }, [props.onSelectThread], ); const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => { const next = event.nativeEvent.contentOffset.y > 6; if (headerIsOverContentRef.current === next) { return; } headerIsOverContentRef.current = next; setHeaderIsOverContent(next); }, []); const handleScrollBeginDrag = useCallback(() => { openSwipeableRef.current?.close(); }, []); const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({ onScroll: handleScroll, onScrollBeginDrag: handleScrollBeginDrag, }); const listExtraData = props.selectedThreadKey ?? ""; const focusSearch = useCallback(() => { const focus = () => { if (props.nativeChrome) { searchBarRef.current?.focus(); return; } searchInputRef.current?.focus(); }; if (!props.visible) { props.onRequestVisibility(); setTimeout(focus, 240); } else { focus(); } return true; }, [props.nativeChrome, props.onRequestVisibility, props.visible]); useHardwareKeyboardCommand("focusSearch", focusSearch); const renderListItem = useCallback( ({ item }: { readonly item: HomeListItem }) => { switch (item.type) { case "header": return ( <ThreadListGroupHeader variant="sidebar" collapsed={item.collapsed} isFirst={item.isFirst} groupKey={item.group.key} onGroupAction={updateGroupDisplay} // Same gating as the compact Home list: aggregated groups have no // single target project, and pending-project groups hold a // placeholder shell rather than a real project. newThreadTarget={item.group.newThreadTarget} onNewThread={props.onNewThreadInProject} project={item.group.representative} threadCount={item.group.threads.length + item.group.pendingTasks.length} title={item.group.title} /> ); case "pending-task": return ( <PendingTaskListRow variant="sidebar" pendingTask={item.pendingTask} environmentLabel={ savedConnectionsById[item.pendingTask.message.environmentId]?.environmentLabel ?? null } isLast={item.isLast} onSelectPendingTask={openPendingTask} onDeletePendingTask={confirmDeletePendingTask} /> ); case "thread": { const thread = item.thread; return ( <ThreadListRow variant="sidebar" thread={thread} environmentLabel={ savedConnectionsById[thread.environmentId]?.environmentLabel ?? null } projectCwd={ projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null } isLast={item.isLast} selected={ scopedThreadKey(thread.environmentId, thread.id) === props.selectedThreadKey } fullSwipeWidth={props.width - 20} onArchiveThread={archiveThread} onDeleteThread={confirmDeleteThread} onSelectThread={handleSelectThread} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} simultaneousSwipeGesture={sidebarScrollGesture} /> ); } case "show-more": return ( <ThreadListShowMoreRow variant="sidebar" hiddenCount={item.hiddenCount} canShowLess={item.canShowLess} groupKey={item.groupKey} onGroupAction={updateGroupDisplay} /> ); } }, [ archiveThread, confirmDeletePendingTask, confirmDeleteThread, handleSelectThread, handleSwipeableClose, handleSwipeableWillOpen, openPendingTask, projectCwdByKey, props.onNewThreadInProject, props.selectedThreadKey, props.width, savedConnectionsById, sidebarScrollGesture, updateGroupDisplay, ], ); const filterIcon = hasCustomHomeListOptions(options) ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle"; const filterMenu = useMemo( () => buildHomeListFilterMenu({ environments, selectedEnvironmentId: options.selectedEnvironmentId,