3822 lines · 139.7 KB · TypeScript
RawDownload
import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";import { GitPullRequestIcon } from "lucide-react";import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests";import { Spinner } from "~/components/ui/spinner";import {  ArchiveIcon,  ArrowUpDownIcon,  ChevronRightIcon,  FolderPlusIcon,  Globe2Icon,  SearchIcon,  SquarePenIcon,  TerminalIcon,  TriangleAlertIcon,} from "lucide-react";import {  ChangeRequestStatusIcon,  prStatusIndicator,  PrStatusTooltipContent,  terminalStatusFromRunningIds,  ThreadStatusLabel,  ThreadWorktreeIndicator,  useLinkedThreadPullRequest,} from "./ThreadStatusIndicators";import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";import { ProjectFavicon } from "./ProjectFavicon";import { useAtomValue } from "@effect/atom-react";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,  ProjectId,  type ScopedThreadRef,  type ResolvedKeybindingsConfig,  type SidebarProjectGroupingMode,  resolveEnvironmentMachineKind,  ThreadId,} from "@t3tools/contracts";import {  parseScopedThreadKey,  scopedProjectKey,  scopedThreadKey,  scopeProjectRef,  scopeThreadRef,} from "@t3tools/client-runtime/environment";import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";import {  isAtomCommandInterrupted,  settlePromise,  squashAtomCommandFailure,} from "@t3tools/client-runtime/state/runtime";import { useNavigate, useParams, useRouter } from "@tanstack/react-router";import {  MAX_SIDEBAR_THREAD_PREVIEW_COUNT,  MIN_SIDEBAR_THREAD_PREVIEW_COUNT,  type SidebarProjectSortOrder,  type SidebarThreadPreviewCount,  type SidebarThreadSortOrder,} from "@t3tools/contracts/settings";import { isDesktopLocalConnectionTarget, isWslConnectionTarget } from "../connection/desktopLocal";import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps";import { isElectron } from "../env";import { useTerminalFocus } from "../hooks/useTerminalFocus";import { useOpenPrLink } from "../lib/openPullRequestLink";import { releaseProjectDraftUploads } from "../lib/composerDraftUploads";import { isTerminalFocused } from "../lib/terminalFocus";import { isMacPlatform } from "../lib/utils";import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore";import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop";import {  readThreadShell,  useProjects,  useThreadShells,  useThreadShellsForProjectRefs,} from "../state/entities";import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";import { useThreadRunningTerminalIds } from "../state/terminalSessions";import { useThreadDiscoveredPorts } from "../portDiscoveryState";import { openDiscoveredPort } from "./preview/openDiscoveredPort";import { useAtomCommand } from "../state/use-atom-command";import { previewEnvironment } from "../state/preview";import {  legacyProjectCwdPreferenceKey,  resolveProjectExpanded,  useUiStateStore,} from "../uiStateStore";import {  resolveShortcutCommand,  shortcutLabelForCommand,  shouldShowThreadJumpHintsForModifiers,  threadJumpCommandForIndex,  threadJumpIndexFromCommand,  threadTraversalDirectionFromCommand,} from "../keybindings";import { isModelPickerOpen } from "../modelPickerVisibility";import { useShortcutModifierState } from "../shortcutModifierState";import { ensureLocalApi, readLocalApi } from "../localApi";import { useComposerDraftStore } from "../composerDraftStore";import { useNewThreadHandler } from "../hooks/useHandleNewThread";import { useDesktopUpdateState } from "../state/desktopUpdate"; import { useThreadActions } from "../hooks/useThreadActions";import { projectEnvironment } from "../state/projects";import { threadEnvironment, useEnvironmentThread } from "../state/threads";import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments";import {  buildThreadRouteParams,  resolveActiveThreadRouteRef,  resolveThreadRouteTarget,} from "../threadRoutes";import { stackedThreadToast, toastManager } from "./ui/toast";import { formatRelativeTimeLabel } from "../timestampFormat";import { Kbd } from "./ui/kbd";import {  getArm64IntelBuildWarningDescription,  getDesktopUpdateActionError,  getDesktopUpdateInstallConfirmationMessage,  isDesktopUpdateButtonDisabled,  resolveDesktopUpdateButtonAction,  shouldShowArm64IntelBuildWarning,  shouldToastDesktopUpdateActionResult,} from "./desktopUpdate.logic";import { showDesktopUpdateDownloadedToast } from "./desktopUpdate.toast";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, MenuTrigger } from "./ui/menu";import {  NumberField,  NumberFieldDecrement,  NumberFieldGroup,  NumberFieldIncrement,  NumberFieldInput,} from "./ui/number-field";import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";import {  SidebarContent,  SidebarGroup,  SidebarMenu,  SidebarMenuButton,  SidebarMenuItem,  SidebarMenuSub,  SidebarMenuSubButton,  SidebarMenuSubItem,  useSidebar,} from "./ui/sidebar";import {  getThreadKeysToDeselectAfterDelete,  useThreadSelectionStore,} from "../threadSelectionStore";import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus";import {  archiveSelectedThreadEntries,  buildMultiSelectThreadContextMenuItems,  deleteSelectedThreadEntries,  getSidebarThreadIdsToPrewarm,  resolveAdjacentThreadId,  isContextMenuPointerDown,  isSidebarNestedLinkClick,  isTrailingDoubleClick,  resolveProjectStatusIndicator,  resolveThreadRowClassName,  resolveThreadStatusPill,  orderItemsByPreferredIds,  shouldClearThreadSelectionOnMouseDown,  sortProjectsForSidebar,  useSidebarRowSubscriptionLease,  useThreadJumpHintVisibility,  ThreadStatusPill,} from "./Sidebar.logic";import { sortThreads } from "../lib/threadSort";import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome";import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";import { useIsMobile } from "~/hooks/useMediaQuery";import { CommandDialogTrigger } from "./ui/command";import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings";import { primaryServerKeybindingsAtom } from "../state/server";import {  derivePhysicalProjectKey,  deriveProjectGroupingOverrideKey,  getProjectOrderKey,  selectProjectGroupingSettings,} from "../logicalProject";import type { SidebarThreadSummary } from "../types";import {  buildPhysicalToLogicalProjectKeyMap,  buildSidebarProjectSnapshots,  type SidebarProjectGroupMember,  type SidebarProjectSnapshot,} from "../sidebarProjectGrouping";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",};const SIDEBAR_ICON_ACTION_BUTTON_CLASS =  "inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-icon-muted hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"; function SidebarThreadDetailPrewarmer({ threadRef }: { readonly threadRef: ScopedThreadRef }) {  useEnvironmentThread(threadRef.environmentId, threadRef.threadId);  return null;} function clampSidebarThreadPreviewCount(value: number): SidebarThreadPreviewCount {  return Math.min(    MAX_SIDEBAR_THREAD_PREVIEW_COUNT,    Math.max(MIN_SIDEBAR_THREAD_PREVIEW_COUNT, value),  ) as SidebarThreadPreviewCount;} function formatProjectMemberActionLabel(  member: SidebarProjectGroupMember,  groupedProjectCount: number,): string {  if (groupedProjectCount <= 1) {    return member.title;  }   return member.environmentLabel    ? `${member.environmentLabel} — ${member.workspaceRoot}`    : member.workspaceRoot;} function projectExpansionPreferenceKeys(project: SidebarProjectSnapshot): string[] {  return [    project.projectKey,    ...project.memberProjects.map((member) => member.physicalProjectKey),    ...project.memberProjects.map((member) => legacyProjectCwdPreferenceKey(member.workspaceRoot)),  ];} 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: ResolvedKeybindingsConfig;  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;  orderedProjectThreadKeys: readonly string[];  isActive: boolean;  openPullRequestsInRightPanel: boolean;  jumpLabel: string | null;  appSettingsConfirmThreadArchive: boolean;  renamingThreadKey: string | null;  renamingTitle: string;  setRenamingTitle: (title: string) => void;  startThreadRename: (threadKey: string, 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) => Promise<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,    threadRef?: ScopedThreadRef,  ) => boolean;  onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void;} const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) {  const {    orderedProjectThreadKeys,    isActive,    openPullRequestsInRightPanel,    jumpLabel,    appSettingsConfirmThreadArchive,    renamingThreadKey,    renamingTitle,    setRenamingTitle,    startThreadRename,    renamingInputRef,    renamingCommittedRef,    confirmingArchiveThreadKey,    setConfirmingArchiveThreadKey,    confirmArchiveButtonRefs,    handleThreadClick,    navigateToThread,    handleMultiSelectContextMenu,    handleThreadContextMenu,    clearSelection,    commitRename,    cancelRename,    attemptArchiveThread,    openPrLink,    onFileDropThreads,    thread,  } = props;  const threadRef = scopeThreadRef(thread.environmentId, thread.id);  const threadKey = scopedThreadKey(threadRef);  const [isFileDragOver, setIsFileDragOver] = useState(false);  const fileDropHandlers = useMemo(    () =>      makeWorkspaceFileDropHandlers({        setDragActive: setIsFileDragOver,        addFiles: (files) => {          onFileDropThreads(threadRef, files);        },      }),    [onFileDropThreads, threadRef],  );  useEffect(() => {    if (!isFileDragOver) return;    const clearFileDrag = () => setIsFileDragOver(false);    window.addEventListener("dragend", clearFileDrag);    return () => window.removeEventListener("dragend", clearFileDrag);  }, [isFileDragOver]);  const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(isActive);  const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]);  const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey));  const runningTerminalIds = useThreadRunningTerminalIds({    environmentId: thread.environmentId,    threadId: thread.id,  });  const isMobile = useIsMobile();  const discoveredPorts = useThreadDiscoveredPorts({    environmentId: thread.environmentId,    threadId: thread.id,  });  const openPreview = useAtomCommand(previewEnvironment.open, {    reportFailure: false,  });  const environment = useEnvironment(thread.environmentId);  const primaryEnvironmentId = usePrimaryEnvironmentId();  // No primary (the hosted app) means every thread is remote, and the machine  // glyph is what tells the environments apart.  const isRemoteThread = thread.environmentId !== primaryEnvironmentId;  const remoteEnvLabel = environment?.label ?? null;  const remoteMachine = resolveEnvironmentMachineKind(environment?.serverConfig ?? null);  // A desktop-local secondary backend (e.g. the WSL backend) shows up as a  // bearer environment whose connection id is prefixed "local:". It runs on the  // user's own machine, so the cloud icon is misleading, label it "Local" and  // suppress the cloud icon (the project header already shows a  // local-environment icon for desktop-local projects, see sidebarProjectGrouping).  const isDesktopLocalThread =    environment !== null && isDesktopLocalConnectionTarget(environment.entry.target);  const threadEnvironmentLabel = isRemoteThread    ? (remoteEnvLabel ?? (isDesktopLocalThread ? "Local" : "Remote"))    : null;  const isHighlighted = isActive || isSelected;  const handleOpenDiscoveredPort = useCallback(    (event: React.MouseEvent<HTMLButtonElement>) => {      const port = discoveredPorts[0];      if (!port) return;      event.preventDefault();      event.stopPropagation();      navigateToThread(threadRef);      void (async () => {        const result = await openDiscoveredPort({ threadRef, port, openPreview });        if (result._tag === "Success" || isAtomCommandInterrupted(result)) {          return;        }        const error = squashAtomCommandFailure(result);        toastManager.add(          stackedThreadToast({            type: "error",            title: "Unable to open preview",            description:              error instanceof Error ? error.message : "The preview could not be opened.",          }),        );      })();    },    [discoveredPorts, navigateToThread, openPreview, threadRef],  );  const isThreadRunning =    thread.session?.status === "running" && thread.session.activeTurnId != null;  const threadStatus = resolveThreadStatusPill({    thread: {      ...thread,      lastVisitedAt,    },  });  const linkedPullRequestStatus = useLinkedThreadPullRequest(    thread.environmentId,    thread.linkedPullRequest,    leaseLiveStatus,    thread.pullRequests,    thread.branchPullRequest,  );  const pr = linkedPullRequestStatus?.pr ?? null;  const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId);  const currentLinkedPr = supportsMultiplePullRequests    ? resolveThreadCurrentPullRequestLink(thread.pullRequests)    : null;  const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider);  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 max-sm:pr-6 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;

Lines 1–500 of 3822.