import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { GitPullRequestIcon } from "lucide-react"; import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; import { Spinner } from "~/components/ui/spinner"; import { ArchiveIcon, ArrowUpDownIcon, ChevronRightIcon, FolderPlusIcon, Globe2Icon, SearchIcon, SquarePenIcon, TerminalIcon, TriangleAlertIcon, } from "lucide-react"; import { ChangeRequestStatusIcon, prStatusIndicator, PrStatusTooltipContent, terminalStatusFromRunningIds, ThreadStatusLabel, ThreadWorktreeIndicator, useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { ProjectFavicon } from "./ProjectFavicon"; import { useAtomValue } from "@effect/atom-react"; 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, ProjectId, type ScopedThreadRef, type ResolvedKeybindingsConfig, type SidebarProjectGroupingMode, resolveEnvironmentMachineKind, ThreadId, } from "@t3tools/contracts"; import { parseScopedThreadKey, scopedProjectKey, scopedThreadKey, scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime/environment"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { isAtomCommandInterrupted, settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { useNavigate, useParams, useRouter } from "@tanstack/react-router"; import { MAX_SIDEBAR_THREAD_PREVIEW_COUNT, MIN_SIDEBAR_THREAD_PREVIEW_COUNT, type SidebarProjectSortOrder, type SidebarThreadPreviewCount, type SidebarThreadSortOrder, } from "@t3tools/contracts/settings"; import { isDesktopLocalConnectionTarget, isWslConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { isElectron } from "../env"; import { useTerminalFocus } from "../hooks/useTerminalFocus"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { releaseProjectDraftUploads } from "../lib/composerDraftUploads"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isMacPlatform } from "../lib/utils"; import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; import { readThreadShell, useProjects, useThreadShells, useThreadShellsForProjectRefs, } from "../state/entities"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { useThreadDiscoveredPorts } from "../portDiscoveryState"; import { openDiscoveredPort } from "./preview/openDiscoveredPort"; import { useAtomCommand } from "../state/use-atom-command"; import { previewEnvironment } from "../state/preview"; import { legacyProjectCwdPreferenceKey, resolveProjectExpanded, useUiStateStore, } from "../uiStateStore"; import { resolveShortcutCommand, shortcutLabelForCommand, shouldShowThreadJumpHintsForModifiers, threadJumpCommandForIndex, threadJumpIndexFromCommand, threadTraversalDirectionFromCommand, } from "../keybindings"; import { isModelPickerOpen } from "../modelPickerVisibility"; import { useShortcutModifierState } from "../shortcutModifierState"; import { ensureLocalApi, readLocalApi } from "../localApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useDesktopUpdateState } from "../state/desktopUpdate"; import { useThreadActions } from "../hooks/useThreadActions"; import { projectEnvironment } from "../state/projects"; import { threadEnvironment, useEnvironmentThread } from "../state/threads"; import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { buildThreadRouteParams, resolveActiveThreadRouteRef, resolveThreadRouteTarget, } from "../threadRoutes"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { Kbd } from "./ui/kbd"; import { getArm64IntelBuildWarningDescription, getDesktopUpdateActionError, getDesktopUpdateInstallConfirmationMessage, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, shouldShowArm64IntelBuildWarning, shouldToastDesktopUpdateActionResult, } from "./desktopUpdate.logic"; import { showDesktopUpdateDownloadedToast } from "./desktopUpdate.toast"; 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, MenuTrigger } from "./ui/menu"; import { NumberField, NumberFieldDecrement, NumberFieldGroup, NumberFieldIncrement, NumberFieldInput, } from "./ui/number-field"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { SidebarContent, SidebarGroup, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, useSidebar, } from "./ui/sidebar"; import { getThreadKeysToDeselectAfterDelete, useThreadSelectionStore, } from "../threadSelectionStore"; import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { archiveSelectedThreadEntries, buildMultiSelectThreadContextMenuItems, deleteSelectedThreadEntries, getSidebarThreadIdsToPrewarm, resolveAdjacentThreadId, isContextMenuPointerDown, isSidebarNestedLinkClick, isTrailingDoubleClick, resolveProjectStatusIndicator, resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, useSidebarRowSubscriptionLease, useThreadJumpHintVisibility, ThreadStatusPill, } from "./Sidebar.logic"; import { sortThreads } from "../lib/threadSort"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useIsMobile } from "~/hooks/useMediaQuery"; import { CommandDialogTrigger } from "./ui/command"; import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; import { primaryServerKeybindingsAtom } from "../state/server"; import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey, getProjectOrderKey, selectProjectGroupingSettings, } from "../logicalProject"; import type { SidebarThreadSummary } from "../types"; import { buildPhysicalToLogicalProjectKeyMap, buildSidebarProjectSnapshots, type SidebarProjectGroupMember, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; 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", }; const SIDEBAR_ICON_ACTION_BUTTON_CLASS = "inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-icon-muted hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"; function SidebarThreadDetailPrewarmer({ threadRef }: { readonly threadRef: ScopedThreadRef }) { useEnvironmentThread(threadRef.environmentId, threadRef.threadId); return null; } function clampSidebarThreadPreviewCount(value: number): SidebarThreadPreviewCount { return Math.min( MAX_SIDEBAR_THREAD_PREVIEW_COUNT, Math.max(MIN_SIDEBAR_THREAD_PREVIEW_COUNT, value), ) as SidebarThreadPreviewCount; } function formatProjectMemberActionLabel( member: SidebarProjectGroupMember, groupedProjectCount: number, ): string { if (groupedProjectCount <= 1) { return member.title; } return member.environmentLabel ? `${member.environmentLabel} — ${member.workspaceRoot}` : member.workspaceRoot; } function projectExpansionPreferenceKeys(project: SidebarProjectSnapshot): string[] { return [ project.projectKey, ...project.memberProjects.map((member) => member.physicalProjectKey), ...project.memberProjects.map((member) => legacyProjectCwdPreferenceKey(member.workspaceRoot)), ]; } 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: ResolvedKeybindingsConfig; 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; orderedProjectThreadKeys: readonly string[]; isActive: boolean; openPullRequestsInRightPanel: boolean; jumpLabel: string | null; appSettingsConfirmThreadArchive: boolean; renamingThreadKey: string | null; renamingTitle: string; setRenamingTitle: (title: string) => void; startThreadRename: (threadKey: string, 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) => Promise; 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, threadRef?: ScopedThreadRef, ) => boolean; onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; } const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { const { orderedProjectThreadKeys, isActive, openPullRequestsInRightPanel, jumpLabel, appSettingsConfirmThreadArchive, renamingThreadKey, renamingTitle, setRenamingTitle, startThreadRename, renamingInputRef, renamingCommittedRef, confirmingArchiveThreadKey, setConfirmingArchiveThreadKey, confirmArchiveButtonRefs, handleThreadClick, navigateToThread, handleMultiSelectContextMenu, handleThreadContextMenu, clearSelection, commitRename, cancelRename, attemptArchiveThread, openPrLink, onFileDropThreads, thread, } = props; const threadRef = scopeThreadRef(thread.environmentId, thread.id); const threadKey = scopedThreadKey(threadRef); const [isFileDragOver, setIsFileDragOver] = useState(false); const fileDropHandlers = useMemo( () => makeWorkspaceFileDropHandlers({ setDragActive: setIsFileDragOver, addFiles: (files) => { onFileDropThreads(threadRef, files); }, }), [onFileDropThreads, threadRef], ); useEffect(() => { if (!isFileDragOver) return; const clearFileDrag = () => setIsFileDragOver(false); window.addEventListener("dragend", clearFileDrag); return () => window.removeEventListener("dragend", clearFileDrag); }, [isFileDragOver]); const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(isActive); const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); const runningTerminalIds = useThreadRunningTerminalIds({ environmentId: thread.environmentId, threadId: thread.id, }); const isMobile = useIsMobile(); const discoveredPorts = useThreadDiscoveredPorts({ environmentId: thread.environmentId, threadId: thread.id, }); const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); const environment = useEnvironment(thread.environmentId); const primaryEnvironmentId = usePrimaryEnvironmentId(); // No primary (the hosted app) means every thread is remote, and the machine // glyph is what tells the environments apart. const isRemoteThread = thread.environmentId !== primaryEnvironmentId; const remoteEnvLabel = environment?.label ?? null; const remoteMachine = resolveEnvironmentMachineKind(environment?.serverConfig ?? null); // A desktop-local secondary backend (e.g. the WSL backend) shows up as a // bearer environment whose connection id is prefixed "local:". It runs on the // user's own machine, so the cloud icon is misleading, label it "Local" and // suppress the cloud icon (the project header already shows a // local-environment icon for desktop-local projects, see sidebarProjectGrouping). const isDesktopLocalThread = environment !== null && isDesktopLocalConnectionTarget(environment.entry.target); const threadEnvironmentLabel = isRemoteThread ? (remoteEnvLabel ?? (isDesktopLocalThread ? "Local" : "Remote")) : null; const isHighlighted = isActive || isSelected; const handleOpenDiscoveredPort = useCallback( (event: React.MouseEvent) => { const port = discoveredPorts[0]; if (!port) return; event.preventDefault(); event.stopPropagation(); navigateToThread(threadRef); void (async () => { const result = await openDiscoveredPort({ threadRef, port, openPreview }); if (result._tag === "Success" || isAtomCommandInterrupted(result)) { return; } const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", title: "Unable to open preview", description: error instanceof Error ? error.message : "The preview could not be opened.", }), ); })(); }, [discoveredPorts, navigateToThread, openPreview, threadRef], ); const isThreadRunning = thread.session?.status === "running" && thread.session.activeTurnId != null; const threadStatus = resolveThreadStatusPill({ thread: { ...thread, lastVisitedAt, }, }); const linkedPullRequestStatus = useLinkedThreadPullRequest( thread.environmentId, thread.linkedPullRequest, leaseLiveStatus, thread.pullRequests, thread.branchPullRequest, ); const pr = linkedPullRequestStatus?.pr ?? null; const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); const currentLinkedPr = supportsMultiplePullRequests ? resolveThreadCurrentPullRequestLink(thread.pullRequests) : null; const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider); 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 max-sm:pr-6 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 handleRowDoubleClick = useCallback( (event: React.MouseEvent) => { // Already renaming this row: a double-click on the row chrome (outside the // input) must not restart and discard the in-progress edit. if (renamingThreadKey === threadKey) return; // On mobile the first tap navigates and closes the sidebar sheet, so the // inline rename can't be shown. Renaming there stays on the context menu. if (isMobile) return; // cmd/ctrl/shift double-clicks are multi-select intent, not rename. if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; // Ignore double-clicks bubbling from nested controls (PR status, port, // archive buttons) — only the row body should enter inline rename. if ((event.target as HTMLElement).closest("button, a")) return; event.preventDefault(); startThreadRename(threadKey, thread.title); }, [isMobile, renamingThreadKey, startThreadRename, threadKey, thread.title], ); 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(); const hasSelection = useThreadSelectionStore.getState().hasSelection(); if (hasSelection && isSelected) { void (async () => { const result = await settlePromise(() => handleMultiSelectContextMenu({ x: event.clientX, y: event.clientY, }), ); if (result._tag === "Failure") { const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", title: "Thread action failed", description: error instanceof Error ? error.message : "An error occurred.", }), ); } })(); return; } if (hasSelection) { clearSelection(); } void (async () => { const result = await settlePromise(() => handleThreadContextMenu(threadRef, { x: event.clientX, y: event.clientY, }), ); if (result._tag === "Failure") { const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", title: "Thread action failed", description: error instanceof Error ? error.message : "An error occurred.", }), ); } })(); }, [clearSelection, handleMultiSelectContextMenu, handleThreadContextMenu, isSelected, threadRef], ); const handlePrClick = useCallback( (event: React.MouseEvent) => { const url = prStatus?.url ?? currentLinkedPr?.url; if (!url) return; const openedInRightPanel = openPrLink( event, url, openPullRequestsInRightPanel ? threadRef : undefined, ); if (openedInRightPanel && openPullRequestsInRightPanel && !isActive) { navigateToThread(threadRef); } }, [ isActive, navigateToThread, openPrLink, openPullRequestsInRightPanel, prStatus, currentLinkedPr, threadRef, ], ); 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]); // Keep clicks/double-clicks inside the rename input from bubbling to the row. // Without stopping `dblclick`, double-clicking to select a word would re-fire // the row's rename handler and reset the in-progress edit back to the title. 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 && pr && ( event.stopPropagation()} onClick={handlePrClick} > } /> )} {!pr && currentLinkedPr ? ( event.stopPropagation()} onClick={handlePrClick} className="text-muted-foreground" aria-label={`PR #${currentLinkedPr.number}, status pending`} > ) : null} {threadStatus && } {renamingThreadKey === threadKey ? ( ) : ( {thread.title} } /> {thread.title} )}
{discoveredPorts.length > 0 && ( } > Open localhost:{discoveredPorts[0]?.port} {discoveredPorts.length > 1 ? ` (+${discoveredPorts.length - 1})` : ""} )} {terminalStatus && ( } > {terminalStatus.label} )}
{isConfirmingArchive ? ( ) : !isThreadRunning ? ( appSettingsConfirmThreadArchive ? (
) : (
} /> Archive ) ) : null} {isRemoteThread && !isDesktopLocalThread && ( } > {threadEnvironmentLabel} )} {jumpLabel ? ( } > {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; activeRouteThreadKey: string | null; openPullRequestsInRightPanel: boolean; threadJumpLabelByKey: ReadonlyMap; appSettingsConfirmThreadArchive: boolean; renamingThreadKey: string | null; renamingTitle: string; setRenamingTitle: (title: string) => void; startThreadRename: (threadKey: string, 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) => Promise; onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => 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, threadRef?: ScopedThreadRef, ) => boolean; 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, activeRouteThreadKey, openPullRequestsInRightPanel, threadJumpLabelByKey, appSettingsConfirmThreadArchive, renamingThreadKey, renamingTitle, setRenamingTitle, startThreadRename, renamingInputRef, renamingCommittedRef, confirmingArchiveThreadKey, setConfirmingArchiveThreadKey, confirmArchiveButtonRefs, attachThreadListAutoAnimateRef, handleThreadClick, navigateToThread, onFileDropThreads, 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.workspaceRoot}.` : "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.workspaceRoot} 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 LocalSecondaryStatus() { const { environments } = useEnvironments(); // The desktop reports which local secondary backends (e.g. the WSL backend) // exist; the hook polls because the bridge has no change event. A backend that // is still cold-booting has no httpBaseUrl yet and isn't in the catalog, so we // surface "Connecting" straight from the bootstrap list and clear it once the // matching environment reports a connected phase. const secondaries = useDesktopLocalBootstraps(); // Connected desktop-local environments keyed by their backend URL so we can // match a bootstrap (which only knows the URL) to its connection phase. const localEnvByUrl = useMemo(() => { const map = new Map(); for (const environment of environments) { if ( isDesktopLocalConnectionTarget(environment.entry.target) && environment.displayUrl !== null ) { map.set(environment.displayUrl, { phase: environment.connection.phase, error: environment.connection.error, }); } } return map; }, [environments]); const connecting: string[] = []; const failed: Array<{ label: string; error: string | null }> = []; for (const bootstrap of secondaries) { const env = bootstrap.httpBaseUrl !== null ? localEnvByUrl.get(bootstrap.httpBaseUrl) : undefined; if (env?.phase === "connected") { continue; } if (env?.phase === "error") { failed.push({ label: bootstrap.label, error: env.error }); continue; } connecting.push(bootstrap.label); } if (connecting.length === 0 && failed.length === 0) { return null; } return ( {connecting.length > 0 ? ( Connecting {connecting.join(", ")} ) : null} {failed.length > 0 ? ( Couldn't connect {failed.map((entry) => entry.label).join(", ")} {failed .map((entry) => entry.error) .filter(Boolean) .join("; ") || "The backend didn't respond."} ) : null} ); } type SortableProjectHandleProps = Pick< ReturnType, "attributes" | "listeners" | "setActivatorNodeRef" >; function ProjectSortMenu({ projectSortOrder, threadSortOrder, threadPreviewCount, onProjectSortOrderChange, onThreadSortOrderChange, onThreadPreviewCountChange, }: { projectSortOrder: SidebarProjectSortOrder; threadSortOrder: SidebarThreadSortOrder; threadPreviewCount: SidebarThreadPreviewCount; onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void; onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; onThreadPreviewCountChange: (count: SidebarThreadPreviewCount) => void; }) { const handleThreadPreviewCountChange = useCallback( (nextValue: number | null) => { if (nextValue === null) { return; } const clampedValue = clampSidebarThreadPreviewCount(nextValue); if (clampedValue !== threadPreviewCount) { onThreadPreviewCountChange(clampedValue); } }, [onThreadPreviewCountChange, threadPreviewCount], ); return ( } > Sidebar options
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} ))}
Visible threads
{ event.stopPropagation(); }} />
); } 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 })}
  • ); } interface SidebarProjectsContentProps { showArm64IntelBuildWarning: boolean; arm64IntelBuildWarningDescription: string | null; desktopUpdateButtonAction: "download" | "install" | "none"; desktopUpdateButtonDisabled: boolean; desktopUpdateActionPending: boolean; handleDesktopUpdateButtonClick: () => void; projectSortOrder: SidebarProjectSortOrder; threadSortOrder: SidebarThreadSortOrder; threadPreviewCount: SidebarThreadPreviewCount; updateSettings: ReturnType; openAddProject: () => void; isManualProjectSorting: boolean; projectDnDSensors: ReturnType; projectCollisionDetection: CollisionDetection; handleProjectDragStart: (event: DragStartEvent) => void; handleProjectDragEnd: (event: DragEndEvent) => void; handleProjectDragCancel: (event: DragCancelEvent) => void; handleNewThread: ReturnType; archiveThread: ReturnType["archiveThread"]; deleteThread: ReturnType["deleteThread"]; sortedProjects: readonly SidebarProjectSnapshot[]; expandedThreadListsByProject: ReadonlySet; activeRouteProjectKey: string | null; routeThreadKey: string | null; openPullRequestsInRightPanel: boolean; 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, desktopUpdateActionPending, handleDesktopUpdateButtonClick, projectSortOrder, threadSortOrder, threadPreviewCount, updateSettings, openAddProject, isManualProjectSorting, projectDnDSensors, projectCollisionDetection, handleProjectDragStart, handleProjectDragEnd, handleProjectDragCancel, handleNewThread, archiveThread, deleteThread, sortedProjects, expandedThreadListsByProject, activeRouteProjectKey, routeThreadKey, openPullRequestsInRightPanel, 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 handleThreadPreviewCountChange = useCallback( (count: SidebarThreadPreviewCount) => { updateSettings({ sidebarThreadPreviewCount: count }); }, [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 LegacySidebar() { const projects = useProjects(); const sidebarThreads = useThreadShells(); const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); const projectOrder = useUiStateStore((store) => store.projectOrder); const reorderProjects = useUiStateStore((store) => store.reorderProjects); const navigate = useNavigate(); const sidebarThreadSortOrder = useClientSettings((s) => s.sidebarThreadSortOrder); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const sidebarThreadPreviewCount = useClientSettings((s) => s.sidebarThreadPreviewCount); const updateSettings = useUpdateClientSettings(); const handleNewThread = useNewThreadHandler(); const { archiveThread, deleteThread } = useThreadActions(); const { isMobile, setOpenMobile } = useSidebar(); const routeTarget = useParams({ strict: false, select: (params) => resolveThreadRouteTarget(params), }); const routeDraftThread = useComposerDraftStore((store) => routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null, ); const routeThreadRef = useMemo( () => resolveActiveThreadRouteRef(routeTarget, routeDraftThread), [routeDraftThread, routeTarget], ); const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; const routeTerminalOpen = useTerminalUiStateStore((state) => routeThreadRef ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen : false, ); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const openAddProjectCommandPalette = useCallback( () => openCommandPalette({ open: "add-project" }), [], ); 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 = useDesktopUpdateState(); const [desktopUpdateActionPending, setDesktopUpdateActionPending] = useState(false); const clearSelection = useThreadSelectionStore((s) => s.clearSelection); const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); const platform = navigator.platform; const shortcutModifiers = useShortcutModifierState(); const terminalFocused = useTerminalFocus(); const { environments } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const environmentLabelById = useMemo( () => new Map( environments.map((environment) => [environment.environmentId, environment.label] as const), ), [environments], ); const desktopLocalEnvironmentIds = useMemo( () => new Set( environments .filter((environment) => isDesktopLocalConnectionTarget(environment.entry.target)) .map((environment) => environment.environmentId), ), [environments], ); const wslEnvironmentIds = useMemo( () => new Set( environments .filter((environment) => isWslConnectionTarget(environment.entry.target)) .map((environment) => environment.environmentId), ), [environments], ); const orderedProjects = useMemo(() => { return orderItemsByPreferredIds({ items: projects, preferredIds: projectOrder, getId: getProjectOrderKey, getPreferenceIds: (project) => [ getProjectOrderKey(project), legacyProjectCwdPreferenceKey(project.workspaceRoot), ], }); }, [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, primaryEnvironmentId, }); }, [orderedProjects, projectGroupingSettings, primaryEnvironmentId]); 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) => environmentLabelById.get(environmentId) ?? null, isDesktopLocalEnvironment: (environmentId) => desktopLocalEnvironmentIds.has(environmentId), isWslEnvironment: (environmentId) => wslEnvironmentIds.has(environmentId), }); }, [ environmentLabelById, desktopLocalEnvironmentIds, wslEnvironmentIds, orderedProjects, projectGroupingSettings, primaryEnvironmentId, ]); 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: routeTerminalOpen, modelPickerOpen: isModelPickerOpen(), }), [routeTerminalOpen], ); 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)); if (isMobile) { setOpenMobile(false); } void navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(threadRef), }); }, [clearSelection, isMobile, navigate, setOpenMobile, 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(orderedProjects.map(getProjectOrderKey), activeMemberKeys, overMemberKeys); }, [orderedProjects, 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 = resolveProjectExpanded( projectExpandedById, projectExpansionPreferenceKeys(project), ); 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 > sidebarThreadPreviewCount; const previewThreads = isThreadListExpanded || !hasOverflowingThreads ? projectThreads : projectThreads.slice(0, sidebarThreadPreviewCount); const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; return renderedThreads.map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), ); }), [ sidebarThreadSortOrder, sidebarThreadPreviewCount, 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 = { terminalFocus: terminalFocused, terminalOpen: routeTerminalOpen, modelPickerOpen: isModelPickerOpen(), }; 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(() => { updateThreadJumpHintsVisibility(shouldShowThreadJumpHintsNow); }, [shouldShowThreadJumpHintsNow, updateThreadJumpHintsVisibility]); useEffect(() => { const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { const shortcutContext = getCurrentSidebarShortcutContext(); if (event.defaultPrevented || event.repeat || isCommandPaletteOpen() || isModelPickerOpen()) { 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 (!useThreadSelectionStore.getState().hasSelection()) 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]); const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState); const desktopUpdateButtonAction = desktopUpdateState ? resolveDesktopUpdateButtonAction(desktopUpdateState) : "none"; const showArm64IntelBuildWarning = isElectron && shouldShowArm64IntelBuildWarning(desktopUpdateState); const arm64IntelBuildWarningDescription = desktopUpdateState && showArm64IntelBuildWarning ? getArm64IntelBuildWarningDescription(desktopUpdateState) : null; const commandPaletteShortcutLabel = isMobile ? null : shortcutLabelForCommand(keybindings, "commandPalette.toggle", newThreadShortcutLabelOptions); const handleDesktopUpdateButtonClick = useCallback(async () => { const bridge = window.desktopBridge; if (!bridge || !desktopUpdateState) return; if ( desktopUpdateButtonDisabled || desktopUpdateButtonAction === "none" || desktopUpdateActionPending ) { return; } setDesktopUpdateActionPending(true); if (desktopUpdateButtonAction === "download") { void bridge .downloadUpdate() .then((result) => { if (result.completed) { showDesktopUpdateDownloadedToast(bridge, result.state); } 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.", }), ); }) .finally(() => setDesktopUpdateActionPending(false)); return; } if (desktopUpdateButtonAction === "install") { let confirmed = false; try { confirmed = await ensureLocalApi().dialogs.confirm( getDesktopUpdateInstallConfirmationMessage(desktopUpdateState), ); } catch (error) { setDesktopUpdateActionPending(false); toastManager.add( stackedThreadToast({ type: "error", title: "Could not confirm update", description: error instanceof Error ? error.message : "Update confirmation failed.", }), ); return; } if (!confirmed) { setDesktopUpdateActionPending(false); 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.", }), ); }) .finally(() => setDesktopUpdateActionPending(false)); } }, [ desktopUpdateActionPending, 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 ( <> {prewarmedSidebarThreadRefs.map((threadRef) => ( ))} ); }