2158 lines · 77.1 KB · TypeScript
RawDownload
import {  ArchiveIcon,  ArrowUpDownIcon,  ChevronRightIcon,  FolderIcon,  GitPullRequestIcon,  PlusIcon,  SettingsIcon,  SquarePenIcon,  TerminalIcon,  TriangleAlertIcon,} from "lucide-react";import { ProjectFavicon } from "./ProjectFavicon";import { autoAnimate } from "@formkit/auto-animate";import {  useCallback,  useEffect,  useMemo,  useRef,  useState,  type Dispatch,  type KeyboardEvent,  type MouseEvent,  type MutableRefObject,  type PointerEvent,  type ReactNode,  type SetStateAction,} 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,  ProjectId,  ThreadId,  type GitStatusResult,} from "@t3tools/contracts";import { Link, useLocation, useNavigate, useParams } from "@tanstack/react-router";import {  type SidebarProjectSortOrder,  type SidebarThreadSortOrder,} from "@t3tools/contracts/settings";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 { 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 { readNativeApi } from "../nativeApi";import { useComposerDraftStore } from "../composerDraftStore";import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useThreadActions } from "../hooks/useThreadActions";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,  SidebarMenuAction,  SidebarMenu,  SidebarMenuButton,  SidebarMenuItem,  SidebarMenuSub,  SidebarMenuSubButton,  SidebarMenuSubItem,  SidebarSeparator,  SidebarTrigger,} from "./ui/sidebar";import { useThreadSelectionStore } from "../threadSelectionStore";import { isNonEmpty as isNonEmptyString } from "effect/String";import {  getVisibleSidebarThreadIds,  getVisibleThreadsForProject,  resolveAdjacentThreadId,  isContextMenuPointerDown,  resolveProjectStatusIndicator,  resolveSidebarNewThreadSeedContext,  resolveSidebarNewThreadEnvMode,  resolveThreadRowClassName,  resolveThreadStatusPill,  orderItemsByPreferredIds,  shouldClearThreadSelectionOnMouseDown,  sortProjectsForSidebar,  sortThreadsForSidebar,  useThreadJumpHintVisibility,} from "./Sidebar.logic";import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";import { useSettings, useUpdateSettings } from "~/hooks/useSettings";import { useServerKeybindings } from "../rpc/serverState";import { useSidebarThreadSummaryById } from "../storeSelectors";import type { Project } 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; type SidebarProjectSnapshot = Project & {  expanded: boolean;};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: NonNullable<ReturnType<typeof resolveThreadStatusPill>>;  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 {  threadId: ThreadId;  projectCwd: string | null;  orderedProjectThreadIds: readonly ThreadId[];  routeThreadId: ThreadId | null;  selectedThreadIds: ReadonlySet<ThreadId>;  showThreadJumpHints: boolean;  jumpLabel: string | null;  appSettingsConfirmThreadArchive: boolean;  renamingThreadId: ThreadId | null;  renamingTitle: string;  setRenamingTitle: (title: string) => void;  renamingInputRef: MutableRefObject<HTMLInputElement | null>;  renamingCommittedRef: MutableRefObject<boolean>;  confirmingArchiveThreadId: ThreadId | null;  setConfirmingArchiveThreadId: Dispatch<SetStateAction<ThreadId | null>>;  confirmArchiveButtonRefs: MutableRefObject<Map<ThreadId, HTMLButtonElement>>;  handleThreadClick: (    event: MouseEvent,    threadId: ThreadId,    orderedProjectThreadIds: readonly ThreadId[],  ) => void;  navigateToThread: (threadId: ThreadId) => void;  handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise<void>;  handleThreadContextMenu: (    threadId: ThreadId,    position: { x: number; y: number },  ) => Promise<void>;  clearSelection: () => void;  commitRename: (threadId: ThreadId, newTitle: string, originalTitle: string) => Promise<void>;  cancelRename: () => void;  attemptArchiveThread: (threadId: ThreadId) => Promise<void>;  openPrLink: (event: MouseEvent<HTMLElement>, prUrl: string) => void;} function SidebarThreadRow(props: SidebarThreadRowProps) {  const thread = useSidebarThreadSummaryById(props.threadId);  const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[props.threadId]);  const runningTerminalIds = useTerminalStateStore(    (state) =>      selectThreadTerminalState(state.terminalStateByThreadId, props.threadId).runningTerminalIds,  );  const gitCwd = thread?.worktreePath ?? props.projectCwd;  const gitStatus = useGitStatus(thread?.branch != null ? gitCwd : null);   if (!thread) {    return null;  }   const isActive = props.routeThreadId === thread.id;  const isSelected = props.selectedThreadIds.has(thread.id);  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 = props.confirmingArchiveThreadId === thread.id && !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";   return (    <SidebarMenuSubItem      className="w-full"      data-thread-item      onMouseLeave={() => {        props.setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current));      }}      onBlurCapture={(event) => {        const currentTarget = event.currentTarget;        requestAnimationFrame(() => {          if (currentTarget.contains(document.activeElement)) {            return;          }          props.setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current));        });      }}    >      <SidebarMenuSubButton        render={<div role="button" tabIndex={0} />}        size="sm"        isActive={isActive}        data-testid={`thread-row-${thread.id}`}        className={`${resolveThreadRowClassName({          isActive,          isSelected,        })} relative isolate`}        onClick={(event) => {          props.handleThreadClick(event, thread.id, props.orderedProjectThreadIds);        }}        onKeyDown={(event) => {          if (event.key !== "Enter" && event.key !== " ") return;          event.preventDefault();          props.navigateToThread(thread.id);        }}        onContextMenu={(event) => {          event.preventDefault();          if (props.selectedThreadIds.size > 0 && props.selectedThreadIds.has(thread.id)) {            void props.handleMultiSelectContextMenu({              x: event.clientX,              y: event.clientY,            });          } else {            if (props.selectedThreadIds.size > 0) {              props.clearSelection();            }            void props.handleThreadContextMenu(thread.id, {              x: event.clientX,              y: event.clientY,            });          }        }}      >        <div className="flex min-w-0 flex-1 items-center gap-1.5 text-left">          {prStatus && (            <Tooltip>              <TooltipTrigger                render={                  <button                    type="button"                    aria-label={prStatus.tooltip}                    className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-ring`}                    onClick={(event) => {                      props.openPrLink(event, prStatus.url);                    }}                  >                    <GitPullRequestIcon className="size-3" />                  </button>                }              />              <TooltipPopup side="top">{prStatus.tooltip}</TooltipPopup>            </Tooltip>          )}          {threadStatus && <ThreadStatusLabel status={threadStatus} />}          {props.renamingThreadId === thread.id ? (            <input              ref={(element) => {                if (element && props.renamingInputRef.current !== element) {                  props.renamingInputRef.current = element;                  element.focus();                  element.select();                }              }}              className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5"              value={props.renamingTitle}              onChange={(event) => props.setRenamingTitle(event.target.value)}              onKeyDown={(event) => {                event.stopPropagation();                if (event.key === "Enter") {                  event.preventDefault();                  props.renamingCommittedRef.current = true;                  void props.commitRename(thread.id, props.renamingTitle, thread.title);                } else if (event.key === "Escape") {                  event.preventDefault();                  props.renamingCommittedRef.current = true;                  props.cancelRename();                }              }}              onBlur={() => {                if (!props.renamingCommittedRef.current) {                  void props.commitRename(thread.id, props.renamingTitle, thread.title);                }              }}              onClick={(event) => event.stopPropagation()}            />          ) : (            <span className="min-w-0 flex-1 truncate text-xs">{thread.title}</span>          )}        </div>        <div className="ml-auto flex shrink-0 items-center gap-1.5">          {terminalStatus && (            <span              role="img"              aria-label={terminalStatus.label}              title={terminalStatus.label}              className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`}            >              <TerminalIcon className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`} />            </span>          )}          <div className="flex min-w-12 justify-end">            {isConfirmingArchive ? (              <button                ref={(element) => {                  if (element) {                    props.confirmArchiveButtonRefs.current.set(thread.id, element);                  } else {                    props.confirmArchiveButtonRefs.current.delete(thread.id);                  }                }}                type="button"                data-thread-selection-safe                data-testid={`thread-archive-confirm-${thread.id}`}                aria-label={`Confirm archive ${thread.title}`}                className="absolute top-1/2 right-1 inline-flex h-5 -translate-y-1/2 cursor-pointer items-center rounded-full bg-destructive/12 px-2 text-[10px] font-medium text-destructive transition-colors hover:bg-destructive/18 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-destructive/40"                onPointerDown={(event) => {                  event.stopPropagation();                }}                onClick={(event) => {                  event.preventDefault();                  event.stopPropagation();                  props.setConfirmingArchiveThreadId((current) =>                    current === thread.id ? null : current,                  );                  void props.attemptArchiveThread(thread.id);                }}              >                Confirm              </button>            ) : !isThreadRunning ? (              props.appSettingsConfirmThreadArchive ? (                <div className="pointer-events-none absolute top-1/2 right-1 -translate-y-1/2 opacity-0 transition-opacity duration-150 group-hover/menu-sub-item:pointer-events-auto group-hover/menu-sub-item:opacity-100 group-focus-within/menu-sub-item:pointer-events-auto group-focus-within/menu-sub-item:opacity-100">                  <button                    type="button"                    data-thread-selection-safe                    data-testid={`thread-archive-${thread.id}`}                    aria-label={`Archive ${thread.title}`}                    className="inline-flex size-5 cursor-pointer items-center justify-center text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"                    onPointerDown={(event) => {                      event.stopPropagation();                    }}                    onClick={(event) => {                      event.preventDefault();                      event.stopPropagation();                      props.setConfirmingArchiveThreadId(thread.id);                      requestAnimationFrame(() => {                        props.confirmArchiveButtonRefs.current.get(thread.id)?.focus();                      });                    }}                  >                    <ArchiveIcon className="size-3.5" />                  </button>                </div>              ) : (

Lines 1–500 of 2158.