"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 (
);
});
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