import { ArchiveIcon, ArrowUpDownIcon, ChevronRightIcon, CloudIcon, FolderIcon, GitPullRequestIcon, PlusIcon, SettingsIcon, SquarePenIcon, TerminalIcon, TriangleAlertIcon, } from "lucide-react"; 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 { DEFAULT_MODEL_BY_PROVIDER, type DesktopUpdateState, type EnvironmentId, ProjectId, type ScopedProjectRef, type ScopedThreadRef, type ThreadEnvMode, ThreadId, type GitStatusResult, } from "@t3tools/contracts"; import { 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 { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; import { selectProjectByRef, selectProjectsAcrossEnvironments, selectSidebarThreadsForProjectRef, selectSidebarThreadsForProjectRefs, selectSidebarThreadsAcrossEnvironments, selectThreadByRef, useStore, } from "../store"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; import { resolveShortcutCommand, shortcutLabelForCommand, shouldShowThreadJumpHints, threadJumpCommandForIndex, threadJumpIndexFromCommand, threadTraversalDirectionFromCommand, } from "../keybindings"; import { useGitStatus } from "../lib/gitStatusState"; import { readLocalApi } from "../localApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useThreadActions } from "../hooks/useThreadActions"; import { buildThreadRouteParams, resolveThreadRouteRef, resolveThreadRouteTarget, } from "../threadRoutes"; import { toastManager } from "./ui/toast"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; import { getArm64IntelBuildWarningDescription, getDesktopUpdateActionError, getDesktopUpdateInstallConfirmationMessage, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, shouldShowArm64IntelBuildWarning, shouldToastDesktopUpdateActionResult, } from "./desktopUpdate.logic"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { Button } from "./ui/button"; import { Menu, MenuGroup, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { SidebarContent, SidebarFooter, SidebarGroup, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, SidebarSeparator, SidebarTrigger, } from "./ui/sidebar"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { isNonEmpty as isNonEmptyString } from "effect/String"; import { resolveAdjacentThreadId, isContextMenuPointerDown, resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, sortThreadsForSidebar, useThreadJumpHintVisibility, ThreadStatusPill, } from "./Sidebar.logic"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { readEnvironmentApi } from "../environmentApi"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; import { deriveLogicalProjectKey } from "../logicalProject"; import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, } from "../environments/runtime"; import type { Project, SidebarThreadSummary } from "../types"; const THREAD_PREVIEW_LIMIT = 6; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", manual: "Manual", }; const SIDEBAR_THREAD_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", }; const SIDEBAR_LIST_ANIMATION_OPTIONS = { duration: 180, easing: "ease-out", } as const; const EMPTY_THREAD_JUMP_LABELS = new Map(); function threadJumpLabelMapsEqual( left: ReadonlyMap, right: ReadonlyMap, ): boolean { if (left === right) { return true; } if (left.size !== right.size) { return false; } for (const [key, value] of left) { if (right.get(key) !== value) { return false; } } return true; } 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; } type EnvironmentPresence = "local-only" | "remote-only" | "mixed"; type SidebarProjectSnapshot = Project & { projectKey: string; environmentPresence: EnvironmentPresence; memberProjectRefs: readonly ScopedProjectRef[]; /** Labels for remote environments this project lives in. */ remoteEnvironmentLabels: readonly string[]; }; interface TerminalStatusIndicator { label: "Terminal process running"; colorClass: string; pulse: boolean; } interface PrStatusIndicator { label: "PR open" | "PR closed" | "PR merged"; colorClass: string; tooltip: string; url: string; } type ThreadPr = GitStatusResult["pr"]; function ThreadStatusLabel({ status, compact = false, }: { status: ThreadStatusPill; compact?: boolean; }) { if (compact) { return ( {status.label} ); } return ( {status.label} ); } function terminalStatusFromRunningIds( runningTerminalIds: string[], ): TerminalStatusIndicator | null { if (runningTerminalIds.length === 0) { return null; } return { label: "Terminal process running", colorClass: "text-teal-600 dark:text-teal-300/90", pulse: true, }; } function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null { if (!pr) return null; if (pr.state === "open") { return { label: "PR open", colorClass: "text-emerald-600 dark:text-emerald-300/90", tooltip: `#${pr.number} PR open: ${pr.title}`, url: pr.url, }; } if (pr.state === "closed") { return { label: "PR closed", colorClass: "text-zinc-500 dark:text-zinc-400/80", tooltip: `#${pr.number} PR closed: ${pr.title}`, url: pr.url, }; } if (pr.state === "merged") { return { label: "PR merged", colorClass: "text-violet-600 dark:text-violet-300/90", tooltip: `#${pr.number} PR merged: ${pr.title}`, url: pr.url, }; } return null; } function resolveThreadPr( threadBranch: string | null, gitStatus: GitStatusResult | null, ): ThreadPr | null { if (threadBranch === null || gitStatus === null || gitStatus.branch !== threadBranch) { return null; } return gitStatus.pr ?? null; } 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} )}
{terminalStatus && ( )}
{isConfirmingArchive ? ( ) : !isThreadRunning ? ( appSettingsConfirmThreadArchive ? (
) : (
} /> Archive ) ) : null} {isRemoteThread && ( } > {threadEnvironmentLabel} )} {jumpLabel ? ( {jumpLabel} ) : ( {formatRelativeTimeLabel(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"} ); }); const SidebarProjectListRow = memo(function SidebarProjectListRow(props: SidebarProjectItemProps) { return ( ); }); function T3Wordmark() { return ( ); } type SortableProjectHandleProps = Pick< ReturnType, "attributes" | "listeners" | "setActivatorNodeRef" >; function ProjectSortMenu({ projectSortOrder, threadSortOrder, onProjectSortOrderChange, onThreadSortOrderChange, }: { projectSortOrder: SidebarProjectSortOrder; threadSortOrder: SidebarThreadSortOrder; onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void; onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; }) { return ( } > Sort projects
Sort projects
{ onProjectSortOrderChange(value as SidebarProjectSortOrder); }} > {(Object.entries(SIDEBAR_SORT_LABELS) as Array<[SidebarProjectSortOrder, string]>).map( ([value, label]) => ( {label} ), )}
Sort threads
{ onThreadSortOrderChange(value as SidebarThreadSortOrder); }} > {( Object.entries(SIDEBAR_THREAD_SORT_LABELS) as Array<[SidebarThreadSortOrder, string]> ).map(([value, label]) => ( {label} ))}
); } function SortableProjectItem({ projectId, disabled = false, children, }: { projectId: 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; updateSettings: ReturnType["updateSettings"]; shouldShowProjectPathEntry: boolean; handleStartAddProject: () => void; isElectron: boolean; isPickingFolder: boolean; isAddingProject: boolean; handlePickFolder: () => Promise; addProjectInputRef: React.RefObject; addProjectError: string | null; newCwd: string; setNewCwd: React.Dispatch>; setAddProjectError: React.Dispatch>; handleAddProject: () => void; setAddingProject: React.Dispatch>; canAddProject: boolean; 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; 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, updateSettings, shouldShowProjectPathEntry, handleStartAddProject, isElectron, isPickingFolder, isAddingProject, handlePickFolder, addProjectInputRef, addProjectError, newCwd, setNewCwd, setAddProjectError, handleAddProject, setAddingProject, canAddProject, isManualProjectSorting, projectDnDSensors, projectCollisionDetection, handleProjectDragStart, handleProjectDragEnd, handleProjectDragCancel, handleNewThread, archiveThread, deleteThread, sortedProjects, expandedThreadListsByProject, activeRouteProjectKey, routeThreadKey, newThreadShortcutLabel, 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 handleAddProjectInputChange = useCallback( (event: React.ChangeEvent) => { setNewCwd(event.target.value); setAddProjectError(null); }, [setAddProjectError, setNewCwd], ); const handleAddProjectInputKeyDown = useCallback( (event: React.KeyboardEvent) => { if (event.key === "Enter") handleAddProject(); if (event.key === "Escape") { setAddingProject(false); setAddProjectError(null); } }, [handleAddProject, setAddProjectError, setAddingProject], ); const handleBrowseForFolderClick = useCallback(() => { void handlePickFolder(); }, [handlePickFolder]); return ( {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( Intel build on Apple Silicon {arm64IntelBuildWarningDescription} {desktopUpdateButtonAction !== "none" ? ( ) : null} ) : null}
    Projects
    } > {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"}
    {shouldShowProjectPathEntry && (
    {isElectron && ( )}
    {addProjectError && (

    {addProjectError}

    )}
    )} {isManualProjectSorting ? ( project.projectKey)} strategy={verticalListSortingStrategy} > {sortedProjects.map((project) => ( {(dragHandleProps) => ( )} ))} ) : ( {sortedProjects.map((project) => ( ))} )} {projectsLength === 0 && !shouldShowProjectPathEntry && (
    No projects yet
    )}
    ); }); export default function Sidebar() { const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); const sidebarThreads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments)); const activeEnvironmentId = useStore((store) => store.activeEnvironmentId); 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 defaultThreadEnvMode = useSettings((s) => s.defaultThreadEnvMode); 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 [addingProject, setAddingProject] = useState(false); const [newCwd, setNewCwd] = useState(""); const [isPickingFolder, setIsPickingFolder] = useState(false); const [isAddingProject, setIsAddingProject] = useState(false); const [addProjectError, setAddProjectError] = useState(null); const addProjectInputRef = useRef(null); const [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 isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform); const platform = navigator.platform; const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; 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: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), }); }, [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(() => { const mapping = new Map(); for (const project of orderedProjects) { const physicalKey = scopedProjectKey(scopeProjectRef(project.environmentId, project.id)); mapping.set(physicalKey, deriveLogicalProjectKey(project)); } return mapping; }, [orderedProjects]); const sidebarProjects = useMemo(() => { // Group projects by logical key while preserving insertion order from // orderedProjects. const groupedMembers = new Map(); for (const project of orderedProjects) { const logicalKey = deriveLogicalProjectKey(project); const existing = groupedMembers.get(logicalKey); if (existing) { existing.push(project); } else { groupedMembers.set(logicalKey, [project]); } } const result: SidebarProjectSnapshot[] = []; const seen = new Set(); for (const project of orderedProjects) { const logicalKey = deriveLogicalProjectKey(project); if (seen.has(logicalKey)) continue; seen.add(logicalKey); const members = groupedMembers.get(logicalKey)!; // Prefer the primary environment's project as the representative. const representative: Project | undefined = (primaryEnvironmentId ? members.find((p) => p.environmentId === primaryEnvironmentId) : undefined) ?? members[0]; if (!representative) continue; const hasLocal = primaryEnvironmentId !== null && members.some((p) => p.environmentId === primaryEnvironmentId); const hasRemote = primaryEnvironmentId !== null ? members.some((p) => p.environmentId !== primaryEnvironmentId) : false; const refs = members.map((p) => scopeProjectRef(p.environmentId, p.id)); const remoteLabels = members .filter((p) => primaryEnvironmentId !== null && p.environmentId !== primaryEnvironmentId) .map((p) => { const rt = savedEnvironmentRuntimeById[p.environmentId]; const saved = savedEnvironmentRegistry[p.environmentId]; return rt?.descriptor?.label ?? saved?.label ?? p.environmentId; }); const snapshot: SidebarProjectSnapshot = { id: representative.id, environmentId: representative.environmentId, name: representative.name, cwd: representative.cwd, repositoryIdentity: representative.repositoryIdentity ?? null, defaultModelSelection: representative.defaultModelSelection, createdAt: representative.createdAt, updatedAt: representative.updatedAt, scripts: representative.scripts, projectKey: logicalKey, environmentPresence: hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", memberProjectRefs: refs, remoteEnvironmentLabels: remoteLabels, }; result.push(snapshot); } return result; }, [ orderedProjects, 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 = scopedProjectKey( scopeProjectRef(activeThread.environmentId, activeThread.projectId), ); return physicalToLogicalKey.get(physicalKey) ?? physicalKey; }, [routeThreadKey, sidebarThreadByKey, physicalToLogicalKey]); // 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 = 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]); const getCurrentSidebarShortcutContext = useCallback( () => ({ terminalFocus: isTerminalFocused(), terminalOpen: routeThreadRef ? selectThreadTerminalState( useTerminalStateStore.getState().terminalStateByThreadKey, routeThreadRef, ).terminalOpen : false, }), [routeThreadRef], ); const newThreadShortcutLabelOptions = useMemo( () => ({ platform, context: { terminalFocus: false, terminalOpen: false, }, }), [platform], ); const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal", newThreadShortcutLabelOptions) ?? shortcutLabelForCommand(keybindings, "chat.new", newThreadShortcutLabelOptions); const focusMostRecentThreadForProject = useCallback( (projectRef: { environmentId: EnvironmentId; projectId: ProjectId }) => { const physicalKey = scopedProjectKey( scopeProjectRef(projectRef.environmentId, projectRef.projectId), ); const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; const latestThread = sortThreadsForSidebar( (threadsByProjectKey.get(logicalKey) ?? []).filter((thread) => thread.archivedAt === null), sidebarThreadSortOrder, )[0]; if (!latestThread) return; void navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(scopeThreadRef(latestThread.environmentId, latestThread.id)), }); }, [sidebarThreadSortOrder, navigate, threadsByProjectKey, physicalToLogicalKey], ); const addProjectFromPath = useCallback( async (rawCwd: string) => { const cwd = rawCwd.trim(); if (!cwd || isAddingProject) return; const api = activeEnvironmentId ? readEnvironmentApi(activeEnvironmentId) : undefined; if (!api) return; setIsAddingProject(true); const finishAddingProject = () => { setIsAddingProject(false); setNewCwd(""); setAddProjectError(null); setAddingProject(false); }; const existing = projects.find((project) => project.cwd === cwd); if (existing) { focusMostRecentThreadForProject({ environmentId: existing.environmentId, projectId: existing.id, }); finishAddingProject(); return; } const projectId = newProjectId(); const createdAt = new Date().toISOString(); const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd; try { await api.orchestration.dispatchCommand({ type: "project.create", commandId: newCommandId(), projectId, title, workspaceRoot: cwd, defaultModelSelection: { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, }, createdAt, }); if (activeEnvironmentId !== null) { await handleNewThread(scopeProjectRef(activeEnvironmentId, projectId), { envMode: defaultThreadEnvMode, }).catch(() => undefined); } } catch (error) { const description = error instanceof Error ? error.message : "An error occurred while adding the project."; setIsAddingProject(false); if (shouldBrowseForProjectImmediately) { toastManager.add({ type: "error", title: "Failed to add project", description, }); } else { setAddProjectError(description); } return; } finishAddingProject(); }, [ focusMostRecentThreadForProject, activeEnvironmentId, handleNewThread, isAddingProject, projects, shouldBrowseForProjectImmediately, defaultThreadEnvMode, ], ); const handleAddProject = () => { void addProjectFromPath(newCwd); }; const canAddProject = newCwd.trim().length > 0 && !isAddingProject; const handlePickFolder = async () => { const api = readLocalApi(); if (!api || isPickingFolder) return; setIsPickingFolder(true); let pickedPath: string | null = null; try { pickedPath = await api.dialogs.pickFolder(); } catch { // Ignore picker failures and leave the current thread selection unchanged. } if (pickedPath) { await addProjectFromPath(pickedPath); } else if (!shouldBrowseForProjectImmediately) { addProjectInputRef.current?.focus(); } setIsPickingFolder(false); }; const handleStartAddProject = () => { setAddProjectError(null); if (shouldBrowseForProjectImmediately) { void handlePickFolder(); return; } setAddingProject((prev) => !prev); }; const 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; reorderProjects(activeProject.projectKey, overProject.projectKey); }, [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 = 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, sidebarProjectByKey, sidebarProjects, visibleThreads, ]); const isManualProjectSorting = sidebarProjectSortOrder === "manual"; const visibleSidebarThreadKeys = useMemo( () => sortedProjects.flatMap((project) => { const projectThreads = sortThreadsForSidebar( (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 [threadJumpLabelByKey, setThreadJumpLabelByKey] = useState>(EMPTY_THREAD_JUMP_LABELS); const threadJumpLabelsRef = useRef>(EMPTY_THREAD_JUMP_LABELS); threadJumpLabelsRef.current = threadJumpLabelByKey; const showThreadJumpHintsRef = useRef(showThreadJumpHints); showThreadJumpHintsRef.current = showThreadJumpHints; const visibleThreadJumpLabelByKey = showThreadJumpHints ? threadJumpLabelByKey : EMPTY_THREAD_JUMP_LABELS; const orderedSidebarThreadKeys = visibleSidebarThreadKeys; useEffect(() => { const clearThreadJumpHints = () => { setThreadJumpLabelByKey((current) => current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS, ); updateThreadJumpHintsVisibility(false); }; const shouldIgnoreThreadJumpHintUpdate = (event: globalThis.KeyboardEvent) => !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey && event.key !== "Meta" && event.key !== "Control" && event.key !== "Alt" && event.key !== "Shift" && !showThreadJumpHintsRef.current && threadJumpLabelsRef.current === EMPTY_THREAD_JUMP_LABELS; const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { if (shouldIgnoreThreadJumpHintUpdate(event)) { return; } const shortcutContext = getCurrentSidebarShortcutContext(); const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { platform, context: shortcutContext, }); if (!shouldShowHints) { if ( showThreadJumpHintsRef.current || threadJumpLabelsRef.current !== EMPTY_THREAD_JUMP_LABELS ) { clearThreadJumpHints(); } } else { setThreadJumpLabelByKey((current) => { const nextLabelMap = buildThreadJumpLabelMap({ keybindings, platform, terminalOpen: shortcutContext.terminalOpen, threadJumpCommandByKey, }); return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap; }); updateThreadJumpHintsVisibility(true); } 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)); }; const onWindowKeyUp = (event: globalThis.KeyboardEvent) => { if (shouldIgnoreThreadJumpHintUpdate(event)) { return; } const shortcutContext = getCurrentSidebarShortcutContext(); const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { platform, context: shortcutContext, }); if (!shouldShowHints) { clearThreadJumpHints(); return; } setThreadJumpLabelByKey((current) => { const nextLabelMap = buildThreadJumpLabelMap({ keybindings, platform, terminalOpen: shortcutContext.terminalOpen, threadJumpCommandByKey, }); return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap; }); updateThreadJumpHintsVisibility(true); }; const onWindowBlur = () => { clearThreadJumpHints(); }; window.addEventListener("keydown", onWindowKeyDown); window.addEventListener("keyup", onWindowKeyUp); window.addEventListener("blur", onWindowBlur); return () => { window.removeEventListener("keydown", onWindowKeyDown); window.removeEventListener("keyup", onWindowKeyUp); window.removeEventListener("blur", onWindowBlur); }; }, [ getCurrentSidebarShortcutContext, keybindings, navigateToThread, orderedSidebarThreadKeys, platform, routeThreadKey, sidebarThreadByKey, threadJumpCommandByKey, threadJumpThreadKeys, updateThreadJumpHintsVisibility, ]); 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 handleDesktopUpdateButtonClick = useCallback(() => { const bridge = window.desktopBridge; if (!bridge || !desktopUpdateState) return; if (desktopUpdateButtonDisabled || desktopUpdateButtonAction === "none") return; if (desktopUpdateButtonAction === "download") { void bridge .downloadUpdate() .then((result) => { if (result.completed) { toastManager.add({ type: "success", title: "Update downloaded", description: "Restart the app from the update button to install it.", }); } if (!shouldToastDesktopUpdateActionResult(result)) return; const actionError = getDesktopUpdateActionError(result); if (!actionError) return; toastManager.add({ type: "error", title: "Could not download update", description: actionError, }); }) .catch((error) => { toastManager.add({ type: "error", title: "Could not start update download", description: error instanceof Error ? error.message : "An unexpected error occurred.", }); }); return; } if (desktopUpdateButtonAction === "install") { const confirmed = window.confirm( getDesktopUpdateInstallConfirmationMessage(desktopUpdateState), ); if (!confirmed) return; void bridge .installUpdate() .then((result) => { if (!shouldToastDesktopUpdateActionResult(result)) return; const actionError = getDesktopUpdateActionError(result); if (!actionError) return; toastManager.add({ type: "error", title: "Could not install update", description: actionError, }); }) .catch((error) => { toastManager.add({ type: "error", title: "Could not install update", description: error instanceof Error ? error.message : "An unexpected error occurred.", }); }); } }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]); const expandThreadListForProject = useCallback((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 ? ( ) : ( <> )} ); }