import { ChevronRightIcon, FolderIcon, GitPullRequestIcon, RocketIcon, TerminalIcon, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { DEFAULT_MODEL, type DesktopUpdateState, ProjectId, ThreadId, type GitStatusResult, type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { 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 } from "../keybindings"; import { type Thread } from "../types"; import { derivePendingApprovals } from "../session-logic"; import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { toastManager } from "./ui/toast"; import { getDesktopUpdateActionError, getDesktopUpdateButtonTooltip, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, shouldHighlightDesktopUpdateError, shouldShowDesktopUpdateButton, shouldToastDesktopUpdateActionResult, } from "./desktopUpdate.logic"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { SidebarContent, SidebarFooter, SidebarGroup, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, SidebarSeparator, SidebarTrigger, } from "./ui/sidebar"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { isNonEmpty as isNonEmptyString } from "effect/String"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; 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 ThreadStatusPill { label: "Working" | "Connecting" | "Completed" | "Pending Approval"; colorClass: string; dotClass: string; pulse: boolean; } 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 hasUnseenCompletion(thread: Thread): boolean { if (!thread.latestTurn?.completedAt) return false; const completedAt = Date.parse(thread.latestTurn.completedAt); if (Number.isNaN(completedAt)) return false; if (!thread.lastVisitedAt) return true; const lastVisitedAt = Date.parse(thread.lastVisitedAt); if (Number.isNaN(lastVisitedAt)) return true; return completedAt > lastVisitedAt; } function threadStatusPill(thread: Thread, hasPendingApprovals: boolean): ThreadStatusPill | null { if (hasPendingApprovals) { return { label: "Pending Approval", colorClass: "text-amber-600 dark:text-amber-300/90", dotClass: "bg-amber-500 dark:bg-amber-300/90", pulse: false, }; } if (thread.session?.status === "running") { return { label: "Working", colorClass: "text-sky-600 dark:text-sky-300/80", dotClass: "bg-sky-500 dark:bg-sky-300/80", pulse: true, }; } if (thread.session?.status === "connecting") { return { label: "Connecting", colorClass: "text-sky-600 dark:text-sky-300/80", dotClass: "bg-sky-500 dark:bg-sky-300/80", pulse: true, }; } if (hasUnseenCompletion(thread)) { return { label: "Completed", colorClass: "text-emerald-600 dark:text-emerald-300/90", dotClass: "bg-emerald-500 dark:bg-emerald-300/90", pulse: false, }; } return null; } 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 ( ); } /** * Derives the server's HTTP origin (scheme + host + port) from the same * sources WsTransport uses, converting ws(s) to http(s). */ function getServerHttpOrigin(): string { const bridgeUrl = window.desktopBridge?.getWsUrl(); const envUrl = import.meta.env.VITE_WS_URL as string | undefined; const wsUrl = bridgeUrl && bridgeUrl.length > 0 ? bridgeUrl : envUrl && envUrl.length > 0 ? envUrl : `ws://${window.location.hostname}:${window.location.port}`; // Parse to extract just the origin, dropping path/query (e.g. ?token=…) const httpUrl = wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:"); try { return new URL(httpUrl).origin; } catch { return httpUrl; } } const serverHttpOrigin = getServerHttpOrigin(); 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")} /> ); } 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 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 { 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 [renamingThreadId, setRenamingThreadId] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); const renamingCommittedRef = useRef(false); const renamingInputRef = useRef(null); const [desktopUpdateState, setDesktopUpdateState] = useState(null); 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 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", }); 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(""); 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; const projectCreated = await api.orchestration .dispatchCommand({ type: "project.create", commandId: newCommandId(), projectId, title, workspaceRoot: cwd, defaultModel: DEFAULT_MODEL, createdAt, }) .then(() => true) .catch(() => false); if (projectCreated) { await handleNewThread(projectId).catch(() => undefined); } finishAddingProject(); }, [focusMostRecentThreadForProject, handleNewThread, isAddingProject, projects], ); const handleAddProject = () => { void addProjectFromPath(newCwd); }; 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); } setIsPickingFolder(false); }; 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: "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 !== "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, ], ); 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 onCreateThreadClick = () => { if (projects.length === 0) { setAddingProject(true); return; } const firstProject = projects[0]; if (firstProject) void handleNewThread(firstProject.id); }; const showDesktopUpdateButton = isElectron && shouldShowDesktopUpdateButton(desktopUpdateState); const desktopUpdateTooltip = desktopUpdateState ? getDesktopUpdateButtonTooltip(desktopUpdateState) : "Update available"; const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState); const desktopUpdateButtonAction = desktopUpdateState ? resolveDesktopUpdateButtonAction(desktopUpdateState) : "none"; 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 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 wordmark = (
Code {APP_STAGE_LABEL}
); const newThreadButton = ( ); return ( <> {isElectron ? ( <> {wordmark} {showDesktopUpdateButton && ( } /> {desktopUpdateTooltip} )}
{newThreadButton}
) : ( {wordmark} {newThreadButton} )} {projects.map((project) => { const visibleThreads = 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); }); return ( { if (open === project.expanded) return; toggleProject(project.id); }} > } onContextMenu={(event) => { event.preventDefault(); void handleProjectContextMenu(project.id, { x: event.clientX, y: event.clientY, }); }} > {project.name} {visibleThreads.length} {visibleThreads.map((thread) => { const isActive = routeThreadId === thread.id; const threadStatus = threadStatusPill( thread, pendingApprovalByThreadId.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)}
); })} } 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={() => { void handleNewThread(project.id); }} > + New thread
); })}
{projects.length === 0 && !addingProject && (
No projects yet.
Add one to get started.
)}
{addingProject ? ( <>

Add project

setNewCwd(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") handleAddProject(); if (event.key === "Escape") setAddingProject(false); }} /> {isElectron && ( )}
) : ( )}
); }