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 { Option } from "effect"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent, type PointerEvent, } 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 { 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 { useUiStateStore } from "../uiStateStore"; import { resolveShortcutCommand, shortcutLabelForCommand, shouldShowThreadJumpHints, threadJumpCommandForIndex, threadJumpIndexFromCommand, threadTraversalDirectionFromCommand, } from "../keybindings"; import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; import { readNativeApi } from "../nativeApi"; import { useGitStatuses } from "../rpc/gitAtoms"; 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, 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 type { Project, Thread } from "../types"; const THREAD_PREVIEW_LIMIT = 6; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", manual: "Manual", }; const SIDEBAR_THREAD_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", }; const SIDEBAR_LIST_ANIMATION_OPTIONS = { duration: 180, easing: "ease-out", } as const; type SidebarThreadSnapshot = Pick< Thread, | "activities" | "archivedAt" | "branch" | "createdAt" | "id" | "interactionMode" | "latestTurn" | "projectId" | "proposedPlans" | "session" | "title" | "updatedAt" | "worktreePath" > & { lastVisitedAt?: string | undefined; latestUserMessageAt: string | null; }; type SidebarProjectSnapshot = Project & { expanded: boolean; }; const sidebarThreadSnapshotCache = new WeakMap< Thread, { lastVisitedAt?: string | undefined; snapshot: SidebarThreadSnapshot } >(); function getLatestUserMessageAt(thread: Thread): string | null { let latestUserMessageAt: string | null = null; for (const message of thread.messages) { if (message.role !== "user") { continue; } if (latestUserMessageAt === null || message.createdAt > latestUserMessageAt) { latestUserMessageAt = message.createdAt; } } return latestUserMessageAt; } function toSidebarThreadSnapshot( thread: Thread, lastVisitedAt: string | undefined, ): SidebarThreadSnapshot { const cached = sidebarThreadSnapshotCache.get(thread); if (cached && cached.lastVisitedAt === lastVisitedAt) { return cached.snapshot; } const snapshot: SidebarThreadSnapshot = { id: thread.id, projectId: thread.projectId, title: thread.title, interactionMode: thread.interactionMode, session: thread.session, createdAt: thread.createdAt, updatedAt: thread.updatedAt, archivedAt: thread.archivedAt, latestTurn: thread.latestTurn, lastVisitedAt, branch: thread.branch, worktreePath: thread.worktreePath, activities: thread.activities, proposedPlans: thread.proposedPlans, latestUserMessageAt: getLatestUserMessageAt(thread), }; sidebarThreadSnapshotCache.set(thread, { lastVisitedAt, snapshot }); return snapshot; } 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>; compact?: boolean; }) { if (compact) { return ( {status.label} ); } return ( {status.label} ); } 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 ( ); } type SortableProjectHandleProps = Pick< ReturnType, "attributes" | "listeners" | "setActivatorNodeRef" >; function ProjectSortMenu({ projectSortOrder, threadSortOrder, onProjectSortOrderChange, onThreadSortOrderChange, }: { projectSortOrder: SidebarProjectSortOrder; threadSortOrder: SidebarThreadSortOrder; onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void; onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; }) { return ( } > Sort projects
Sort projects
{ onProjectSortOrderChange(value as SidebarProjectSortOrder); }} > {(Object.entries(SIDEBAR_SORT_LABELS) as Array<[SidebarProjectSortOrder, string]>).map( ([value, label]) => ( {label} ), )}
Sort threads
{ onThreadSortOrderChange(value as SidebarThreadSortOrder); }} > {( Object.entries(SIDEBAR_THREAD_SORT_LABELS) as Array<[SidebarThreadSortOrder, string]> ).map(([value, label]) => ( {label} ))}
); } 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 (
  • {children({ attributes, listeners, setActivatorNodeRef })}
  • ); } export default function Sidebar() { const projects = useStore((store) => store.projects); const serverThreads = useStore((store) => store.threads); const { projectExpandedById, projectOrder, threadLastVisitedAtById } = useUiStateStore( useShallow((store) => ({ projectExpandedById: store.projectExpandedById, projectOrder: store.projectOrder, threadLastVisitedAtById: store.threadLastVisitedAtById, })), ); const markThreadUnread = useUiStateStore((store) => store.markThreadUnread); const toggleProject = useUiStateStore((store) => store.toggleProject); const reorderProjects = useUiStateStore((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 keybindings = useServerKeybindings(); const [addingProject, setAddingProject] = useState(false); const [newCwd, setNewCwd] = useState(""); const [isPickingFolder, setIsPickingFolder] = useState(false); const [isAddingProject, setIsAddingProject] = useState(false); const [addProjectError, setAddProjectError] = useState(null); const addProjectInputRef = useRef(null); const [renamingThreadId, setRenamingThreadId] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); const [confirmingArchiveThreadId, setConfirmingArchiveThreadId] = useState(null); const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< ReadonlySet >(() => new Set()); const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); const renamingCommittedRef = useRef(false); const renamingInputRef = useRef(null); const confirmArchiveButtonRefs = useRef(new Map()); const dragInProgressRef = useRef(false); const suppressProjectClickAfterDragRef = useRef(false); const suppressProjectClickForContextMenuRef = useRef(false); const [desktopUpdateState, setDesktopUpdateState] = useState(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 orderedProjects = useMemo(() => { return orderItemsByPreferredIds({ items: projects, preferredIds: projectOrder, getId: (project) => project.id, }); }, [projectOrder, projects]); const sidebarProjects = useMemo( () => orderedProjects.map((project) => ({ ...project, expanded: projectExpandedById[project.id] ?? true, })), [orderedProjects, projectExpandedById], ); const threads = useMemo( () => serverThreads.map((thread) => toSidebarThreadSnapshot(thread, threadLastVisitedAtById[thread.id]), ), [serverThreads, threadLastVisitedAtById], ); 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.worktreePath ?? projectCwdById.get(thread.projectId) ?? null, })), [projectCwdById, threads], ); const threadGitStatusCwds = useMemo( () => [ ...new Set( threadGitTargets .filter((target) => target.branch !== null) .map((target) => target.cwd) .filter((cwd): cwd is string => cwd !== null), ), ], [threadGitTargets], ); const threadGitStatusResults = useGitStatuses(threadGitStatusCwds); const prByThreadId = useMemo(() => { const statusByCwd = new Map(); for (let index = 0; index < threadGitStatusCwds.length; index += 1) { const cwd = threadGitStatusCwds[index]; if (!cwd) continue; const status = threadGitStatusResults[index] ? Option.getOrUndefined(AsyncResult.value(threadGitStatusResults[index]!)) : undefined; if (status) { statusByCwd.set(cwd, status); } } const map = new Map(); 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, threadGitStatusResults, threadGitTargets]); const openPrLink = useCallback((event: React.MouseEvent, 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], ); const addProjectFromPath = useCallback( async (rawCwd: string) => { const cwd = rawCwd.trim(); if (!cwd || isAddingProject) return; const api = readNativeApi(); if (!api) return; setIsAddingProject(true); const finishAddingProject = () => { setIsAddingProject(false); setNewCwd(""); setAddProjectError(null); setAddingProject(false); }; const existing = projects.find((project) => project.cwd === cwd); if (existing) { focusMostRecentThreadForProject(existing.id); finishAddingProject(); return; } const projectId = newProjectId(); const createdAt = new Date().toISOString(); const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd; try { await api.orchestration.dispatchCommand({ type: "project.create", commandId: newCommandId(), projectId, title, workspaceRoot: cwd, defaultModelSelection: { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, }, createdAt, }); await handleNewThread(projectId, { envMode: appSettings.defaultThreadEnvMode, }).catch(() => undefined); } catch (error) { const description = error instanceof Error ? error.message : "An error occurred while adding the project."; setIsAddingProject(false); if (shouldBrowseForProjectImmediately) { toastManager.add({ type: "error", title: "Failed to add project", description, }); } else { setAddProjectError(description); } return; } finishAddingProject(); }, [ focusMostRecentThreadForProject, handleNewThread, isAddingProject, projects, shouldBrowseForProjectImmediately, appSettings.defaultThreadEnvMode, ], ); const handleAddProject = () => { void addProjectFromPath(newCwd); }; const canAddProject = newCwd.trim().length > 0 && !isAddingProject; const handlePickFolder = async () => { const api = readNativeApi(); if (!api || isPickingFolder) return; setIsPickingFolder(true); let pickedPath: string | null = null; try { pickedPath = await api.dialogs.pickFolder(); } catch { // Ignore picker failures and leave the current thread selection unchanged. } if (pickedPath) { await addProjectFromPath(pickedPath); } else if (!shouldBrowseForProjectImmediately) { addProjectInputRef.current?.focus(); } setIsPickingFolder(false); }; const handleStartAddProject = () => { setAddProjectError(null); if (shouldBrowseForProjectImmediately) { void handlePickFolder(); return; } setAddingProject((prev) => !prev); }; const cancelRename = useCallback(() => { setRenamingThreadId(null); renamingInputRef.current = null; }, []); const commitRename = useCallback( async (threadId: ThreadId, newTitle: string, originalTitle: string) => { const finishRename = () => { setRenamingThreadId((current) => { if (current !== threadId) return current; renamingInputRef.current = null; return null; }); }; const trimmed = newTitle.trim(); if (trimmed.length === 0) { toastManager.add({ type: "warning", title: "Thread title cannot be empty", }); finishRename(); return; } if (trimmed === originalTitle) { finishRename(); return; } const api = readNativeApi(); if (!api) { finishRename(); return; } try { await api.orchestration.dispatchCommand({ type: "thread.meta.update", commandId: newCommandId(), threadId, title: trimmed, }); } catch (error) { toastManager.add({ type: "error", title: "Failed to rename thread", description: error instanceof Error ? error.message : "An error occurred.", }); } finishRename(); }, [], ); const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ threadId: ThreadId; }>({ onCopy: (ctx) => { toastManager.add({ type: "success", title: "Thread ID copied", description: ctx.threadId, }); }, onError: (error) => { toastManager.add({ type: "error", title: "Failed to copy thread ID", description: error instanceof Error ? error.message : "An error occurred.", }); }, }); const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string; }>({ onCopy: (ctx) => { toastManager.add({ type: "success", title: "Path copied", description: ctx.path, }); }, onError: (error) => { toastManager.add({ type: "error", title: "Failed to copy path", description: error instanceof Error ? error.message : "An error occurred.", }); }, }); const handleThreadContextMenu = useCallback( async (threadId: ThreadId, position: { x: number; y: number }) => { const api = readNativeApi(); if (!api) return; const thread = threads.find((t) => t.id === threadId); if (!thread) return; const threadWorkspacePath = thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null; const clicked = await api.contextMenu.show( [ { id: "rename", label: "Rename thread" }, { id: "mark-unread", label: "Mark unread" }, { id: "copy-path", label: "Copy Path" }, { id: "copy-thread-id", label: "Copy Thread ID" }, { id: "delete", label: "Delete", destructive: true }, ], position, ); if (clicked === "rename") { setRenamingThreadId(threadId); setRenamingTitle(thread.title); renamingCommittedRef.current = false; return; } if (clicked === "mark-unread") { markThreadUnread(threadId, thread.latestTurn?.completedAt); return; } if (clicked === "copy-path") { if (!threadWorkspacePath) { toastManager.add({ type: "error", title: "Path unavailable", description: "This thread does not have a workspace path to copy.", }); return; } copyPathToClipboard(threadWorkspacePath, { path: threadWorkspacePath }); return; } if (clicked === "copy-thread-id") { copyThreadIdToClipboard(threadId, { threadId }); return; } if (clicked !== "delete") 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; } } await deleteThread(threadId); }, [ appSettings.confirmThreadDelete, copyPathToClipboard, copyThreadIdToClipboard, deleteThread, markThreadUnread, projectCwdById, threads, ], ); const handleMultiSelectContextMenu = useCallback( async (position: { x: number; y: number }) => { const api = readNativeApi(); if (!api) return; const ids = [...selectedThreadIds]; if (ids.length === 0) return; const count = ids.length; const clicked = await api.contextMenu.show( [ { id: "mark-unread", label: `Mark unread (${count})` }, { id: "delete", label: `Delete (${count})`, destructive: true }, ], position, ); if (clicked === "mark-unread") { for (const id of ids) { const thread = threads.find((candidate) => candidate.id === id); markThreadUnread(id, thread?.latestTurn?.completedAt); } clearSelection(); return; } if (clicked !== "delete") return; if (appSettings.confirmThreadDelete) { const confirmed = await api.dialogs.confirm( [ `Delete ${count} thread${count === 1 ? "" : "s"}?`, "This permanently clears conversation history for these threads.", ].join("\n"), ); if (!confirmed) return; } const deletedIds = new Set(ids); for (const id of ids) { await deleteThread(id, { deletedThreadIds: deletedIds }); } removeFromSelection(ids); }, [ appSettings.confirmThreadDelete, clearSelection, deleteThread, markThreadUnread, removeFromSelection, selectedThreadIds, threads, ], ); const handleThreadClick = useCallback( (event: MouseEvent, threadId: ThreadId, orderedProjectThreadIds: readonly ThreadId[]) => { const isMac = isMacPlatform(navigator.platform); const isModClick = isMac ? event.metaKey : event.ctrlKey; const isShiftClick = event.shiftKey; if (isModClick) { event.preventDefault(); toggleThreadSelection(threadId); return; } if (isShiftClick) { event.preventDefault(); rangeSelectTo(threadId, orderedProjectThreadIds); return; } // Plain click — clear selection, set anchor for future shift-clicks, and navigate if (selectedThreadIds.size > 0) { clearSelection(); } setSelectionAnchor(threadId); void navigate({ to: "/$threadId", params: { threadId }, }); }, [ clearSelection, navigate, rangeSelectTo, selectedThreadIds.size, setSelectionAnchor, toggleThreadSelection, ], ); const navigateToThread = useCallback( (threadId: ThreadId) => { if (selectedThreadIds.size > 0) { clearSelection(); } setSelectionAnchor(threadId); void navigate({ to: "/$threadId", params: { threadId }, }); }, [clearSelection, navigate, selectedThreadIds.size, setSelectionAnchor], ); const handleProjectContextMenu = useCallback( async (projectId: ProjectId, position: { x: number; y: number }) => { const api = readNativeApi(); if (!api) return; const project = projects.find((entry) => entry.id === projectId); if (!project) return; const clicked = await api.contextMenu.show( [ { id: "copy-path", label: "Copy Project Path" }, { id: "delete", label: "Remove project", destructive: true }, ], position, ); if (clicked === "copy-path") { copyPathToClipboard(project.cwd, { path: project.cwd }); return; } if (clicked !== "delete") return; const projectThreads = 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 removing it.", }); return; } const confirmed = await api.dialogs.confirm(`Remove project "${project.name}"?`); if (!confirmed) return; try { const projectDraftThread = getDraftThreadByProjectId(projectId); if (projectDraftThread) { clearComposerDraftForThread(projectDraftThread.threadId); } clearProjectDraftThreadId(projectId); await api.orchestration.dispatchCommand({ type: "project.delete", commandId: newCommandId(), projectId, }); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error removing project."; console.error("Failed to remove project", { projectId, error }); toastManager.add({ type: "error", title: `Failed to remove "${project.name}"`, description: message, }); } }, [ clearComposerDraftForThread, clearProjectDraftThreadId, copyPathToClipboard, getDraftThreadByProjectId, projects, threads, ], ); const projectDnDSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 }, }), ); const projectCollisionDetection = useCallback((args) => { const pointerCollisions = pointerWithin(args); if (pointerCollisions.length > 0) { return pointerCollisions; } return closestCorners(args); }, []); const handleProjectDragEnd = useCallback( (event: DragEndEvent) => { if (appSettings.sidebarProjectSortOrder !== "manual") { dragInProgressRef.current = false; return; } dragInProgressRef.current = false; const { active, over } = event; if (!over || active.id === over.id) return; const activeProject = sidebarProjects.find((project) => project.id === active.id); const overProject = sidebarProjects.find((project) => project.id === over.id); if (!activeProject || !overProject) return; reorderProjects(activeProject.id, overProject.id); }, [appSettings.sidebarProjectSortOrder, reorderProjects, sidebarProjects], ); const handleProjectDragStart = useCallback( (_event: DragStartEvent) => { if (appSettings.sidebarProjectSortOrder !== "manual") { return; } dragInProgressRef.current = true; suppressProjectClickAfterDragRef.current = true; }, [appSettings.sidebarProjectSortOrder], ); const handleProjectDragCancel = useCallback((_event: DragCancelEvent) => { dragInProgressRef.current = false; }, []); const animatedProjectListsRef = useRef(new WeakSet()); const attachProjectListAutoAnimateRef = useCallback((node: HTMLElement | null) => { if (!node || animatedProjectListsRef.current.has(node)) { return; } autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS); animatedProjectListsRef.current.add(node); }, []); const animatedThreadListsRef = useRef(new WeakSet()); const attachThreadListAutoAnimateRef = useCallback((node: HTMLElement | null) => { if (!node || animatedThreadListsRef.current.has(node)) { return; } autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS); animatedThreadListsRef.current.add(node); }, []); const handleProjectTitlePointerDownCapture = useCallback( (event: PointerEvent) => { suppressProjectClickForContextMenuRef.current = false; if ( isContextMenuPointerDown({ button: event.button, ctrlKey: event.ctrlKey, isMac: isMacPlatform(navigator.platform), }) ) { // Keep context-menu gestures from arming the sortable drag sensor. event.stopPropagation(); } suppressProjectClickAfterDragRef.current = false; }, [], ); const visibleThreads = useMemo( () => threads.filter((thread) => thread.archivedAt === null), [threads], ); const sortedProjects = useMemo( () => sortProjectsForSidebar(sidebarProjects, visibleThreads, appSettings.sidebarProjectSortOrder), [appSettings.sidebarProjectSortOrder, sidebarProjects, visibleThreads], ); const isManualProjectSorting = appSettings.sidebarProjectSortOrder === "manual"; const renderedProjects = useMemo( () => sortedProjects.map((project) => { const projectThreads = sortThreadsForSidebar( visibleThreads.filter((thread) => thread.projectId === project.id), appSettings.sidebarThreadSortOrder, ); const threadStatuses = new Map( projectThreads.map((thread) => [ thread.id, resolveThreadStatusPill({ thread, hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0, hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0, }), ]), ); const projectStatus = resolveProjectStatusIndicator( projectThreads.map((thread) => threadStatuses.get(thread.id) ?? null), ); const activeThreadId = routeThreadId ?? undefined; const isThreadListExpanded = expandedThreadListsByProject.has(project.id); const pinnedCollapsedThread = !project.expanded && activeThreadId ? (projectThreads.find((thread) => thread.id === activeThreadId) ?? null) : null; const shouldShowThreadPanel = project.expanded || pinnedCollapsedThread !== null; const { hasHiddenThreads, hiddenThreads, visibleThreads: visibleProjectThreads, } = getVisibleThreadsForProject({ threads: projectThreads, activeThreadId, isThreadListExpanded, previewLimit: THREAD_PREVIEW_LIMIT, }); const hiddenThreadStatus = resolveProjectStatusIndicator( hiddenThreads.map((thread) => threadStatuses.get(thread.id) ?? null), ); const orderedProjectThreadIds = projectThreads.map((thread) => thread.id); const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : visibleProjectThreads; const showEmptyThreadState = project.expanded && projectThreads.length === 0; return { hasHiddenThreads, hiddenThreadStatus, orderedProjectThreadIds, project, projectStatus, projectThreads, threadStatuses, renderedThreads, showEmptyThreadState, shouldShowThreadPanel, isThreadListExpanded, }; }), [ appSettings.sidebarThreadSortOrder, expandedThreadListsByProject, routeThreadId, sortedProjects, visibleThreads, ], ); const visibleSidebarThreadIds = useMemo( () => getVisibleSidebarThreadIds(renderedProjects), [renderedProjects], ); const threadJumpCommandById = useMemo(() => { const mapping = new Map>>(); for (const [visibleThreadIndex, threadId] of visibleSidebarThreadIds.entries()) { const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); if (!jumpCommand) { return mapping; } mapping.set(threadId, jumpCommand); } return mapping; }, [visibleSidebarThreadIds]); const threadJumpThreadIds = useMemo( () => [...threadJumpCommandById.keys()], [threadJumpCommandById], ); const threadJumpLabelById = useMemo(() => { const mapping = new Map(); for (const [threadId, command] of threadJumpCommandById) { const label = shortcutLabelForCommand(keybindings, command, sidebarShortcutLabelOptions); if (label) { mapping.set(threadId, label); } } return mapping; }, [keybindings, sidebarShortcutLabelOptions, threadJumpCommandById]); const orderedSidebarThreadIds = visibleSidebarThreadIds; useEffect(() => { const getShortcutContext = () => ({ terminalFocus: isTerminalFocused(), terminalOpen: routeTerminalOpen, }); const onWindowKeyDown = (event: KeyboardEvent) => { updateThreadJumpHintsVisibility( shouldShowThreadJumpHints(event, keybindings, { platform, context: getShortcutContext(), }), ); if (event.defaultPrevented || event.repeat) { return; } const command = resolveShortcutCommand(event, keybindings, { platform, context: getShortcutContext(), }); const traversalDirection = threadTraversalDirectionFromCommand(command); if (traversalDirection !== null) { const targetThreadId = resolveAdjacentThreadId({ threadIds: orderedSidebarThreadIds, currentThreadId: routeThreadId, direction: traversalDirection, }); if (!targetThreadId) { return; } event.preventDefault(); event.stopPropagation(); navigateToThread(targetThreadId); return; } const jumpIndex = threadJumpIndexFromCommand(command ?? ""); if (jumpIndex === null) { return; } const targetThreadId = threadJumpThreadIds[jumpIndex]; if (!targetThreadId) { return; } event.preventDefault(); event.stopPropagation(); navigateToThread(targetThreadId); }; const onWindowKeyUp = (event: KeyboardEvent) => { updateThreadJumpHintsVisibility( shouldShowThreadJumpHints(event, keybindings, { platform, context: getShortcutContext(), }), ); }; const onWindowBlur = () => { updateThreadJumpHintsVisibility(false); }; window.addEventListener("keydown", onWindowKeyDown); window.addEventListener("keyup", onWindowKeyUp); window.addEventListener("blur", onWindowBlur); return () => { window.removeEventListener("keydown", onWindowKeyDown); window.removeEventListener("keyup", onWindowKeyUp); window.removeEventListener("blur", onWindowBlur); }; }, [ keybindings, navigateToThread, orderedSidebarThreadIds, platform, routeTerminalOpen, routeThreadId, threadJumpThreadIds, updateThreadJumpHintsVisibility, ]); function renderProjectItem( renderedProject: (typeof renderedProjects)[number], dragHandleProps: SortableProjectHandleProps | null, ) { const { hasHiddenThreads, hiddenThreadStatus, orderedProjectThreadIds, project, projectStatus, projectThreads, threadStatuses, renderedThreads, showEmptyThreadState, shouldShowThreadPanel, isThreadListExpanded, } = renderedProject; const renderThreadRow = (thread: (typeof projectThreads)[number]) => { const isActive = routeThreadId === thread.id; const isSelected = selectedThreadIds.has(thread.id); const isHighlighted = isActive || isSelected; const jumpLabel = threadJumpLabelById.get(thread.id) ?? null; const isThreadRunning = thread.session?.status === "running" && thread.session.activeTurnId != null; const threadStatus = threadStatuses.get(thread.id) ?? null; const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null); const terminalStatus = terminalStatusFromRunningIds( selectThreadTerminalState(terminalStateByThreadId, thread.id).runningTerminalIds, ); const isConfirmingArchive = 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 ( { setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current)); }} onBlurCapture={(event) => { const currentTarget = event.currentTarget; requestAnimationFrame(() => { if (currentTarget.contains(document.activeElement)) { return; } setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current)); }); }} > } size="sm" isActive={isActive} data-testid={`thread-row-${thread.id}`} className={`${resolveThreadRowClassName({ isActive, isSelected, })} relative isolate`} onClick={(event) => { handleThreadClick(event, thread.id, orderedProjectThreadIds); }} onKeyDown={(event) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); navigateToThread(thread.id); }} onContextMenu={(event) => { event.preventDefault(); if (selectedThreadIds.size > 0 && selectedThreadIds.has(thread.id)) { void handleMultiSelectContextMenu({ x: event.clientX, y: event.clientY, }); } else { if (selectedThreadIds.size > 0) { clearSelection(); } void handleThreadContextMenu(thread.id, { x: event.clientX, y: event.clientY, }); } }} >
    {prStatus && ( { openPrLink(event, prStatus.url); }} > } /> {prStatus.tooltip} )} {threadStatus && } {renamingThreadId === thread.id ? ( { if (el && renamingInputRef.current !== el) { renamingInputRef.current = el; el.focus(); el.select(); } }} className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" value={renamingTitle} onChange={(e) => setRenamingTitle(e.target.value)} onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Enter") { e.preventDefault(); renamingCommittedRef.current = true; void commitRename(thread.id, renamingTitle, thread.title); } else if (e.key === "Escape") { e.preventDefault(); renamingCommittedRef.current = true; cancelRename(); } }} onBlur={() => { if (!renamingCommittedRef.current) { void commitRename(thread.id, renamingTitle, thread.title); } }} onClick={(e) => e.stopPropagation()} /> ) : ( {thread.title} )}
    {terminalStatus && ( )}
    {isConfirmingArchive ? ( ) : !isThreadRunning ? ( appSettings.confirmThreadArchive ? (
    ) : (
    } /> Archive ) ) : null} {showThreadJumpHints && jumpLabel ? ( {jumpLabel} ) : ( {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)} )}
    ); }; return ( <>
    handleProjectTitleClick(event, project.id)} onKeyDown={(event) => handleProjectTitleKeyDown(event, project.id)} onContextMenu={(event) => { event.preventDefault(); suppressProjectClickForContextMenuRef.current = true; void handleProjectContextMenu(project.id, { x: event.clientX, y: event.clientY, }); }} > {!project.expanded && projectStatus ? ( } showOnHover className="top-1 right-1.5 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground" onClick={(event) => { event.preventDefault(); event.stopPropagation(); void handleNewThread(project.id, { envMode: resolveSidebarNewThreadEnvMode({ defaultEnvMode: appSettings.defaultThreadEnvMode, }), }); }} > } /> {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"}
    {shouldShowThreadPanel && showEmptyThreadState ? (
    No threads yet
    ) : null} {shouldShowThreadPanel && renderedThreads.map((thread) => renderThreadRow(thread))} {project.expanded && hasHiddenThreads && !isThreadListExpanded && ( } data-thread-selection-safe size="sm" className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" onClick={() => { expandThreadListForProject(project.id); }} > {hiddenThreadStatus && } Show more )} {project.expanded && hasHiddenThreads && isThreadListExpanded && ( } data-thread-selection-safe size="sm" className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" onClick={() => { collapseThreadListForProject(project.id); }} > Show less )}
    ); } const handleProjectTitleClick = useCallback( (event: React.MouseEvent, projectId: ProjectId) => { if (suppressProjectClickForContextMenuRef.current) { suppressProjectClickForContextMenuRef.current = false; event.preventDefault(); event.stopPropagation(); return; } if (dragInProgressRef.current) { event.preventDefault(); event.stopPropagation(); return; } if (suppressProjectClickAfterDragRef.current) { // Consume the synthetic click emitted after a drag release. suppressProjectClickAfterDragRef.current = false; event.preventDefault(); event.stopPropagation(); return; } if (selectedThreadIds.size > 0) { clearSelection(); } toggleProject(projectId); }, [clearSelection, selectedThreadIds.size, toggleProject], ); const handleProjectTitleKeyDown = useCallback( (event: React.KeyboardEvent, projectId: ProjectId) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); if (dragInProgressRef.current) { return; } toggleProject(projectId); }, [toggleProject], ); useEffect(() => { const onMouseDown = (event: globalThis.MouseEvent) => { if (selectedThreadIds.size === 0) return; const target = event.target instanceof HTMLElement ? event.target : null; if (!shouldClearThreadSelectionOnMouseDown(target)) return; clearSelection(); }; window.addEventListener("mousedown", onMouseDown); return () => { window.removeEventListener("mousedown", onMouseDown); }; }, [clearSelection, selectedThreadIds.size]); useEffect(() => { if (!isElectron) return; const bridge = window.desktopBridge; if ( !bridge || typeof bridge.getUpdateState !== "function" || typeof bridge.onUpdateState !== "function" ) { return; } let disposed = false; let receivedSubscriptionUpdate = false; const unsubscribe = bridge.onUpdateState((nextState) => { if (disposed) return; receivedSubscriptionUpdate = true; setDesktopUpdateState(nextState); }); void bridge .getUpdateState() .then((nextState) => { if (disposed || receivedSubscriptionUpdate) return; setDesktopUpdateState(nextState); }) .catch(() => undefined); return () => { disposed = true; unsubscribe(); }; }, []); const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState); const desktopUpdateButtonAction = desktopUpdateState ? resolveDesktopUpdateButtonAction(desktopUpdateState) : "none"; const showArm64IntelBuildWarning = isElectron && shouldShowArm64IntelBuildWarning(desktopUpdateState); const arm64IntelBuildWarningDescription = desktopUpdateState && showArm64IntelBuildWarning ? getArm64IntelBuildWarningDescription(desktopUpdateState) : null; const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal", sidebarShortcutLabelOptions) ?? shortcutLabelForCommand(keybindings, "chat.new", sidebarShortcutLabelOptions); const handleDesktopUpdateButtonClick = useCallback(() => { const bridge = window.desktopBridge; if (!bridge || !desktopUpdateState) return; if (desktopUpdateButtonDisabled || desktopUpdateButtonAction === "none") return; if (desktopUpdateButtonAction === "download") { void bridge .downloadUpdate() .then((result) => { if (result.completed) { toastManager.add({ type: "success", title: "Update downloaded", description: "Restart the app from the update button to install it.", }); } if (!shouldToastDesktopUpdateActionResult(result)) return; const actionError = getDesktopUpdateActionError(result); if (!actionError) return; toastManager.add({ type: "error", title: "Could not download update", description: actionError, }); }) .catch((error) => { toastManager.add({ type: "error", title: "Could not start update download", description: error instanceof Error ? error.message : "An unexpected error occurred.", }); }); return; } if (desktopUpdateButtonAction === "install") { const confirmed = window.confirm( getDesktopUpdateInstallConfirmationMessage(desktopUpdateState), ); if (!confirmed) return; void bridge .installUpdate() .then((result) => { if (!shouldToastDesktopUpdateActionResult(result)) return; const actionError = getDesktopUpdateActionError(result); if (!actionError) return; toastManager.add({ type: "error", title: "Could not install update", description: actionError, }); }) .catch((error) => { toastManager.add({ type: "error", title: "Could not install update", description: error instanceof Error ? error.message : "An unexpected error occurred.", }); }); } }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]); const expandThreadListForProject = useCallback((projectId: ProjectId) => { setExpandedThreadListsByProject((current) => { if (current.has(projectId)) return current; const next = new Set(current); next.add(projectId); return next; }); }, []); const collapseThreadListForProject = useCallback((projectId: ProjectId) => { setExpandedThreadListsByProject((current) => { if (!current.has(projectId)) return current; const next = new Set(current); next.delete(projectId); return next; }); }, []); const wordmark = (
    Code {APP_STAGE_LABEL}
    } /> Version {APP_VERSION} ); return ( <> {isElectron ? ( {wordmark} ) : ( {wordmark} )} {isOnSettings ? ( ) : ( <> {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( Intel build on Apple Silicon {arm64IntelBuildWarningDescription} {desktopUpdateButtonAction !== "none" ? ( ) : null} ) : null}
    Projects
    { updateSettings({ sidebarProjectSortOrder: sortOrder }); }} onThreadSortOrderChange={(sortOrder) => { updateSettings({ sidebarThreadSortOrder: sortOrder }); }} /> } > {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"}
    {shouldShowProjectPathEntry && (
    {isElectron && ( )}
    { setNewCwd(event.target.value); setAddProjectError(null); }} onKeyDown={(event) => { if (event.key === "Enter") handleAddProject(); if (event.key === "Escape") { setAddingProject(false); setAddProjectError(null); } }} autoFocus />
    {addProjectError && (

    {addProjectError}

    )}
    )} {isManualProjectSorting ? ( renderedProject.project.id)} strategy={verticalListSortingStrategy} > {renderedProjects.map((renderedProject) => ( {(dragHandleProps) => renderProjectItem(renderedProject, dragHandleProps)} ))} ) : ( {renderedProjects.map((renderedProject) => ( {renderProjectItem(renderedProject, null)} ))} )} {projects.length === 0 && !shouldShowProjectPathEntry && (
    No projects yet
    )}
    void navigate({ to: "/settings" })} > Settings )} ); }