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 MouseEvent, type PointerEvent,} from "react";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, type ResolvedKeybindingsConfig,} from "@t3tools/contracts";import { useQueries, useQuery } from "@tanstack/react-query";import { 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 { resolveShortcutCommand, shortcutLabelForCommand, shouldShowThreadJumpHints, threadJumpCommandForIndex, threadJumpIndexFromCommand, threadTraversalDirectionFromCommand,} from "../keybindings";import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic";import { gitStatusQueryOptions } from "../lib/gitReactQuery";import { serverConfigQueryOptions } from "../lib/serverReactQuery";import { readNativeApi } from "../nativeApi";import { useComposerDraftStore } from "../composerDraftStore";import { useHandleNewThread } from "../hooks/useHandleNewThread";import { useThreadActions } from "../hooks/useThreadActions";import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";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, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, sortThreadsForSidebar,} from "./Sidebar.logic";import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";import { useSettings, useUpdateSettings } from "~/hooks/useSettings";import { isProjectWorkspaceAvailable, isThreadWorkspaceAvailable } from "../workspaceAvailability"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];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;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 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 T3Wordmark() { return ( <svg aria-label="T3" className="h-2.5 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> );} type SortableProjectHandleProps = Pick< ReturnType<typeof useSortable>, "attributes" | "listeners" | "setActivatorNodeRef">; function ProjectSortMenu({ projectSortOrder, threadSortOrder, onProjectSortOrderChange, onThreadSortOrderChange,}: { projectSortOrder: SidebarProjectSortOrder; threadSortOrder: SidebarThreadSortOrder; onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void; onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;}) { return ( <Menu> <Tooltip> <TooltipTrigger render={ <MenuTrigger className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground" /> } > <ArrowUpDownIcon className="size-3.5" /> </TooltipTrigger> <TooltipPopup side="right">Sort projects</TooltipPopup> </Tooltip> <MenuPopup align="end" side="bottom" className="min-w-44"> <MenuGroup> <div className="px-2 py-1 sm:text-xs font-medium text-muted-foreground"> Sort projects </div> <MenuRadioGroup value={projectSortOrder} onValueChange={(value) => { onProjectSortOrderChange(value as SidebarProjectSortOrder); }} > {(Object.entries(SIDEBAR_SORT_LABELS) as Array<[SidebarProjectSortOrder, string]>).map( ([value, label]) => ( <MenuRadioItem key={value} value={value} className="min-h-7 py-1 sm:text-xs"> {label} </MenuRadioItem> ), )} </MenuRadioGroup> </MenuGroup> <MenuGroup> <div className="px-2 pt-2 pb-1 sm:text-xs font-medium text-muted-foreground"> Sort threads </div> <MenuRadioGroup value={threadSortOrder} onValueChange={(value) => { onThreadSortOrderChange(value as SidebarThreadSortOrder); }} > {( Object.entries(SIDEBAR_THREAD_SORT_LABELS) as Array<[SidebarThreadSortOrder, string]> ).map(([value, label]) => ( <MenuRadioItem key={value} value={value} className="min-h-7 py-1 sm:text-xs"> {label} </MenuRadioItem> ))} </MenuRadioGroup> </MenuGroup> </MenuPopup> </Menu> );} function SortableProjectItem({ projectId, disabled = false, children,}: { projectId: ProjectId; disabled?: boolean; children: (handleProps: SortableProjectHandleProps) => React.ReactNode;}) { const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging, isOver, } = useSortable({ id: projectId, disabled }); return ( <li ref={setNodeRef} style={{ transform: CSS.Translate.toString(transform), transition, }} className={`group/menu-item relative rounded-md ${ isDragging ? "z-20 opacity-80" : "" } ${isOver && !isDragging ? "ring-1 ring-primary/40" : ""}`} data-sidebar="menu-item" data-slot="sidebar-menu-item" > {children({ attributes, listeners, setActivatorNodeRef })} </li> );} export default function Sidebar() { const projects = useStore((store) => store.projects); const threads = useStore((store) => store.threads); const markThreadUnread = useStore((store) => store.markThreadUnread); const toggleProject = useStore((store) => store.toggleProject); const reorderProjects = useStore((store) => store.reorderProjects); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); const getDraftThreadByProjectId = useComposerDraftStore( (store) => store.getDraftThreadByProjectId, ); const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId); const clearProjectDraftThreadId = useComposerDraftStore( (store) => store.clearProjectDraftThreadId, ); const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); const isOnSettings = pathname.startsWith("/settings"); const appSettings = useSettings(); const { updateSettings } = useUpdateSettings(); const { handleNewThread } = useHandleNewThread(); const { archiveThread, deleteThread } = useThreadActions(); 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 [addingProject, setAddingProject] = useState(false); const [newCwd, setNewCwd] = useState(""); const [isPickingFolder, setIsPickingFolder] = useState(false); const [isAddingProject, setIsAddingProject] = useState(false); const [addProjectError, setAddProjectError] = useState<string | null>(null); const addProjectInputRef = useRef<HTMLInputElement | null>(null); const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null); const [renamingTitle, setRenamingTitle] = useState(""); const [hoveredThreadId, setHoveredThreadId] = useState<ThreadId | null>(null); const [confirmingArchiveThreadId, setConfirmingArchiveThreadId] = useState<ThreadId | null>(null); const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< ReadonlySet<ProjectId> >(() => new Set()); const [showThreadJumpHints, setShowThreadJumpHints] = useState(false); const renamingCommittedRef = useRef(false); const renamingInputRef = useRef<HTMLInputElement | null>(null); const dragInProgressRef = useRef(false); const suppressProjectClickAfterDragRef = useRef(false); const suppressProjectClickForContextMenuRef = useRef(false); const [desktopUpdateState, setDesktopUpdateState] = useState<DesktopUpdateState | null>(null); const selectedThreadIds = useThreadSelectionStore((s) => s.selectedThreadIds); const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo); const clearSelection = useThreadSelectionStore((s) => s.clearSelection); const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform); const platform = navigator.platform; const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; const projectCwdById = useMemo( () => new Map(projects.map((project) => [project.id, project.cwd] as const)), [projects], ); const routeTerminalOpen = routeThreadId ? selectThreadTerminalState(terminalStateByThreadId, routeThreadId).terminalOpen : false; const sidebarShortcutLabelOptions = useMemo( () => ({ platform, context: { terminalFocus: false, terminalOpen: routeTerminalOpen, }, }), [platform, routeTerminalOpen], ); const threadGitTargets = useMemo( () => threads.map((thread) => ({ threadId: thread.id, branch: thread.branch, cwd: thread.effectiveCwd, enabled: isThreadWorkspaceAvailable(thread), })), [threads], ); const threadGitStatusCwds = useMemo( () => [ ...new Set( threadGitTargets .filter((target) => target.enabled && target.branch !== null) .map((target) => target.cwd) .filter((cwd): cwd is string => cwd !== null), ), ], [threadGitTargets], ); const threadGitStatusQueries = useQueries({ queries: threadGitStatusCwds.map((cwd) => ({ ...gitStatusQueryOptions(cwd, true), staleTime: 30_000, refetchInterval: 60_000, })), }); const prByThreadId = useMemo(() => { const statusByCwd = new Map<string, GitStatusResult>(); for (let index = 0; index < threadGitStatusCwds.length; index += 1) { const cwd = threadGitStatusCwds[index]; if (!cwd) continue; const status = threadGitStatusQueries[index]?.data; if (status) { statusByCwd.set(cwd, status); } } const map = new Map<ThreadId, ThreadPr>(); for (const target of threadGitTargets) { const status = target.cwd ? statusByCwd.get(target.cwd) : undefined; const branchMatches = target.branch !== null && status?.branch !== null && status?.branch === target.branch; map.set(target.threadId, branchMatches ? (status?.pr ?? null) : null); } return map; }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]); const openPrLink = useCallback((event: React.MouseEvent<HTMLElement>, prUrl: string) => { event.preventDefault(); event.stopPropagation(); const api = readNativeApi(); if (!api) { toastManager.add({ type: "error", title: "Link opening is unavailable.", }); return; } void api.shell.openExternal(prUrl).catch((error) => { toastManager.add({ type: "error", title: "Unable to open PR link", description: error instanceof Error ? error.message : "An error occurred.", }); }); }, []); const attemptArchiveThread = useCallback( async (threadId: ThreadId) => { try { await archiveThread(threadId); } catch (error) { toastManager.add({ type: "error", title: "Failed to archive thread", description: error instanceof Error ? error.message : "An error occurred.", }); } }, [archiveThread], ); const focusMostRecentThreadForProject = useCallback( (projectId: ProjectId) => { const latestThread = sortThreadsForSidebar( threads.filter((thread) => thread.projectId === projectId && thread.archivedAt === null), appSettings.sidebarThreadSortOrder, )[0]; if (!latestThread) return; void navigate({ to: "/$threadId", params: { threadId: latestThread.id }, }); }, [appSettings.sidebarThreadSortOrder, navigate, threads], ); Lines 1–500 of 1995. Load more
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 MouseEvent, type PointerEvent, } from "react"; 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, type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { useQueries, useQuery } from "@tanstack/react-query"; import { 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 { resolveShortcutCommand, shortcutLabelForCommand, shouldShowThreadJumpHints, threadJumpCommandForIndex, threadJumpIndexFromCommand, threadTraversalDirectionFromCommand, } from "../keybindings"; import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; import { gitStatusQueryOptions } from "../lib/gitReactQuery"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useThreadActions } from "../hooks/useThreadActions"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; 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, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, sortThreadsForSidebar, } from "./Sidebar.logic"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { isProjectWorkspaceAvailable, isThreadWorkspaceAvailable } from "../workspaceAvailability"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; 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; 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 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 T3Wordmark() { return ( <svg aria-label="T3" className="h-2.5 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> ); } type SortableProjectHandleProps = Pick< ReturnType<typeof useSortable>, "attributes" | "listeners" | "setActivatorNodeRef" >; function ProjectSortMenu({ projectSortOrder, threadSortOrder, onProjectSortOrderChange, onThreadSortOrderChange, }: { projectSortOrder: SidebarProjectSortOrder; threadSortOrder: SidebarThreadSortOrder; onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void; onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; }) { return ( <Menu> <Tooltip> <TooltipTrigger render={ <MenuTrigger className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground" /> } > <ArrowUpDownIcon className="size-3.5" /> </TooltipTrigger> <TooltipPopup side="right">Sort projects</TooltipPopup> </Tooltip> <MenuPopup align="end" side="bottom" className="min-w-44"> <MenuGroup> <div className="px-2 py-1 sm:text-xs font-medium text-muted-foreground"> Sort projects </div> <MenuRadioGroup value={projectSortOrder} onValueChange={(value) => { onProjectSortOrderChange(value as SidebarProjectSortOrder); }} > {(Object.entries(SIDEBAR_SORT_LABELS) as Array<[SidebarProjectSortOrder, string]>).map( ([value, label]) => ( <MenuRadioItem key={value} value={value} className="min-h-7 py-1 sm:text-xs"> {label} </MenuRadioItem> ), )} </MenuRadioGroup> </MenuGroup> <MenuGroup> <div className="px-2 pt-2 pb-1 sm:text-xs font-medium text-muted-foreground"> Sort threads </div> <MenuRadioGroup value={threadSortOrder} onValueChange={(value) => { onThreadSortOrderChange(value as SidebarThreadSortOrder); }} > {( Object.entries(SIDEBAR_THREAD_SORT_LABELS) as Array<[SidebarThreadSortOrder, string]> ).map(([value, label]) => ( <MenuRadioItem key={value} value={value} className="min-h-7 py-1 sm:text-xs"> {label} </MenuRadioItem> ))} </MenuRadioGroup> </MenuGroup> </MenuPopup> </Menu> ); } function SortableProjectItem({ projectId, disabled = false, children, }: { projectId: ProjectId; disabled?: boolean; children: (handleProps: SortableProjectHandleProps) => React.ReactNode; }) { const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging, isOver, } = useSortable({ id: projectId, disabled }); return ( <li ref={setNodeRef} style={{ transform: CSS.Translate.toString(transform), transition, }} className={`group/menu-item relative rounded-md ${ isDragging ? "z-20 opacity-80" : "" } ${isOver && !isDragging ? "ring-1 ring-primary/40" : ""}`} data-sidebar="menu-item" data-slot="sidebar-menu-item" > {children({ attributes, listeners, setActivatorNodeRef })} </li> ); } export default function Sidebar() { const projects = useStore((store) => store.projects); const threads = useStore((store) => store.threads); const markThreadUnread = useStore((store) => store.markThreadUnread); const toggleProject = useStore((store) => store.toggleProject); const reorderProjects = useStore((store) => store.reorderProjects); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); const getDraftThreadByProjectId = useComposerDraftStore( (store) => store.getDraftThreadByProjectId, ); const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId); const clearProjectDraftThreadId = useComposerDraftStore( (store) => store.clearProjectDraftThreadId, ); const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); const isOnSettings = pathname.startsWith("/settings"); const appSettings = useSettings(); const { updateSettings } = useUpdateSettings(); const { handleNewThread } = useHandleNewThread(); const { archiveThread, deleteThread } = useThreadActions(); 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 [addingProject, setAddingProject] = useState(false); const [newCwd, setNewCwd] = useState(""); const [isPickingFolder, setIsPickingFolder] = useState(false); const [isAddingProject, setIsAddingProject] = useState(false); const [addProjectError, setAddProjectError] = useState<string | null>(null); const addProjectInputRef = useRef<HTMLInputElement | null>(null); const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null); const [renamingTitle, setRenamingTitle] = useState(""); const [hoveredThreadId, setHoveredThreadId] = useState<ThreadId | null>(null); const [confirmingArchiveThreadId, setConfirmingArchiveThreadId] = useState<ThreadId | null>(null); const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< ReadonlySet<ProjectId> >(() => new Set()); const [showThreadJumpHints, setShowThreadJumpHints] = useState(false); const renamingCommittedRef = useRef(false); const renamingInputRef = useRef<HTMLInputElement | null>(null); const dragInProgressRef = useRef(false); const suppressProjectClickAfterDragRef = useRef(false); const suppressProjectClickForContextMenuRef = useRef(false); const [desktopUpdateState, setDesktopUpdateState] = useState<DesktopUpdateState | null>(null); const selectedThreadIds = useThreadSelectionStore((s) => s.selectedThreadIds); const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo); const clearSelection = useThreadSelectionStore((s) => s.clearSelection); const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform); const platform = navigator.platform; const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; const projectCwdById = useMemo( () => new Map(projects.map((project) => [project.id, project.cwd] as const)), [projects], ); const routeTerminalOpen = routeThreadId ? selectThreadTerminalState(terminalStateByThreadId, routeThreadId).terminalOpen : false; const sidebarShortcutLabelOptions = useMemo( () => ({ platform, context: { terminalFocus: false, terminalOpen: routeTerminalOpen, }, }), [platform, routeTerminalOpen], ); const threadGitTargets = useMemo( () => threads.map((thread) => ({ threadId: thread.id, branch: thread.branch, cwd: thread.effectiveCwd, enabled: isThreadWorkspaceAvailable(thread), })), [threads], ); const threadGitStatusCwds = useMemo( () => [ ...new Set( threadGitTargets .filter((target) => target.enabled && target.branch !== null) .map((target) => target.cwd) .filter((cwd): cwd is string => cwd !== null), ), ], [threadGitTargets], ); const threadGitStatusQueries = useQueries({ queries: threadGitStatusCwds.map((cwd) => ({ ...gitStatusQueryOptions(cwd, true), staleTime: 30_000, refetchInterval: 60_000, })), }); const prByThreadId = useMemo(() => { const statusByCwd = new Map<string, GitStatusResult>(); for (let index = 0; index < threadGitStatusCwds.length; index += 1) { const cwd = threadGitStatusCwds[index]; if (!cwd) continue; const status = threadGitStatusQueries[index]?.data; if (status) { statusByCwd.set(cwd, status); } } const map = new Map<ThreadId, ThreadPr>(); for (const target of threadGitTargets) { const status = target.cwd ? statusByCwd.get(target.cwd) : undefined; const branchMatches = target.branch !== null && status?.branch !== null && status?.branch === target.branch; map.set(target.threadId, branchMatches ? (status?.pr ?? null) : null); } return map; }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]); const openPrLink = useCallback((event: React.MouseEvent<HTMLElement>, prUrl: string) => { event.preventDefault(); event.stopPropagation(); const api = readNativeApi(); if (!api) { toastManager.add({ type: "error", title: "Link opening is unavailable.", }); return; } void api.shell.openExternal(prUrl).catch((error) => { toastManager.add({ type: "error", title: "Unable to open PR link", description: error instanceof Error ? error.message : "An error occurred.", }); }); }, []); const attemptArchiveThread = useCallback( async (threadId: ThreadId) => { try { await archiveThread(threadId); } catch (error) { toastManager.add({ type: "error", title: "Failed to archive thread", description: error instanceof Error ? error.message : "An error occurred.", }); } }, [archiveThread], ); const focusMostRecentThreadForProject = useCallback( (projectId: ProjectId) => { const latestThread = sortThreadsForSidebar( threads.filter((thread) => thread.projectId === projectId && thread.archivedAt === null), appSettings.sidebarThreadSortOrder, )[0]; if (!latestThread) return; void navigate({ to: "/$threadId", params: { threadId: latestThread.id }, }); }, [appSettings.sidebarThreadSortOrder, navigate, threads], );