import type { EnvironmentProject, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import { threadSearchMatchKey, type EnvironmentThreadSearchMatch, } from "@t3tools/client-runtime/state/thread-search"; import { LegendList } from "@legendapp/list/react-native"; import type { MenuAction } from "@react-native-menu/menu"; import { useAtomValue } from "@effect/atom-react"; import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts"; import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { LayoutChangeEvent } from "react-native"; import { Platform, Pressable, StyleSheet, TextInput, View } 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 { AppText as Text } from "../../components/AppText"; import { CompactBrandTitle } from "../../components/CompactBrandTitle"; 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 { useProjects, useThreadShells } from "../../state/entities"; import { useThreadSearch } from "../../state/queries"; import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; import { environmentServerConfigsAtom } from "../../state/server"; 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_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, EMPTY_HOME_LIST_LAYOUT, 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 { getConnectionAwareBrandHeaderOptions, WorkspaceConnectionTitle, } from "../home/WorkspaceConnectionTitle"; 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 { ThreadListV2PendingRow, ThreadListV2Row, ThreadListV2SettledShelfHeader, ThreadListV2SnoozedShelfHeader, } from "./thread-list-v2-items"; import { buildThreadListV2Items, buildThreadListV2ListItems, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, type ThreadListV2ListItem, } from "./threadListV2"; /** The sidebar list serves both lists: v1 grouped items or, when the Thread List v2 beta is on, flat v2 rows with queued tasks spliced in, and a settled "Show more" pager. */ type SidebarListItem = | HomeListItem | ThreadListV2ListItem | { readonly type: "v2-show-more"; readonly key: string; readonly hiddenCount: number }; const SIDEBAR_STICKY_HEADER_HEIGHT = 106; 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 ; } return ; } function NativeSidebarContainer(props: ThreadNavigationSidebarProps) { return ( ); } function ThreadNavigationSidebarPane( props: ThreadNavigationSidebarProps & { readonly nativeChrome: boolean }, ) { const insets = useSafeAreaInsets(); const projects = useProjects(); const threads = useThreadShells(); const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); const { savedConnectionsById } = useSavedRemoteConnections(); const searchInputRef = useRef(null); const searchBarRef = useRef(null); const openSwipeableRef = useRef(null); const sidebarScrollGesture = useMemo(() => Gesture.Native(), []); const { archiveThread, confirmDeleteThread, settleThread, snoozeThread, unsnoozeThread, unsettleThread, pinThread, unpinThread, movePinnedThread, regenerateThreadTitle, } = useThreadListActions(); const threadListV2Enabled = useThreadListV2Enabled(); 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 searchEnvironmentIds = useMemo( () => options.selectedEnvironmentId === null ? workspaceEnvironments .filter((environment) => environment.connectionState === "connected") .map((environment) => environment.environmentId) : workspaceEnvironments.some( (environment) => environment.environmentId === options.selectedEnvironmentId && environment.connectionState === "connected", ) ? [options.selectedEnvironmentId] : [], [options.selectedEnvironmentId, workspaceEnvironments], ); const threadSearch = useThreadSearch(searchEnvironmentIds, props.searchQuery); const threadSearchMatchByKey = useMemo(() => { const matches = new Map(); for (const match of threadSearch.matches) { if (match.source === "user" || match.source === "assistant") { matches.set(threadSearchMatchKey(match), match); } } return matches; }, [threadSearch.matches]); const matchedThreadKeys = useMemo( () => new Set(threadSearch.matches.map(threadSearchMatchKey)), [threadSearch.matches], ); const [selectedProjectKey, setSelectedProjectKey] = useState(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( () => threadListV2Enabled ? [] : selectedProjectRefs === null ? projects : projects.filter((project) => selectedProjectRefs.has(scopedProjectKey(project.environmentId, project.id)), ), [threadListV2Enabled, projects, selectedProjectRefs], ); const scopedThreads = useMemo( () => threadListV2Enabled ? [] : selectedProjectRefs === null ? threads : threads.filter((thread) => selectedProjectRefs.has(scopedProjectKey(thread.environmentId, thread.projectId)), ), [threadListV2Enabled, selectedProjectRefs, threads], ); const scopedPendingTasks = useMemo( () => threadListV2Enabled ? [] : selectedProjectRefs === null ? pendingTasks : pendingTasks.filter((pendingTask) => selectedProjectRefs.has( scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), ), ), [threadListV2Enabled, pendingTasks, selectedProjectRefs], ); const groups = useMemo( () => threadListV2Enabled ? [] : buildHomeThreadGroups({ projects: scopedProjects, threads: scopedThreads, pendingTasks: scopedPendingTasks, environmentId: options.selectedEnvironmentId, searchQuery: props.searchQuery, matchedThreadKeys, projectSortOrder: options.projectSortOrder, threadSortOrder: options.threadSortOrder, projectGroupingMode: options.projectGroupingMode, }), [ threadListV2Enabled, matchedThreadKeys, options, props.searchQuery, scopedPendingTasks, scopedProjects, scopedThreads, ], ); const [groupDisplayStates, setGroupDisplayStates] = useState< ReadonlyMap >(() => 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( () => threadListV2Enabled ? EMPTY_HOME_LIST_LAYOUT : buildHomeListLayout({ groups, displayStates: groupDisplayStates, showAllThreads: hasSearchQuery, }), [threadListV2Enabled, groups, groupDisplayStates, hasSearchQuery], ); const projectCwdByKey = useMemo(() => { const map = new Map(); for (const project of projects) { map.set(scopedProjectKey(project.environmentId, project.id), project.workspaceRoot); } return map; }, [projects]); const projectByKey = useMemo(() => { const map = new Map(); 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. // 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), [], ); const { loaded: shelfPreferencesLoaded, settledShelfExpanded, snoozedShelfExpanded, toggleSettledShelf, toggleSnoozedShelf, } = useThreadListV2ShelfPreferences(); // The queued-start and snooze helpers need a clock while the pane stays open. 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 because the mount-time value can be hours old. 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(); 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(); for (const [environmentId, config] of serverConfigs) { if (config.environment.capabilities.threadSnooze === true) { supported.add(environmentId); } } return supported; }, [serverConfigs]); const pinningEnvironmentIds = useMemo(() => { const supported = new Set(); for (const [environmentId, config] of serverConfigs) { if (config.environment.capabilities.threadPinning === true) { supported.add(environmentId); } } return supported; }, [serverConfigs]); const pinReorderEnvironmentIds = useMemo(() => { const supported = new Set(); for (const [environmentId, config] of serverConfigs) { if (config.environment.capabilities.threadPinReorder === true) { supported.add(environmentId); } } return supported; }, [serverConfigs]); const titleRegenerationEnvironmentIds = useMemo(() => { const supported = new Set(); for (const [environmentId, config] of serverConfigs) { if (config.environment.capabilities.threadTitleRegeneration === true) { supported.add(environmentId); } } return supported; }, [serverConfigs]); const machineByEnvironmentId = useMemo( () => new Map( [...serverConfigs].map( ([environmentId, config]) => [environmentId, resolveEnvironmentMachineKind(config)] as const, ), ), [serverConfigs], ); // Canonical arranged pinned order for Move up/down flags — computed from // all shells so search/scope filtering never disables a valid move. const arrangedPinnedKeys = useMemo(() => { const pinned = sortPinnedThreadsByOrderKey( threads.filter( (thread) => thread.pinnedAt != null && thread.archivedAt === null && pinReorderEnvironmentIds.has(thread.environmentId), ), ); return pinned.map((thread) => `${thread.environmentId}:${thread.id}`); }, [pinReorderEnvironmentIds, threads]); const threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0, snoozedCount: 0, snoozedShelfHeaderIndex: null, settledCount: 0, settledShelfHeaderIndex: null, nextSnoozeWakeAt: null, }; return buildThreadListV2Items({ threads: threads.filter((thread) => thread.archivedAt === null), environmentId: options.selectedEnvironmentId, projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs, searchQuery: props.searchQuery, matchedThreadKeys, settlementEnvironmentIds, snoozeEnvironmentIds, settledLimit: settledVisibleCount, now: new Date().toISOString(), snoozedShelfExpanded, settledShelfExpanded, selectedThreadKey: props.selectedThreadKey ?? null, }); }, [ nowMinute, snoozeWakeTick, snoozedShelfExpanded, settledShelfExpanded, props.selectedThreadKey, options.selectedEnvironmentId, props.searchQuery, matchedThreadKeys, 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(() => { if (!threadListV2Enabled) return listLayout.items; // Queued offline tasks are not thread shells, so the v2 item builder // never sees them; the shared splice puts them below the active block // (mirrors the compact Home v2 list) where they 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[] = buildThreadListV2ListItems({ items: threadListV2Layout.items, pendingTasks: v2PendingTasks, snoozedCount: threadListV2Layout.snoozedCount, snoozedShelfExpanded, snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex, settledCount: threadListV2Layout.settledCount, settledShelfExpanded, settledShelfHeaderIndex: threadListV2Layout.settledShelfHeaderIndex, snoozeLabelNow: `${nowMinute}:00.000Z`, }); if (settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0) { items.push({ type: "v2-show-more", key: "v2-show-more", hiddenCount: threadListV2Layout.hiddenSettledCount, }); } return items; }, [ listLayout.items, nowMinute, options.selectedEnvironmentId, pendingTasks, props.searchQuery, selectedProjectRefs, settledShelfExpanded, snoozedShelfExpanded, threadListV2Enabled, threadListV2Layout, ]); const listMenuActions = useMemo( () => [ { 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), })), ], }, ...(projectFilterOptions.length === 0 ? [] : ([ { id: "project", title: "Project", subactions: [ { id: "project:all", title: "All projects", subtitle: "Show threads from every project", state: selectedProjectKey === null ? "on" : "off", }, ...projectFilterOptions.map((project) => ({ id: `project:${project.key}`, title: project.label, state: selectedProjectKey === project.key ? ("on" as const) : ("off" as const), })), ], }, ] satisfies MenuAction[])), // v2 lays the list out in fixed creation order — offering sort/group // controls it silently ignores would be a lie. Environment still // scopes the v2 partition, so it stays. ...(threadListV2Enabled ? [] : ([ { 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", })), }, ] satisfies MenuAction[])), ], [environments, options, projectFilterOptions, selectedProjectKey, threadListV2Enabled], ); 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; } if (event === "project:all") { setSelectedProjectKey(null); return; } if (event.startsWith("project:")) { const projectKey = event.slice("project:".length); if (projectFilterOptions.some((project) => project.key === projectKey)) { setSelectedProjectKey(projectKey); } 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; } }, [ environments, projectFilterOptions, setProjectSortOrder, setSelectedEnvironmentId, setThreadSortOrder, ], ); const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState(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 handleScrollBeginDrag = useCallback(() => { openSwipeableRef.current?.close(); }, []); const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({ onScrollBeginDrag: handleScrollBeginDrag, }); // Project shells load after the first rows draw, so the maps they feed have // to bust the recycler's memoization — otherwise a row keeps the blank // favicon and fallback title it was first rendered with. const listExtraData = useMemo( () => ({ selectedThreadKey: props.selectedThreadKey ?? "", projectByKey, projectCwdByKey, projectTitleByProjectKey, savedConnectionsById, serverConfigs, snoozePresetMinute: nowMinute, threadSearchMatchByKey, }), [ props.selectedThreadKey, projectByKey, projectCwdByKey, projectTitleByProjectKey, savedConnectionsById, serverConfigs, nowMinute, threadSearchMatchByKey, ], ); const sidebarItemsAreEqual = useCallback( (previous: SidebarListItem, item: SidebarListItem): boolean => { if (previous.type === "v2-thread" && item.type === "v2-thread") { return ( previous.key === item.key && previous.item.thread === item.item.thread && previous.item.variant === item.item.variant && previous.item.snoozed === item.item.snoozed && previous.item.pinned === item.item.pinned && previous.snoozeWakeLabelText === item.snoozeWakeLabelText ); } if (previous.type === "v2-show-more" && item.type === "v2-show-more") { return previous.hiddenCount === item.hiddenCount; } if (previous.type === "v2-pending" && item.type === "v2-pending") { return ( previous.pendingTask === item.pendingTask && previous.showPendingDivider === item.showPendingDivider ); } if (previous.type === "v2-snoozed-shelf" && item.type === "v2-snoozed-shelf") { return previous.count === item.count && previous.expanded === item.expanded; } if (previous.type === "v2-settled-shelf" && item.type === "v2-settled-shelf") { return previous.count === item.count && previous.expanded === item.expanded; } if ( previous.type === "v2-thread" || previous.type === "v2-show-more" || previous.type === "v2-pending" || previous.type === "v2-snoozed-shelf" || previous.type === "v2-settled-shelf" || item.type === "v2-thread" || item.type === "v2-show-more" || item.type === "v2-pending" || item.type === "v2-snoozed-shelf" || item.type === "v2-settled-shelf" ) { return false; } return homeListItemsAreEqual(previous, item); }, [], ); 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: SidebarListItem }) => { switch (item.type) { case "v2-pending": { const pendingScopeKey = scopedProjectKey( item.pendingTask.message.environmentId, item.pendingTask.creation.projectId, ); return ( 1 ? (savedConnectionsById[item.pendingTask.message.environmentId] ?.environmentLabel ?? null) : null } environmentMachine={machineByEnvironmentId.get( item.pendingTask.message.environmentId, )} pane="sidebar" showPendingDivider={item.showPendingDivider} onSelectPendingTask={openPendingTask} onDeletePendingTask={confirmDeletePendingTask} /> ); } case "v2-thread": { const thread = item.item.thread; const scopeKey = scopedProjectKey(thread.environmentId, thread.projectId); return ( provider.instanceId === (thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId), )?.driver ?? null } environmentLabel={ Object.keys(savedConnectionsById).length > 1 ? (savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) : null } environmentMachine={machineByEnvironmentId.get(thread.environmentId)} searchMatch={threadSearchMatchByKey.get( threadSearchMatchKey({ environmentId: thread.environmentId, threadId: thread.id, }), )} searchQuery={props.searchQuery} pane="sidebar" selected={ scopedThreadKey(thread.environmentId, thread.id) === props.selectedThreadKey } fullSwipeWidth={props.width - 20} onSelectThread={handleSelectThread} onDeleteThread={confirmDeleteThread} onArchiveThread={archiveThread} onRegenerateThreadTitle={regenerateThreadTitle} titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)} settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} onSettleThread={settleThread} snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} pinReorderSupported={pinReorderEnvironmentIds.has(thread.environmentId)} canMovePinnedUp={ arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`) > 0 } canMovePinnedDown={(() => { const index = arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`); return index !== -1 && index < arrangedPinnedKeys.length - 1; })()} onSnoozeThread={snoozeThread} onUnsnoozeThread={unsnoozeThread} onUnsettleThread={unsettleThread} onPinThread={pinThread} onUnpinThread={unpinThread} onMovePinnedThread={movePinnedThread} projectCwd={projectCwdByKey.get(scopeKey) ?? null} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} simultaneousSwipeGesture={sidebarScrollGesture} /> ); } case "v2-snoozed-shelf": return ( ); case "v2-settled-shelf": return ( ); case "v2-show-more": return ( ({ opacity: pressed ? 0.6 : 1 })} > Show more ({item.hiddenCount} settled hidden) ); case "header": return ( ); case "pending-task": return ( ); case "thread": { const thread = item.thread; return ( ); } case "show-more": return ( ); } }, [ archiveThread, arrangedPinnedKeys, confirmDeletePendingTask, confirmDeleteThread, handleSelectThread, handleSwipeableClose, handleSwipeableWillOpen, machineByEnvironmentId, movePinnedThread, openPendingTask, pinReorderEnvironmentIds, pinThread, pinningEnvironmentIds, projectByKey, projectCwdByKey, projectTitleByProjectKey, regenerateThreadTitle, props.onNewThreadInProject, props.searchQuery, props.selectedThreadKey, props.width, savedConnectionsById, serverConfigs, shelfPreferencesLoaded, threadSearchMatchByKey, titleRegenerationEnvironmentIds, settleThread, settlementEnvironmentIds, showMoreSettled, sidebarScrollGesture, snoozeEnvironmentIds, snoozeThread, nowMinute, toggleSettledShelf, toggleSnoozedShelf, unpinThread, unsettleThread, unsnoozeThread, updateGroupDisplay, ], ); // v2 ignores the sort/group options, so only the environment filter can // light the "customized" state while the beta is on. const filterCustomized = threadListV2Enabled ? options.selectedEnvironmentId !== null || selectedProjectKey !== null : hasCustomHomeListOptions({ ...options, selectedProjectKey }); const filterIcon = filterCustomized ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle"; const filterMenu = useMemo( () => buildHomeListFilterMenu({ environments, projects: projectFilterOptions, selectedEnvironmentId: options.selectedEnvironmentId, selectedProjectKey, projectSortOrder: options.projectSortOrder, threadSortOrder: options.threadSortOrder, onEnvironmentChange: setSelectedEnvironmentId, onProjectChange: setSelectedProjectKey, onProjectSortOrderChange: setProjectSortOrder, onThreadSortOrderChange: setThreadSortOrder, listOrganization: !threadListV2Enabled, }), [ environments, options, projectFilterOptions, selectedProjectKey, setProjectSortOrder, setSelectedEnvironmentId, setThreadSortOrder, threadListV2Enabled, ], ); const nativeHeaderItems = useMemo( () => createSidebarHeaderItems({ filterIcon, filterMenu, onOpenSettings: props.onOpenSettings, }), [filterIcon, filterMenu, props.onOpenSettings], ); // Snoozed threads need no special case: the shelf header is a list row // even while collapsed. const listEmpty = ( {catalogState.isLoadingConnections ? "Loading threads…" : props.searchQuery.trim().length > 0 ? threadSearch.isPending ? "Searching thread messages…" : "No matching threads" : selectedProjectScope !== null ? `No threads in ${selectedProjectScope.title}` : "No threads yet"} ); if (props.nativeChrome) { return ( <> { props.onSearchQueryChange(""); }, onChangeText: (event) => { props.onSearchQueryChange(event.nativeEvent.text); }, }, unstable_headerRightItems: () => nativeHeaderItems, }} /> item.type} itemsAreEqual={sidebarItemsAreEqual} keyExtractor={(item) => item.key} renderItem={renderListItem} automaticallyAdjustsScrollIndicatorInsets={NATIVE_LIQUID_GLASS_SUPPORTED} contentInsetAdjustmentBehavior={ NATIVE_LIQUID_GLASS_SUPPORTED ? "automatic" : "never" } contentContainerStyle={[ styles.threadListContent, { paddingBottom: Math.max(insets.bottom, 16) + 16, paddingTop: 6, }, ]} keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" {...scrollGateHandlers} recycleItems scrollEventThrottle={16} showsVerticalScrollIndicator={false} style={styles.threadList} ListEmptyComponent={listEmpty} /> ); } return ( item.type} itemsAreEqual={sidebarItemsAreEqual} keyExtractor={(item) => item.key} renderItem={renderListItem} contentContainerStyle={[ styles.threadListContent, { paddingBottom: Platform.OS === "android" ? Math.max(insets.bottom, 16) + 88 - insets.bottom : 16 + insets.bottom, paddingTop: topListInset, }, ]} keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" {...scrollGateHandlers} recycleItems scrollEventThrottle={16} showsVerticalScrollIndicator={false} style={styles.threadList} ListEmptyComponent={listEmpty} /> {/* Title slot doubles as the connection status surface: while an environment reconnects, the brand fades to a status label in place (no layout shift in the list below). */} } /> ); } const styles = StyleSheet.create({ threadList: { flex: 1, }, threadListContent: { paddingHorizontal: 8, }, });