import { ArrowLeftIcon, ChevronRightIcon, FolderIcon, GitPullRequestIcon, PlusIcon, RocketIcon, SettingsIcon, SquarePenIcon, TerminalIcon, TriangleAlertIcon, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { DndContext, type DragCancelEvent, type CollisionDetection, PointerSensor, type DragStartEvent, closestCorners, pointerWithin, useSensor, useSensors, type DragEndEvent, } from "@dnd-kit/core"; import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { restrictToParentElement, restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; import { DEFAULT_RUNTIME_MODE, DEFAULT_MODEL_BY_PROVIDER, type DesktopUpdateState, ProjectId, ThreadId, type GitStatusResult, type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; import { useAppSettings } from "../appSettings"; import { isElectron } from "../env"; import { APP_STAGE_LABEL } from "../branding"; import { newCommandId, newProjectId, newThreadId } from "../lib/utils"; import { useStore } from "../store"; import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings"; import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; import { resolveServerHttpOrigin } from "../serverConnection"; import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { toastManager } from "./ui/toast"; import { getArm64IntelBuildWarningDescription, getDesktopUpdateActionError, getDesktopUpdateButtonTooltip, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, shouldShowArm64IntelBuildWarning, shouldHighlightDesktopUpdateError, shouldShowDesktopUpdateButton, shouldToastDesktopUpdateActionResult, } from "./desktopUpdate.logic"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { Button } from "./ui/button"; import { Collapsible, CollapsibleContent } from "./ui/collapsible"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { SidebarContent, SidebarFooter, SidebarGroup, SidebarHeader, SidebarMenuAction, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, SidebarSeparator, SidebarTrigger, } from "./ui/sidebar"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { isNonEmpty as isNonEmptyString } from "effect/String"; import { resolveThreadStatusPill } from "./Sidebar.logic"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const THREAD_PREVIEW_LIMIT = 6; async function copyTextToClipboard(text: string): Promise { if (typeof navigator === "undefined" || navigator.clipboard?.writeText === undefined) { throw new Error("Clipboard API unavailable."); } await navigator.clipboard.writeText(text); } function formatRelativeTime(iso: string): string { const diff = Date.now() - new Date(iso).getTime(); const minutes = Math.floor(diff / 60_000); if (minutes < 1) return "just now"; if (minutes < 60) return `${minutes}m ago`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h ago`; return `${Math.floor(hours / 24)}d ago`; } interface TerminalStatusIndicator { label: "Terminal process running"; colorClass: string; pulse: boolean; } interface PrStatusIndicator { label: "PR open" | "PR closed" | "PR merged"; colorClass: string; tooltip: string; url: string; } type ThreadPr = GitStatusResult["pr"]; function terminalStatusFromRunningIds( runningTerminalIds: string[], ): TerminalStatusIndicator | null { if (runningTerminalIds.length === 0) { return null; } return { label: "Terminal process running", colorClass: "text-teal-600 dark:text-teal-300/90", pulse: true, }; } function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null { if (!pr) return null; if (pr.state === "open") { return { label: "PR open", colorClass: "text-emerald-600 dark:text-emerald-300/90", tooltip: `#${pr.number} PR open: ${pr.title}`, url: pr.url, }; } if (pr.state === "closed") { return { label: "PR closed", colorClass: "text-zinc-500 dark:text-zinc-400/80", tooltip: `#${pr.number} PR closed: ${pr.title}`, url: pr.url, }; } if (pr.state === "merged") { return { label: "PR merged", colorClass: "text-violet-600 dark:text-violet-300/90", tooltip: `#${pr.number} PR merged: ${pr.title}`, url: pr.url, }; } return null; } function T3Wordmark() { return ( ); } const serverHttpOrigin = resolveServerHttpOrigin(); function ProjectFavicon({ cwd }: { cwd: string }) { const [status, setStatus] = useState<"loading" | "loaded" | "error">("loading"); const src = `${serverHttpOrigin}/api/project-favicon?cwd=${encodeURIComponent(cwd)}`; if (status === "error") { return ; } return ( setStatus("loaded")} onError={() => setStatus("error")} /> ); } type SortableProjectHandleProps = Pick, "attributes" | "listeners">; function SortableProjectItem({ projectId, children, }: { projectId: ProjectId; children: (handleProps: SortableProjectHandleProps) => React.ReactNode; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, isOver } = useSortable({ id: projectId }); return (
  • {children({ attributes, listeners })}
  • ); } export default function Sidebar() { const projects = useStore((store) => store.projects); const threads = useStore((store) => store.threads); const markThreadUnread = useStore((store) => store.markThreadUnread); const toggleProject = useStore((store) => store.toggleProject); const reorderProjects = useStore((store) => store.reorderProjects); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft); const getDraftThreadByProjectId = useComposerDraftStore( (store) => store.getDraftThreadByProjectId, ); const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId); const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState); const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const clearProjectDraftThreadId = useComposerDraftStore( (store) => store.clearProjectDraftThreadId, ); const clearProjectDraftThreadById = useComposerDraftStore( (store) => store.clearProjectDraftThreadById, ); const navigate = useNavigate(); const isOnSettings = useLocation({ select: (loc) => loc.pathname === "/settings" }); const { settings: appSettings } = useAppSettings(); const routeThreadId = useParams({ strict: false, select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), }); const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({ ...serverConfigQueryOptions(), select: (config) => config.keybindings, }); const queryClient = useQueryClient(); const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient })); const [addingProject, setAddingProject] = useState(false); const [newCwd, setNewCwd] = useState(""); const [isPickingFolder, setIsPickingFolder] = useState(false); const [isAddingProject, setIsAddingProject] = useState(false); const [addProjectError, setAddProjectError] = useState(null); const addProjectInputRef = useRef(null); const [renamingThreadId, setRenamingThreadId] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< ReadonlySet >(() => new Set()); const renamingCommittedRef = useRef(false); const renamingInputRef = useRef(null); const dragInProgressRef = useRef(false); const suppressProjectClickAfterDragRef = useRef(false); const [desktopUpdateState, setDesktopUpdateState] = useState(null); const shouldBrowseForProjectImmediately = isElectron; const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; const pendingApprovalByThreadId = useMemo(() => { const map = new Map(); for (const thread of threads) { map.set(thread.id, derivePendingApprovals(thread.activities).length > 0); } return map; }, [threads]); const pendingUserInputByThreadId = useMemo(() => { const map = new Map(); for (const thread of threads) { map.set(thread.id, derivePendingUserInputs(thread.activities).length > 0); } return map; }, [threads]); const projectCwdById = useMemo( () => new Map(projects.map((project) => [project.id, project.cwd] as const)), [projects], ); const threadGitTargets = useMemo( () => threads.map((thread) => ({ threadId: thread.id, branch: thread.branch, cwd: thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null, })), [projectCwdById, threads], ); const threadGitStatusCwds = useMemo( () => [ ...new Set( threadGitTargets .filter((target) => target.branch !== null) .map((target) => target.cwd) .filter((cwd): cwd is string => cwd !== null), ), ], [threadGitTargets], ); const threadGitStatusQueries = useQueries({ queries: threadGitStatusCwds.map((cwd) => ({ ...gitStatusQueryOptions(cwd), staleTime: 30_000, refetchInterval: 60_000, })), }); const prByThreadId = useMemo(() => { const statusByCwd = new Map(); for (let index = 0; index < threadGitStatusCwds.length; index += 1) { const cwd = threadGitStatusCwds[index]; if (!cwd) continue; const status = threadGitStatusQueries[index]?.data; if (status) { statusByCwd.set(cwd, status); } } const map = new Map(); for (const target of threadGitTargets) { const status = target.cwd ? statusByCwd.get(target.cwd) : undefined; const branchMatches = target.branch !== null && status?.branch !== null && status?.branch === target.branch; map.set(target.threadId, branchMatches ? (status?.pr ?? null) : null); } return map; }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]); const openPrLink = useCallback((event: React.MouseEvent, prUrl: string) => { event.preventDefault(); event.stopPropagation(); const api = readNativeApi(); if (!api) { toastManager.add({ type: "error", title: "Link opening is unavailable.", }); return; } void api.shell.openExternal(prUrl).catch((error) => { toastManager.add({ type: "error", title: "Unable to open PR link", description: error instanceof Error ? error.message : "An error occurred.", }); }); }, []); const handleNewThread = useCallback( ( projectId: ProjectId, options?: { branch?: string | null; worktreePath?: string | null; envMode?: DraftThreadEnvMode; }, ): Promise => { const hasBranchOption = options?.branch !== undefined; const hasWorktreePathOption = options?.worktreePath !== undefined; const hasEnvModeOption = options?.envMode !== undefined; const storedDraftThread = getDraftThreadByProjectId(projectId); if (storedDraftThread) { return (async () => { if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { setDraftThreadContext(storedDraftThread.threadId, { ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), }); } setProjectDraftThreadId(projectId, storedDraftThread.threadId); if (routeThreadId === storedDraftThread.threadId) { return; } await navigate({ to: "/$threadId", params: { threadId: storedDraftThread.threadId }, }); })(); } clearProjectDraftThreadId(projectId); const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null; if (activeDraftThread && routeThreadId && activeDraftThread.projectId === projectId) { if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { setDraftThreadContext(routeThreadId, { ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), }); } setProjectDraftThreadId(projectId, routeThreadId); return Promise.resolve(); } const threadId = newThreadId(); const createdAt = new Date().toISOString(); return (async () => { setProjectDraftThreadId(projectId, threadId, { createdAt, branch: options?.branch ?? null, worktreePath: options?.worktreePath ?? null, envMode: options?.envMode ?? "local", runtimeMode: DEFAULT_RUNTIME_MODE, }); await navigate({ to: "/$threadId", params: { threadId }, }); })(); }, [ clearProjectDraftThreadId, getDraftThreadByProjectId, navigate, getDraftThread, routeThreadId, setDraftThreadContext, setProjectDraftThreadId, ], ); const focusMostRecentThreadForProject = useCallback( (projectId: ProjectId) => { const latestThread = threads .filter((thread) => thread.projectId === projectId) .toSorted((a, b) => { const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); if (byDate !== 0) return byDate; return b.id.localeCompare(a.id); })[0]; if (!latestThread) return; void navigate({ to: "/$threadId", params: { threadId: latestThread.id }, }); }, [navigate, threads], ); const addProjectFromPath = useCallback( async (rawCwd: string) => { const cwd = rawCwd.trim(); if (!cwd || isAddingProject) return; const api = readNativeApi(); if (!api) return; setIsAddingProject(true); const finishAddingProject = () => { setIsAddingProject(false); setNewCwd(""); setAddProjectError(null); setAddingProject(false); }; const existing = projects.find((project) => project.cwd === cwd); if (existing) { focusMostRecentThreadForProject(existing.id); finishAddingProject(); return; } const projectId = newProjectId(); const createdAt = new Date().toISOString(); const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd; try { await api.orchestration.dispatchCommand({ type: "project.create", commandId: newCommandId(), projectId, title, workspaceRoot: cwd, defaultModel: DEFAULT_MODEL_BY_PROVIDER.codex, createdAt, }); await handleNewThread(projectId).catch(() => undefined); } catch (error) { const description = error instanceof Error ? error.message : "An error occurred while adding the project."; setIsAddingProject(false); if (shouldBrowseForProjectImmediately) { toastManager.add({ type: "error", title: "Failed to add project", description, }); } else { setAddProjectError(description); } return; } finishAddingProject(); }, [ focusMostRecentThreadForProject, handleNewThread, isAddingProject, projects, shouldBrowseForProjectImmediately, ], ); const handleAddProject = () => { void addProjectFromPath(newCwd); }; const canAddProject = newCwd.trim().length > 0 && !isAddingProject; const handlePickFolder = async () => { const api = readNativeApi(); if (!api || isPickingFolder) return; setIsPickingFolder(true); let pickedPath: string | null = null; try { pickedPath = await api.dialogs.pickFolder(); } catch { // Ignore picker failures and leave the current thread selection unchanged. } if (pickedPath) { await addProjectFromPath(pickedPath); } else if (!shouldBrowseForProjectImmediately) { addProjectInputRef.current?.focus(); } setIsPickingFolder(false); }; const handleStartAddProject = () => { setAddProjectError(null); if (shouldBrowseForProjectImmediately) { void handlePickFolder(); return; } setAddingProject((prev) => !prev); }; const cancelRename = useCallback(() => { setRenamingThreadId(null); renamingInputRef.current = null; }, []); const commitRename = useCallback( async (threadId: ThreadId, newTitle: string, originalTitle: string) => { const finishRename = () => { setRenamingThreadId((current) => { if (current !== threadId) return current; renamingInputRef.current = null; return null; }); }; const trimmed = newTitle.trim(); if (trimmed.length === 0) { toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); finishRename(); return; } if (trimmed === originalTitle) { finishRename(); return; } const api = readNativeApi(); if (!api) { finishRename(); return; } try { await api.orchestration.dispatchCommand({ type: "thread.meta.update", commandId: newCommandId(), threadId, title: trimmed, }); } catch (error) { toastManager.add({ type: "error", title: "Failed to rename thread", description: error instanceof Error ? error.message : "An error occurred.", }); } finishRename(); }, [], ); const handleThreadContextMenu = useCallback( async (threadId: ThreadId, position: { x: number; y: number }) => { const api = readNativeApi(); if (!api) return; const clicked = await api.contextMenu.show( [ { id: "rename", label: "Rename thread" }, { id: "mark-unread", label: "Mark unread" }, { id: "copy-thread-id", label: "Copy Thread ID" }, { id: "delete", label: "Delete", destructive: true }, ], position, ); const thread = threads.find((t) => t.id === threadId); if (!thread) return; if (clicked === "rename") { setRenamingThreadId(threadId); setRenamingTitle(thread.title); renamingCommittedRef.current = false; return; } if (clicked === "mark-unread") { markThreadUnread(threadId); return; } if (clicked === "copy-thread-id") { try { await copyTextToClipboard(threadId); toastManager.add({ type: "success", title: "Thread ID copied", description: threadId, }); } catch (error) { toastManager.add({ type: "error", title: "Failed to copy thread ID", description: error instanceof Error ? error.message : "An error occurred.", }); } return; } if (clicked !== "delete") return; if (appSettings.confirmThreadDelete) { const confirmed = await api.dialogs.confirm( [ `Delete thread "${thread.title}"?`, "This permanently clears conversation history for this thread.", ].join("\n"), ); if (!confirmed) { return; } } const threadProject = projects.find((project) => project.id === thread.projectId); const orphanedWorktreePath = getOrphanedWorktreePathForThread(threads, threadId); const displayWorktreePath = orphanedWorktreePath ? formatWorktreePathForDisplay(orphanedWorktreePath) : null; const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== undefined; const shouldDeleteWorktree = canDeleteWorktree && (await api.dialogs.confirm( [ "This thread is the only one linked to this worktree:", displayWorktreePath ?? orphanedWorktreePath, "", "Delete the worktree too?", ].join("\n"), )); if (thread.session && thread.session.status !== "closed") { await api.orchestration .dispatchCommand({ type: "thread.session.stop", commandId: newCommandId(), threadId, createdAt: new Date().toISOString(), }) .catch(() => undefined); } try { await api.terminal.close({ threadId, deleteHistory: true, }); } catch { // Terminal may already be closed } const shouldNavigateToFallback = routeThreadId === threadId; const fallbackThreadId = threads.find((entry) => entry.id !== threadId)?.id ?? null; await api.orchestration.dispatchCommand({ type: "thread.delete", commandId: newCommandId(), threadId, }); clearComposerDraftForThread(threadId); clearProjectDraftThreadById(thread.projectId, thread.id); clearTerminalState(threadId); if (shouldNavigateToFallback) { if (fallbackThreadId) { void navigate({ to: "/$threadId", params: { threadId: fallbackThreadId }, replace: true, }); } else { void navigate({ to: "/", replace: true }); } } if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) { return; } try { await removeWorktreeMutation.mutateAsync({ cwd: threadProject.cwd, path: orphanedWorktreePath, force: true, }); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error removing worktree."; console.error("Failed to remove orphaned worktree after thread deletion", { threadId, projectCwd: threadProject.cwd, worktreePath: orphanedWorktreePath, error, }); toastManager.add({ type: "error", title: "Thread deleted, but worktree removal failed", description: `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}`, }); } }, [ appSettings.confirmThreadDelete, clearComposerDraftForThread, clearProjectDraftThreadById, clearTerminalState, markThreadUnread, navigate, projects, removeWorktreeMutation, routeThreadId, threads, ], ); const handleProjectContextMenu = useCallback( async (projectId: ProjectId, position: { x: number; y: number }) => { const api = readNativeApi(); if (!api) return; const clicked = await api.contextMenu.show( [{ id: "delete", label: "Delete", destructive: true }], position, ); if (clicked !== "delete") return; const project = projects.find((entry) => entry.id === projectId); if (!project) return; const projectThreads = threads.filter((thread) => thread.projectId === projectId); if (projectThreads.length > 0) { toastManager.add({ type: "warning", title: "Project is not empty", description: "Delete all threads in this project before deleting it.", }); return; } const confirmed = await api.dialogs.confirm( [`Delete project "${project.name}"?`, "This action cannot be undone."].join("\n"), ); if (!confirmed) return; try { const projectDraftThread = getDraftThreadByProjectId(projectId); if (projectDraftThread) { clearComposerDraftForThread(projectDraftThread.threadId); } clearProjectDraftThreadId(projectId); await api.orchestration.dispatchCommand({ type: "project.delete", commandId: newCommandId(), projectId, }); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error deleting project."; console.error("Failed to remove project", { projectId, error }); toastManager.add({ type: "error", title: `Failed to delete "${project.name}"`, description: message, }); } }, [ clearComposerDraftForThread, clearProjectDraftThreadId, getDraftThreadByProjectId, projects, threads, ], ); const projectDnDSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 }, }), ); const projectCollisionDetection = useCallback((args) => { const pointerCollisions = pointerWithin(args); if (pointerCollisions.length > 0) { return pointerCollisions; } return closestCorners(args); }, []); const handleProjectDragEnd = useCallback( (event: DragEndEvent) => { dragInProgressRef.current = false; const { active, over } = event; if (!over || active.id === over.id) return; const activeProject = projects.find((project) => project.id === active.id); const overProject = projects.find((project) => project.id === over.id); if (!activeProject || !overProject) return; reorderProjects(activeProject.id, overProject.id); }, [projects, reorderProjects], ); const handleProjectDragStart = useCallback((_event: DragStartEvent) => { dragInProgressRef.current = true; suppressProjectClickAfterDragRef.current = true; }, []); const handleProjectDragCancel = useCallback((_event: DragCancelEvent) => { dragInProgressRef.current = false; }, []); const handleProjectTitlePointerDownCapture = useCallback(() => { suppressProjectClickAfterDragRef.current = false; }, []); const handleProjectTitleClick = useCallback( (event: React.MouseEvent, projectId: ProjectId) => { if (dragInProgressRef.current) { event.preventDefault(); event.stopPropagation(); return; } if (suppressProjectClickAfterDragRef.current) { // Consume the synthetic click emitted after a drag release. suppressProjectClickAfterDragRef.current = false; event.preventDefault(); event.stopPropagation(); return; } toggleProject(projectId); }, [toggleProject], ); const handleProjectTitleKeyDown = useCallback( (event: React.KeyboardEvent, projectId: ProjectId) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); if (dragInProgressRef.current) { return; } toggleProject(projectId); }, [toggleProject], ); useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { const activeThread = routeThreadId ? threads.find((thread) => thread.id === routeThreadId) : undefined; const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null; if (isChatNewLocalShortcut(event, keybindings)) { const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? projects[0]?.id; if (!projectId) return; event.preventDefault(); void handleNewThread(projectId); return; } if (!isChatNewShortcut(event, keybindings)) return; const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? projects[0]?.id; if (!projectId) return; event.preventDefault(); void handleNewThread(projectId, { branch: activeThread?.branch ?? activeDraftThread?.branch ?? null, worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null, envMode: activeDraftThread?.envMode ?? (activeThread?.worktreePath ? "worktree" : "local"), }); }; window.addEventListener("keydown", onWindowKeyDown); return () => { window.removeEventListener("keydown", onWindowKeyDown); }; }, [getDraftThread, handleNewThread, keybindings, projects, routeThreadId, threads]); 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 showDesktopUpdateButton = isElectron && shouldShowDesktopUpdateButton(desktopUpdateState); const desktopUpdateTooltip = desktopUpdateState ? getDesktopUpdateButtonTooltip(desktopUpdateState) : "Update available"; const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState); const desktopUpdateButtonAction = desktopUpdateState ? resolveDesktopUpdateButtonAction(desktopUpdateState) : "none"; const showArm64IntelBuildWarning = isElectron && shouldShowArm64IntelBuildWarning(desktopUpdateState); const arm64IntelBuildWarningDescription = desktopUpdateState && showArm64IntelBuildWarning ? getArm64IntelBuildWarningDescription(desktopUpdateState) : null; const desktopUpdateButtonInteractivityClasses = desktopUpdateButtonDisabled ? "cursor-not-allowed opacity-60" : "hover:bg-accent hover:text-foreground"; const desktopUpdateButtonClasses = desktopUpdateState?.status === "downloaded" ? "text-emerald-500" : desktopUpdateState?.status === "downloading" ? "text-sky-400" : shouldHighlightDesktopUpdateError(desktopUpdateState) ? "text-rose-500 animate-pulse" : "text-amber-500 animate-pulse"; const newThreadShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "chat.newLocal") ?? shortcutLabelForCommand(keybindings, "chat.new"), [keybindings], ); 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") { void bridge .installUpdate() .then((result) => { if (!shouldToastDesktopUpdateActionResult(result)) return; const actionError = getDesktopUpdateActionError(result); if (!actionError) return; toastManager.add({ type: "error", title: "Could not install update", description: actionError, }); }) .catch((error) => { toastManager.add({ type: "error", title: "Could not install update", description: error instanceof Error ? error.message : "An unexpected error occurred.", }); }); } }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]); const expandThreadListForProject = useCallback((projectId: ProjectId) => { setExpandedThreadListsByProject((current) => { if (current.has(projectId)) return current; const next = new Set(current); next.add(projectId); return next; }); }, []); const collapseThreadListForProject = useCallback((projectId: ProjectId) => { setExpandedThreadListsByProject((current) => { if (!current.has(projectId)) return current; const next = new Set(current); next.delete(projectId); return next; }); }, []); const wordmark = (
    Code {APP_STAGE_LABEL}
    ); return ( <> {isElectron ? ( <> {wordmark} {showDesktopUpdateButton && ( } /> {desktopUpdateTooltip} )} ) : ( {wordmark} )} {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( Intel build on Apple Silicon {arm64IntelBuildWarningDescription} {desktopUpdateButtonAction !== "none" ? ( ) : null} ) : null}
    Projects } > Add project
    {shouldShowProjectPathEntry && (
    {isElectron && ( )}
    { setNewCwd(event.target.value); setAddProjectError(null); }} onKeyDown={(event) => { if (event.key === "Enter") handleAddProject(); if (event.key === "Escape") { setAddingProject(false); setAddProjectError(null); } }} autoFocus />
    {addProjectError && (

    {addProjectError}

    )}
    )} project.id)} strategy={verticalListSortingStrategy} > {projects.map((project) => { const projectThreads = threads .filter((thread) => thread.projectId === project.id) .toSorted((a, b) => { const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); if (byDate !== 0) return byDate; return b.id.localeCompare(a.id); }); const isThreadListExpanded = expandedThreadListsByProject.has(project.id); const hasHiddenThreads = projectThreads.length > THREAD_PREVIEW_LIMIT; const visibleThreads = hasHiddenThreads && !isThreadListExpanded ? projectThreads.slice(0, THREAD_PREVIEW_LIMIT) : projectThreads; return ( {(dragHandleProps) => (
    handleProjectTitleClick(event, project.id)} onKeyDown={(event) => handleProjectTitleKeyDown(event, project.id)} onContextMenu={(event) => { event.preventDefault(); void handleProjectContextMenu(project.id, { x: event.clientX, y: event.clientY, }); }} > {project.name} } showOnHover className="top-1 right-1 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground" onClick={(event) => { event.preventDefault(); event.stopPropagation(); void handleNewThread(project.id); }} > } /> {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"}
    {visibleThreads.map((thread) => { const isActive = routeThreadId === thread.id; const threadStatus = resolveThreadStatusPill({ thread, hasPendingApprovals: pendingApprovalByThreadId.get(thread.id) === true, hasPendingUserInput: pendingUserInputByThreadId.get(thread.id) === true, }); const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null); const terminalStatus = terminalStatusFromRunningIds( selectThreadTerminalState(terminalStateByThreadId, thread.id) .runningTerminalIds, ); return ( } size="sm" isActive={isActive} className={`h-7 w-full translate-x-0 cursor-default justify-start px-2 text-left hover:bg-accent hover:text-foreground ${ isActive ? "bg-accent/85 text-foreground font-medium ring-1 ring-border/70 dark:bg-accent/55 dark:ring-border/50" : "text-muted-foreground" }`} onClick={() => { void navigate({ to: "/$threadId", params: { threadId: thread.id }, }); }} onKeyDown={(event) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); void navigate({ to: "/$threadId", params: { threadId: thread.id }, }); }} onContextMenu={(event) => { event.preventDefault(); void handleThreadContextMenu(thread.id, { x: event.clientX, y: event.clientY, }); }} >
    {prStatus && ( { openPrLink(event, prStatus.url); }} > } /> {prStatus.tooltip} )} {threadStatus && ( {threadStatus.label} )} {renamingThreadId === thread.id ? ( { if (el && renamingInputRef.current !== el) { renamingInputRef.current = el; el.focus(); el.select(); } }} className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" value={renamingTitle} onChange={(e) => setRenamingTitle(e.target.value)} onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Enter") { e.preventDefault(); renamingCommittedRef.current = true; void commitRename(thread.id, renamingTitle, thread.title); } else if (e.key === "Escape") { e.preventDefault(); renamingCommittedRef.current = true; cancelRename(); } }} onBlur={() => { if (!renamingCommittedRef.current) { void commitRename(thread.id, renamingTitle, thread.title); } }} onClick={(e) => e.stopPropagation()} /> ) : ( {thread.title} )}
    {terminalStatus && ( )} {formatRelativeTime(thread.createdAt)}
    ); })} {hasHiddenThreads && !isThreadListExpanded && ( } size="sm" className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" onClick={() => { expandThreadListForProject(project.id); }} > Show more )} {hasHiddenThreads && isThreadListExpanded && ( } size="sm" className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" onClick={() => { collapseThreadListForProject(project.id); }} > Show less )}
    )}
    ); })}
    {projects.length === 0 && !shouldShowProjectPathEntry && (
    No projects yet
    )}
    {isOnSettings ? ( window.history.back()} > Back ) : ( void navigate({ to: "/settings" })} > Settings )} ); }