3362 lines · 120.6 KB · TypeScript
RawDownload
import {  ArchiveIcon,  ArrowUpDownIcon,  ChevronRightIcon,  CloudIcon,  GitPullRequestIcon,  PlusIcon,  SearchIcon,  SettingsIcon,  SquarePenIcon,  TerminalIcon,  TriangleAlertIcon,} from "lucide-react";import {  prStatusIndicator,  resolveThreadPr,  terminalStatusFromRunningIds,  ThreadStatusLabel,} from "./ThreadStatusIndicators";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 {  type ContextMenuItem,  type DesktopUpdateState,  ProjectId,  type ScopedThreadRef,  type SidebarProjectGroupingMode,  type ThreadEnvMode,  ThreadId,} from "@t3tools/contracts";import {  parseScopedThreadKey,  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 { isMacPlatform, newCommandId } from "../lib/utils";import {  selectProjectByRef,  selectProjectsAcrossEnvironments,  selectSidebarThreadsForProjectRefs,  selectSidebarThreadsAcrossEnvironments,  selectThreadByRef,  useStore,} from "../store";import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";import { useUiStateStore } from "../uiStateStore";import {  resolveShortcutCommand,  shortcutLabelForCommand,  shouldShowThreadJumpHintsForModifiers,  threadJumpCommandForIndex,  threadJumpIndexFromCommand,  threadTraversalDirectionFromCommand,} from "../keybindings";import { useModelPickerOpen } from "../modelPickerOpenState";import { useShortcutModifierState } from "../shortcutModifierState";import { useGitStatus } from "../lib/gitStatusState";import { readLocalApi } from "../localApi";import { useComposerDraftStore } from "../composerDraftStore";import { useNewThreadHandler } from "../hooks/useHandleNewThread";import { retainThreadDetailSubscription } from "../environments/runtime/service"; import { useThreadActions } from "../hooks/useThreadActions";import {  buildThreadRouteParams,  resolveThreadRouteRef,  resolveThreadRouteTarget,} from "../threadRoutes";import { stackedThreadToast, toastManager } from "./ui/toast";import { formatRelativeTimeLabel } from "../timestampFormat";import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";import { Kbd } from "./ui/kbd";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 {  Dialog,  DialogDescription,  DialogFooter,  DialogHeader,  DialogPanel,  DialogPopup,  DialogTitle,} from "./ui/dialog";import { Input } from "./ui/input";import {  Menu,  MenuGroup,  MenuPopup,  MenuRadioGroup,  MenuRadioItem,  MenuSeparator,  MenuTrigger,} from "./ui/menu";import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";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 { useCommandPaletteStore } from "../commandPaletteStore";import {  getSidebarThreadIdsToPrewarm,  resolveAdjacentThreadId,  isContextMenuPointerDown,  resolveProjectStatusIndicator,  resolveSidebarNewThreadSeedContext,  resolveSidebarNewThreadEnvMode,  resolveThreadRowClassName,  resolveThreadStatusPill,  orderItemsByPreferredIds,  shouldClearThreadSelectionOnMouseDown,  sortProjectsForSidebar,  useThreadJumpHintVisibility,  ThreadStatusPill,} from "./Sidebar.logic";import { sortThreads } from "../lib/threadSort";import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";import { CommandDialogTrigger } from "./ui/command";import { readEnvironmentApi } from "../environmentApi";import { useSettings, useUpdateSettings } from "~/hooks/useSettings";import { useServerKeybindings } from "../rpc/serverState";import {  derivePhysicalProjectKey,  deriveProjectGroupingOverrideKey,  getProjectOrderKey,} from "../logicalProject";import {  useSavedEnvironmentRegistryStore,  useSavedEnvironmentRuntimeStore,} from "../environments/runtime";import type { SidebarThreadSummary } from "../types";import {  buildPhysicalToLogicalProjectKeyMap,  buildSidebarProjectSnapshots,  type SidebarProjectGroupMember,  type SidebarProjectSnapshot,} from "../sidebarProjectGrouping";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>();const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = {  repository: "Group by repository",  repository_path: "Group by repository path",  separate: "Keep separate",}; function formatProjectMemberActionLabel(  member: SidebarProjectGroupMember,  groupedProjectCount: number,): string {  if (groupedProjectCount <= 1) {    return member.name;  }   return member.environmentLabel ? `${member.environmentLabel} — ${member.cwd}` : member.cwd;} function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): string {  switch (mode) {    case "repository":      return "Projects from the same repository share one sidebar row.";    case "repository_path":      return "Projects group only when both the repository and repo-relative path match.";    case "separate":      return "Every project path gets its own sidebar row.";  }} 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;} 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,      isSelected,      threadRef,    ],  );  const handlePrClick = useCallback(    (event: React.MouseEvent<HTMLButtonElement>) => {      if (!prStatus) return;      openPrLink(event, prStatus.url);    },    [openPrLink, prStatus],  );  const handleRenameInputRef = useCallback(    (element: HTMLInputElement | null) => {      if (element && renamingInputRef.current !== element) {        renamingInputRef.current = element;        element.focus();        element.select();      }    },    [renamingInputRef],  );  const handleRenameInputChange = useCallback(    (event: React.ChangeEvent<HTMLInputElement>) => {      setRenamingTitle(event.target.value);    },    [setRenamingTitle],  );  const handleRenameInputKeyDown = useCallback(    (event: React.KeyboardEvent<HTMLInputElement>) => {      event.stopPropagation();      if (event.key === "Enter") {        event.preventDefault();        renamingCommittedRef.current = true;        void commitRename(threadRef, renamingTitle, thread.title);      } else if (event.key === "Escape") {        event.preventDefault();        renamingCommittedRef.current = true;        cancelRename();      }    },    [cancelRename, commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef],  );  const handleRenameInputBlur = useCallback(() => {    if (!renamingCommittedRef.current) {      void commitRename(threadRef, renamingTitle, thread.title);    }  }, [commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef]);  const handleRenameInputClick = useCallback((event: React.MouseEvent<HTMLInputElement>) => {    event.stopPropagation();  }, []);  const handleConfirmArchiveRef = useCallback(    (element: HTMLButtonElement | null) => {      if (element) {        confirmArchiveButtonRefs.current.set(threadKey, element);      } else {        confirmArchiveButtonRefs.current.delete(threadKey);      }    },    [confirmArchiveButtonRefs, threadKey],  );  const stopPropagationOnPointerDown = useCallback(    (event: React.PointerEvent<HTMLButtonElement>) => {      event.stopPropagation();    },    [],  );  const handleConfirmArchiveClick = useCallback(    (event: React.MouseEvent<HTMLButtonElement>) => {

Lines 1–500 of 3362.