"use client"; import Link from "next/link"; import { useState, useEffect, useMemo, useRef, useCallback, memo } from "react"; import { useRouter } from "next/navigation"; import { cn } from "@/lib/cn"; import type { ProjectInfo } from "@/lib/project-name"; import { getAttentionLevel, type DashboardSession } from "@/lib/types"; import { isOrchestratorSession } from "@aoagents/ao-core/types"; import { getSessionTitle, humanizeBranch } from "@/lib/format"; import { usePopoverClamp } from "@/hooks/usePopoverClamp"; import { projectDashboardPath, projectReviewPath, projectSessionPath } from "@/lib/routes"; import { ThemeToggle } from "./ThemeToggle"; import { AddProjectModal } from "./AddProjectModal"; import { ProjectSettingsModal } from "./ProjectSettingsModal"; /** Minimal shape needed to render an orchestrator link in the sidebar. */ export interface ProjectSidebarOrchestrator { id: string; projectId: string; } interface ProjectSidebarProps { projects: ProjectInfo[]; sessions: DashboardSession[] | null; /** * Per-project orchestrator link. Sourced upstream from `/api/sessions` * (the `orchestrators` field), which already applies the canonical * "prefer live, fall back to terminal" selection. Not derivable from * `sessions`: the sessions endpoint strips orchestrators out. */ orchestrators?: ProjectSidebarOrchestrator[]; activeProjectId: string | undefined; activeSessionId: string | undefined; loading?: boolean; error?: boolean; onRetry?: () => void; collapsed?: boolean; onToggleCollapsed?: () => void; onMobileClose?: () => void; } type SessionDotLevel = "respond" | "review" | "action" | "pending" | "working" | "merge" | "done"; const SessionDot = memo(function SessionDot({ level }: { level: SessionDotLevel }) { return (
); }); // ProjectSidebar consumes `getAttentionLevel()` without passing a mode, // so the function defaults to "detailed" and `action` never appears here // in practice. The entry is kept for exhaustiveness — TypeScript requires // every `AttentionLevel` variant to be present in this `Record` — and // as forward-compat in case the sidebar ever opts into simple mode. const SHOW_SESSION_ID_KEY = "ao:sidebar:show-session-id"; function loadShowSessionId(): boolean { if (typeof window === "undefined") return false; try { return window.localStorage.getItem(SHOW_SESSION_ID_KEY) === "true"; } catch { return false; } } const SHOW_KILLED_KEY = "ao:sidebar:show-killed"; const SHOW_DONE_KEY = "ao:sidebar:show-done"; const EXPANDED_PROJECTS_KEY = "ao:sidebar:expanded-projects"; function loadShowKilled(): boolean { if (typeof window === "undefined") return false; try { return window.sessionStorage.getItem(SHOW_KILLED_KEY) === "true"; } catch { return false; } } function loadShowDone(): boolean { if (typeof window === "undefined") return false; try { return window.sessionStorage.getItem(SHOW_DONE_KEY) === "true"; } catch { return false; } } function loadExpandedProjects(): Set | null { if (typeof window === "undefined") return null; try { const raw = window.sessionStorage.getItem(EXPANDED_PROJECTS_KEY); if (!raw) return null; const parsed = JSON.parse(raw); if (Array.isArray(parsed)) return new Set(parsed); return null; } catch { return null; } } export function ProjectSidebar(props: ProjectSidebarProps) { if (props.projects.length === 0) { return ; } return ; } interface SessionRowProps { session: DashboardSession; level: SessionDotLevel; isActive: boolean; showSessionId: boolean; pendingRename: string | undefined; onNavigate: (href: string, session: DashboardSession) => void; onStartRename: (session: DashboardSession, title: string) => void; } const SessionRow = memo(function SessionRow({ session, level, isActive, showSessionId, pendingRename, onNavigate, onStartRename, }: SessionRowProps) { const effectiveDisplayName = pendingRename !== undefined ? pendingRename : session.displayNameUserSet ? (session.displayName ?? "") : ""; const title = effectiveDisplayName !== "" ? effectiveDisplayName : (session.branch ?? getSessionTitle(session)); const sessionHref = projectSessionPath(session.projectId, session.id); return (
{ if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return; e.preventDefault(); onNavigate(sessionHref, session); }} className="project-sidebar__sess-link flex flex-1 min-w-0 items-center gap-[7px]" aria-current={isActive ? "page" : undefined} aria-label={`Open ${title}`} >
{title} {showSessionId ? (
{session.id}
) : null}
); }); function ProjectSidebarEmpty({ collapsed = false }: { collapsed?: boolean }) { const [addProjectOpen, setAddProjectOpen] = useState(false); if (collapsed) { return ( ); } return ( ); } function ProjectSidebarInner({ projects, sessions, orchestrators, activeProjectId, activeSessionId, loading = false, error = false, onRetry, collapsed = false, onToggleCollapsed: _onToggleCollapsed, onMobileClose, }: ProjectSidebarProps) { const router = useRouter(); const _isLoading = loading || sessions === null; const [expandedProjects, setExpandedProjects] = useState>( () => loadExpandedProjects() ?? new Set(activeProjectId && activeProjectId !== "all" ? [activeProjectId] : []), ); const [showKilled, setShowKilled] = useState(loadShowKilled); const [showDone, setShowDone] = useState(loadShowDone); const [showSessionId, setShowSessionId] = useState(loadShowSessionId); // Inline session-rename state. Only one row is edited at a time. `pendingRenames` // mirrors the in-flight / just-saved value so the new label appears immediately // without waiting for the next SSE refresh. const [editingSessionId, setEditingSessionId] = useState(null); const [editingValue, setEditingValue] = useState(""); const [pendingRenames, setPendingRenames] = useState>(new Map()); const [settingsOpen, setSettingsOpen] = useState(false); const [projectMenuOpenId, setProjectMenuOpenId] = useState(null); const [projectSettingsProjectId, setProjectSettingsProjectId] = useState(null); const [deletingProjectId, setDeletingProjectId] = useState(null); const [removedProjectIds, setRemovedProjectIds] = useState>(new Set()); const [addProjectOpen, setAddProjectOpen] = useState(false); const settingsRef = useRef(null); const settingsPopoverRef = useRef(null); const projectMenuRef = useRef(null); const projectMenuPopoverRef = useRef(null); usePopoverClamp(settingsOpen, settingsPopoverRef); usePopoverClamp(Boolean(projectMenuOpenId), projectMenuPopoverRef); // Persist the session-id preference across reloads. useEffect(() => { try { window.localStorage.setItem(SHOW_SESSION_ID_KEY, String(showSessionId)); } catch { // localStorage unavailable — accept the in-memory state for this session. } }, [showSessionId]); useEffect(() => { try { window.sessionStorage.setItem(SHOW_KILLED_KEY, String(showKilled)); } catch { // sessionStorage unavailable — accept in-memory state. } }, [showKilled]); useEffect(() => { try { window.sessionStorage.setItem(SHOW_DONE_KEY, String(showDone)); } catch { // sessionStorage unavailable — accept in-memory state. } }, [showDone]); useEffect(() => { try { window.sessionStorage.setItem(EXPANDED_PROJECTS_KEY, JSON.stringify([...expandedProjects])); } catch { // sessionStorage unavailable — accept in-memory state. } }, [expandedProjects]); // Close the settings popover on outside click or Escape. useEffect(() => { if (!settingsOpen) return; const handlePointer = (e: MouseEvent) => { if (settingsRef.current && !settingsRef.current.contains(e.target as Node)) { setSettingsOpen(false); } }; const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") setSettingsOpen(false); }; document.addEventListener("mousedown", handlePointer); document.addEventListener("keydown", handleKey); return () => { document.removeEventListener("mousedown", handlePointer); document.removeEventListener("keydown", handleKey); }; }, [settingsOpen]); useEffect(() => { if (!projectMenuOpenId) return; const handlePointer = (e: MouseEvent) => { if (projectMenuRef.current && !projectMenuRef.current.contains(e.target as Node)) { setProjectMenuOpenId(null); } }; const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") setProjectMenuOpenId(null); }; document.addEventListener("mousedown", handlePointer); document.addEventListener("keydown", handleKey); return () => { document.removeEventListener("mousedown", handlePointer); document.removeEventListener("keydown", handleKey); }; }, [projectMenuOpenId]); useEffect(() => { if (activeProjectId && activeProjectId !== "all") { setExpandedProjects((prev) => new Set([...prev, activeProjectId])); } }, [activeProjectId]); useEffect(() => { setRemovedProjectIds((prev) => { if (prev.size === 0) return prev; const next = new Set( [...prev].filter((projectId) => !projects.some((project) => project.id === projectId)), ); return next.size === prev.size ? prev : next; }); }, [projects]); const visibleProjects = useMemo( () => projects.filter((project) => !removedProjectIds.has(project.id)), [projects, removedProjectIds], ); const prefixByProject = useMemo( () => new Map(visibleProjects.map((p) => [p.id, p.sessionPrefix ?? p.id])), [visibleProjects], ); const allPrefixes = useMemo( () => visibleProjects.map((p) => p.sessionPrefix ?? p.id), [visibleProjects], ); const orchestratorByProject = useMemo( () => new Map((orchestrators ?? []).map((o) => [o.projectId, o])), [orchestrators], ); // Stable ref so sessionsByProject can read latest sessions without depending // on the array reference (which changes every SSE tick even when content is unchanged). const sessionsRef = useRef(sessions); sessionsRef.current = sessions; // Content-based key — only changes when session IDs, statuses, or projects change. // Used as the sole sessions-related dependency of sessionsByProject below. const sessionsKey = useMemo( () => (sessions ?? []) .map( (s) => `${s.id}:${s.status}:${s.activity ?? ""}:${s.projectId}:${s.displayName ?? ""}:${s.displayNameUserSet ? "1" : "0"}:${s.branch ?? ""}:${s.issueTitle ?? ""}:${s.pr?.title ?? ""}:${s.summary ?? ""}`, ) .join("|"), [sessions], ); const sessionsByProject = useMemo(() => { const map = new Map(); // Build a set of valid project IDs to filter sessions strictly const validProjectIds = new Set(visibleProjects.map((p) => p.id)); // Read via ref so this memo only reruns when sessionsKey changes (content // changed), not when sessions gets a new array reference with identical data. for (const s of sessionsRef.current ?? []) { // Only include sessions whose projectId matches a configured project if (!validProjectIds.has(s.projectId)) continue; if (isOrchestratorSession(s, prefixByProject.get(s.projectId), allPrefixes)) continue; // Keep terminal sessions visible when they still need human attention. // Otherwise ACTION-column cards disappear from the sidebar just because // their runtime has ended. const level = getAttentionLevel(s); if (level === "done") { if (s.status === "killed" ? !showKilled && !showDone : !showDone) continue; } const list = map.get(s.projectId) ?? []; list.push(s); map.set(s.projectId, list); } return map; }, [sessionsKey, prefixByProject, allPrefixes, visibleProjects, showKilled, showDone]); // Clear an optimistic rename once the prop session.displayName catches up. // Without this, we'd keep masking the server value forever after a save. useEffect(() => { if (pendingRenames.size === 0 || !sessions) return; const next = new Map(pendingRenames); let changed = false; for (const session of sessions) { const pending = next.get(session.id); if (pending !== undefined && (session.displayName ?? "") === pending) { next.delete(session.id); changed = true; } } if (changed) setPendingRenames(next); }, [sessions, pendingRenames]); const pendingRenamesRef = useRef(pendingRenames); pendingRenamesRef.current = pendingRenames; const startRename = useCallback( (session: DashboardSession, currentTitle: string) => { // Prefer the in-flight optimistic value over the prop — if the user opens // rename while a previous PATCH is still propagating, the prop still shows // the pre-rename value but we want the input to start from the latest. // Auto-derived displayName isn't pre-filled (user-set flag absent) — start // from the live title so the user types over the visible label. const pending = pendingRenamesRef.current.get(session.id); const initial = pending ?? (session.displayNameUserSet ? (session.displayName ?? "") : ""); setEditingSessionId(session.id); setEditingValue(initial || currentTitle); }, [], ); const cancelRename = () => { setEditingSessionId(null); setEditingValue(""); }; const submitRename = async (sessionId: string) => { // Guard against double-submit. submitRename is wired to both Enter (which // unmounts the input) and onBlur (which can fire during that unmount in // some browsers); without this, both paths would fire a PATCH. if (editingSessionId !== sessionId) return; // Trim, but allow empty — empty means "revert to default" on the server. const next = editingValue.trim(); setEditingSessionId(null); setEditingValue(""); setPendingRenames((prev) => { const map = new Map(prev); map.set(sessionId, next); return map; }); try { const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ displayName: next === "" ? null : next }), }); if (!res.ok) { const body = (await res.json().catch(() => null)) as { error?: string } | null; throw new Error(body?.error ?? "Failed to rename session"); } } catch { // Roll back the optimistic update so the row reverts to the prop value. // The user sees the original name return — no further notification is // needed for this niche failure path. setPendingRenames((prev) => { const map = new Map(prev); map.delete(sessionId); return map; }); } }; const navigate = useCallback( (url: string, session?: DashboardSession) => { if (session) { try { sessionStorage.setItem(`ao-session-nav:${session.id}`, JSON.stringify(session)); } catch { // sessionStorage unavailable — silent fallback } } router.push(url); onMobileClose?.(); }, [router, onMobileClose], ); const toggleExpand = (projectId: string) => { setExpandedProjects((prev) => { const next = new Set(prev); if (next.has(projectId)) { next.delete(projectId); } else { next.add(projectId); } return next; }); }; const handleRemoveProject = async (project: ProjectInfo) => { const confirmed = window.confirm( `Remove project ${project.name} from AO? This clears its AO sessions/history and removes it from the portfolio, but keeps the repository folder on disk.`, ); if (!confirmed) return; setDeletingProjectId(project.id); try { const response = await fetch(`/api/projects/${encodeURIComponent(project.id)}`, { method: "DELETE", }); const body = await response.json().catch(() => null); if (!response.ok) { throw new Error( (body && typeof body === "object" && "error" in body && typeof body.error === "string" ? body.error : null) ?? "Failed to remove project.", ); } setRemovedProjectIds((prev) => new Set(prev).add(project.id)); setExpandedProjects((prev) => { const next = new Set(prev); next.delete(project.id); return next; }); setProjectMenuOpenId(null); if (activeProjectId === project.id) { router.push("/"); } else if ("refresh" in router && typeof router.refresh === "function") { router.refresh(); } onMobileClose?.(); } catch (error) { window.alert(error instanceof Error ? error.message : "Failed to remove project."); } finally { setDeletingProjectId(null); } }; if (collapsed) { return ( ); } return ( ); }