import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react";import { useCallback, useEffect, useMemo, useState } from "react";import { useMutation, 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 { 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 { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;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 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<string, boolean>(); for (const thread of state.threads) { map.set(thread.id, derivePendingApprovals(thread.events).length > 0); } return map; }, [state.threads]); const handleNewThread = useCallback( (projectId: string) => { 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: null, 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], ); useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { const isNewThreadShortcut = event.metaKey && event.shiftKey && !event.ctrlKey && !event.altKey && event.key.toLowerCase() === "o"; if (!isNewThreadShortcut) return; const activeThread = state.threads.find((t) => t.id === state.activeThreadId); const projectId = activeThread?.projectId ?? state.projects[0]?.id; if (!projectId) return; event.preventDefault(); handleNewThread(projectId); }; window.addEventListener("keydown", onWindowKeyDown); return () => { window.removeEventListener("keydown", onWindowKeyDown); }; }, [handleNewThread, state.activeThreadId, state.projects, state.threads]); return ( <aside className="sidebar flex h-full w-[260px] shrink-0 flex-col border-r border-border bg-card"> {/* Branding */} <div className={`flex items-center gap-2.5 px-4 ${isElectron ? "drag-region h-[52px] pl-[76px]" : "py-4"}`} > <span className="flex-1 text-sm font-semibold tracking-tight text-foreground"> T3 <span className="font-normal text-muted-foreground">Code</span> </span> <button type="button" className="rounded-md p-1.5 text-muted-foreground/80 transition-colors duration-150 hover:bg-accent hover:text-muted-foreground" onClick={() => setTheme(THEME_CYCLE[theme])} aria-label={`Theme: ${theme}`} title={`Theme: ${theme}`} > {theme === "system" ? ( <MonitorIcon className="size-3.5" /> ) : theme === "light" ? ( <SunIcon className="size-3.5" /> ) : ( <MoonIcon className="size-3.5" /> )} </button> </div> {/* New thread (global) */} <div className="px-3 pb-3"> <button type="button" className="flex w-full items-center gap-2 rounded-lg border border-border bg-secondary px-3 py-2 text-xs text-muted-foreground transition-colors duration-150 hover:bg-accent" onClick={() => { if (state.projects.length === 0) { setAddingProject(true); return; } const firstProject = state.projects[0]; if (firstProject) handleNewThread(firstProject.id); }} > <span className="text-foreground">+</span> New thread </button> </div> {/* Project list */} <nav className="flex-1 overflow-y-auto px-2"> {state.projects.map((project) => { const threads = state.threads .filter((t) => t.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 ( <div key={project.id} className="mb-1"> {/* Project header */} <button type="button" className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-150 hover:bg-accent" onClick={() => dispatch({ type: "TOGGLE_PROJECT", projectId: project.id })} > <span className="text-[10px] text-muted-foreground/70"> {project.expanded ? "▼" : "▶"} </span> <span className="flex-1 truncate text-xs font-medium text-foreground/90"> {project.name} </span> <span className="text-[10px] text-muted-foreground/60">{threads.length}</span> </button> {/* Threads */} {project.expanded && ( <div className="ml-2 border-l border-border/80 pl-2"> {threads.map((thread) => { const isActive = state.activeThreadId === thread.id; const threadStatus = threadStatusPill( thread, pendingApprovalByThreadId.get(thread.id) === true, ); const terminalStatus = terminalStatusIndicator(thread); return ( <button key={thread.id} type="button" className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-150 ${ isActive ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-secondary" }`} onClick={() => dispatch({ type: "SET_ACTIVE_THREAD", threadId: thread.id, }) } onContextMenu={(e) => { e.preventDefault(); void handleThreadContextMenu(thread.id, { x: e.clientX, y: e.clientY, }); }} > <div className="flex min-w-0 flex-1 items-center gap-1.5"> {threadStatus && ( <span className={`inline-flex items-center gap-1 text-[10px] ${threadStatus.colorClass}`} > <span className={`h-1.5 w-1.5 rounded-full ${threadStatus.dotClass} ${ threadStatus.pulse ? "animate-pulse" : "" }`} /> <span className="hidden md:inline">{threadStatus.label}</span> </span> )} <span className="min-w-0 flex-1 truncate text-xs">{thread.title}</span> </div> <div className="ml-2 flex shrink-0 items-center gap-1.5"> {terminalStatus && ( <span role="img" aria-label={terminalStatus.label} title={terminalStatus.label} className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`} > <TerminalIcon className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`} /> </span> )} <span className="text-[10px] text-muted-foreground/40"> {formatRelativeTime(thread.createdAt)} </span> </div> </button> ); })} {/* New thread within project */} <button type="button" className="flex w-full items-center gap-1 px-2 py-1 text-[10px] text-muted-foreground/60 transition-colors duration-150 hover:text-muted-foreground/80" onClick={() => handleNewThread(project.id)} > <span>+</span> New thread </button> </div> )} </div>Lines 1–500 of 570. Load more
import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useMutation, 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 { 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 { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const; 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 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<string, boolean>(); for (const thread of state.threads) { map.set(thread.id, derivePendingApprovals(thread.events).length > 0); } return map; }, [state.threads]); const handleNewThread = useCallback( (projectId: string) => { 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: null, 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], ); useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { const isNewThreadShortcut = event.metaKey && event.shiftKey && !event.ctrlKey && !event.altKey && event.key.toLowerCase() === "o"; if (!isNewThreadShortcut) return; const activeThread = state.threads.find((t) => t.id === state.activeThreadId); const projectId = activeThread?.projectId ?? state.projects[0]?.id; if (!projectId) return; event.preventDefault(); handleNewThread(projectId); }; window.addEventListener("keydown", onWindowKeyDown); return () => { window.removeEventListener("keydown", onWindowKeyDown); }; }, [handleNewThread, state.activeThreadId, state.projects, state.threads]); return ( <aside className="sidebar flex h-full w-[260px] shrink-0 flex-col border-r border-border bg-card"> {/* Branding */} <div className={`flex items-center gap-2.5 px-4 ${isElectron ? "drag-region h-[52px] pl-[76px]" : "py-4"}`} > <span className="flex-1 text-sm font-semibold tracking-tight text-foreground"> T3 <span className="font-normal text-muted-foreground">Code</span> </span> <button type="button" className="rounded-md p-1.5 text-muted-foreground/80 transition-colors duration-150 hover:bg-accent hover:text-muted-foreground" onClick={() => setTheme(THEME_CYCLE[theme])} aria-label={`Theme: ${theme}`} title={`Theme: ${theme}`} > {theme === "system" ? ( <MonitorIcon className="size-3.5" /> ) : theme === "light" ? ( <SunIcon className="size-3.5" /> ) : ( <MoonIcon className="size-3.5" /> )} </button> </div> {/* New thread (global) */} <div className="px-3 pb-3"> <button type="button" className="flex w-full items-center gap-2 rounded-lg border border-border bg-secondary px-3 py-2 text-xs text-muted-foreground transition-colors duration-150 hover:bg-accent" onClick={() => { if (state.projects.length === 0) { setAddingProject(true); return; } const firstProject = state.projects[0]; if (firstProject) handleNewThread(firstProject.id); }} > <span className="text-foreground">+</span> New thread </button> </div> {/* Project list */} <nav className="flex-1 overflow-y-auto px-2"> {state.projects.map((project) => { const threads = state.threads .filter((t) => t.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 ( <div key={project.id} className="mb-1"> {/* Project header */} <button type="button" className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-150 hover:bg-accent" onClick={() => dispatch({ type: "TOGGLE_PROJECT", projectId: project.id })} > <span className="text-[10px] text-muted-foreground/70"> {project.expanded ? "▼" : "▶"} </span> <span className="flex-1 truncate text-xs font-medium text-foreground/90"> {project.name} </span> <span className="text-[10px] text-muted-foreground/60">{threads.length}</span> </button> {/* Threads */} {project.expanded && ( <div className="ml-2 border-l border-border/80 pl-2"> {threads.map((thread) => { const isActive = state.activeThreadId === thread.id; const threadStatus = threadStatusPill( thread, pendingApprovalByThreadId.get(thread.id) === true, ); const terminalStatus = terminalStatusIndicator(thread); return ( <button key={thread.id} type="button" className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-150 ${ isActive ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-secondary" }`} onClick={() => dispatch({ type: "SET_ACTIVE_THREAD", threadId: thread.id, }) } onContextMenu={(e) => { e.preventDefault(); void handleThreadContextMenu(thread.id, { x: e.clientX, y: e.clientY, }); }} > <div className="flex min-w-0 flex-1 items-center gap-1.5"> {threadStatus && ( <span className={`inline-flex items-center gap-1 text-[10px] ${threadStatus.colorClass}`} > <span className={`h-1.5 w-1.5 rounded-full ${threadStatus.dotClass} ${ threadStatus.pulse ? "animate-pulse" : "" }`} /> <span className="hidden md:inline">{threadStatus.label}</span> </span> )} <span className="min-w-0 flex-1 truncate text-xs">{thread.title}</span> </div> <div className="ml-2 flex shrink-0 items-center gap-1.5"> {terminalStatus && ( <span role="img" aria-label={terminalStatus.label} title={terminalStatus.label} className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`} > <TerminalIcon className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`} /> </span> )} <span className="text-[10px] text-muted-foreground/40"> {formatRelativeTime(thread.createdAt)} </span> </div> </button> ); })} {/* New thread within project */} <button type="button" className="flex w-full items-center gap-1 px-2 py-1 text-[10px] text-muted-foreground/60 transition-colors duration-150 hover:text-muted-foreground/80" onClick={() => handleNewThread(project.id)} > <span>+</span> New thread </button> </div> )} </div>