agent-orchestrator/packages/web/src/components/ProjectSidebar.tsx

393 lines
14 KiB
TypeScript

"use client";
import { useState, useEffect, useMemo } from "react";
import { useRouter, usePathname } from "next/navigation";
import { cn } from "@/lib/cn";
import type { ProjectInfo } from "@/lib/project-name";
import { getAttentionLevel, type DashboardSession, type AttentionLevel } from "@/lib/types";
import { isOrchestratorSession } from "@composio/ao-core/types";
import { getSessionTitle } from "@/lib/format";
interface ProjectSidebarProps {
projects: ProjectInfo[];
sessions: DashboardSession[];
activeProjectId: string | undefined;
activeSessionId: string | undefined;
collapsed?: boolean;
onToggleCollapsed?: () => void;
mobileOpen?: boolean;
onMobileClose?: () => void;
}
type ProjectHealth = "red" | "yellow" | "green" | "gray";
function computeProjectHealth(sessions: DashboardSession[]): ProjectHealth {
const workers = sessions.filter((s) => !isOrchestratorSession(s));
if (workers.length === 0) return "gray";
for (const s of workers) {
if (getAttentionLevel(s) === "respond") return "red";
}
for (const s of workers) {
const lvl = getAttentionLevel(s);
if (lvl === "review" || lvl === "pending") return "yellow";
}
return "green";
}
const healthDotColor: Record<ProjectHealth, string> = {
red: "var(--color-status-error)",
yellow: "var(--color-status-attention)",
green: "var(--color-status-ready)",
gray: "var(--color-text-tertiary)",
};
const sessionDotColor: Record<AttentionLevel, string> = {
merge: "var(--color-status-ready)",
respond: "var(--color-status-error)",
review: "var(--color-accent-orange)",
pending: "var(--color-status-attention)",
working: "var(--color-status-working)",
done: "var(--color-text-tertiary)",
};
const sessionToneLabel: Record<AttentionLevel, string> = {
merge: "merge",
respond: "reply",
review: "review",
pending: "wait",
working: "live",
done: "done",
};
function SessionDot({ level }: { level: AttentionLevel }) {
return (
<div
className={cn(
"h-[7px] w-[7px] shrink-0 rounded-full",
level === "respond" && "animate-[activity-pulse_2s_ease-in-out_infinite]",
)}
style={{ background: sessionDotColor[level] }}
/>
);
}
function HealthDot({ health }: { health: ProjectHealth }) {
return (
<div
className={cn(
"h-2 w-2 shrink-0 rounded-full",
health === "red" && "animate-[activity-pulse_2s_ease-in-out_infinite]",
)}
style={{ background: healthDotColor[health] }}
/>
);
}
export function ProjectSidebar(props: ProjectSidebarProps) {
if (props.projects.length <= 1) {
return null;
}
return <ProjectSidebarInner {...props} />;
}
function ProjectSidebarInner({
projects,
sessions,
activeProjectId,
activeSessionId,
collapsed = false,
onToggleCollapsed,
mobileOpen = false,
onMobileClose,
}: ProjectSidebarProps) {
const router = useRouter();
const pathname = usePathname();
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(
() => new Set(activeProjectId && activeProjectId !== "all" ? [activeProjectId] : []),
);
useEffect(() => {
if (activeProjectId && activeProjectId !== "all") {
setExpandedProjects((prev) => new Set([...prev, activeProjectId]));
}
}, [activeProjectId]);
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 handleProjectHeaderClick = (projectId: string) => {
toggleExpand(projectId);
router.push(pathname + `?project=${encodeURIComponent(projectId)}`);
};
const sessionsByProject = useMemo(() => {
const map = new Map<string, { all: DashboardSession[]; workers: DashboardSession[] }>();
let totalWorkers = 0;
let needsInput = 0;
let reviewLoad = 0;
for (const s of sessions) {
let entry = map.get(s.projectId);
if (!entry) {
entry = { all: [], workers: [] };
map.set(s.projectId, entry);
}
entry.all.push(s);
if (!isOrchestratorSession(s)) {
entry.workers.push(s);
totalWorkers++;
}
const lvl = getAttentionLevel(s);
if (lvl === "respond") needsInput++;
if (lvl === "review" || lvl === "pending") reviewLoad++;
}
return { map, totalWorkers, needsInput, reviewLoad };
}, [sessions]);
const { totalWorkers: totalWorkerSessions, needsInput: needsInputCount, reviewLoad: reviewLoadCount } = sessionsByProject;
if (collapsed) {
return (
<aside className={cn("project-sidebar project-sidebar--collapsed flex h-full w-[56px] flex-col items-center py-3", mobileOpen && "project-sidebar--mobile-open")}>
<div className="flex flex-1 flex-col items-center gap-2">
{projects.map((project) => {
const entry = sessionsByProject.map.get(project.id);
const health = entry ? computeProjectHealth(entry.all) : ("gray" as ProjectHealth);
const isActive = activeProjectId === project.id;
const initial = project.name.charAt(0).toUpperCase();
return (
<button
key={project.id}
type="button"
onClick={() => router.push(pathname + `?project=${encodeURIComponent(project.id)}`)}
className={cn(
"project-sidebar__collapsed-project",
isActive && "project-sidebar__collapsed-project--active",
)}
title={project.name}
>
<span className="project-sidebar__avatar">{initial}</span>
{health !== "gray" && (
<span
className={cn(
"project-sidebar__health-indicator",
health === "red" && "animate-[activity-pulse_2s_ease-in-out_infinite]",
)}
style={{ background: healthDotColor[health] }}
/>
)}
</button>
);
})}
</div>
<button
type="button"
onClick={onToggleCollapsed}
className="project-sidebar__collapsed-toggle mt-auto"
aria-label="Show project sidebar"
>
<svg
fill="none"
stroke="currentColor"
strokeWidth="1.8"
viewBox="0 0 24 24"
className="h-4 w-4"
>
<rect x="3.5" y="4.5" width="17" height="15" rx="2" />
<path d="M9 4.5v15M12 10l3 2-3 2" />
</svg>
</button>
</aside>
);
}
return (
<>
{mobileOpen && (
<div className="sidebar-mobile-backdrop" onClick={onMobileClose} />
)}
<aside className={cn("project-sidebar flex h-full w-[244px] flex-col", mobileOpen && "project-sidebar--mobile-open")}>
<div className="project-sidebar__header px-4 pb-3 pt-4">
<div className="project-sidebar__eyebrow">Portfolio</div>
<div className="project-sidebar__title-row">
<div>
<h2 className="project-sidebar__title">Projects</h2>
<p className="project-sidebar__subtitle">Live project overview.</p>
</div>
<div className="project-sidebar__badge">{projects.length}</div>
</div>
<div className="project-sidebar__summary">
<div className="project-sidebar__metric">
<span className="project-sidebar__metric-value">{totalWorkerSessions}</span>
<span className="project-sidebar__metric-label">active</span>
</div>
<div className="project-sidebar__metric">
<span
className="project-sidebar__metric-value"
style={{ color: "var(--color-status-attention)" }}
>
{reviewLoadCount}
</span>
<span className="project-sidebar__metric-label">review</span>
</div>
<div className="project-sidebar__metric">
<span
className="project-sidebar__metric-value"
style={{ color: "var(--color-status-error)" }}
>
{needsInputCount}
</span>
<span className="project-sidebar__metric-label">blocked</span>
</div>
</div>
</div>
<nav className="flex-1 overflow-y-auto px-2 pb-3">
<button
onClick={() => router.push(pathname + "?project=all")}
className={cn(
"project-sidebar__item mb-1 flex w-full items-center gap-2 px-2.5 py-[9px] text-left text-[12px] font-medium transition-colors",
activeProjectId === undefined || activeProjectId === "all"
? "project-sidebar__item--active text-[var(--color-accent)]"
: "text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]",
)}
>
<svg
className="h-3.5 w-3.5 shrink-0 opacity-50"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
viewBox="0 0 24 24"
>
<path d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
</svg>
All Projects
</button>
<div className="project-sidebar__divider mx-2 my-2" />
{projects.map((project) => {
const entry = sessionsByProject.map.get(project.id);
const projectSessions = entry?.all ?? [];
const workerSessions = entry?.workers ?? [];
const health = computeProjectHealth(projectSessions);
const isExpanded = expandedProjects.has(project.id);
const isActive = activeProjectId === project.id;
return (
<div key={project.id} className="mb-0.5">
{/* Project header */}
<button
onClick={() => handleProjectHeaderClick(project.id)}
className={cn(
"project-sidebar__item flex w-full items-center gap-2 px-2.5 py-[9px] text-left text-[12px] font-medium transition-colors",
isActive
? "project-sidebar__item--active text-[var(--color-accent)]"
: "text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]",
)}
>
<svg
className={cn(
"h-3 w-3 shrink-0 opacity-40 transition-transform duration-150",
isExpanded && "rotate-90",
)}
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="m9 18 6-6-6-6" />
</svg>
<HealthDot health={health} />
<span className="min-w-0 flex-1 truncate">{project.name}</span>
{workerSessions.length > 0 && (
<span className="project-sidebar__count shrink-0 px-1.5 py-px text-[10px] tabular-nums text-[var(--color-text-tertiary)]">
{workerSessions.length}
</span>
)}
</button>
{isExpanded && workerSessions.length > 0 && (
<div className="project-sidebar__children ml-3 py-0.5">
{workerSessions.filter((s) => getAttentionLevel(s) !== "done").map((session) => {
const level = getAttentionLevel(session);
const isSessionActive = activeSessionId === session.id;
const title = getSessionTitle(session);
return (
<div
key={session.id}
role="button"
tabIndex={0}
onClick={() =>
router.push(
`${pathname}?project=${encodeURIComponent(project.id)}&session=${encodeURIComponent(session.id)}`,
)
}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
router.push(
`${pathname}?project=${encodeURIComponent(project.id)}&session=${encodeURIComponent(session.id)}`,
);
}
}}
className={cn(
"project-sidebar__session group flex w-full cursor-pointer items-center gap-2 py-[6px] pl-3 pr-2 transition-colors",
isSessionActive
? "project-sidebar__session--active text-[var(--color-accent)]"
: "text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]",
)}
>
<SessionDot level={level} />
<span className="min-w-0 flex-1 truncate text-[11px]">{title}</span>
<span className="project-sidebar__session-tone">
{sessionToneLabel[level]}
</span>
<a
href={`/sessions/${encodeURIComponent(session.id)}`}
onClick={(e) => e.stopPropagation()}
className="project-sidebar__session-id shrink-0 font-mono text-[9px] hover:underline"
title={session.id}
>
{session.id.slice(0, 8)}
</a>
</div>
);
})}
</div>
)}
</div>
);
})}
</nav>
<div className="border-t border-[var(--color-border-subtle)] p-2">
<button type="button" onClick={onToggleCollapsed} className="project-sidebar__collapse-btn">
<svg
fill="none"
stroke="currentColor"
strokeWidth="1.8"
viewBox="0 0 24 24"
className="h-3.5 w-3.5"
>
<rect x="3.5" y="4.5" width="17" height="15" rx="2" />
<path d="M9 4.5v15M15 10l-3 2 3 2" />
</svg>
Hide sidebar
</button>
</div>
</aside>
</>
);
}