1221 lines · 45.8 KB · TypeScript
RawDownload
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.