3185 lines · 114.1 KB · TypeScript
RawDownload
import {  ArchiveIcon,  ArrowUpDownIcon,  ChevronRightIcon,  CloudIcon,  FolderIcon,  GitPullRequestIcon,  PlusIcon,  SettingsIcon,  SquarePenIcon,  TerminalIcon,  TriangleAlertIcon,} from "lucide-react";import { ProjectFavicon } from "./ProjectFavicon";import { autoAnimate } from "@formkit/auto-animate";import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react";import { useShallow } from "zustand/react/shallow";import {  DndContext,  type DragCancelEvent,  type CollisionDetection,  PointerSensor,  type DragStartEvent,  closestCorners,  pointerWithin,  useSensor,  useSensors,  type DragEndEvent,} from "@dnd-kit/core";import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers";import { CSS } from "@dnd-kit/utilities";import {  DEFAULT_MODEL_BY_PROVIDER,  type DesktopUpdateState,  type EnvironmentId,  ProjectId,  type ScopedProjectRef,  type ScopedThreadRef,  type ThreadEnvMode,  ThreadId,  type GitStatusResult,} from "@t3tools/contracts";import {  scopedProjectKey,  scopedThreadKey,  scopeProjectRef,  scopeThreadRef,} from "@t3tools/client-runtime";import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router";import {  type SidebarProjectSortOrder,  type SidebarThreadSortOrder,} from "@t3tools/contracts/settings";import { usePrimaryEnvironmentId } from "../environments/primary";import { isElectron } from "../env";import { APP_STAGE_LABEL, APP_VERSION } from "../branding";import { isTerminalFocused } from "../lib/terminalFocus";import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils";import {  selectProjectByRef,  selectProjectsAcrossEnvironments,  selectSidebarThreadsForProjectRef,  selectSidebarThreadsForProjectRefs,  selectSidebarThreadsAcrossEnvironments,  selectThreadByRef,  useStore,} from "../store";import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";import { useUiStateStore } from "../uiStateStore";import {  resolveShortcutCommand,  shortcutLabelForCommand,  shouldShowThreadJumpHints,  threadJumpCommandForIndex,  threadJumpIndexFromCommand,  threadTraversalDirectionFromCommand,} from "../keybindings";import { useGitStatus } from "../lib/gitStatusState";import { readLocalApi } from "../localApi";import { useComposerDraftStore } from "../composerDraftStore";import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useThreadActions } from "../hooks/useThreadActions";import {  buildThreadRouteParams,  resolveThreadRouteRef,  resolveThreadRouteTarget,} from "../threadRoutes";import { toastManager } from "./ui/toast";import { formatRelativeTimeLabel } from "../timestampFormat";import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";import {  getArm64IntelBuildWarningDescription,  getDesktopUpdateActionError,  getDesktopUpdateInstallConfirmationMessage,  isDesktopUpdateButtonDisabled,  resolveDesktopUpdateButtonAction,  shouldShowArm64IntelBuildWarning,  shouldToastDesktopUpdateActionResult,} from "./desktopUpdate.logic";import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";import { Button } from "./ui/button";import { Menu, MenuGroup, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu";import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";import {  SidebarContent,  SidebarFooter,  SidebarGroup,  SidebarHeader,  SidebarMenu,  SidebarMenuButton,  SidebarMenuItem,  SidebarMenuSub,  SidebarMenuSubButton,  SidebarMenuSubItem,  SidebarSeparator,  SidebarTrigger,} from "./ui/sidebar";import { useThreadSelectionStore } from "../threadSelectionStore";import { isNonEmpty as isNonEmptyString } from "effect/String";import {  resolveAdjacentThreadId,  isContextMenuPointerDown,  resolveProjectStatusIndicator,  resolveSidebarNewThreadSeedContext,  resolveSidebarNewThreadEnvMode,  resolveThreadRowClassName,  resolveThreadStatusPill,  orderItemsByPreferredIds,  shouldClearThreadSelectionOnMouseDown,  sortProjectsForSidebar,  sortThreadsForSidebar,  useThreadJumpHintVisibility,  ThreadStatusPill,} from "./Sidebar.logic";import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";import { readEnvironmentApi } from "../environmentApi";import { useSettings, useUpdateSettings } from "~/hooks/useSettings";import { useServerKeybindings } from "../rpc/serverState";import { deriveLogicalProjectKey } from "../logicalProject";import {  useSavedEnvironmentRegistryStore,  useSavedEnvironmentRuntimeStore,} from "../environments/runtime";import type { Project, SidebarThreadSummary } from "../types";const THREAD_PREVIEW_LIMIT = 6;const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {  updated_at: "Last user message",  created_at: "Created at",  manual: "Manual",};const SIDEBAR_THREAD_SORT_LABELS: Record<SidebarThreadSortOrder, string> = {  updated_at: "Last user message",  created_at: "Created at",};const SIDEBAR_LIST_ANIMATION_OPTIONS = {  duration: 180,  easing: "ease-out",} as const;const EMPTY_THREAD_JUMP_LABELS = new Map<string, string>(); function threadJumpLabelMapsEqual(  left: ReadonlyMap<string, string>,  right: ReadonlyMap<string, string>,): boolean {  if (left === right) {    return true;  }  if (left.size !== right.size) {    return false;  }  for (const [key, value] of left) {    if (right.get(key) !== value) {      return false;    }  }  return true;} function buildThreadJumpLabelMap(input: {  keybindings: ReturnType<typeof useServerKeybindings>;  platform: string;  terminalOpen: boolean;  threadJumpCommandByKey: ReadonlyMap<    string,    NonNullable<ReturnType<typeof threadJumpCommandForIndex>>  >;}): ReadonlyMap<string, string> {  if (input.threadJumpCommandByKey.size === 0) {    return EMPTY_THREAD_JUMP_LABELS;  }   const shortcutLabelOptions = {    platform: input.platform,    context: {      terminalFocus: false,      terminalOpen: input.terminalOpen,    },  } as const;  const mapping = new Map<string, string>();  for (const [threadKey, command] of input.threadJumpCommandByKey) {    const label = shortcutLabelForCommand(input.keybindings, command, shortcutLabelOptions);    if (label) {      mapping.set(threadKey, label);    }  }  return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS;} type EnvironmentPresence = "local-only" | "remote-only" | "mixed"; type SidebarProjectSnapshot = Project & {  projectKey: string;  environmentPresence: EnvironmentPresence;  memberProjectRefs: readonly ScopedProjectRef[];  /** Labels for remote environments this project lives in. */  remoteEnvironmentLabels: readonly string[];};interface TerminalStatusIndicator {  label: "Terminal process running";  colorClass: string;  pulse: boolean;} interface PrStatusIndicator {  label: "PR open" | "PR closed" | "PR merged";  colorClass: string;  tooltip: string;  url: string;} type ThreadPr = GitStatusResult["pr"]; function ThreadStatusLabel({  status,  compact = false,}: {  status: ThreadStatusPill;  compact?: boolean;}) {  if (compact) {    return (      <span        title={status.label}        className={`inline-flex size-3.5 shrink-0 items-center justify-center ${status.colorClass}`}      >        <span          className={`size-[9px] rounded-full ${status.dotClass} ${            status.pulse ? "animate-pulse" : ""          }`}        />        <span className="sr-only">{status.label}</span>      </span>    );  }   return (    <span      title={status.label}      className={`inline-flex items-center gap-1 text-[10px] ${status.colorClass}`}    >      <span        className={`h-1.5 w-1.5 rounded-full ${status.dotClass} ${          status.pulse ? "animate-pulse" : ""        }`}      />      <span className="hidden md:inline">{status.label}</span>    </span>  );} function terminalStatusFromRunningIds(  runningTerminalIds: string[],): TerminalStatusIndicator | null {  if (runningTerminalIds.length === 0) {    return null;  }  return {    label: "Terminal process running",    colorClass: "text-teal-600 dark:text-teal-300/90",    pulse: true,  };} function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null {  if (!pr) return null;   if (pr.state === "open") {    return {      label: "PR open",      colorClass: "text-emerald-600 dark:text-emerald-300/90",      tooltip: `#${pr.number} PR open: ${pr.title}`,      url: pr.url,    };  }  if (pr.state === "closed") {    return {      label: "PR closed",      colorClass: "text-zinc-500 dark:text-zinc-400/80",      tooltip: `#${pr.number} PR closed: ${pr.title}`,      url: pr.url,    };  }  if (pr.state === "merged") {    return {      label: "PR merged",      colorClass: "text-violet-600 dark:text-violet-300/90",      tooltip: `#${pr.number} PR merged: ${pr.title}`,      url: pr.url,    };  }  return null;} function resolveThreadPr(  threadBranch: string | null,  gitStatus: GitStatusResult | null,): ThreadPr | null {  if (threadBranch === null || gitStatus === null || gitStatus.branch !== threadBranch) {    return null;  }   return gitStatus.pr ?? null;} interface SidebarThreadRowProps {  thread: SidebarThreadSummary;  projectCwd: string | null;  orderedProjectThreadKeys: readonly string[];  isActive: boolean;  jumpLabel: string | null;  appSettingsConfirmThreadArchive: boolean;  renamingThreadKey: string | null;  renamingTitle: string;  setRenamingTitle: (title: string) => void;  renamingInputRef: React.RefObject<HTMLInputElement | null>;  renamingCommittedRef: React.RefObject<boolean>;  confirmingArchiveThreadKey: string | null;  setConfirmingArchiveThreadKey: React.Dispatch<React.SetStateAction<string | null>>;  confirmArchiveButtonRefs: React.RefObject<Map<string, HTMLButtonElement>>;  handleThreadClick: (    event: React.MouseEvent,    threadRef: ScopedThreadRef,    orderedProjectThreadKeys: readonly string[],  ) => void;  navigateToThread: (threadRef: ScopedThreadRef) => void;  handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise<void>;  handleThreadContextMenu: (    threadRef: ScopedThreadRef,    position: { x: number; y: number },  ) => Promise<void>;  clearSelection: () => void;  commitRename: (    threadRef: ScopedThreadRef,    newTitle: string,    originalTitle: string,  ) => Promise<void>;  cancelRename: () => void;  attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise<void>;  openPrLink: (event: React.MouseEvent<HTMLElement>, prUrl: string) => void;} const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) {  const {    orderedProjectThreadKeys,    isActive,    jumpLabel,    appSettingsConfirmThreadArchive,    renamingThreadKey,    renamingTitle,    setRenamingTitle,    renamingInputRef,    renamingCommittedRef,    confirmingArchiveThreadKey,    setConfirmingArchiveThreadKey,    confirmArchiveButtonRefs,    handleThreadClick,    navigateToThread,    handleMultiSelectContextMenu,    handleThreadContextMenu,    clearSelection,    commitRename,    cancelRename,    attemptArchiveThread,    openPrLink,    thread,  } = props;  const threadRef = scopeThreadRef(thread.environmentId, thread.id);  const threadKey = scopedThreadKey(threadRef);  const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]);  const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey));  const hasSelection = useThreadSelectionStore((state) => state.selectedThreadKeys.size > 0);  const runningTerminalIds = useTerminalStateStore(    (state) =>      selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds,  );  const primaryEnvironmentId = usePrimaryEnvironmentId();  const isRemoteThread =    primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId;  const remoteEnvLabel = useSavedEnvironmentRuntimeStore(    (s) => s.byId[thread.environmentId]?.descriptor?.label ?? null,  );  const remoteEnvSavedLabel = useSavedEnvironmentRegistryStore(    (s) => s.byId[thread.environmentId]?.label ?? null,  );  const threadEnvironmentLabel = isRemoteThread    ? (remoteEnvLabel ?? remoteEnvSavedLabel ?? "Remote")    : null;  // For grouped projects, the thread may belong to a different environment  // than the representative project.  Look up the thread's own project cwd  // so git status (and thus PR detection) queries the correct path.  const threadProjectCwd = useStore(    useMemo(      () => (state: import("../store").AppState) =>        selectProjectByRef(state, scopeProjectRef(thread.environmentId, thread.projectId))?.cwd ??        null,      [thread.environmentId, thread.projectId],    ),  );  const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd;  const gitStatus = useGitStatus({    environmentId: thread.environmentId,    cwd: thread.branch != null ? gitCwd : null,  });  const isHighlighted = isActive || isSelected;  const isThreadRunning =    thread.session?.status === "running" && thread.session.activeTurnId != null;  const threadStatus = resolveThreadStatusPill({    thread: {      ...thread,      lastVisitedAt,    },  });  const pr = resolveThreadPr(thread.branch, gitStatus.data);  const prStatus = prStatusIndicator(pr);  const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);  const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning;  const threadMetaClassName = isConfirmingArchive    ? "pointer-events-none opacity-0"    : !isThreadRunning      ? "pointer-events-none transition-opacity duration-150 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0"      : "pointer-events-none";  const clearConfirmingArchive = useCallback(() => {    setConfirmingArchiveThreadKey((current) => (current === threadKey ? null : current));  }, [setConfirmingArchiveThreadKey, threadKey]);  const handleMouseLeave = useCallback(() => {    clearConfirmingArchive();  }, [clearConfirmingArchive]);  const handleBlurCapture = useCallback(    (event: React.FocusEvent<HTMLLIElement>) => {      const currentTarget = event.currentTarget;      requestAnimationFrame(() => {        if (currentTarget.contains(document.activeElement)) {          return;        }        clearConfirmingArchive();      });    },    [clearConfirmingArchive],  );  const handleRowClick = useCallback(    (event: React.MouseEvent) => {      handleThreadClick(event, threadRef, orderedProjectThreadKeys);    },    [handleThreadClick, orderedProjectThreadKeys, threadRef],  );  const handleRowKeyDown = useCallback(    (event: React.KeyboardEvent) => {      if (event.key !== "Enter" && event.key !== " ") return;      event.preventDefault();      navigateToThread(threadRef);    },    [navigateToThread, threadRef],  );  const handleRowContextMenu = useCallback(    (event: React.MouseEvent) => {      event.preventDefault();      if (hasSelection && isSelected) {        void handleMultiSelectContextMenu({          x: event.clientX,          y: event.clientY,        });        return;      }       if (hasSelection) {        clearSelection();      }      void handleThreadContextMenu(threadRef, {        x: event.clientX,        y: event.clientY,      });    },    [      clearSelection,      handleMultiSelectContextMenu,      handleThreadContextMenu,      hasSelection,

Lines 1–500 of 3185.