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 { useAtomValue } from "@effect/atom-react";import { AsyncResult } from "effect/unstable/reactivity";import type { EnvironmentId } from "@t3tools/contracts";import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native";import { Platform, Pressable, 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 { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";import { NativeStackScreenOptions } from "../../native/StackHeader";import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";import { useThemeColor } from "../../lib/useThemeColor";import { useProjects, useThreadShells } from "../../state/entities";import { mobilePreferencesAtom } from "../../state/preferences";import { environmentServerConfigsAtom } from "../../state/server";import { usePendingNewTasks, type PendingNewTask } 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_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 { buildHomeProjectScopes, 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";import { ThreadListV2Row } from "./thread-list-v2-items";import { buildThreadListV2Items, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, type ThreadListV2Item,} from "./threadListV2"; /** The sidebar list serves both lists: v1 grouped items or, when the Thread List v2 beta is on, queued offline tasks, flat v2 rows, and a settled "Show more" pager. */type SidebarListItem = | HomeListItem | { readonly type: "v2-pending-task"; readonly key: string; readonly pendingTask: PendingNewTask; readonly isLast: boolean; } | { readonly type: "v2-thread"; readonly key: string; readonly item: ThreadListV2Item } | { readonly type: "v2-show-more"; readonly key: string; readonly hiddenCount: number }; /** * 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, settleThread, unsettleThread } = useThreadListActions(); const preferencesResult = useAtomValue(mobilePreferencesAtom); const threadListV2Enabled = AsyncResult.isSuccess(preferencesResult) && preferencesResult.value.threadListV2Enabled === true; 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, setProjectSortOrder, setThreadSortOrder } = useHomeListOptions(availableEnvironmentIds); const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null); const projectScopes = useMemo( () => buildHomeProjectScopes({ projects, environmentId: options.selectedEnvironmentId, projectGroupingMode: options.projectGroupingMode, }), [options.projectGroupingMode, options.selectedEnvironmentId, projects], ); const projectFilterOptions = useMemo( () => projectScopes.map((scope) => ({ key: scope.key, label: scope.title, })), [projectScopes], ); const projectTitleByProjectKey = useMemo( () => new Map( projectScopes.flatMap((scope) => scope.projectRefs.map( (projectRef) => [ scopedProjectKey(projectRef.environmentId, projectRef.projectId), scope.title, ] as const, ), ), ), [projectScopes], ); const selectedProjectScope = useMemo( () => selectedProjectKey === null ? null : (projectScopes.find((scope) => scope.key === selectedProjectKey) ?? null), [projectScopes, selectedProjectKey], ); useEffect(() => { if ( selectedProjectKey !== null && !projectFilterOptions.some((project) => project.key === selectedProjectKey) ) { setSelectedProjectKey(null); } }, [projectFilterOptions, selectedProjectKey]); const selectedProjectRefs = useMemo( () => selectedProjectScope === null ? null : new Set( selectedProjectScope.projectRefs.map((projectRef) => scopedProjectKey(projectRef.environmentId, projectRef.projectId), ), ), [selectedProjectScope], ); const scopedProjects = useMemo( () => selectedProjectRefs === null ? projects : projects.filter((project) => selectedProjectRefs.has(scopedProjectKey(project.environmentId, project.id)), ), [projects, selectedProjectRefs], ); const scopedThreads = useMemo( () => selectedProjectRefs === null ? threads : threads.filter((thread) => selectedProjectRefs.has(scopedProjectKey(thread.environmentId, thread.projectId)), ), [selectedProjectRefs, threads], ); const scopedPendingTasks = useMemo( () => selectedProjectRefs === null ? pendingTasks : pendingTasks.filter((pendingTask) => selectedProjectRefs.has( scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), ), ), [pendingTasks, selectedProjectRefs], ); const groups = useMemo( () => buildHomeThreadGroups({ projects: scopedProjects, threads: scopedThreads, pendingTasks: scopedPendingTasks, environmentId: options.selectedEnvironmentId, searchQuery: props.searchQuery, projectSortOrder: options.projectSortOrder, threadSortOrder: options.threadSortOrder, projectGroupingMode: options.projectGroupingMode, }), [options, props.searchQuery, scopedPendingTasks, scopedProjects, scopedThreads], ); 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 projectByKey = useMemo(() => { const map = new Map<string, EnvironmentProject>(); for (const project of projects) { map.set(scopedProjectKey(project.environmentId, project.id), project); } return map; }, [projects]); // Thread List v2 (beta) support — same model as the compact Home list // (HomeScreen.tsx): flat creation-order card block + settled recency tail. // PR states stream in per-row; merged/closed PRs auto-settle their thread // on the next partition. const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< ReadonlyMap<string, "open" | "closed" | "merged"> >(() => new Map()); const handleChangeRequestState = useCallback( (threadKey: string, state: "open" | "closed" | "merged" | null) => { setChangeRequestStateByKey((current) => { if ((current.get(threadKey) ?? null) === state) return current; const next = new Map(current); if (state === null) { next.delete(threadKey); } else { next.set(threadKey, state); } return next; }); }, [], ); // The settled tail renders in pages; expansion resets when the filter // context changes so environment/search flips never inherit a deep page. const [settledVisibleCount, setSettledVisibleCount] = useState( THREAD_LIST_V2_SETTLED_INITIAL_COUNT, ); const settledResetKey = `${options.selectedEnvironmentId ?? "all"}:${selectedProjectKey ?? "all"}:${props.searchQuery.trim()}`; const lastSettledResetKeyRef = useRef(settledResetKey); if (lastSettledResetKeyRef.current !== settledResetKey) { lastSettledResetKeyRef.current = settledResetKey; setSettledVisibleCount(THREAD_LIST_V2_SETTLED_INITIAL_COUNT); } const showMoreSettled = useCallback( () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT), [], ); // now ticks per minute so the inactivity auto-settle boundary is actually // crossed while the pane stays open; without a clock dependency the // partition memoizes a frozen "now". const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); // Snooze wake times are second-precise; a counter bumped exactly at the // next wake boundary re-runs the partition with a fresh clock so a woken // thread reappears immediately instead of on the next minute tick. const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); useEffect(() => { if (!threadListV2Enabled) return; // Refresh immediately on enable: the mount-time value can be hours old // by the time the beta is switched on, which would misclassify the // inactivity auto-settle boundary until the first tick. setNowMinute(new Date().toISOString().slice(0, 16)); const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); return () => clearInterval(id); }, [threadListV2Enabled]); // Threads on servers without the settlement capability never classify as // settled (the user could neither un-settle nor pin them). const serverConfigs = useAtomValue(environmentServerConfigsAtom); const settlementEnvironmentIds = useMemo(() => { const supported = new Set<EnvironmentId>(); for (const [environmentId, config] of serverConfigs) { if (config.environment.capabilities.threadSettlement === true) { supported.add(environmentId); } } return supported; }, [serverConfigs]); const snoozeEnvironmentIds = useMemo(() => { const supported = new Set<EnvironmentId>(); for (const [environmentId, config] of serverConfigs) { if (config.environment.capabilities.threadSnooze === true) { supported.add(environmentId); } } return supported; }, [serverConfigs]); const threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0, snoozedCount: 0, nextSnoozeWakeAt: null }; return buildThreadListV2Items({ threads: threads.filter((thread) => thread.archivedAt === null), environmentId: options.selectedEnvironmentId, projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs, searchQuery: props.searchQuery, changeRequestStateByKey, settlementEnvironmentIds, snoozeEnvironmentIds, settledLimit: settledVisibleCount, now: `${nowMinute}:00.000Z`, snoozeNow: new Date().toISOString(), }); }, [ changeRequestStateByKey, nowMinute, snoozeWakeTick, options.selectedEnvironmentId, props.searchQuery, settledVisibleCount, settlementEnvironmentIds, snoozeEnvironmentIds, threadListV2Enabled, threads, selectedProjectScope, ]); // Re-partition the moment the earliest snooze expires (clamped to the // signed-32-bit setTimeout range; far-future wakes re-arm at the clamp). const nextSnoozeWakeAt = threadListV2Layout.nextSnoozeWakeAt; useEffect(() => { if (nextSnoozeWakeAt === null) return; const wakeAtMs = Date.parse(nextSnoozeWakeAt); if (Number.isNaN(wakeAtMs)) return; const delayMs = Math.min(Math.max(0, wakeAtMs - Date.now()) + 50, 2_147_483_647); const id = setTimeout(() => bumpSnoozeWakeTick((tick) => tick + 1), delayMs); return () => clearTimeout(id); // snoozeWakeTick must re-arm the timer even when nextSnoozeWakeAt is // unchanged: after a clamped fire (wake beyond the 32-bit setTimeout // range) the boundary string is identical and the chain would die. }, [nextSnoozeWakeAt, snoozeWakeTick]); const listItems = useMemo<readonly SidebarListItem[]>(() => { if (!threadListV2Enabled) return listLayout.items; // Queued offline tasks render above the thread rows (mirrors the // compact Home v2 list): they are not thread shells, so the v2 item // builder never sees them, but they must stay visible and deletable // while their environment is offline. Same environment scope and // search filter as the list. const v2SearchQuery = props.searchQuery.trim().toLocaleLowerCase(); const v2PendingTasks = pendingTasks.filter( (pendingTask) => (options.selectedEnvironmentId === null || pendingTask.message.environmentId === options.selectedEnvironmentId) && (selectedProjectRefs === null || selectedProjectRefs.has( scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), )) && (v2SearchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), ); const items: SidebarListItem[] = v2PendingTasks.map((pendingTask, index) => ({ type: "v2-pending-task" as const, key: `v2-pending:${pendingTask.message.messageId}`, pendingTask,Lines 1–500 of 1206. 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 { useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; import type { EnvironmentId } from "@t3tools/contracts"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native"; import { Platform, Pressable, 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 { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useThemeColor } from "../../lib/useThemeColor"; import { useProjects, useThreadShells } from "../../state/entities"; import { mobilePreferencesAtom } from "../../state/preferences"; import { environmentServerConfigsAtom } from "../../state/server"; import { usePendingNewTasks, type PendingNewTask } 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_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 { buildHomeProjectScopes, 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"; import { ThreadListV2Row } from "./thread-list-v2-items"; import { buildThreadListV2Items, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, type ThreadListV2Item, } from "./threadListV2"; /** The sidebar list serves both lists: v1 grouped items or, when the Thread List v2 beta is on, queued offline tasks, flat v2 rows, and a settled "Show more" pager. */ type SidebarListItem = | HomeListItem | { readonly type: "v2-pending-task"; readonly key: string; readonly pendingTask: PendingNewTask; readonly isLast: boolean; } | { readonly type: "v2-thread"; readonly key: string; readonly item: ThreadListV2Item } | { readonly type: "v2-show-more"; readonly key: string; readonly hiddenCount: number }; /** * 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, settleThread, unsettleThread } = useThreadListActions(); const preferencesResult = useAtomValue(mobilePreferencesAtom); const threadListV2Enabled = AsyncResult.isSuccess(preferencesResult) && preferencesResult.value.threadListV2Enabled === true; 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, setProjectSortOrder, setThreadSortOrder } = useHomeListOptions(availableEnvironmentIds); const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null); const projectScopes = useMemo( () => buildHomeProjectScopes({ projects, environmentId: options.selectedEnvironmentId, projectGroupingMode: options.projectGroupingMode, }), [options.projectGroupingMode, options.selectedEnvironmentId, projects], ); const projectFilterOptions = useMemo( () => projectScopes.map((scope) => ({ key: scope.key, label: scope.title, })), [projectScopes], ); const projectTitleByProjectKey = useMemo( () => new Map( projectScopes.flatMap((scope) => scope.projectRefs.map( (projectRef) => [ scopedProjectKey(projectRef.environmentId, projectRef.projectId), scope.title, ] as const, ), ), ), [projectScopes], ); const selectedProjectScope = useMemo( () => selectedProjectKey === null ? null : (projectScopes.find((scope) => scope.key === selectedProjectKey) ?? null), [projectScopes, selectedProjectKey], ); useEffect(() => { if ( selectedProjectKey !== null && !projectFilterOptions.some((project) => project.key === selectedProjectKey) ) { setSelectedProjectKey(null); } }, [projectFilterOptions, selectedProjectKey]); const selectedProjectRefs = useMemo( () => selectedProjectScope === null ? null : new Set( selectedProjectScope.projectRefs.map((projectRef) => scopedProjectKey(projectRef.environmentId, projectRef.projectId), ), ), [selectedProjectScope], ); const scopedProjects = useMemo( () => selectedProjectRefs === null ? projects : projects.filter((project) => selectedProjectRefs.has(scopedProjectKey(project.environmentId, project.id)), ), [projects, selectedProjectRefs], ); const scopedThreads = useMemo( () => selectedProjectRefs === null ? threads : threads.filter((thread) => selectedProjectRefs.has(scopedProjectKey(thread.environmentId, thread.projectId)), ), [selectedProjectRefs, threads], ); const scopedPendingTasks = useMemo( () => selectedProjectRefs === null ? pendingTasks : pendingTasks.filter((pendingTask) => selectedProjectRefs.has( scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), ), ), [pendingTasks, selectedProjectRefs], ); const groups = useMemo( () => buildHomeThreadGroups({ projects: scopedProjects, threads: scopedThreads, pendingTasks: scopedPendingTasks, environmentId: options.selectedEnvironmentId, searchQuery: props.searchQuery, projectSortOrder: options.projectSortOrder, threadSortOrder: options.threadSortOrder, projectGroupingMode: options.projectGroupingMode, }), [options, props.searchQuery, scopedPendingTasks, scopedProjects, scopedThreads], ); 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 projectByKey = useMemo(() => { const map = new Map<string, EnvironmentProject>(); for (const project of projects) { map.set(scopedProjectKey(project.environmentId, project.id), project); } return map; }, [projects]); // Thread List v2 (beta) support — same model as the compact Home list // (HomeScreen.tsx): flat creation-order card block + settled recency tail. // PR states stream in per-row; merged/closed PRs auto-settle their thread // on the next partition. const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< ReadonlyMap<string, "open" | "closed" | "merged"> >(() => new Map()); const handleChangeRequestState = useCallback( (threadKey: string, state: "open" | "closed" | "merged" | null) => { setChangeRequestStateByKey((current) => { if ((current.get(threadKey) ?? null) === state) return current; const next = new Map(current); if (state === null) { next.delete(threadKey); } else { next.set(threadKey, state); } return next; }); }, [], ); // The settled tail renders in pages; expansion resets when the filter // context changes so environment/search flips never inherit a deep page. const [settledVisibleCount, setSettledVisibleCount] = useState( THREAD_LIST_V2_SETTLED_INITIAL_COUNT, ); const settledResetKey = `${options.selectedEnvironmentId ?? "all"}:${selectedProjectKey ?? "all"}:${props.searchQuery.trim()}`; const lastSettledResetKeyRef = useRef(settledResetKey); if (lastSettledResetKeyRef.current !== settledResetKey) { lastSettledResetKeyRef.current = settledResetKey; setSettledVisibleCount(THREAD_LIST_V2_SETTLED_INITIAL_COUNT); } const showMoreSettled = useCallback( () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT), [], ); // now ticks per minute so the inactivity auto-settle boundary is actually // crossed while the pane stays open; without a clock dependency the // partition memoizes a frozen "now". const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); // Snooze wake times are second-precise; a counter bumped exactly at the // next wake boundary re-runs the partition with a fresh clock so a woken // thread reappears immediately instead of on the next minute tick. const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); useEffect(() => { if (!threadListV2Enabled) return; // Refresh immediately on enable: the mount-time value can be hours old // by the time the beta is switched on, which would misclassify the // inactivity auto-settle boundary until the first tick. setNowMinute(new Date().toISOString().slice(0, 16)); const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); return () => clearInterval(id); }, [threadListV2Enabled]); // Threads on servers without the settlement capability never classify as // settled (the user could neither un-settle nor pin them). const serverConfigs = useAtomValue(environmentServerConfigsAtom); const settlementEnvironmentIds = useMemo(() => { const supported = new Set<EnvironmentId>(); for (const [environmentId, config] of serverConfigs) { if (config.environment.capabilities.threadSettlement === true) { supported.add(environmentId); } } return supported; }, [serverConfigs]); const snoozeEnvironmentIds = useMemo(() => { const supported = new Set<EnvironmentId>(); for (const [environmentId, config] of serverConfigs) { if (config.environment.capabilities.threadSnooze === true) { supported.add(environmentId); } } return supported; }, [serverConfigs]); const threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0, snoozedCount: 0, nextSnoozeWakeAt: null }; return buildThreadListV2Items({ threads: threads.filter((thread) => thread.archivedAt === null), environmentId: options.selectedEnvironmentId, projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs, searchQuery: props.searchQuery, changeRequestStateByKey, settlementEnvironmentIds, snoozeEnvironmentIds, settledLimit: settledVisibleCount, now: `${nowMinute}:00.000Z`, snoozeNow: new Date().toISOString(), }); }, [ changeRequestStateByKey, nowMinute, snoozeWakeTick, options.selectedEnvironmentId, props.searchQuery, settledVisibleCount, settlementEnvironmentIds, snoozeEnvironmentIds, threadListV2Enabled, threads, selectedProjectScope, ]); // Re-partition the moment the earliest snooze expires (clamped to the // signed-32-bit setTimeout range; far-future wakes re-arm at the clamp). const nextSnoozeWakeAt = threadListV2Layout.nextSnoozeWakeAt; useEffect(() => { if (nextSnoozeWakeAt === null) return; const wakeAtMs = Date.parse(nextSnoozeWakeAt); if (Number.isNaN(wakeAtMs)) return; const delayMs = Math.min(Math.max(0, wakeAtMs - Date.now()) + 50, 2_147_483_647); const id = setTimeout(() => bumpSnoozeWakeTick((tick) => tick + 1), delayMs); return () => clearTimeout(id); // snoozeWakeTick must re-arm the timer even when nextSnoozeWakeAt is // unchanged: after a clamped fire (wake beyond the 32-bit setTimeout // range) the boundary string is identical and the chain would die. }, [nextSnoozeWakeAt, snoozeWakeTick]); const listItems = useMemo<readonly SidebarListItem[]>(() => { if (!threadListV2Enabled) return listLayout.items; // Queued offline tasks render above the thread rows (mirrors the // compact Home v2 list): they are not thread shells, so the v2 item // builder never sees them, but they must stay visible and deletable // while their environment is offline. Same environment scope and // search filter as the list. const v2SearchQuery = props.searchQuery.trim().toLocaleLowerCase(); const v2PendingTasks = pendingTasks.filter( (pendingTask) => (options.selectedEnvironmentId === null || pendingTask.message.environmentId === options.selectedEnvironmentId) && (selectedProjectRefs === null || selectedProjectRefs.has( scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), )) && (v2SearchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), ); const items: SidebarListItem[] = v2PendingTasks.map((pendingTask, index) => ({ type: "v2-pending-task" as const, key: `v2-pending:${pendingTask.message.messageId}`, pendingTask,