1206 lines · 44.7 KB · TypeScript
RawDownload
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.