import { ArchiveIcon, ArrowUpDownIcon, ChevronRightIcon, CloudIcon, GitPullRequestIcon, PlusIcon, SearchIcon, SettingsIcon, SquarePenIcon, TerminalIcon, TriangleAlertIcon, } from "lucide-react"; import { prStatusIndicator, resolveThreadPr, terminalStatusFromRunningIds, ThreadStatusLabel, } from "./ThreadStatusIndicators"; import { ProjectFavicon } from "./ProjectFavicon"; import { autoAnimate } from "@formkit/auto-animate"; import React, { useCallback, useEffect, memo, useMemo, useRef, useState } 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 { type ContextMenuItem, type DesktopUpdateState, ProjectId, type ScopedThreadRef, type SidebarProjectGroupingMode, type ThreadEnvMode, ThreadId, } from "@t3tools/contracts"; import { parseScopedThreadKey, scopedProjectKey, scopedThreadKey, scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime"; import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; import { type SidebarProjectSortOrder, type SidebarThreadSortOrder, } from "@t3tools/contracts/settings"; import { usePrimaryEnvironmentId } from "../environments/primary"; import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isMacPlatform, newCommandId } from "../lib/utils"; import { selectProjectByRef, selectProjectsAcrossEnvironments, selectSidebarThreadsForProjectRefs, selectSidebarThreadsAcrossEnvironments, selectThreadByRef, useStore, } from "../store"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; import { resolveShortcutCommand, shortcutLabelForCommand, shouldShowThreadJumpHintsForModifiers, threadJumpCommandForIndex, threadJumpIndexFromCommand, threadTraversalDirectionFromCommand, } from "../keybindings"; import { useModelPickerOpen } from "../modelPickerOpenState"; import { useShortcutModifierState } from "../shortcutModifierState"; import { useGitStatus } from "../lib/gitStatusState"; import { readLocalApi } from "../localApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { retainThreadDetailSubscription } from "../environments/runtime/service"; import { useThreadActions } from "../hooks/useThreadActions"; import { buildThreadRouteParams, resolveThreadRouteRef, resolveThreadRouteTarget, } from "../threadRoutes"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; import { Kbd } from "./ui/kbd"; 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 { Dialog, DialogDescription, DialogFooter, DialogHeader, DialogPanel, DialogPopup, DialogTitle, } from "./ui/dialog"; import { Input } from "./ui/input"; import { Menu, MenuGroup, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuSeparator, MenuTrigger, } from "./ui/menu"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { SidebarContent, SidebarFooter, SidebarGroup, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, SidebarSeparator, SidebarTrigger, } from "./ui/sidebar"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { useCommandPaletteStore } from "../commandPaletteStore"; import { getSidebarThreadIdsToPrewarm, resolveAdjacentThreadId, isContextMenuPointerDown, resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, useThreadJumpHintVisibility, ThreadStatusPill, } from "./Sidebar.logic"; import { sortThreads } from "../lib/threadSort"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { CommandDialogTrigger } from "./ui/command"; import { readEnvironmentApi } from "../environmentApi"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey, getProjectOrderKey, } from "../logicalProject"; import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, } from "../environments/runtime"; import type { SidebarThreadSummary } from "../types"; import { buildPhysicalToLogicalProjectKeyMap, buildSidebarProjectSnapshots, type SidebarProjectGroupMember, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; 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; const EMPTY_THREAD_JUMP_LABELS = new Map(); const PROJECT_GROUPING_MODE_LABELS: Record = { repository: "Group by repository", repository_path: "Group by repository path", separate: "Keep separate", }; function formatProjectMemberActionLabel( member: SidebarProjectGroupMember, groupedProjectCount: number, ): string { if (groupedProjectCount <= 1) { return member.name; } return member.environmentLabel ? `${member.environmentLabel} — ${member.cwd}` : member.cwd; } function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): string { switch (mode) { case "repository": return "Projects from the same repository share one sidebar row."; case "repository_path": return "Projects group only when both the repository and repo-relative path match."; case "separate": return "Every project path gets its own sidebar row."; } } function buildThreadJumpLabelMap(input: { keybindings: ReturnType; platform: string; terminalOpen: boolean; threadJumpCommandByKey: ReadonlyMap< string, NonNullable> >; }): ReadonlyMap { if (input.threadJumpCommandByKey.size === 0) { return EMPTY_THREAD_JUMP_LABELS; } const shortcutLabelOptions = { platform: input.platform, context: { terminalFocus: false, terminalOpen: input.terminalOpen, }, } as const; const mapping = new Map(); for (const [threadKey, command] of input.threadJumpCommandByKey) { const label = shortcutLabelForCommand(input.keybindings, command, shortcutLabelOptions); if (label) { mapping.set(threadKey, label); } } return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; } interface SidebarThreadRowProps { thread: SidebarThreadSummary; projectCwd: string | null; orderedProjectThreadKeys: readonly string[]; isActive: boolean; jumpLabel: string | null; appSettingsConfirmThreadArchive: boolean; renamingThreadKey: string | null; renamingTitle: string; setRenamingTitle: (title: string) => void; renamingInputRef: React.RefObject; renamingCommittedRef: React.RefObject; confirmingArchiveThreadKey: string | null; setConfirmingArchiveThreadKey: React.Dispatch>; confirmArchiveButtonRefs: React.RefObject>; handleThreadClick: ( event: React.MouseEvent, threadRef: ScopedThreadRef, orderedProjectThreadKeys: readonly string[], ) => void; navigateToThread: (threadRef: ScopedThreadRef) => void; handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; handleThreadContextMenu: ( threadRef: ScopedThreadRef, position: { x: number; y: number }, ) => Promise; clearSelection: () => void; commitRename: ( threadRef: ScopedThreadRef, newTitle: string, originalTitle: string, ) => Promise; cancelRename: () => void; attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; openPrLink: (event: React.MouseEvent, prUrl: string) => void; } const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { const { orderedProjectThreadKeys, isActive, jumpLabel, appSettingsConfirmThreadArchive, renamingThreadKey, renamingTitle, setRenamingTitle, renamingInputRef, renamingCommittedRef, confirmingArchiveThreadKey, setConfirmingArchiveThreadKey, confirmArchiveButtonRefs, handleThreadClick, navigateToThread, handleMultiSelectContextMenu, handleThreadContextMenu, clearSelection, commitRename, cancelRename, attemptArchiveThread, openPrLink, thread, } = props; const threadRef = scopeThreadRef(thread.environmentId, thread.id); const threadKey = scopedThreadKey(threadRef); const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); const hasSelection = useThreadSelectionStore((state) => state.selectedThreadKeys.size > 0); const runningTerminalIds = useTerminalStateStore( (state) => selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds, ); const primaryEnvironmentId = usePrimaryEnvironmentId(); const isRemoteThread = primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; const remoteEnvLabel = useSavedEnvironmentRuntimeStore( (s) => s.byId[thread.environmentId]?.descriptor?.label ?? null, ); const remoteEnvSavedLabel = useSavedEnvironmentRegistryStore( (s) => s.byId[thread.environmentId]?.label ?? null, ); const threadEnvironmentLabel = isRemoteThread ? (remoteEnvLabel ?? remoteEnvSavedLabel ?? "Remote") : null; // For grouped projects, the thread may belong to a different environment // than the representative project. Look up the thread's own project cwd // so git status (and thus PR detection) queries the correct path. const threadProjectCwd = useStore( useMemo( () => (state: import("../store").AppState) => selectProjectByRef(state, scopeProjectRef(thread.environmentId, thread.projectId))?.cwd ?? null, [thread.environmentId, thread.projectId], ), ); const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd; const gitStatus = useGitStatus({ environmentId: thread.environmentId, cwd: thread.branch != null ? gitCwd : null, }); const isHighlighted = isActive || isSelected; const isThreadRunning = thread.session?.status === "running" && thread.session.activeTurnId != null; const threadStatus = resolveThreadStatusPill({ thread: { ...thread, lastVisitedAt, }, }); const pr = resolveThreadPr(thread.branch, gitStatus.data); const prStatus = prStatusIndicator(pr); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !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"; const clearConfirmingArchive = useCallback(() => { setConfirmingArchiveThreadKey((current) => (current === threadKey ? null : current)); }, [setConfirmingArchiveThreadKey, threadKey]); const handleMouseLeave = useCallback(() => { clearConfirmingArchive(); }, [clearConfirmingArchive]); const handleBlurCapture = useCallback( (event: React.FocusEvent) => { const currentTarget = event.currentTarget; requestAnimationFrame(() => { if (currentTarget.contains(document.activeElement)) { return; } clearConfirmingArchive(); }); }, [clearConfirmingArchive], ); const handleRowClick = useCallback( (event: React.MouseEvent) => { handleThreadClick(event, threadRef, orderedProjectThreadKeys); }, [handleThreadClick, orderedProjectThreadKeys, threadRef], ); const handleRowKeyDown = useCallback( (event: React.KeyboardEvent) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); navigateToThread(threadRef); }, [navigateToThread, threadRef], ); const handleRowContextMenu = useCallback( (event: React.MouseEvent) => { event.preventDefault(); if (hasSelection && isSelected) { void handleMultiSelectContextMenu({ x: event.clientX, y: event.clientY, }); return; } if (hasSelection) { clearSelection(); } void handleThreadContextMenu(threadRef, { x: event.clientX, y: event.clientY, }); }, [ clearSelection, handleMultiSelectContextMenu, handleThreadContextMenu, hasSelection, isSelected, threadRef, ], ); const handlePrClick = useCallback( (event: React.MouseEvent) => { if (!prStatus) return; openPrLink(event, prStatus.url); }, [openPrLink, prStatus], ); const handleRenameInputRef = useCallback( (element: HTMLInputElement | null) => { if (element && renamingInputRef.current !== element) { renamingInputRef.current = element; element.focus(); element.select(); } }, [renamingInputRef], ); const handleRenameInputChange = useCallback( (event: React.ChangeEvent) => { setRenamingTitle(event.target.value); }, [setRenamingTitle], ); const handleRenameInputKeyDown = useCallback( (event: React.KeyboardEvent) => { event.stopPropagation(); if (event.key === "Enter") { event.preventDefault(); renamingCommittedRef.current = true; void commitRename(threadRef, renamingTitle, thread.title); } else if (event.key === "Escape") { event.preventDefault(); renamingCommittedRef.current = true; cancelRename(); } }, [cancelRename, commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef], ); const handleRenameInputBlur = useCallback(() => { if (!renamingCommittedRef.current) { void commitRename(threadRef, renamingTitle, thread.title); } }, [commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef]); const handleRenameInputClick = useCallback((event: React.MouseEvent) => { event.stopPropagation(); }, []); const handleConfirmArchiveRef = useCallback( (element: HTMLButtonElement | null) => { if (element) { confirmArchiveButtonRefs.current.set(threadKey, element); } else { confirmArchiveButtonRefs.current.delete(threadKey); } }, [confirmArchiveButtonRefs, threadKey], ); const stopPropagationOnPointerDown = useCallback( (event: React.PointerEvent) => { event.stopPropagation(); }, [], ); const handleConfirmArchiveClick = useCallback( (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); clearConfirmingArchive(); void attemptArchiveThread(threadRef); }, [attemptArchiveThread, clearConfirmingArchive, threadRef], ); const handleStartArchiveConfirmation = useCallback( (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); setConfirmingArchiveThreadKey(threadKey); requestAnimationFrame(() => { confirmArchiveButtonRefs.current.get(threadKey)?.focus(); }); }, [confirmArchiveButtonRefs, setConfirmingArchiveThreadKey, threadKey], ); const handleArchiveImmediateClick = useCallback( (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); void attemptArchiveThread(threadRef); }, [attemptArchiveThread, threadRef], ); const rowButtonRender = useMemo(() =>
, []); return (
{prStatus && ( } /> {prStatus.tooltip} )} {threadStatus && } {renamingThreadKey === threadKey ? ( ) : ( {thread.title} } /> {thread.title} )}
{terminalStatus && ( )}
{isConfirmingArchive ? ( ) : !isThreadRunning ? ( appSettingsConfirmThreadArchive ? (
) : (
} /> Archive ) ) : null} {isRemoteThread && ( } > {threadEnvironmentLabel} )} {jumpLabel ? ( {jumpLabel} ) : ( {formatRelativeTimeLabel( thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, )} )}
); }); interface SidebarProjectThreadListProps { projectKey: string; projectExpanded: boolean; hasOverflowingThreads: boolean; hiddenThreadStatus: ThreadStatusPill | null; orderedProjectThreadKeys: readonly string[]; renderedThreads: readonly SidebarThreadSummary[]; showEmptyThreadState: boolean; shouldShowThreadPanel: boolean; isThreadListExpanded: boolean; projectCwd: string; activeRouteThreadKey: string | null; threadJumpLabelByKey: ReadonlyMap; appSettingsConfirmThreadArchive: boolean; renamingThreadKey: string | null; renamingTitle: string; setRenamingTitle: (title: string) => void; renamingInputRef: React.RefObject; renamingCommittedRef: React.RefObject; confirmingArchiveThreadKey: string | null; setConfirmingArchiveThreadKey: React.Dispatch>; confirmArchiveButtonRefs: React.RefObject>; attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; handleThreadClick: ( event: React.MouseEvent, threadRef: ScopedThreadRef, orderedProjectThreadKeys: readonly string[], ) => void; navigateToThread: (threadRef: ScopedThreadRef) => void; handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; handleThreadContextMenu: ( threadRef: ScopedThreadRef, position: { x: number; y: number }, ) => Promise; clearSelection: () => void; commitRename: ( threadRef: ScopedThreadRef, newTitle: string, originalTitle: string, ) => Promise; cancelRename: () => void; attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; openPrLink: (event: React.MouseEvent, prUrl: string) => void; expandThreadListForProject: (projectKey: string) => void; collapseThreadListForProject: (projectKey: string) => void; } const SidebarProjectThreadList = memo(function SidebarProjectThreadList( props: SidebarProjectThreadListProps, ) { const { projectKey, projectExpanded, hasOverflowingThreads, hiddenThreadStatus, orderedProjectThreadKeys, renderedThreads, showEmptyThreadState, shouldShowThreadPanel, isThreadListExpanded, projectCwd, activeRouteThreadKey, threadJumpLabelByKey, appSettingsConfirmThreadArchive, renamingThreadKey, renamingTitle, setRenamingTitle, renamingInputRef, renamingCommittedRef, confirmingArchiveThreadKey, setConfirmingArchiveThreadKey, confirmArchiveButtonRefs, attachThreadListAutoAnimateRef, handleThreadClick, navigateToThread, handleMultiSelectContextMenu, handleThreadContextMenu, clearSelection, commitRename, cancelRename, attemptArchiveThread, openPrLink, expandThreadListForProject, collapseThreadListForProject, } = props; const showMoreButtonRender = useMemo(() => } /> {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} { if (!open) { closeProjectRenameDialog(); } }} > Rename project {projectRenameTarget ? `Update the title for ${projectRenameTarget.cwd}.` : "Update the project title."}
Project title setProjectRenameTitle(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); void submitProjectRename(); } }} />
{projectRenameTarget?.environmentLabel ? (

Environment: {projectRenameTarget.environmentLabel}

) : null}
{ if (!open) { closeProjectGroupingDialog(); } }} > Project grouping {projectGroupingTarget ? `Choose how ${projectGroupingTarget.cwd} should be grouped in the sidebar.` : "Choose how this project should be grouped in the sidebar."}
Grouping rule

