2732 lines · 114.0 KB · TypeScript
RawDownload
import { autoAnimate } from "@formkit/auto-animate";import { useAtomValue } from "@effect/atom-react";import {  canSnooze,  effectiveSettled,  effectiveSnoozed,  threadWokeAt,} from "@t3tools/client-runtime/state/thread-settled";import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";import {  scopeProjectRef,  scopeThreadRef,  scopedThreadKey,} from "@t3tools/client-runtime/environment";import type { ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts";import {  AlarmClockIcon,  AlarmClockOffIcon,  CheckIcon,  ChevronDownIcon,  CircleAlertIcon,  CircleCheckIcon,  CircleDashedIcon,  ClockIcon,  CopyIcon,  FolderIcon,  FolderPlusIcon,  GitBranchIcon,  EllipsisIcon,  MessageSquareIcon,  PlusIcon,  SearchIcon,  ServerIcon,  SquarePenIcon,  Trash2Icon,  Undo2Icon,} from "lucide-react";import {  memo,  useCallback,  useEffect,  useMemo,  useRef,  useState,  type KeyboardEvent as ReactKeyboardEvent,  type MouseEvent as ReactMouseEvent,  type ReactNode,} from "react";import { useParams, useRouter } from "@tanstack/react-router"; import {  isAtomCommandInterrupted,  settlePromise,  squashAtomCommandFailure,} from "@t3tools/client-runtime/state/runtime";import { isElectron } from "../env";import {  resolveShortcutCommand,  shortcutLabelForCommand,  shouldShowThreadJumpHintsForModifiers,  threadJumpCommandForIndex,  threadJumpIndexFromCommand,  threadTraversalDirectionFromCommand,} from "../keybindings";import { useShortcutModifierState } from "../shortcutModifierState";import { isTerminalFocused } from "../lib/terminalFocus";import { isModelPickerOpen } from "../modelPickerVisibility";import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";import { isMacPlatform } from "~/lib/utils";import { useOpenPrLink } from "../lib/openPullRequestLink";import { readLocalApi } from "../localApi";import {  deriveProjectGroupingOverrideKey,  getProjectOrderKey,  selectProjectGroupingSettings,} from "../logicalProject";import {  buildSidebarProjectSnapshots,  type SidebarProjectGroupMember,  type SidebarProjectSnapshot,} from "../sidebarProjectGrouping";import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";import { useThreadSelectionStore } from "../threadSelectionStore";import { useThreadActions } from "../hooks/useThreadActions";import { useHandleNewThread } from "../hooks/useHandleNewThread";import { openCommandPalette } from "../commandPaletteBus";import { startNewThreadFromContext } from "../lib/chatThreadActions";import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings";import { useCopyToClipboard } from "../hooks/useCopyToClipboard";import { useNowMinute } from "../hooks/useNowMinute";import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";import { useProjects, useThreadShells } from "../state/entities";import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server";import { vcsEnvironment } from "../state/vcs";import { threadEnvironment } from "../state/threads";import { projectEnvironment } from "../state/projects";import { useEnvironmentQuery } from "../state/query";import { useAtomCommand } from "../state/use-atom-command";import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes";import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat";import type { SidebarThreadSummary } from "../types";import { cn } from "~/lib/utils";import {  formatWorkingDurationLabel,  firstValidTimestampMs,  hasUnseenCompletion,  isTrailingDoubleClick,  orderItemsByPreferredIds,  resolveAdjacentThreadId,  resolveSettledTimestamp,  resolveSidebarV2Status,  resolveWorkingStartedAt,  shouldNavigateAfterProjectRemoval,  sortLogicalProjectsForSidebar,  sortSettledThreadsForSidebarV2,  sortThreadsForSidebarV2,} from "./Sidebar.logic";import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";import {  prStatusIndicator,  resolveThreadPr,  settledPrHoverColorClass,} from "./ThreadStatusIndicators";import {  resolveSnoozePresets,  snoozeWakeDescription,  snoozeWakeLabel,  type SnoozePreset,} from "./Sidebar.snooze";import { ProjectFavicon } from "./ProjectFavicon";import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances";import { primaryServerProvidersAtom } from "../state/server";import { stackedThreadToast, toastManager } from "./ui/toast";import { CommandDialogTrigger } from "./ui/command";import { Button } from "./ui/button";import {  Dialog,  DialogDescription,  DialogFooter,  DialogHeader,  DialogPanel,  DialogPopup,  DialogTitle,} from "./ui/dialog";import { Input } from "./ui/input";import { Kbd } from "./ui/kbd";import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu";import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar";import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome";import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip";import { useComposerDraftStore } from "../composerDraftStore"; // Settled-tail paging: recent history is the common lookup; the deep tail// stays behind an explicit Show more.const SETTLED_TAIL_INITIAL_COUNT = 10;const SETTLED_TAIL_PAGE_COUNT = 25;const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = {  repository: "Group by repository",  repository_path: "Group by repository path",  separate: "Keep separate",}; function compactSidebarTimeLabel(label: string): string {  if (label === "just now") return "now";  return label.endsWith(" ago") ? label.slice(0, -4) : label;} function threadTimeLabel(thread: SidebarThreadSummary): string {  const timestamp = thread.latestUserMessageAt ?? thread.updatedAt;  return compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp));} // Settled rows read "how long ago did this wrap up", matching their sort// key: both go through resolveSettledTimestamp so label and order can't// disagree.function settledTimeLabel(thread: SidebarThreadSummary): string {  const timestamp = resolveSettledTimestamp(thread);  return timestamp === null ? "" : compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp));} // Floats at the row's right edge, vertically centered, while the jump// modifier is held. An overlay pill instead of an inline slot: the hint// must neither displace the status/time label (holding ⌘ used to blank// out "Working") nor shift any layout when it appears. pointer-events-none// so it never swallows clicks meant for the settle/un-settle buttons it// can overlap.function JumpHintBadge(props: { label: string }) {  return (    <span      aria-hidden      className="pointer-events-none absolute right-1.5 top-1/2 z-10 inline-flex h-5 -translate-y-1/2 items-center rounded-full border border-border/80 bg-background/95 px-1.5 font-mono text-[10px] font-medium tracking-tight text-foreground shadow-sm"    >      {props.label}    </span>  );} // Self-ticking so only this span re-renders each second, not the whole row.function WorkingDuration(props: { startedAt: string | null }) {  const startedMs = props.startedAt !== null ? Date.parse(props.startedAt) : Number.NaN;  const [, setTick] = useState(0);  useEffect(() => {    if (Number.isNaN(startedMs)) return;    const id = window.setInterval(() => setTick((tick) => tick + 1), 1_000);    return () => window.clearInterval(id);  }, [startedMs]);  if (Number.isNaN(startedMs)) return null;  return <span className="tabular-nums">{formatWorkingDurationLabel(Date.now() - startedMs)}</span>;} function SidebarV2ThreadTooltip({  thread,  projectTitle,  projectCwd,  environmentLabel,  driverKind,  modelInstanceId,  modelLabel,  branchMismatch,}: {  thread: SidebarThreadSummary;  projectTitle: string | null;  projectCwd: string | null;  environmentLabel: string | null;  driverKind: ProviderInstanceEntry["driverKind"] | null;  modelInstanceId: string;  modelLabel: string;  branchMismatch: {    threadBranch: string;    currentBranch: string;  } | null;}) {  return (    <TooltipPopup      side="right"      align="start"      sideOffset={8}      className="dropdown-glass max-w-80 border-0! text-left whitespace-normal shadow-lg/10 before:hidden dark:shadow-none"      style={{        background:          "color-mix(in srgb, var(--popover) 18%, color-mix(in srgb, var(--popover) var(--glass-opacity), transparent))",      }}    >      <div className="flex max-w-80 flex-col gap-2 p-2">        <div className="whitespace-nowrap text-sm font-medium text-foreground">{thread.title}</div>        <div className="grid gap-1.5 text-xs text-muted-foreground">          {projectTitle ? (            <div className="flex min-w-0 items-center gap-2">              <ProjectFavicon                environmentId={thread.environmentId}                cwd={projectCwd ?? ""}                className="size-4 shrink-0 stroke-muted-foreground"              />              <div className="min-w-0 wrap-break-word text-foreground/90">{projectTitle}</div>            </div>          ) : null}          {environmentLabel ? (            <div className="flex min-w-0 items-center gap-2">              <ServerIcon className="size-4 shrink-0 stroke-muted-foreground" />              <div className="min-w-0 wrap-break-word text-foreground/90">{environmentLabel}</div>            </div>          ) : null}          {thread.branch ? (            <div className="flex min-w-0 items-center gap-2">              <GitBranchIcon className="size-4 shrink-0 stroke-muted-foreground" />              <div className="min-w-0 wrap-break-word text-foreground/90">{thread.branch}</div>            </div>          ) : null}          {branchMismatch ? (            <div className="flex min-w-0 items-start gap-2 text-warning">              <CircleAlertIcon aria-hidden className="mt-0.5 size-4 shrink-0 stroke-current" />              <div className="min-w-0 flex-1 wrap-break-word leading-5">                You're currently checked out on another branch.              </div>            </div>          ) : null}          {driverKind ? (            <div className="flex min-w-0 items-center gap-2">              <ProviderInstanceIcon                driverKind={driverKind}                displayName={thread.session?.providerName ?? modelInstanceId}                iconClassName="size-4 shrink-0"              />              <div className="min-w-0 wrap-break-word text-foreground/90">{modelLabel}</div>            </div>          ) : null}          {thread.session?.lastError ? (            <div className="flex min-w-0 items-center gap-2 text-red-600 dark:text-red-400">              <CircleAlertIcon className="size-4 shrink-0 stroke-current" />              <div className="min-w-0 wrap-break-word">{thread.session.lastError}</div>            </div>          ) : null}        </div>      </div>    </TooltipPopup>  );} /** * Hover entry point for snooze: a clock button opening the preset menu. * Controlled by the row (which also uses the open state to pin its hover * actions while the menu is up). */function SnoozePopoverButton(props: {  open: boolean;  onOpenChange: (open: boolean) => void;  onSnooze: (preset: SnoozePreset) => void;}) {  const { open, onOpenChange, onSnooze } = props;  // Presets resolve at open time so "In 1 hour" is relative to the click,  // not to when the row mounted.  const presets = useMemo(() => (open ? resolveSnoozePresets(new Date()) : []), [open]);  return (    <Popover open={open} onOpenChange={onOpenChange}>      <PopoverTrigger        render={          <button            type="button"            aria-label="Snooze thread"            onClick={(event) => event.stopPropagation()}            onDoubleClick={(event) => event.stopPropagation()}            className="inline-flex h-full cursor-pointer items-center gap-0.5 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground"          />        }      >        <ClockIcon className="size-3" />      </PopoverTrigger>      <PopoverPopup side="bottom" align="end" className="w-56" viewportClassName="p-1">        {presets.map((preset) => (          <button            key={preset.id}            type="button"            onClick={(event) => {              event.stopPropagation();              onOpenChange(false);              onSnooze(preset);            }}            className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-foreground/90 hover:bg-accent hover:text-foreground"          >            <span className="flex-1">{preset.label}</span>            <span className="font-mono text-[10px] text-muted-foreground/60 tabular-nums">              {preset.whenLabel}            </span>          </button>        ))}      </PopoverPopup>    </Popover>  );} const SidebarV2Row = memo(function SidebarV2Row(props: {  thread: SidebarThreadSummary;  variant: "card" | "slim";  // Slim rows are either settled (action: un-settle) or merely quiet  // (seen Ready threads — action: settle).  variantAction: "settle" | "unsettle" | "unsnooze";  // False on environments whose server predates thread.settle/unsettle:  // the lifecycle affordances hide entirely rather than fail on click.  settlementSupported: boolean;  // Same contract for thread.snooze/unsnooze.  snoozeSupported: boolean;  // Compact wake countdown ("2h") for rows in the snoozed shelf.  snoozeWakeLabelText: string | null;  // When a snooze ended (timer or early wake); drives the Woke pill until  // the user visits the thread.  wokeAt: string | null;  isActive: boolean;  jumpLabel: string | null;  currentEnvironmentId: string | null;  environmentLabel: string | null;  projectCwd: string | null;  projectTitle: string | null;  providerEntryByInstanceId: ReadonlyMap<string, ProviderInstanceEntry>;  onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void;  onThreadActivate: (threadRef: ScopedThreadRef) => void;  onStartRename: (threadRef: ScopedThreadRef, title: string) => void;  onRenameTitleChange: (title: string) => void;  onCommitRename: (threadRef: ScopedThreadRef, title: string, originalTitle: string) => void;  onCancelRename: () => void;  isRenaming: boolean;  renamingTitle: string;  onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void;  onSettle: (threadRef: ScopedThreadRef) => void;  onUnsettle: (threadRef: ScopedThreadRef) => void;  onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void;  onUnsnooze: (threadRef: ScopedThreadRef) => void;  onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void;}) {  const {    isRenaming,    onChangeRequestState,    onCancelRename,    onCommitRename,    onContextMenu,    onRenameTitleChange,    onSettle,    onSnooze,    onStartRename,    onThreadActivate,    onThreadClick,    onUnsettle,    onUnsnooze,    renamingTitle,    thread,    variant,    variantAction,  } = props;  const threadRef = useMemo(    () => scopeThreadRef(thread.environmentId, thread.id),    [thread.environmentId, thread.id],  );  const threadKey = scopedThreadKey(threadRef);  const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]);  const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey));  const openPrLink = useOpenPrLink();   // Same semantics as v1 (never-visited counts as read): flipping the beta  // flag must not light up every historical thread as unread.  const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt });  const status = resolveSidebarV2Status(thread);  // A woken thread reappears at its original position (the sort is  // deliberately static), so the pill has to carry the weight. Snoozing is  // an explicit act, so unlike Done, a never-visited woke thread still  // shows the pill; visiting clears it. An unparseable visit timestamp  // counts as never-visited — corrupt local data must not eat the wake  // signal.  const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt);  const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt);  const isWoke = wokeAtDate !== null && (lastVisitedDate === null || lastVisitedDate < wokeAtDate);  // In-flight rows (working, or waiting on approval/input) fade as a whole:  // there is nothing for the user to do yet, so prominence is reserved for  // rows that need a human — done (unread), read-but-unsettled, failed, and  // freshly woken. The status label keeps its hue, so waiting rows stay  // findable. In-flight rows recede the same as read-ready ones (inbox-zero:  // working threads aren't your problem yet) — only the colored status label  // stands out.  const isInFlight = status === "working" || status === "approval" || status === "input";  const shouldRecede =    (status === "ready" || isInFlight) && !isUnread && !isWoke && !props.isActive && !isSelected;  // Status hues follow the system-wide convention set by sidebar v1 and the  // mobile Live Activity/widgets (amber approval, indigo input, sky working)  // so a thread reads the same color everywhere it surfaces.  const topStatus =    status === "working"      ? {          label: "Working",          icon: "working" as const,          className:            "animate-sidebar-working-text text-sky-600 motion-reduce:animate-none dark:text-sky-400",        }      : status === "approval"        ? {            label: "Approval",            icon: null,            className: "text-amber-700 dark:text-amber-300",          }        : status === "input"          ? {              label: "Input",              icon: null,              className: "text-indigo-600 dark:text-indigo-300",            }          : status === "failed"            ? {                label: "Failed",                icon: null,                className: "text-red-700 dark:text-red-300",              }            : isWoke              ? {                  label: "Woke",                  icon: "woke" as const,                  className: "text-amber-700 dark:text-amber-300",                }              : isUnread                ? {                    label: "Done",                    icon: "done" as const,                    className: "text-emerald-700 dark:text-emerald-300",                  }                : null;   const gitCwd = thread.worktreePath ?? props.projectCwd;  const gitStatus = useEnvironmentQuery(    (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null      ? vcsEnvironment.status({          environmentId: thread.environmentId,          input: { cwd: gitCwd },        })      : null,  );  const branchMismatch = resolveLocalCheckoutBranchMismatch({    effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree",    activeWorktreePath: thread.worktreePath,    activeThreadBranch: thread.branch,    currentGitBranch: gitStatus.data?.refName ?? null,

Lines 1–500 of 2732.