import { LegendList, type LegendListRef, type LegendListRenderItemProps,} from "@legendapp/list/react-native";import { type EnvironmentProject, type EnvironmentThreadShell,} from "@t3tools/client-runtime/state/shell";import { threadSearchMatchKey, type EnvironmentThreadSearchMatch,} from "@t3tools/client-runtime/state/thread-search";import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";import type { EnvironmentId, SidebarProjectGroupingMode, SidebarThreadSortOrder,} from "@t3tools/contracts";import { useAtomSet, useAtomValue } from "@effect/atom-react";import { AsyncResult } from "effect/unstable/reactivity";import { useCallback, useEffect, useMemo, useRef, useState } from "react";import { ActivityIndicator, FlatList, Platform, Pressable, View } from "react-native";import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";import { useSafeAreaInsets } from "react-native-safe-area-context";import { useThemeColor } from "../../lib/useThemeColor"; import { AppText as Text } from "../../components/AppText";import { EmptyState } from "../../components/EmptyState";import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel";import type { SavedRemoteConnection } from "../../lib/connection";import { scopedProjectKey } from "../../lib/scopedEntities";import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";import { useThreadSearch } from "../../state/queries";import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";import { environmentServerConfigsAtom } from "../../state/server";import type { PendingNewTask } from "../../state/use-pending-new-tasks";import { PendingTaskListRow, ThreadListGroupHeader, ThreadListRow, ThreadListShowMoreRow,} from "../threads/thread-list-items";import { ThreadListV2PendingRow, ThreadListV2Row, ThreadListV2SettledShelfHeader, ThreadListV2SnoozedShelfHeader,} from "../threads/thread-list-v2-items";import { buildThreadListV2Items, buildThreadListV2ListItems, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, type ThreadListV2ListItem,} from "../threads/threadListV2";import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu";import { buildHomeListLayout, DEFAULT_GROUP_DISPLAY_STATE, homeListItemsAreEqual, nextGroupDisplayState, type HomeGroupDisplayAction, type HomeGroupDisplayState, type HomeListItem,} from "./homeListItems";import { buildHomeProjectScopes, buildHomeThreadGroups, sortHomeProjectScopes, type HomeProjectSortOrder,} from "./homeThreadList";import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions"; /* ─── Types ──────────────────────────────────────────────────────────── */ interface HomeScreenProps { readonly projects: ReadonlyArray<EnvironmentProject>; readonly threads: ReadonlyArray<EnvironmentThreadShell>; readonly pendingTasks: ReadonlyArray<PendingNewTask>; readonly catalogState: WorkspaceState; readonly savedConnectionsById: Readonly<Record<string, SavedRemoteConnection>>; readonly environments: ReadonlyArray< HomeListFilterMenuEnvironment & Pick<WorkspaceEnvironment, "connectionState"> >; readonly searchQuery: string; readonly selectedEnvironmentId: EnvironmentId | null; readonly selectedProjectKey: string | null; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; readonly projectGroupingMode: SidebarProjectGroupingMode; readonly onSearchQueryChange: (query: string) => void; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; readonly onProjectChange: (projectKey: string | null) => void; readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; readonly onAddConnection: () => void; readonly onOpenSettings: () => void; readonly onStartNewTask: () => void; readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; /** Resolves true iff the settle was dispatched and succeeded. */ readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>; readonly onSnoozeThread: ( thread: EnvironmentThreadShell, snoozedUntil: string, ) => Promise<boolean>; readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => Promise<boolean>; readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; readonly onPinThread: (thread: EnvironmentThreadShell) => Promise<boolean>; readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>; readonly onMovePinnedThread: ( thread: EnvironmentThreadShell, direction: "up" | "down", ) => Promise<boolean>; readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise<boolean>; readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; readonly onNewThreadInProject: (project: EnvironmentProject) => void;} /* ─── Layout constants ───────────────────────────────────────────────── */ const ESTIMATED_THREAD_ROW_HEIGHT = 72;const PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT = 44;/** * Top spacing between the list and the Android custom header. The Android * header (AndroidHomeHeader) is rendered in-flow above this screen and * already consumes the top safe-area inset, so the list only needs breathing * room here. */ function deriveEmptyState(props: { readonly catalogState: WorkspaceState; readonly projectCount: number;}): { readonly title: string; readonly detail: string; readonly loading: boolean } { const { catalogState } = props; if (catalogState.isLoadingConnections) { return { title: "Loading environments", detail: "Checking saved environments on this device.", loading: true, }; } if (!catalogState.hasConnections) { return { title: "No environments connected", detail: "Add an environment to load projects and start coding sessions.", loading: false, }; } if ( (catalogState.connectionState === "available" || catalogState.connectionState === "offline" || catalogState.connectionState === "error") && !catalogState.hasLoadedShellSnapshot ) { return { title: "Environment unavailable", detail: catalogState.connectionError ?? "The saved environment is offline. Check the URL or start the environment, then retry.", loading: false, }; } if ( catalogState.hasConnectingEnvironment && !catalogState.hasLoadedShellSnapshot && catalogState.connectionError === null ) { return { title: "Connecting to environment", detail: "Loading projects and threads from the saved environment.", loading: true, }; } if (props.projectCount === 0 && catalogState.hasLoadedShellSnapshot) { return { title: "No projects found", detail: "The connected environment did not report any projects.", loading: false, }; } return { title: "No threads yet", detail: "Create a task to start a new coding session in one of your connected projects.", loading: false, };} function HomeTopContentSpacer() { return <View className="h-4" />;} /* ─── Main screen ────────────────────────────────────────────────────── */ export function HomeScreen(props: HomeScreenProps) { const [groupDisplayStates, setGroupDisplayStates] = useState< ReadonlyMap<string, HomeGroupDisplayState> >(() => new Map()); const preferencesResult = useAtomValue(mobilePreferencesAtom); const threadListV2Enabled = useThreadListV2Enabled(); const autoSettleOnMerge = !AsyncResult.isSuccess(preferencesResult) || preferencesResult.value.autoSettleOnMerge !== false; const savePreferences = useAtomSet(updateMobilePreferencesAtom); const openSwipeableRef = useRef<SwipeableMethods | null>(null); const listRef = useRef<LegendListRef | null>(null); const insets = useSafeAreaInsets(); const accentColor = useThemeColor("--color-icon-muted"); const iosBottomToolbarClearance = Platform.OS === "ios" && !NATIVE_LIQUID_GLASS_SUPPORTED ? PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT : 0; const searchEnvironmentIds = useMemo( () => props.selectedEnvironmentId === null ? props.environments .filter((environment) => environment.connectionState === "connected") .map((environment) => environment.environmentId) : props.environments.some( (environment) => environment.environmentId === props.selectedEnvironmentId && environment.connectionState === "connected", ) ? [props.selectedEnvironmentId] : [], [props.environments, props.selectedEnvironmentId], ); const threadSearch = useThreadSearch(searchEnvironmentIds, props.searchQuery); const threadSearchMatchByKey = useMemo(() => { const matches = new Map<string, EnvironmentThreadSearchMatch>(); 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 effectiveGroupDisplayStates = useMemo(() => { const next = new Map(groupDisplayStates); if (!AsyncResult.isSuccess(preferencesResult)) { return next; } for (const key of preferencesResult.value.collapsedProjectGroups ?? []) { const existing = next.get(key); next.set(key, { ...(existing ?? DEFAULT_GROUP_DISPLAY_STATE), collapsed: true, }); } return next; }, [groupDisplayStates, preferencesResult]); const effectiveGroupDisplayStatesRef = useRef(effectiveGroupDisplayStates); effectiveGroupDisplayStatesRef.current = effectiveGroupDisplayStates; const updateGroupDisplay = useCallback( (key: string, action: HomeGroupDisplayAction) => { const next = new Map(effectiveGroupDisplayStatesRef.current); next.set(key, nextGroupDisplayState(next.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action)); effectiveGroupDisplayStatesRef.current = next; setGroupDisplayStates(next); if (action === "toggle-collapsed") { const collapsedProjectGroups: string[] = []; for (const [groupKey, state] of next) { if (state.collapsed) { collapsedProjectGroups.push(groupKey); } } savePreferences({ collapsedProjectGroups }); } }, [savePreferences], ); 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 handleScrollBeginDrag = useCallback(() => { openSwipeableRef.current?.close(); }, []); const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({ onScrollBeginDrag: handleScrollBeginDrag, }); const projectScopes = useMemo( () => buildHomeProjectScopes({ projects: props.projects, environmentId: props.selectedEnvironmentId, projectGroupingMode: props.projectGroupingMode, }), [props.projectGroupingMode, props.projects, props.selectedEnvironmentId], ); const selectedProjectScope = useMemo( () => props.selectedProjectKey === null ? null : (projectScopes.find( (scope) => scope.key === props.selectedProjectKey || scope.projectRefs.some( (projectRef) => scopedProjectKey(projectRef.environmentId, projectRef.projectId) === props.selectedProjectKey, ), ) ?? null), [projectScopes, props.selectedProjectKey], ); const selectedProjectRefKeys = useMemo( () => selectedProjectScope === null ? null : new Set( selectedProjectScope.projectRefs.map((projectRef) => scopedProjectKey(projectRef.environmentId, projectRef.projectId), ), ), [selectedProjectScope], ); const scopedProjects = useMemo( () => selectedProjectRefKeys === null ? props.projects : props.projects.filter((project) => selectedProjectRefKeys.has(scopedProjectKey(project.environmentId, project.id)), ), [props.projects, selectedProjectRefKeys], ); const scopedThreads = useMemo( () => selectedProjectRefKeys === null ? props.threads : props.threads.filter((thread) => selectedProjectRefKeys.has(scopedProjectKey(thread.environmentId, thread.projectId)), ), [props.threads, selectedProjectRefKeys], ); const scopedPendingTasks = useMemo( () => selectedProjectRefKeys === null ? props.pendingTasks : props.pendingTasks.filter((pendingTask) => selectedProjectRefKeys.has( scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), ), ), [props.pendingTasks, selectedProjectRefKeys], ); const projectGroups = useMemo( () => buildHomeThreadGroups({ projects: scopedProjects, threads: scopedThreads, pendingTasks: scopedPendingTasks, environmentId: props.selectedEnvironmentId, searchQuery: props.searchQuery, matchedThreadKeys, projectSortOrder: props.projectSortOrder, threadSortOrder: props.threadSortOrder, projectGroupingMode: props.projectGroupingMode, }), [ props.projectGroupingMode, props.projectSortOrder, props.searchQuery, props.selectedEnvironmentId, props.threadSortOrder, matchedThreadKeys, scopedPendingTasks, scopedProjects, scopedThreads, ], ); const hasSearchQuery = props.searchQuery.trim().length > 0; const listLayout = useMemo( () => buildHomeListLayout({ groups: projectGroups, displayStates: effectiveGroupDisplayStates, showAllThreads: hasSearchQuery, }), [projectGroups, effectiveGroupDisplayStates, hasSearchQuery], ); const projectCwdByKey = useMemo(() => { const map = new Map<string, string>(); for (const project of props.projects) { map.set(scopedProjectKey(project.environmentId, project.id), project.workspaceRoot); } return map; }, [props.projects]); const projectByKey = useMemo(() => { const map = new Map<string, EnvironmentProject>(); for (const project of props.projects) { map.set(scopedProjectKey(project.environmentId, project.id), project); } return map; }, [props.projects]); const v2ProjectScopeKey = props.selectedProjectKey; const v2ScopeProjects = useMemo( () => sortHomeProjectScopes({ scopes: projectScopes, threads: props.threads, pendingTasks: props.pendingTasks, projectSortOrder: props.projectSortOrder, }), [ props.pendingTasks, props.projects, props.projectSortOrder, props.selectedEnvironmentId, props.threads, projectScopes, ], ); const v2ScopedProjectGroup = useMemo( () => v2ProjectScopeKey === null ? null : (v2ScopeProjects.find( (scope) => scope.key === v2ProjectScopeKey || scope.projectRefs.some( (projectRef) => scopedProjectKey(projectRef.environmentId, projectRef.projectId) === v2ProjectScopeKey, ), ) ?? null), [v2ProjectScopeKey, v2ScopeProjects], ); const v2ProjectTitleByProjectKey = useMemo( () => new Map( v2ScopeProjects.flatMap((scope) => scope.projectRefs.map( (projectRef) => [ scopedProjectKey(projectRef.environmentId, projectRef.projectId), scope.title, ] as const, ), ), ), [v2ScopeProjects], ); const v2ScopedProjectKeys = useMemo( () => v2ScopedProjectGroup === null ? null : new Set( v2ScopedProjectGroup.projectRefs.map((projectRef) => scopedProjectKey(projectRef.environmentId, projectRef.projectId), ), ), [v2ScopedProjectGroup], ); // Thread List v2 (beta): one flat list in creation order, no grouping. // Settled threads collapse into a recency tail below the card block. // Settled threads stay in the live shell stream (settled ≠ archived), so // the partition works directly off live shells — no snapshot merging or // optimistic holds. // PR states stream in per-row. The next partition applies the configured // merge rule and the always-on close rule, matching web. 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);Lines 1–500 of 1221. Load more
import { LegendList, type LegendListRef, type LegendListRenderItemProps, } from "@legendapp/list/react-native"; import { type EnvironmentProject, type EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import { threadSearchMatchKey, type EnvironmentThreadSearchMatch, } from "@t3tools/client-runtime/state/thread-search"; import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; import type { EnvironmentId, SidebarProjectGroupingMode, SidebarThreadSortOrder, } from "@t3tools/contracts"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ActivityIndicator, FlatList, Platform, Pressable, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; import { AppText as Text } from "../../components/AppText"; import { EmptyState } from "../../components/EmptyState"; import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel"; import type { SavedRemoteConnection } from "../../lib/connection"; import { scopedProjectKey } from "../../lib/scopedEntities"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { useThreadSearch } from "../../state/queries"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { environmentServerConfigsAtom } from "../../state/server"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { PendingTaskListRow, ThreadListGroupHeader, ThreadListRow, ThreadListShowMoreRow, } from "../threads/thread-list-items"; import { ThreadListV2PendingRow, ThreadListV2Row, ThreadListV2SettledShelfHeader, ThreadListV2SnoozedShelfHeader, } from "../threads/thread-list-v2-items"; import { buildThreadListV2Items, buildThreadListV2ListItems, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, type ThreadListV2ListItem, } from "../threads/threadListV2"; import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu"; import { buildHomeListLayout, DEFAULT_GROUP_DISPLAY_STATE, homeListItemsAreEqual, nextGroupDisplayState, type HomeGroupDisplayAction, type HomeGroupDisplayState, type HomeListItem, } from "./homeListItems"; import { buildHomeProjectScopes, buildHomeThreadGroups, sortHomeProjectScopes, type HomeProjectSortOrder, } from "./homeThreadList"; import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions"; /* ─── Types ──────────────────────────────────────────────────────────── */ interface HomeScreenProps { readonly projects: ReadonlyArray<EnvironmentProject>; readonly threads: ReadonlyArray<EnvironmentThreadShell>; readonly pendingTasks: ReadonlyArray<PendingNewTask>; readonly catalogState: WorkspaceState; readonly savedConnectionsById: Readonly<Record<string, SavedRemoteConnection>>; readonly environments: ReadonlyArray< HomeListFilterMenuEnvironment & Pick<WorkspaceEnvironment, "connectionState"> >; readonly searchQuery: string; readonly selectedEnvironmentId: EnvironmentId | null; readonly selectedProjectKey: string | null; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; readonly projectGroupingMode: SidebarProjectGroupingMode; readonly onSearchQueryChange: (query: string) => void; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; readonly onProjectChange: (projectKey: string | null) => void; readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; readonly onAddConnection: () => void; readonly onOpenSettings: () => void; readonly onStartNewTask: () => void; readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; /** Resolves true iff the settle was dispatched and succeeded. */ readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>; readonly onSnoozeThread: ( thread: EnvironmentThreadShell, snoozedUntil: string, ) => Promise<boolean>; readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => Promise<boolean>; readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; readonly onPinThread: (thread: EnvironmentThreadShell) => Promise<boolean>; readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>; readonly onMovePinnedThread: ( thread: EnvironmentThreadShell, direction: "up" | "down", ) => Promise<boolean>; readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise<boolean>; readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; readonly onNewThreadInProject: (project: EnvironmentProject) => void; } /* ─── Layout constants ───────────────────────────────────────────────── */ const ESTIMATED_THREAD_ROW_HEIGHT = 72; const PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT = 44; /** * Top spacing between the list and the Android custom header. The Android * header (AndroidHomeHeader) is rendered in-flow above this screen and * already consumes the top safe-area inset, so the list only needs breathing * room here. */ function deriveEmptyState(props: { readonly catalogState: WorkspaceState; readonly projectCount: number; }): { readonly title: string; readonly detail: string; readonly loading: boolean } { const { catalogState } = props; if (catalogState.isLoadingConnections) { return { title: "Loading environments", detail: "Checking saved environments on this device.", loading: true, }; } if (!catalogState.hasConnections) { return { title: "No environments connected", detail: "Add an environment to load projects and start coding sessions.", loading: false, }; } if ( (catalogState.connectionState === "available" || catalogState.connectionState === "offline" || catalogState.connectionState === "error") && !catalogState.hasLoadedShellSnapshot ) { return { title: "Environment unavailable", detail: catalogState.connectionError ?? "The saved environment is offline. Check the URL or start the environment, then retry.", loading: false, }; } if ( catalogState.hasConnectingEnvironment && !catalogState.hasLoadedShellSnapshot && catalogState.connectionError === null ) { return { title: "Connecting to environment", detail: "Loading projects and threads from the saved environment.", loading: true, }; } if (props.projectCount === 0 && catalogState.hasLoadedShellSnapshot) { return { title: "No projects found", detail: "The connected environment did not report any projects.", loading: false, }; } return { title: "No threads yet", detail: "Create a task to start a new coding session in one of your connected projects.", loading: false, }; } function HomeTopContentSpacer() { return <View className="h-4" />; } /* ─── Main screen ────────────────────────────────────────────────────── */ export function HomeScreen(props: HomeScreenProps) { const [groupDisplayStates, setGroupDisplayStates] = useState< ReadonlyMap<string, HomeGroupDisplayState> >(() => new Map()); const preferencesResult = useAtomValue(mobilePreferencesAtom); const threadListV2Enabled = useThreadListV2Enabled(); const autoSettleOnMerge = !AsyncResult.isSuccess(preferencesResult) || preferencesResult.value.autoSettleOnMerge !== false; const savePreferences = useAtomSet(updateMobilePreferencesAtom); const openSwipeableRef = useRef<SwipeableMethods | null>(null); const listRef = useRef<LegendListRef | null>(null); const insets = useSafeAreaInsets(); const accentColor = useThemeColor("--color-icon-muted"); const iosBottomToolbarClearance = Platform.OS === "ios" && !NATIVE_LIQUID_GLASS_SUPPORTED ? PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT : 0; const searchEnvironmentIds = useMemo( () => props.selectedEnvironmentId === null ? props.environments .filter((environment) => environment.connectionState === "connected") .map((environment) => environment.environmentId) : props.environments.some( (environment) => environment.environmentId === props.selectedEnvironmentId && environment.connectionState === "connected", ) ? [props.selectedEnvironmentId] : [], [props.environments, props.selectedEnvironmentId], ); const threadSearch = useThreadSearch(searchEnvironmentIds, props.searchQuery); const threadSearchMatchByKey = useMemo(() => { const matches = new Map<string, EnvironmentThreadSearchMatch>(); 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 effectiveGroupDisplayStates = useMemo(() => { const next = new Map(groupDisplayStates); if (!AsyncResult.isSuccess(preferencesResult)) { return next; } for (const key of preferencesResult.value.collapsedProjectGroups ?? []) { const existing = next.get(key); next.set(key, { ...(existing ?? DEFAULT_GROUP_DISPLAY_STATE), collapsed: true, }); } return next; }, [groupDisplayStates, preferencesResult]); const effectiveGroupDisplayStatesRef = useRef(effectiveGroupDisplayStates); effectiveGroupDisplayStatesRef.current = effectiveGroupDisplayStates; const updateGroupDisplay = useCallback( (key: string, action: HomeGroupDisplayAction) => { const next = new Map(effectiveGroupDisplayStatesRef.current); next.set(key, nextGroupDisplayState(next.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action)); effectiveGroupDisplayStatesRef.current = next; setGroupDisplayStates(next); if (action === "toggle-collapsed") { const collapsedProjectGroups: string[] = []; for (const [groupKey, state] of next) { if (state.collapsed) { collapsedProjectGroups.push(groupKey); } } savePreferences({ collapsedProjectGroups }); } }, [savePreferences], ); 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 handleScrollBeginDrag = useCallback(() => { openSwipeableRef.current?.close(); }, []); const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({ onScrollBeginDrag: handleScrollBeginDrag, }); const projectScopes = useMemo( () => buildHomeProjectScopes({ projects: props.projects, environmentId: props.selectedEnvironmentId, projectGroupingMode: props.projectGroupingMode, }), [props.projectGroupingMode, props.projects, props.selectedEnvironmentId], ); const selectedProjectScope = useMemo( () => props.selectedProjectKey === null ? null : (projectScopes.find( (scope) => scope.key === props.selectedProjectKey || scope.projectRefs.some( (projectRef) => scopedProjectKey(projectRef.environmentId, projectRef.projectId) === props.selectedProjectKey, ), ) ?? null), [projectScopes, props.selectedProjectKey], ); const selectedProjectRefKeys = useMemo( () => selectedProjectScope === null ? null : new Set( selectedProjectScope.projectRefs.map((projectRef) => scopedProjectKey(projectRef.environmentId, projectRef.projectId), ), ), [selectedProjectScope], ); const scopedProjects = useMemo( () => selectedProjectRefKeys === null ? props.projects : props.projects.filter((project) => selectedProjectRefKeys.has(scopedProjectKey(project.environmentId, project.id)), ), [props.projects, selectedProjectRefKeys], ); const scopedThreads = useMemo( () => selectedProjectRefKeys === null ? props.threads : props.threads.filter((thread) => selectedProjectRefKeys.has(scopedProjectKey(thread.environmentId, thread.projectId)), ), [props.threads, selectedProjectRefKeys], ); const scopedPendingTasks = useMemo( () => selectedProjectRefKeys === null ? props.pendingTasks : props.pendingTasks.filter((pendingTask) => selectedProjectRefKeys.has( scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), ), ), [props.pendingTasks, selectedProjectRefKeys], ); const projectGroups = useMemo( () => buildHomeThreadGroups({ projects: scopedProjects, threads: scopedThreads, pendingTasks: scopedPendingTasks, environmentId: props.selectedEnvironmentId, searchQuery: props.searchQuery, matchedThreadKeys, projectSortOrder: props.projectSortOrder, threadSortOrder: props.threadSortOrder, projectGroupingMode: props.projectGroupingMode, }), [ props.projectGroupingMode, props.projectSortOrder, props.searchQuery, props.selectedEnvironmentId, props.threadSortOrder, matchedThreadKeys, scopedPendingTasks, scopedProjects, scopedThreads, ], ); const hasSearchQuery = props.searchQuery.trim().length > 0; const listLayout = useMemo( () => buildHomeListLayout({ groups: projectGroups, displayStates: effectiveGroupDisplayStates, showAllThreads: hasSearchQuery, }), [projectGroups, effectiveGroupDisplayStates, hasSearchQuery], ); const projectCwdByKey = useMemo(() => { const map = new Map<string, string>(); for (const project of props.projects) { map.set(scopedProjectKey(project.environmentId, project.id), project.workspaceRoot); } return map; }, [props.projects]); const projectByKey = useMemo(() => { const map = new Map<string, EnvironmentProject>(); for (const project of props.projects) { map.set(scopedProjectKey(project.environmentId, project.id), project); } return map; }, [props.projects]); const v2ProjectScopeKey = props.selectedProjectKey; const v2ScopeProjects = useMemo( () => sortHomeProjectScopes({ scopes: projectScopes, threads: props.threads, pendingTasks: props.pendingTasks, projectSortOrder: props.projectSortOrder, }), [ props.pendingTasks, props.projects, props.projectSortOrder, props.selectedEnvironmentId, props.threads, projectScopes, ], ); const v2ScopedProjectGroup = useMemo( () => v2ProjectScopeKey === null ? null : (v2ScopeProjects.find( (scope) => scope.key === v2ProjectScopeKey || scope.projectRefs.some( (projectRef) => scopedProjectKey(projectRef.environmentId, projectRef.projectId) === v2ProjectScopeKey, ), ) ?? null), [v2ProjectScopeKey, v2ScopeProjects], ); const v2ProjectTitleByProjectKey = useMemo( () => new Map( v2ScopeProjects.flatMap((scope) => scope.projectRefs.map( (projectRef) => [ scopedProjectKey(projectRef.environmentId, projectRef.projectId), scope.title, ] as const, ), ), ), [v2ScopeProjects], ); const v2ScopedProjectKeys = useMemo( () => v2ScopedProjectGroup === null ? null : new Set( v2ScopedProjectGroup.projectRefs.map((projectRef) => scopedProjectKey(projectRef.environmentId, projectRef.projectId), ), ), [v2ScopedProjectGroup], ); // Thread List v2 (beta): one flat list in creation order, no grouping. // Settled threads collapse into a recency tail below the card block. // Settled threads stay in the live shell stream (settled ≠ archived), so // the partition works directly off live shells — no snapshot merging or // optimistic holds. // PR states stream in per-row. The next partition applies the configured // merge rule and the always-on close rule, matching web. 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);