import { ChevronRightIcon, TerminalIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { DEFAULT_MODEL, ProjectId, ThreadId, type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { useMutation, 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 { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; import { toastManager } from "./ui/toast"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; 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" | "Awaiting response"; colorClass: string; dotClass: string; pulse: boolean; } interface TerminalStatusIndicator { label: "Terminal process running"; colorClass: string; pulse: boolean; } function hasUnseenCompletion(thread: Thread): boolean { if (!thread.latestTurnCompletedAt) return false; const completedAt = Date.parse(thread.latestTurnCompletedAt); 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: "Awaiting response", 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 terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null { if (thread.runningTerminalIds.length === 0) { return null; } return { label: "Terminal process running", colorClass: "text-teal-600 dark:text-teal-300/90", pulse: true, }; } function T3Wordmark() { return ( ); } export default function Sidebar() { const { state, dispatch } = useStore(); 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 pendingApprovalByThreadId = useMemo(() => new Map(), []); const handleNewThread = useCallback( ( projectId: ProjectId, options?: { branch?: string | null; worktreePath?: string | null; }, ): Promise => { const api = readNativeApi(); if (!api) return Promise.resolve(); const threadId = newThreadId(); const createdAt = new Date().toISOString(); const model = state.projects.find((project) => project.id === projectId)?.model ?? DEFAULT_MODEL; return (async () => { await api.orchestration.dispatchCommand({ type: "thread.create", commandId: newCommandId(), threadId, projectId, title: "New thread", model, branch: options?.branch ?? null, worktreePath: options?.worktreePath ?? null, createdAt, }); // Ensure route guards can see the new thread before navigating. try { const snapshot = await api.orchestration.getSnapshot(); dispatch({ type: "SYNC_SERVER_READ_MODEL", readModel: snapshot }); } catch { // Event stream can still hydrate the thread shortly after dispatch. } await navigate({ to: "/$threadId", params: { threadId }, }); })(); }, [dispatch, navigate, state.projects], ); const focusMostRecentThreadForProject = useCallback( (projectId: ProjectId) => { const latestThread = state.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, state.threads], ); const addProjectFromPath = useCallback( async (rawCwd: string) => { const cwd = rawCwd.trim(); if (!cwd || isAddingProject) return; const api = readNativeApi(); setIsAddingProject(true); try { if (!api) return; const existing = state.projects.find((project) => project.cwd === cwd); if (existing) { focusMostRecentThreadForProject(existing.id); return; } const projectId = newProjectId(); const createdAt = new Date().toISOString(); const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd; await api.orchestration.dispatchCommand({ type: "project.create", commandId: newCommandId(), projectId, title, workspaceRoot: cwd, defaultModel: DEFAULT_MODEL, createdAt, }); await handleNewThread(projectId); } finally { setIsAddingProject(false); setNewCwd(""); setAddingProject(false); } }, [focusMostRecentThreadForProject, handleNewThread, isAddingProject, state.projects], ); const handleAddProject = () => { void addProjectFromPath(newCwd); }; const handlePickFolder = async () => { const api = readNativeApi(); if (!api || isPickingFolder) return; setIsPickingFolder(true); try { const pickedPath = await api.dialogs.pickFolder(); if (!pickedPath) return; await addProjectFromPath(pickedPath); } finally { setIsPickingFolder(false); } }; const handleThreadContextMenu = useCallback( async (threadId: ThreadId, position: { x: number; y: number }) => { const api = readNativeApi(); if (!api) return; const clicked = await api.contextMenu.show([{ id: "delete", label: "Delete" }], position); if (clicked !== "delete") return; const thread = state.threads.find((t) => t.id === threadId); if (!thread) 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 = state.projects.find((project) => project.id === thread.projectId); const orphanedWorktreePath = getOrphanedWorktreePathForThread(state.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 = state.threads.find((entry) => entry.id !== threadId)?.id ?? null; await api.orchestration.dispatchCommand({ type: "thread.delete", commandId: newCommandId(), 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, navigate, removeWorktreeMutation, routeThreadId, state.projects, state.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" }], position); if (clicked !== "delete") return; const project = state.projects.find((entry) => entry.id === projectId); if (!project) return; const projectThreads = state.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 { 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, }); } }, [state.projects, state.threads], ); useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { const activeThread = routeThreadId ? state.threads.find((thread) => thread.id === routeThreadId) : undefined; if (isChatNewLocalShortcut(event, keybindings)) { const projectId = activeThread?.projectId ?? state.projects[0]?.id; if (!projectId) return; event.preventDefault(); void handleNewThread(projectId); return; } if (!isChatNewShortcut(event, keybindings)) return; const projectId = activeThread?.projectId ?? state.projects[0]?.id; if (!projectId) return; event.preventDefault(); void handleNewThread(projectId, { branch: activeThread?.branch ?? null, worktreePath: activeThread?.worktreePath ?? null, }); }; window.addEventListener("keydown", onWindowKeyDown); return () => { window.removeEventListener("keydown", onWindowKeyDown); }; }, [handleNewThread, keybindings, routeThreadId, state.projects, state.threads]); const onCreateThreadClick = () => { if (state.projects.length === 0) { setAddingProject(true); return; } const firstProject = state.projects[0]; if (firstProject) void handleNewThread(firstProject.id); }; const wordmark = (
Code {APP_STAGE_LABEL}
); const newThreadButton = ( ); return ( <> {isElectron ? ( <> {wordmark}
{newThreadButton}
) : ( {wordmark} {newThreadButton} )} {state.projects.map((project) => { const threads = state.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; dispatch({ type: "TOGGLE_PROJECT", projectId: project.id }); }} > } onContextMenu={(event) => { event.preventDefault(); void handleProjectContextMenu(project.id, { x: event.clientX, y: event.clientY, }); }} > {project.name} {threads.length} {threads.map((thread) => { const isActive = routeThreadId === thread.id; const threadStatus = threadStatusPill( thread, pendingApprovalByThreadId.get(thread.id) === true, ); const terminalStatus = terminalStatusIndicator(thread); return ( } size="sm" isActive={isActive} className={`h-7 w-full translate-x-0 justify-start px-2 text-left hover:bg-accent hover:text-foreground ${ isActive ? "text-foreground" : "text-muted-foreground" }`} onClick={() => { void navigate({ to: "/$threadId", params: { threadId: thread.id }, }); }} onContextMenu={(event) => { event.preventDefault(); void handleThreadContextMenu(thread.id, { x: event.clientX, y: event.clientY, }); }} >
{threadStatus && ( {threadStatus.label} )} {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
); })}
{state.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 && ( )}
) : ( )}
); }