{projectGroupingSelection === "inherit" ? projectGroupingModeDescription(projectGroupingSettings.sidebarProjectGroupingMode) : projectGroupingModeDescription(projectGroupingSelection)}

); }); const SidebarProjectListRow = memo(function SidebarProjectListRow(props: SidebarProjectItemProps) { return ( ); }); function T3Wordmark() { return ( ); } type SortableProjectHandleProps = Pick< ReturnType, "attributes" | "listeners" | "setActivatorNodeRef" >; function ProjectSortMenu({ projectSortOrder, threadSortOrder, projectGroupingMode, onProjectSortOrderChange, onThreadSortOrderChange, onProjectGroupingModeChange, }: { projectSortOrder: SidebarProjectSortOrder; threadSortOrder: SidebarThreadSortOrder; projectGroupingMode: SidebarProjectGroupingMode; onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void; onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => 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} ))}
Group projects
{ if (value === "repository" || value === "repository_path" || value === "separate") { onProjectGroupingModeChange(value); } }} > {( Object.entries(PROJECT_GROUPING_MODE_LABELS) as Array< [SidebarProjectGroupingMode, string] > ).map(([value, label]) => ( {label} ))}
); } function SortableProjectItem({ projectId, disabled = false, children, }: { projectId: string; 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 })}
  • ); } const SidebarChromeHeader = memo(function SidebarChromeHeader({ isElectron, }: { isElectron: boolean; }) { const wordmark = (
    Code {APP_STAGE_LABEL} } /> Version {APP_VERSION}
    ); return isElectron ? ( {wordmark} ) : ( {wordmark} ); }); const SidebarChromeFooter = memo(function SidebarChromeFooter() { const navigate = useNavigate(); const handleSettingsClick = useCallback(() => { void navigate({ to: "/settings" }); }, [navigate]); return ( Settings ); }); interface SidebarProjectsContentProps { showArm64IntelBuildWarning: boolean; arm64IntelBuildWarningDescription: string | null; desktopUpdateButtonAction: "download" | "install" | "none"; desktopUpdateButtonDisabled: boolean; handleDesktopUpdateButtonClick: () => void; projectSortOrder: SidebarProjectSortOrder; threadSortOrder: SidebarThreadSortOrder; projectGroupingMode: SidebarProjectGroupingMode; updateSettings: ReturnType["updateSettings"]; openAddProject: () => void; isManualProjectSorting: boolean; projectDnDSensors: ReturnType; projectCollisionDetection: CollisionDetection; handleProjectDragStart: (event: DragStartEvent) => void; handleProjectDragEnd: (event: DragEndEvent) => void; handleProjectDragCancel: (event: DragCancelEvent) => void; handleNewThread: ReturnType["handleNewThread"]; archiveThread: ReturnType["archiveThread"]; deleteThread: ReturnType["deleteThread"]; sortedProjects: readonly SidebarProjectSnapshot[]; expandedThreadListsByProject: ReadonlySet; activeRouteProjectKey: string | null; routeThreadKey: string | null; newThreadShortcutLabel: string | null; commandPaletteShortcutLabel: string | null; threadJumpLabelByKey: ReadonlyMap; attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; expandThreadListForProject: (projectKey: string) => void; collapseThreadListForProject: (projectKey: string) => void; dragInProgressRef: React.RefObject; suppressProjectClickAfterDragRef: React.RefObject; suppressProjectClickForContextMenuRef: React.RefObject; attachProjectListAutoAnimateRef: (node: HTMLElement | null) => void; projectsLength: number; } const SidebarProjectsContent = memo(function SidebarProjectsContent( props: SidebarProjectsContentProps, ) { const { showArm64IntelBuildWarning, arm64IntelBuildWarningDescription, desktopUpdateButtonAction, desktopUpdateButtonDisabled, handleDesktopUpdateButtonClick, projectSortOrder, threadSortOrder, projectGroupingMode, updateSettings, openAddProject, isManualProjectSorting, projectDnDSensors, projectCollisionDetection, handleProjectDragStart, handleProjectDragEnd, handleProjectDragCancel, handleNewThread, archiveThread, deleteThread, sortedProjects, expandedThreadListsByProject, activeRouteProjectKey, routeThreadKey, newThreadShortcutLabel, commandPaletteShortcutLabel, threadJumpLabelByKey, attachThreadListAutoAnimateRef, expandThreadListForProject, collapseThreadListForProject, dragInProgressRef, suppressProjectClickAfterDragRef, suppressProjectClickForContextMenuRef, attachProjectListAutoAnimateRef, projectsLength, } = props; const handleProjectSortOrderChange = useCallback( (sortOrder: SidebarProjectSortOrder) => { updateSettings({ sidebarProjectSortOrder: sortOrder }); }, [updateSettings], ); const handleThreadSortOrderChange = useCallback( (sortOrder: SidebarThreadSortOrder) => { updateSettings({ sidebarThreadSortOrder: sortOrder }); }, [updateSettings], ); const handleProjectGroupingModeChange = useCallback( (groupingMode: SidebarProjectGroupingMode) => { updateSettings({ sidebarProjectGroupingMode: groupingMode }); }, [updateSettings], ); return ( } > Search {commandPaletteShortcutLabel ? ( {commandPaletteShortcutLabel} ) : null} {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( Intel build on Apple Silicon {arm64IntelBuildWarningDescription} {desktopUpdateButtonAction !== "none" ? ( ) : null} ) : null}
    Projects
    } > Add project
    {isManualProjectSorting ? ( project.projectKey)} strategy={verticalListSortingStrategy} > {sortedProjects.map((project) => ( {(dragHandleProps) => ( )} ))} ) : ( {sortedProjects.map((project) => ( ))} )} {projectsLength === 0 && (
    No projects yet
    )}
    ); }); export default function Sidebar() { const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); const sidebarThreads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments)); const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); const projectOrder = useUiStateStore((store) => store.projectOrder); const reorderProjects = useUiStateStore((store) => store.reorderProjects); const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); const isOnSettings = pathname.startsWith("/settings"); const sidebarThreadSortOrder = useSettings((s) => s.sidebarThreadSortOrder); const sidebarProjectSortOrder = useSettings((s) => s.sidebarProjectSortOrder); const sidebarProjectGroupingMode = useSettings((s) => s.sidebarProjectGroupingMode); const projectGroupingSettings = useSettings((settings) => ({ sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, })); const { updateSettings } = useUpdateSettings(); const { handleNewThread } = useNewThreadHandler(); const { archiveThread, deleteThread } = useThreadActions(); const routeThreadRef = useParams({ strict: false, select: (params) => resolveThreadRouteRef(params), }); const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; const keybindings = useServerKeybindings(); const openAddProjectCommandPalette = useCommandPaletteStore((store) => store.openAddProject); const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< ReadonlySet >(() => new Set()); const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); const dragInProgressRef = useRef(false); const suppressProjectClickAfterDragRef = useRef(false); const suppressProjectClickForContextMenuRef = useRef(false); const [desktopUpdateState, setDesktopUpdateState] = useState(null); const selectedThreadCount = useThreadSelectionStore((s) => s.selectedThreadKeys.size); const clearSelection = useThreadSelectionStore((s) => s.clearSelection); const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); const platform = navigator.platform; const shortcutModifiers = useShortcutModifierState(); const modelPickerOpen = useModelPickerOpen(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); const orderedProjects = useMemo(() => { return orderItemsByPreferredIds({ items: projects, preferredIds: projectOrder, getId: getProjectOrderKey, }); }, [projectOrder, projects]); // Build a mapping from physical project key → logical project key for // cross-environment grouping. Projects that share a repositoryIdentity // canonicalKey are treated as one logical project in the sidebar. const physicalToLogicalKey = useMemo(() => { return buildPhysicalToLogicalProjectKeyMap({ projects: orderedProjects, settings: projectGroupingSettings, }); }, [orderedProjects, projectGroupingSettings]); const projectPhysicalKeyByScopedRef = useMemo( () => new Map( orderedProjects.map((project) => [ scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), derivePhysicalProjectKey(project), ]), ), [orderedProjects], ); const sidebarProjects = useMemo(() => { return buildSidebarProjectSnapshots({ projects: orderedProjects, settings: projectGroupingSettings, primaryEnvironmentId, resolveEnvironmentLabel: (environmentId) => { const rt = savedEnvironmentRuntimeById[environmentId]; const saved = savedEnvironmentRegistry[environmentId]; return rt?.descriptor?.label ?? saved?.label ?? null; }, }); }, [ orderedProjects, projectGroupingSettings, primaryEnvironmentId, savedEnvironmentRegistry, savedEnvironmentRuntimeById, ]); const sidebarProjectByKey = useMemo( () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), [sidebarProjects], ); const sidebarThreadByKey = useMemo( () => new Map( sidebarThreads.map( (thread) => [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, ), ), [sidebarThreads], ); // Resolve the active route's project key to a logical key so it matches the // sidebar's grouped project entries. const activeRouteProjectKey = useMemo(() => { if (!routeThreadKey) { return null; } const activeThread = sidebarThreadByKey.get(routeThreadKey); if (!activeThread) return null; const physicalKey = projectPhysicalKeyByScopedRef.get( scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)), ) ?? scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)); return physicalToLogicalKey.get(physicalKey) ?? physicalKey; }, [routeThreadKey, sidebarThreadByKey, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); // Group threads by logical project key so all threads from grouped projects // are displayed together. const threadsByProjectKey = useMemo(() => { const next = new Map(); for (const thread of sidebarThreads) { const physicalKey = projectPhysicalKeyByScopedRef.get( scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; const existing = next.get(logicalKey); if (existing) { existing.push(thread); } else { next.set(logicalKey, [thread]); } } return next; }, [sidebarThreads, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); const getCurrentSidebarShortcutContext = useCallback( () => ({ terminalFocus: isTerminalFocused(), terminalOpen: routeThreadRef ? selectThreadTerminalState( useTerminalStateStore.getState().terminalStateByThreadKey, routeThreadRef, ).terminalOpen : false, modelPickerOpen, }), [modelPickerOpen, routeThreadRef], ); const newThreadShortcutLabelOptions = useMemo( () => ({ platform, context: { terminalFocus: false, terminalOpen: false, }, }), [platform], ); const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal", newThreadShortcutLabelOptions) ?? shortcutLabelForCommand(keybindings, "chat.new", newThreadShortcutLabelOptions); const navigateToThread = useCallback( (threadRef: ScopedThreadRef) => { if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { clearSelection(); } setSelectionAnchor(scopedThreadKey(threadRef)); void navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(threadRef), }); }, [clearSelection, navigate, setSelectionAnchor], ); 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 (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.projectKey === active.id); const overProject = sidebarProjects.find((project) => project.projectKey === over.id); if (!activeProject || !overProject) return; const activeMemberKeys = activeProject.memberProjects.map( (member) => member.physicalProjectKey, ); const overMemberKeys = overProject.memberProjects.map((member) => member.physicalProjectKey); reorderProjects(activeMemberKeys, overMemberKeys); }, [sidebarProjectSortOrder, reorderProjects, sidebarProjects], ); const handleProjectDragStart = useCallback( (_event: DragStartEvent) => { if (sidebarProjectSortOrder !== "manual") { return; } dragInProgressRef.current = true; suppressProjectClickAfterDragRef.current = true; }, [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 visibleThreads = useMemo( () => sidebarThreads.filter((thread) => thread.archivedAt === null), [sidebarThreads], ); const sortedProjects = useMemo(() => { const sortableProjects = sidebarProjects.map((project) => ({ ...project, id: project.projectKey, })); const sortableThreads = visibleThreads.map((thread) => { const physicalKey = projectPhysicalKeyByScopedRef.get( scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); return { ...thread, projectId: (physicalToLogicalKey.get(physicalKey) ?? physicalKey) as ProjectId, }; }); return sortProjectsForSidebar( sortableProjects, sortableThreads, sidebarProjectSortOrder, ).flatMap((project) => { const resolvedProject = sidebarProjectByKey.get(project.id); return resolvedProject ? [resolvedProject] : []; }); }, [ sidebarProjectSortOrder, physicalToLogicalKey, projectPhysicalKeyByScopedRef, sidebarProjectByKey, sidebarProjects, visibleThreads, ]); const isManualProjectSorting = sidebarProjectSortOrder === "manual"; const visibleSidebarThreadKeys = useMemo( () => sortedProjects.flatMap((project) => { const projectThreads = sortThreads( (threadsByProjectKey.get(project.projectKey) ?? []).filter( (thread) => thread.archivedAt === null, ), sidebarThreadSortOrder, ); const projectExpanded = projectExpandedById[project.projectKey] ?? true; const activeThreadKey = routeThreadKey ?? undefined; const pinnedCollapsedThread = !projectExpanded && activeThreadKey ? (projectThreads.find( (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === activeThreadKey, ) ?? null) : null; const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; if (!shouldShowThreadPanel) { return []; } const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey); const hasOverflowingThreads = projectThreads.length > THREAD_PREVIEW_LIMIT; const previewThreads = isThreadListExpanded || !hasOverflowingThreads ? projectThreads : projectThreads.slice(0, THREAD_PREVIEW_LIMIT); const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; return renderedThreads.map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), ); }), [ sidebarThreadSortOrder, expandedThreadListsByProject, projectExpandedById, routeThreadKey, sortedProjects, threadsByProjectKey, ], ); const threadJumpCommandByKey = useMemo(() => { const mapping = new Map>>(); for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) { const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); if (!jumpCommand) { return mapping; } mapping.set(threadKey, jumpCommand); } return mapping; }, [visibleSidebarThreadKeys]); const threadJumpThreadKeys = useMemo( () => [...threadJumpCommandByKey.keys()], [threadJumpCommandByKey], ); const sidebarShortcutContext = useMemo( () => ({ terminalFocus: false, terminalOpen: routeThreadRef ? selectThreadTerminalState( useTerminalStateStore.getState().terminalStateByThreadKey, routeThreadRef, ).terminalOpen : false, modelPickerOpen, }), [modelPickerOpen, routeThreadRef], ); const threadJumpLabelByKey = useMemo( () => buildThreadJumpLabelMap({ keybindings, platform, terminalOpen: sidebarShortcutContext.terminalOpen, threadJumpCommandByKey, }), [keybindings, platform, sidebarShortcutContext.terminalOpen, threadJumpCommandByKey], ); const shouldShowThreadJumpHintsNow = shouldShowThreadJumpHintsForModifiers( shortcutModifiers, keybindings, { platform, context: sidebarShortcutContext, }, ); const visibleThreadJumpLabelByKey = showThreadJumpHints ? threadJumpLabelByKey : EMPTY_THREAD_JUMP_LABELS; const orderedSidebarThreadKeys = visibleSidebarThreadKeys; const prewarmedSidebarThreadKeys = useMemo( () => getSidebarThreadIdsToPrewarm(visibleSidebarThreadKeys), [visibleSidebarThreadKeys], ); const prewarmedSidebarThreadRefs = useMemo( () => prewarmedSidebarThreadKeys.flatMap((threadKey) => { const ref = parseScopedThreadKey(threadKey); return ref ? [ref] : []; }), [prewarmedSidebarThreadKeys], ); useEffect(() => { const releases = prewarmedSidebarThreadRefs.map((ref) => retainThreadDetailSubscription(ref.environmentId, ref.threadId), ); return () => { for (const release of releases) { release(); } }; }, [prewarmedSidebarThreadRefs]); useEffect(() => { updateThreadJumpHintsVisibility(shouldShowThreadJumpHintsNow); }, [shouldShowThreadJumpHintsNow, updateThreadJumpHintsVisibility]); useEffect(() => { const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { const shortcutContext = getCurrentSidebarShortcutContext(); if (event.defaultPrevented || event.repeat) { return; } const command = resolveShortcutCommand(event, keybindings, { platform, context: shortcutContext, }); const traversalDirection = threadTraversalDirectionFromCommand(command); if (traversalDirection !== null) { const targetThreadKey = resolveAdjacentThreadId({ threadIds: orderedSidebarThreadKeys, currentThreadId: routeThreadKey, direction: traversalDirection, }); if (!targetThreadKey) { return; } const targetThread = sidebarThreadByKey.get(targetThreadKey); if (!targetThread) { return; } event.preventDefault(); event.stopPropagation(); navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); return; } const jumpIndex = threadJumpIndexFromCommand(command ?? ""); if (jumpIndex === null) { return; } const targetThreadKey = threadJumpThreadKeys[jumpIndex]; if (!targetThreadKey) { return; } const targetThread = sidebarThreadByKey.get(targetThreadKey); if (!targetThread) { return; } event.preventDefault(); event.stopPropagation(); navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); }; window.addEventListener("keydown", onWindowKeyDown); return () => { window.removeEventListener("keydown", onWindowKeyDown); }; }, [ getCurrentSidebarShortcutContext, keybindings, navigateToThread, orderedSidebarThreadKeys, platform, routeThreadKey, sidebarThreadByKey, threadJumpThreadKeys, ]); useEffect(() => { const onMouseDown = (event: globalThis.MouseEvent) => { if (selectedThreadCount === 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, selectedThreadCount]); 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 commandPaletteShortcutLabel = shortcutLabelForCommand( keybindings, "commandPalette.toggle", newThreadShortcutLabelOptions, ); 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( stackedThreadToast({ type: "error", title: "Could not download update", description: actionError, }), ); }) .catch((error) => { toastManager.add( stackedThreadToast({ 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( stackedThreadToast({ type: "error", title: "Could not install update", description: actionError, }), ); }) .catch((error) => { toastManager.add( stackedThreadToast({ type: "error", title: "Could not install update", description: error instanceof Error ? error.message : "An unexpected error occurred.", }), ); }); } }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]); const expandThreadListForProject = useCallback((projectKey: string) => { setExpandedThreadListsByProject((current) => { if (current.has(projectKey)) return current; const next = new Set(current); next.add(projectKey); return next; }); }, []); const collapseThreadListForProject = useCallback((projectKey: string) => { setExpandedThreadListsByProject((current) => { if (!current.has(projectKey)) return current; const next = new Set(current); next.delete(projectKey); return next; }); }, []); return ( <> {isOnSettings ? ( ) : ( <> )} ); }