import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; import { getHotkeyManager } from "@tanstack/react-hotkeys"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; import { DEFAULT_MODEL } from "../model-logic"; import { derivePendingApprovals } from "../session-logic"; import { useStore } from "../store"; import { isChatNewLocalShortcut, isChatNewShortcut, shortcutsForCommands } from "../keybindings"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, type Project, type Thread, } from "../types"; import { useNativeApi } from "../hooks/useNativeApi"; import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { toastManager } from "./ui/toast"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const; 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`; } function inferProjectName(cwd: string): string { const normalized = cwd.replace(/\\/g, "/").replace(/\/+$/, ""); const parts = normalized.split("/"); const last = parts[parts.length - 1]; return last && last.length > 0 ? last : "project"; } 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, }; } export default function Sidebar() { const { state, dispatch } = useStore(); const api = useNativeApi(); const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({ ...serverConfigQueryOptions(api), select: (config) => config.keybindings, }); const queryClient = useQueryClient(); const removeWorktreeMutation = useMutation( gitRemoveWorktreeMutationOptions({ api, queryClient }), ); const { theme, setTheme } = useTheme(); const [addingProject, setAddingProject] = useState(false); const [newCwd, setNewCwd] = useState(""); const [isPickingFolder, setIsPickingFolder] = useState(false); const [isAddingProject, setIsAddingProject] = useState(false); const pendingApprovalByThreadId = useMemo(() => { const map = new Map(); for (const thread of state.threads) { map.set(thread.id, derivePendingApprovals(thread.events).length > 0); } return map; }, [state.threads]); const handleNewThread = useCallback( ( projectId: string, options?: { branch?: string | null; worktreePath?: string | null; }, ) => { dispatch({ type: "ADD_THREAD", thread: { id: crypto.randomUUID(), codexThreadId: null, projectId, title: "New thread", model: state.projects.find((p) => p.id === projectId)?.model ?? DEFAULT_MODEL, terminalOpen: false, terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, terminalIds: [DEFAULT_THREAD_TERMINAL_ID], runningTerminalIds: [], activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, terminalGroups: [ { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID], }, ], activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, session: null, messages: [], events: [], error: null, createdAt: new Date().toISOString(), branch: options?.branch ?? null, worktreePath: options?.worktreePath ?? null, }, }); }, [dispatch, state.projects], ); const focusMostRecentThreadForProject = useCallback( (projectId: string) => { 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; dispatch({ type: "SET_ACTIVE_THREAD", threadId: latestThread.id, }); }, [dispatch, state.threads], ); const addProjectFromPath = useCallback( async (rawCwd: string) => { const cwd = rawCwd.trim(); if (!cwd || isAddingProject) return; setIsAddingProject(true); try { if (isElectron && api) { const result = await api.projects.add({ cwd }); const project: Project = { id: result.project.id, name: result.project.name, cwd: result.project.cwd, model: DEFAULT_MODEL, expanded: true, }; const existingById = state.projects.find((p) => p.id === project.id); const existingByCwd = state.projects.find((p) => p.cwd === project.cwd); if (!existingById && !existingByCwd) { dispatch({ type: "ADD_PROJECT", project }); } const resolvedProjectId = existingByCwd?.id ?? project.id; if (result.created) { handleNewThread(resolvedProjectId); } else { focusMostRecentThreadForProject(resolvedProjectId); } } else { const existing = state.projects.find((project) => project.cwd === cwd); if (existing) { focusMostRecentThreadForProject(existing.id); return; } const name = inferProjectName(cwd); const project: Project = { id: crypto.randomUUID(), name, cwd, model: DEFAULT_MODEL, expanded: true, }; dispatch({ type: "ADD_PROJECT", project }); handleNewThread(project.id); } } finally { setIsAddingProject(false); setNewCwd(""); setAddingProject(false); } }, [ api, dispatch, focusMostRecentThreadForProject, handleNewThread, isAddingProject, state.projects, ], ); const handleAddProject = () => { void addProjectFromPath(newCwd); }; const handlePickFolder = async () => { 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: string, position: { x: number; y: number }) => { 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; 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"), )); // Stop active session if running if (thread.session?.sessionId) { try { await api.providers.stopSession({ sessionId: thread.session.sessionId, }); } catch { // Session may already be stopped } } try { await api.terminal.close({ threadId, deleteHistory: true, }); } catch { // Terminal may already be closed } dispatch({ type: "DELETE_THREAD", threadId }); if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) { return; } try { await removeWorktreeMutation.mutateAsync({ cwd: threadProject.cwd, path: orphanedWorktreePath, }); } catch { // Worktree deletion is best-effort and should not block thread deletion. } }, [api, dispatch, removeWorktreeMutation, state.projects, state.threads], ); const handleProjectContextMenu = useCallback( async (projectId: string, position: { x: number; y: number }) => { 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; if (isElectron) { try { await api.projects.remove({ id: 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, }); return; } } dispatch({ type: "DELETE_PROJECT", projectId }); }, [api, dispatch, state.projects, state.threads], ); const chatHotkeys = useMemo( () => shortcutsForCommands(keybindings, ["chat.new", "chat.newLocal"]), [keybindings], ); useEffect(() => { const manager = getHotkeyManager(); const handles = chatHotkeys.map((hotkey) => manager.register( hotkey, (event) => { const activeThread = state.threads.find((t) => t.id === state.activeThreadId); if (isChatNewLocalShortcut(event, keybindings)) { const projectId = activeThread?.projectId ?? state.projects[0]?.id; if (!projectId) return; event.preventDefault(); handleNewThread(projectId); return; } if (!isChatNewShortcut(event, keybindings)) return; const projectId = activeThread?.projectId ?? state.projects[0]?.id; if (!projectId) return; event.preventDefault(); handleNewThread(projectId, { branch: activeThread?.branch ?? null, worktreePath: activeThread?.worktreePath ?? null, }); }, { conflictBehavior: "allow", ignoreInputs: false, preventDefault: false, stopPropagation: false, target: window, }, ), ); return () => { for (const handle of handles) { handle.unregister(); } }; }, [chatHotkeys, handleNewThread, keybindings, state.activeThreadId, state.projects, state.threads]); return ( ); }