793 lines · 29.0 KB · TypeScript
RawDownload
import { ChevronRightIcon, FolderIcon, TerminalIcon } from "lucide-react";import { useCallback, useEffect, useMemo, useState } from "react";import {  DEFAULT_MODEL,  ProjectId,  ThreadId,  type ResolvedKeybindingsConfig,} from "@t3tools/contracts";import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";import { useNavigate, useParams } from "@tanstack/react-router";import { useAppSettings } from "../appSettings";import { isElectron } from "../env";import { APP_STAGE_LABEL } from "../branding";import { newCommandId, newProjectId, newThreadId } from "../lib/utils";import { useStore } from "../store";import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings";import { type Thread } from "../types";import { derivePendingApprovals } from "../session-logic";import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery";import { serverConfigQueryOptions } from "../lib/serverReactQuery";import { readNativeApi } from "../nativeApi";import { toastManager } from "./ui/toast";import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";import {  SidebarContent,  SidebarFooter,  SidebarGroup,  SidebarHeader,  SidebarMenu,  SidebarMenuButton,  SidebarMenuItem,  SidebarMenuSub,  SidebarMenuSubButton,  SidebarMenuSubItem,  SidebarSeparator,  SidebarTrigger,} from "./ui/sidebar";import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";import { isNonEmpty as isNonEmptyString } from "effect/String"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; function formatRelativeTime(iso: string): string {  const diff = Date.now() - new Date(iso).getTime();  const minutes = Math.floor(diff / 60_000);  if (minutes < 1) return "just now";  if (minutes < 60) return `${minutes}m ago`;  const hours = Math.floor(minutes / 60);  if (hours < 24) return `${hours}h ago`;  return `${Math.floor(hours / 24)}d ago`;} interface ThreadStatusPill {  label: "Working" | "Connecting" | "Completed" | "Pending Approval";  colorClass: string;  dotClass: string;  pulse: boolean;} interface TerminalStatusIndicator {  label: "Terminal process running";  colorClass: string;  pulse: boolean;} function hasUnseenCompletion(thread: Thread): boolean {  if (!thread.latestTurn?.completedAt) return false;  const completedAt = Date.parse(thread.latestTurn.completedAt);  if (Number.isNaN(completedAt)) return false;  if (!thread.lastVisitedAt) return true;   const lastVisitedAt = Date.parse(thread.lastVisitedAt);  if (Number.isNaN(lastVisitedAt)) return true;  return completedAt > lastVisitedAt;} function threadStatusPill(thread: Thread, hasPendingApprovals: boolean): ThreadStatusPill | null {  if (hasPendingApprovals) {    return {      label: "Pending Approval",      colorClass: "text-amber-600 dark:text-amber-300/90",      dotClass: "bg-amber-500 dark:bg-amber-300/90",      pulse: false,    };  }   if (thread.session?.status === "running") {    return {      label: "Working",      colorClass: "text-sky-600 dark:text-sky-300/80",      dotClass: "bg-sky-500 dark:bg-sky-300/80",      pulse: true,    };  }   if (thread.session?.status === "connecting") {    return {      label: "Connecting",      colorClass: "text-sky-600 dark:text-sky-300/80",      dotClass: "bg-sky-500 dark:bg-sky-300/80",      pulse: true,    };  }   if (hasUnseenCompletion(thread)) {    return {      label: "Completed",      colorClass: "text-emerald-600 dark:text-emerald-300/90",      dotClass: "bg-emerald-500 dark:bg-emerald-300/90",      pulse: false,    };  }   return null;} function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null {  if (thread.runningTerminalIds.length === 0) {    return null;  }  return {    label: "Terminal process running",    colorClass: "text-teal-600 dark:text-teal-300/90",    pulse: true,  };} function T3Wordmark() {  return (    <svg      aria-label="T3"      className="h-3 w-auto shrink-0 text-foreground"      viewBox="15.5309 37 94.3941 56.96"      xmlns="http://www.w3.org/2000/svg"    >      <path        d="M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z"        fill="currentColor"      />    </svg>  );} /** * Derives the server's HTTP origin (scheme + host + port) from the same * sources WsTransport uses, converting ws(s) to http(s). */function getServerHttpOrigin(): string {  const bridgeUrl = window.desktopBridge?.getWsUrl();  const envUrl = import.meta.env.VITE_WS_URL as string | undefined;  const wsUrl =    bridgeUrl && bridgeUrl.length > 0      ? bridgeUrl      : envUrl && envUrl.length > 0        ? envUrl        : `ws://${window.location.hostname}:${window.location.port}`;  // Parse to extract just the origin, dropping path/query (e.g. ?token=…)  const httpUrl = wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:");  try {    return new URL(httpUrl).origin;  } catch {    return httpUrl;  }} const serverHttpOrigin = getServerHttpOrigin(); function ProjectFavicon({ cwd }: { cwd: string }) {  const [status, setStatus] = useState<"loading" | "loaded" | "error">("loading");   const src = `${serverHttpOrigin}/api/project-favicon?cwd=${encodeURIComponent(cwd)}`;   if (status === "error") {    return <FolderIcon className="size-3.5 shrink-0 text-muted-foreground/50" />;  }   return (    <img      src={src}      alt=""      className={`size-3.5 shrink-0 rounded-sm object-contain ${status === "loading" ? "hidden" : ""}`}      onLoad={() => setStatus("loaded")}      onError={() => setStatus("error")}    />  );} export default function Sidebar() {  const { state, dispatch } = useStore();  const navigate = useNavigate();  const { settings: appSettings } = useAppSettings();  const routeThreadId = useParams({    strict: false,    select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),  });  const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({    ...serverConfigQueryOptions(),    select: (config) => config.keybindings,  });  const queryClient = useQueryClient();  const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient }));  const [addingProject, setAddingProject] = useState(false);  const [newCwd, setNewCwd] = useState("");  const [isPickingFolder, setIsPickingFolder] = useState(false);  const [isAddingProject, setIsAddingProject] = useState(false);  const pendingApprovalByThreadId = useMemo(() => {    const map = new Map<ThreadId, boolean>();    for (const thread of state.threads) {      map.set(thread.id, derivePendingApprovals(thread.activities).length > 0);    }    return map;  }, [state.threads]);   const handleNewThread = useCallback(    (      projectId: ProjectId,      options?: {        branch?: string | null;        worktreePath?: string | null;      },    ): Promise<void> => {      const api = readNativeApi();      if (!api) return Promise.resolve();      const threadId = newThreadId();      const createdAt = new Date().toISOString();      const model =        state.projects.find((project) => project.id === projectId)?.model ?? DEFAULT_MODEL;      return (async () => {        await api.orchestration.dispatchCommand({          type: "thread.create",          commandId: newCommandId(),          threadId,          projectId,          title: "New thread",          model,          branch: options?.branch ?? null,          worktreePath: options?.worktreePath ?? null,          createdAt,        });         // Ensure route guards can see the new thread before navigating.        try {          const snapshot = await api.orchestration.getSnapshot();          dispatch({ type: "SYNC_SERVER_READ_MODEL", readModel: snapshot });        } catch {          // Event stream can still hydrate the thread shortly after dispatch.        }         await navigate({          to: "/$threadId",          params: { threadId },        });      })();    },    [dispatch, navigate, state.projects],  );   const focusMostRecentThreadForProject = useCallback(    (projectId: ProjectId) => {      const latestThread = state.threads        .filter((thread) => thread.projectId === projectId)        .toSorted((a, b) => {          const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();          if (byDate !== 0) return byDate;          return b.id.localeCompare(a.id);        })[0];      if (!latestThread) return;       void navigate({        to: "/$threadId",        params: { threadId: latestThread.id },      });    },    [navigate, state.threads],  );   const addProjectFromPath = useCallback(    async (rawCwd: string) => {      const cwd = rawCwd.trim();      if (!cwd || isAddingProject) return;      const api = readNativeApi();       setIsAddingProject(true);      try {        if (!api) return;        const existing = state.projects.find((project) => project.cwd === cwd);        if (existing) {          focusMostRecentThreadForProject(existing.id);          return;        }         const projectId = newProjectId();        const createdAt = new Date().toISOString();        const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd;        await api.orchestration.dispatchCommand({          type: "project.create",          commandId: newCommandId(),          projectId,          title,          workspaceRoot: cwd,          defaultModel: DEFAULT_MODEL,          createdAt,        });        await handleNewThread(projectId);      } finally {        setIsAddingProject(false);        setNewCwd("");        setAddingProject(false);      }    },    [focusMostRecentThreadForProject, handleNewThread, isAddingProject, state.projects],  );   const handleAddProject = () => {    void addProjectFromPath(newCwd);  };   const handlePickFolder = async () => {    const api = readNativeApi();    if (!api || isPickingFolder) return;    setIsPickingFolder(true);    try {      const pickedPath = await api.dialogs.pickFolder();      if (!pickedPath) return;      await addProjectFromPath(pickedPath);    } finally {      setIsPickingFolder(false);    }  };   const handleThreadContextMenu = useCallback(    async (threadId: ThreadId, position: { x: number; y: number }) => {      const api = readNativeApi();      if (!api) return;      const clicked = await api.contextMenu.show(        [          { id: "mark-unread", label: "Mark unread" },          { id: "delete", label: "Delete" },        ],        position,      );      if (clicked === "mark-unread") {        dispatch({ type: "MARK_THREAD_UNREAD", threadId });        return;      }      if (clicked !== "delete") return;       const thread = state.threads.find((t) => t.id === threadId);      if (!thread) return;      if (appSettings.confirmThreadDelete) {        const confirmed = await api.dialogs.confirm(          [            `Delete thread "${thread.title}"?`,            "This permanently clears conversation history for this thread.",          ].join("\n"),        );        if (!confirmed) {          return;        }      }      const threadProject = state.projects.find((project) => project.id === thread.projectId);      const orphanedWorktreePath = getOrphanedWorktreePathForThread(state.threads, threadId);      const displayWorktreePath = orphanedWorktreePath        ? formatWorktreePathForDisplay(orphanedWorktreePath)        : null;      const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== undefined;      const shouldDeleteWorktree =        canDeleteWorktree &&        (await api.dialogs.confirm(          [            "This thread is the only one linked to this worktree:",            displayWorktreePath ?? orphanedWorktreePath,            "",            "Delete the worktree too?",          ].join("\n"),        ));       if (thread.session && thread.session.status !== "closed") {        await api.orchestration          .dispatchCommand({            type: "thread.session.stop",            commandId: newCommandId(),            threadId,            createdAt: new Date().toISOString(),          })          .catch(() => undefined);      }       try {        await api.terminal.close({          threadId,          deleteHistory: true,        });      } catch {        // Terminal may already be closed      }       const shouldNavigateToFallback = routeThreadId === threadId;      const fallbackThreadId = state.threads.find((entry) => entry.id !== threadId)?.id ?? null;      await api.orchestration.dispatchCommand({        type: "thread.delete",        commandId: newCommandId(),        threadId,      });      if (shouldNavigateToFallback) {        if (fallbackThreadId) {          void navigate({            to: "/$threadId",            params: { threadId: fallbackThreadId },            replace: true,          });        } else {          void navigate({ to: "/", replace: true });        }      }       if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) {        return;      }       try {        await removeWorktreeMutation.mutateAsync({          cwd: threadProject.cwd,          path: orphanedWorktreePath,          force: true,        });      } catch (error) {        const message = error instanceof Error ? error.message : "Unknown error removing worktree.";        console.error("Failed to remove orphaned worktree after thread deletion", {          threadId,          projectCwd: threadProject.cwd,          worktreePath: orphanedWorktreePath,          error,        });        toastManager.add({          type: "error",          title: "Thread deleted, but worktree removal failed",          description: `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}`,        });      }    },    [      appSettings.confirmThreadDelete,      dispatch,      navigate,      removeWorktreeMutation,      routeThreadId,      state.projects,      state.threads,    ],  );   const handleProjectContextMenu = useCallback(    async (projectId: ProjectId, position: { x: number; y: number }) => {      const api = readNativeApi();      if (!api) return;      const clicked = await api.contextMenu.show([{ id: "delete", label: "Delete" }], position);      if (clicked !== "delete") return;       const project = state.projects.find((entry) => entry.id === projectId);      if (!project) return;       const projectThreads = state.threads.filter((thread) => thread.projectId === projectId);      if (projectThreads.length > 0) {        toastManager.add({          type: "warning",          title: "Project is not empty",          description: "Delete all threads in this project before deleting it.",        });        return;      }       const confirmed = await api.dialogs.confirm(        [`Delete project "${project.name}"?`, "This action cannot be undone."].join("\n"),      );      if (!confirmed) return;       try {        await api.orchestration.dispatchCommand({          type: "project.delete",          commandId: newCommandId(),          projectId,        });      } catch (error) {        const message = error instanceof Error ? error.message : "Unknown error deleting project.";        console.error("Failed to remove project", { projectId, error });        toastManager.add({          type: "error",          title: `Failed to delete "${project.name}"`,          description: message,        });      }    },    [state.projects, state.threads],  );   useEffect(() => {    const onWindowKeyDown = (event: KeyboardEvent) => {      const activeThread = routeThreadId        ? state.threads.find((thread) => thread.id === routeThreadId)

Lines 1–500 of 793.