Merge pull request #528 from ashish921998/ashish/chore/kanban-redesign

feat(web): add light/dark mode theme toggle
This commit is contained in:
suraj_markup 2026-03-23 17:44:04 +05:30 committed by GitHub
commit 4741ba2461
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 3362 additions and 922 deletions

View File

@ -38,6 +38,7 @@
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"next": "^15.1.0",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"ws": "^8.19.0",

View File

@ -247,7 +247,7 @@ describe("SessionCard", () => {
});
const session = makeSession({ status: "mergeable", activity: "idle", pr });
render(<SessionCard session={session} />);
expect(screen.getByText("Merge PR #42")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /merge/i })).toBeInTheDocument();
});
it("calls onMerge when merge button is clicked", () => {
@ -265,7 +265,7 @@ describe("SessionCard", () => {
});
const session = makeSession({ status: "mergeable", activity: "idle", pr });
render(<SessionCard session={session} onMerge={onMerge} />);
fireEvent.click(screen.getByText("Merge PR #42"));
fireEvent.click(screen.getByRole("button", { name: /merge/i }));
expect(onMerge).toHaveBeenCalledWith(42);
});
@ -396,7 +396,7 @@ describe("SessionCard", () => {
expect(screen.getByText("ask to fix")).toBeInTheDocument();
});
it("hides action buttons when agent is active", () => {
it("shows action buttons even when agent is active", () => {
const pr = makePR({
state: "open",
ciStatus: "failing",
@ -412,24 +412,19 @@ describe("SessionCard", () => {
});
const session = makeSession({ activity: "active", pr });
render(<SessionCard session={session} />);
expect(screen.queryByText("ask to fix")).not.toBeInTheDocument();
expect(screen.getByText("ask to fix")).toBeInTheDocument();
});
it("expands detail panel on click", () => {
it("shows issue details in the compact card footer", () => {
const session = makeSession({ id: "test-1", issueId: "INT-100", pr: null });
const { container } = render(<SessionCard session={session} />);
expect(screen.queryByText("INT-100")).not.toBeInTheDocument();
// Click the card (not a button/link)
fireEvent.click(container.firstElementChild!);
expect(screen.getByText("INT-100")).toBeInTheDocument();
expect(screen.getByText("No PR associated with this session.")).toBeInTheDocument();
render(<SessionCard session={session} />);
expect(screen.getAllByText("INT-100")).toHaveLength(2);
});
it("shows terminate button in expanded view", () => {
it("shows icon-only terminate button in the footer", () => {
const session = makeSession({ pr: null });
const { container } = render(<SessionCard session={session} />);
fireEvent.click(container.firstElementChild!);
expect(screen.getByText("terminate")).toBeInTheDocument();
render(<SessionCard session={session} />);
expect(screen.getByRole("button", { name: /terminate session/i })).toBeInTheDocument();
});
});
@ -444,9 +439,9 @@ describe("AttentionZone", () => {
expect(screen.getByText("2")).toBeInTheDocument();
});
it("renders nothing when sessions array is empty", () => {
const { container } = render(<AttentionZone level="respond" sessions={[]} />);
expect(container.firstElementChild).toBeNull();
it("renders empty state when sessions array is empty", () => {
render(<AttentionZone level="respond" sessions={[]} />);
expect(screen.getByText("No sessions")).toBeInTheDocument();
});
it("shows session cards when not collapsed", () => {
@ -463,27 +458,11 @@ describe("AttentionZone", () => {
expect(screen.getByText("Working")).toBeInTheDocument();
});
it("done zone is collapsed by default", () => {
it("done zone always shows sessions (kanban columns are always expanded)", () => {
const sessions = [makeSession({ id: "s1" })];
render(<AttentionZone level="done" sessions={sessions} />);
// done is defaultCollapsed: true, so session id should not be visible
expect(screen.queryByText("s1")).not.toBeInTheDocument();
expect(screen.getByText("Done")).toBeInTheDocument();
});
it("toggles collapsed state on click", () => {
const sessions = [makeSession({ id: "s1" })];
render(<AttentionZone level="done" sessions={sessions} />);
// done starts collapsed
expect(screen.queryByText("s1")).not.toBeInTheDocument();
// Click the zone header to expand
fireEvent.click(screen.getByText("Done"));
expect(screen.getByText("s1")).toBeInTheDocument();
// Click again to collapse
fireEvent.click(screen.getByText("Done"));
expect(screen.queryByText("s1")).not.toBeInTheDocument();
});
it("passes callbacks to SessionCards", () => {

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { IBM_Plex_Sans, IBM_Plex_Mono } from "next/font/google";
import { IBM_Plex_Sans, IBM_Plex_Mono, JetBrains_Mono } from "next/font/google";
import { ThemeProvider } from "next-themes";
import { getProjectName } from "@/lib/project-name";
import "./globals.css";
@ -17,6 +18,13 @@ const ibmPlexMono = IBM_Plex_Mono({
weight: ["300", "400", "500"],
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-jetbrains-mono",
display: "swap",
weight: ["400", "500"],
});
export async function generateMetadata(): Promise<Metadata> {
const projectName = getProjectName();
return {
@ -30,9 +38,11 @@ export async function generateMetadata(): Promise<Metadata> {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`dark ${ibmPlexSans.variable} ${ibmPlexMono.variable}`}>
<html lang="en" className={`dark ${ibmPlexSans.variable} ${ibmPlexMono.variable} ${jetbrainsMono.variable}`} suppressHydrationWarning>
<body className="bg-[var(--color-bg-base)] text-[var(--color-text-primary)] antialiased">
{children}
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false}>
{children}
</ThemeProvider>
</body>
</html>
);

View File

@ -1,13 +1,12 @@
"use client";
import { memo, useState } from "react";
import { memo } from "react";
import type { DashboardSession, AttentionLevel } from "@/lib/types";
import { SessionCard } from "./SessionCard";
interface AttentionZoneProps {
level: AttentionLevel;
sessions: DashboardSession[];
variant?: "column" | "grid";
onSend?: (sessionId: string, message: string) => void;
onKill?: (sessionId: string) => void;
onMerge?: (prNumber: number) => void;
@ -19,87 +18,68 @@ const zoneConfig: Record<
{
label: string;
color: string;
defaultCollapsed: boolean;
caption: string;
}
> = {
merge: {
label: "Merge",
label: "Ready",
color: "var(--color-status-ready)",
defaultCollapsed: false,
caption: "Cleared to land",
},
respond: {
label: "Respond",
color: "var(--color-status-error)",
defaultCollapsed: false,
caption: "Human judgment needed",
},
review: {
label: "Review",
color: "var(--color-accent-orange)",
defaultCollapsed: false,
caption: "Code waiting on eyes",
},
pending: {
label: "Pending",
color: "var(--color-status-attention)",
defaultCollapsed: false,
caption: "Blocked on system state",
},
working: {
label: "Working",
color: "var(--color-status-working)",
defaultCollapsed: false,
caption: "Agents are actively moving",
},
done: {
label: "Done",
color: "var(--color-text-tertiary)",
defaultCollapsed: true,
caption: "Completed or exited",
},
};
/**
* Kanban column always renders (even when empty) to preserve
* the board shape. Cards scroll independently within each column.
*/
function AttentionZoneView({
level,
sessions,
variant = "grid",
onSend,
onKill,
onMerge,
onRestore,
}: AttentionZoneProps) {
const config = zoneConfig[level];
const [collapsed, setCollapsed] = useState(config.defaultCollapsed);
if (sessions.length === 0) return null;
return (
<div className="kanban-column" data-level={level}>
<div className="kanban-column__header">
<div className="kanban-column__title-row">
<div className="kanban-column__dot" style={{ background: config.color }} />
<span className="kanban-column__title">{config.label}</span>
<span className="kanban-column__count">{sessions.length}</span>
</div>
<p className="kanban-column__caption">{config.caption}</p>
</div>
if (variant === "column") {
return (
<div className="flex flex-col">
{/* Column header */}
<button
className="mb-2.5 flex items-center gap-2 py-0.5 text-left"
onClick={() => setCollapsed(!collapsed)}
>
<div className="h-1.5 w-1.5 shrink-0 rounded-full" style={{ background: config.color }} />
<span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">
{config.label}
</span>
<span
className="rounded-full px-1.5 py-0 text-[10px] font-medium tabular-nums text-[var(--color-text-muted)]"
style={{ background: "var(--color-bg-subtle)" }}
>
{sessions.length}
</span>
<div className="flex-1" />
<svg
className="h-3 w-3 shrink-0 text-[var(--color-text-muted)] transition-transform duration-150"
style={{ transform: collapsed ? "rotate(-90deg)" : "rotate(0deg)" }}
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M19 9l-7 7-7-7" />
</svg>
</button>
{!collapsed && (
<div className="kanban-column-body">
{sessions.length > 0 ? (
<div className="flex flex-col gap-2">
{sessions.map((session) => (
<SessionCard
@ -112,57 +92,12 @@ function AttentionZoneView({
/>
))}
</div>
) : (
<div className="kanban-column__empty">
<span className="kanban-column__empty-label">No sessions</span>
</div>
)}
</div>
);
}
return (
<div className="mb-7">
{/* Zone header: [●] LABEL ──────────────────────────────── count [▾] */}
<button
className="mb-3 flex w-full items-center gap-2.5 py-0.5 text-left"
onClick={() => setCollapsed(!collapsed)}
>
{/* Semantic dot — only zone-colored element */}
<div className="h-1.5 w-1.5 shrink-0 rounded-full" style={{ background: config.color }} />
{/* Label — neutral, not zone-colored */}
<span className="text-[10px] font-medium uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">
{config.label}
</span>
{/* Divider */}
<div className="h-px flex-1 bg-[var(--color-border-subtle)]" />
{/* Count — plain */}
<span className="tabular-nums text-[11px] text-[var(--color-text-muted)]">
{sessions.length}
</span>
{/* Collapse chevron */}
<svg
className="h-3 w-3 shrink-0 text-[var(--color-text-muted)] transition-transform duration-150"
style={{ transform: collapsed ? "rotate(-90deg)" : "rotate(0deg)" }}
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M19 9l-7 7-7-7" />
</svg>
</button>
{!collapsed && (
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-3">
{sessions.map((session) => (
<SessionCard
key={session.id}
session={session}
onSend={onSend}
onKill={onKill}
onMerge={onMerge}
onRestore={onRestore}
/>
))}
</div>
)}
</div>
);
}
@ -170,7 +105,6 @@ function AttentionZoneView({
function areAttentionZonePropsEqual(prev: AttentionZoneProps, next: AttentionZoneProps): boolean {
return (
prev.level === next.level &&
prev.variant === next.variant &&
prev.onSend === next.onSend &&
prev.onKill === next.onKill &&
prev.onMerge === next.onMerge &&

View File

@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import {
type DashboardSession,
type DashboardStats,
@ -10,14 +11,16 @@ import {
type DashboardOrchestratorLink,
getAttentionLevel,
isPRRateLimited,
CI_STATUS,
} from "@/lib/types";
import { CI_STATUS } from "@composio/ao-core/types";
import { AttentionZone } from "./AttentionZone";
import { PRTableRow } from "./PRStatus";
import { DynamicFavicon } from "./DynamicFavicon";
import { useSessionEvents } from "@/hooks/useSessionEvents";
import { ProjectSidebar } from "./ProjectSidebar";
import { ThemeToggle } from "./ThemeToggle";
import type { ProjectInfo } from "@/lib/project-name";
import { EmptyState } from "./Skeleton";
interface DashboardProps {
initialSessions: DashboardSession[];
@ -58,15 +61,23 @@ export function Dashboard({
initialGlobalPause,
projectId,
);
const searchParams = useSearchParams();
const activeSessionId = searchParams.get("session") ?? undefined;
const [rateLimitDismissed, setRateLimitDismissed] = useState(false);
const [globalPauseDismissed, setGlobalPauseDismissed] = useState(false);
const [activeOrchestrators, setActiveOrchestrators] =
useState<DashboardOrchestratorLink[]>(orchestratorLinks);
const [spawningProjectIds, setSpawningProjectIds] = useState<string[]>([]);
const [spawnErrors, setSpawnErrors] = useState<Record<string, string>>({});
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const showSidebar = projects.length > 1;
const allProjectsView = showSidebar && projectId === undefined;
const displaySessions = useMemo(() => {
if (allProjectsView || !activeSessionId) return sessions;
return sessions.filter((s) => s.id === activeSessionId);
}, [sessions, allProjectsView, activeSessionId]);
useEffect(() => {
setActiveOrchestrators((current) => mergeOrchestrators(current, orchestratorLinks));
}, [orchestratorLinks]);
@ -80,11 +91,11 @@ export function Dashboard({
working: [],
done: [],
};
for (const session of sessions) {
for (const session of displaySessions) {
zones[getAttentionLevel(session)].push(session);
}
return zones;
}, [sessions]);
}, [displaySessions]);
const sessionsByProject = useMemo(() => {
const groupedSessions = new Map<string, DashboardSession[]>();
@ -100,14 +111,14 @@ export function Dashboard({
}, [sessions]);
const openPRs = useMemo(() => {
return sessions
return displaySessions
.filter(
(session): session is DashboardSession & { pr: DashboardPR } =>
session.pr?.state === "open",
)
.map((session) => session.pr)
.sort((a, b) => mergeScore(a) - mergeScore(b));
}, [sessions]);
}, [displaySessions]);
const projectOverviews = useMemo(() => {
if (!allProjectsView) return [];
@ -214,7 +225,9 @@ export function Dashboard({
}
};
const hasKanbanSessions = KANBAN_LEVELS.some((level) => grouped[level].length > 0);
const hasAnySessions = KANBAN_LEVELS.some(
(level) => grouped[level].length > 0,
);
const anyRateLimited = useMemo(
() => sessions.some((session) => session.pr && isPRRateLimited(session.pr)),
@ -245,22 +258,45 @@ export function Dashboard({
}, [globalPause?.pausedUntil, globalPause?.reason, globalPause?.sourceSessionId]);
return (
<div className="flex h-screen">
{showSidebar && <ProjectSidebar projects={projects} activeProjectId={projectId} />}
<div className="flex-1 overflow-y-auto px-8 py-7">
<div className="dashboard-shell flex h-screen">
{showSidebar && (
<ProjectSidebar
projects={projects}
sessions={sessions}
activeProjectId={projectId}
activeSessionId={activeSessionId}
collapsed={sidebarCollapsed}
onToggleCollapsed={() => setSidebarCollapsed((current) => !current)}
/>
)}
<div className="dashboard-main flex-1 overflow-y-auto px-4 py-4 md:px-7 md:py-6">
<DynamicFavicon sessions={sessions} projectName={projectName} />
<div className="mb-8 flex items-center justify-between border-b border-[var(--color-border-subtle)] pb-6">
<div className="flex items-center gap-6">
<h1 className="text-[17px] font-semibold tracking-[-0.02em] text-[var(--color-text-primary)]">
{projectName ?? "Orchestrator"}
</h1>
<StatusLine stats={liveStats} />
<section className="dashboard-hero mb-5">
<div className="dashboard-hero__backdrop" />
<div className="dashboard-hero__content">
<div className="dashboard-hero__primary">
<div className="dashboard-hero__heading">
<div>
<h1 className="dashboard-title">{projectName ?? "Orchestrator"}</h1>
<p className="dashboard-subtitle">
Live sessions, review pressure, and merge readiness.
</p>
</div>
</div>
<StatusCards stats={liveStats} />
</div>
<div className="dashboard-hero__meta">
<div className="flex items-center gap-3">
{!allProjectsView && <OrchestratorControl orchestrators={activeOrchestrators} />}
<ThemeToggle />
</div>
</div>
</div>
{!allProjectsView && <OrchestratorControl orchestrators={activeOrchestrators} />}
</div>
</section>
{globalPause && !globalPauseDismissed && (
<div className="mb-6 flex items-center gap-2.5 rounded border border-[rgba(239,68,68,0.25)] bg-[rgba(239,68,68,0.05)] px-3.5 py-2.5 text-[11px] text-[var(--color-status-error)]">
<div className="dashboard-alert mb-6 flex items-center gap-2.5 border border-[color-mix(in_srgb,var(--color-status-error)_25%,transparent)] bg-[var(--color-tint-red)] px-3.5 py-2.5 text-[11px] text-[var(--color-status-error)]">
<svg
className="h-3.5 w-3.5 shrink-0"
fill="none"
@ -299,7 +335,7 @@ export function Dashboard({
)}
{anyRateLimited && !rateLimitDismissed && (
<div className="mb-6 flex items-center gap-2.5 rounded border border-[rgba(245,158,11,0.25)] bg-[rgba(245,158,11,0.05)] px-3.5 py-2.5 text-[11px] text-[var(--color-status-attention)]">
<div className="dashboard-alert mb-6 flex items-center gap-2.5 border border-[color-mix(in_srgb,var(--color-status-attention)_25%,transparent)] bg-[var(--color-tint-yellow)] px-3.5 py-2.5 text-[11px] text-[var(--color-status-attention)]">
<svg
className="h-3.5 w-3.5 shrink-0"
fill="none"
@ -341,46 +377,45 @@ export function Dashboard({
/>
)}
{!allProjectsView && hasKanbanSessions && (
<div className="mb-8 flex gap-4 overflow-x-auto pb-2">
{KANBAN_LEVELS.map((level) =>
grouped[level].length > 0 ? (
<div key={level} className="min-w-[200px] flex-1">
<AttentionZone
level={level}
sessions={grouped[level]}
variant="column"
onSend={handleSend}
onKill={handleKill}
onMerge={handleMerge}
onRestore={handleRestore}
/>
</div>
) : null,
)}
{!allProjectsView && hasAnySessions && (
<div className="kanban-board-wrap">
<div className="board-section-head">
<div>
<h2 className="board-section-head__title">Attention Board</h2>
<p className="board-section-head__subtitle">
Triage by required intervention, not by chronology.
</p>
</div>
<div className="board-section-head__legend">
<BoardLegendItem label="Human action" tone="var(--color-status-error)" />
<BoardLegendItem label="Review queue" tone="var(--color-accent-orange)" />
<BoardLegendItem label="Ready to land" tone="var(--color-status-ready)" />
</div>
</div>
<div className="kanban-board">
{KANBAN_LEVELS.map((level) => (
<AttentionZone
key={level}
level={level}
sessions={grouped[level]}
onSend={handleSend}
onKill={handleKill}
onMerge={handleMerge}
onRestore={handleRestore}
/>
))}
</div>
</div>
)}
{!allProjectsView && grouped.done.length > 0 && (
<div className="mb-8">
<AttentionZone
level="done"
sessions={grouped.done}
variant="grid"
onSend={handleSend}
onKill={handleKill}
onMerge={handleMerge}
onRestore={handleRestore}
/>
</div>
)}
{!allProjectsView && !hasAnySessions && <EmptyState />}
{openPRs.length > 0 && (
<div className="mx-auto max-w-[900px]">
<h2 className="mb-3 px-1 text-[10px] font-bold uppercase tracking-[0.10em] text-[var(--color-text-tertiary)]">
Pull Requests
</h2>
<div className="overflow-hidden rounded-[6px] border border-[var(--color-border-default)]">
<div className="overflow-hidden border border-[var(--color-border-default)]">
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-[var(--color-border-muted)]">
@ -426,7 +461,7 @@ function OrchestratorControl({ orchestrators }: { orchestrators: DashboardOrches
return (
<a
href={`/sessions/${encodeURIComponent(orchestrator.id)}`}
className="orchestrator-btn flex items-center gap-2 rounded-[7px] px-4 py-2 text-[12px] font-semibold hover:no-underline"
className="orchestrator-btn flex items-center gap-2 px-4 py-2 text-[12px] font-semibold hover:no-underline"
>
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-accent)] opacity-80" />
orchestrator
@ -445,7 +480,7 @@ function OrchestratorControl({ orchestrators }: { orchestrators: DashboardOrches
return (
<details className="group relative">
<summary className="orchestrator-btn flex cursor-pointer list-none items-center gap-2 rounded-[7px] px-4 py-2 text-[12px] font-semibold hover:no-underline">
<summary className="orchestrator-btn flex cursor-pointer list-none items-center gap-2 px-4 py-2 text-[12px] font-semibold hover:no-underline">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-accent)] opacity-80" />
{orchestrators.length} orchestrators
<svg
@ -458,7 +493,7 @@ function OrchestratorControl({ orchestrators }: { orchestrators: DashboardOrches
<path d="m9 18 6-6-6-6" />
</svg>
</summary>
<div className="absolute right-0 top-[calc(100%+0.5rem)] z-10 min-w-[220px] overflow-hidden rounded-[10px] border border-[var(--color-border-default)] bg-[var(--color-bg-elevated)] shadow-[0_18px_40px_rgba(0,0,0,0.18)]">
<div className="absolute right-0 top-[calc(100%+0.5rem)] z-10 min-w-[220px] overflow-hidden border border-[var(--color-border-default)] bg-[var(--color-bg-elevated)] shadow-[0_18px_40px_rgba(0,0,0,0.18)]">
{orchestrators.map((orchestrator, index) => (
<a
key={orchestrator.id}
@ -509,7 +544,7 @@ function ProjectOverviewGrid({
{overviews.map(({ project, orchestrator, sessionCount, openPRCount, counts }) => (
<section
key={project.id}
className="rounded-[10px] border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] p-4"
className="border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] p-4"
>
<div className="mb-4 flex items-start justify-between gap-3">
<div>
@ -523,7 +558,7 @@ function ProjectOverviewGrid({
</div>
<a
href={`/?project=${encodeURIComponent(project.id)}`}
className="rounded-[7px] border border-[var(--color-border-default)] px-3 py-1.5 text-[11px] font-medium text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)] hover:no-underline"
className="border border-[var(--color-border-default)] px-3 py-1.5 text-[11px] font-medium text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)] hover:no-underline"
>
Open project
</a>
@ -557,7 +592,7 @@ function ProjectOverviewGrid({
{orchestrator ? (
<a
href={`/sessions/${encodeURIComponent(orchestrator.id)}`}
className="orchestrator-btn flex items-center gap-2 rounded-[7px] px-3 py-1.5 text-[11px] font-semibold hover:no-underline"
className="orchestrator-btn flex items-center gap-2 px-3 py-1.5 text-[11px] font-semibold hover:no-underline"
>
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-accent)] opacity-80" />
orchestrator
@ -567,7 +602,7 @@ function ProjectOverviewGrid({
type="button"
onClick={() => void onSpawnOrchestrator(project)}
disabled={spawningProjectIds.includes(project.id)}
className="orchestrator-btn rounded-[7px] px-3 py-1.5 text-[11px] font-semibold disabled:cursor-wait disabled:opacity-70"
className="orchestrator-btn px-3 py-1.5 text-[11px] font-semibold disabled:cursor-wait disabled:opacity-70"
>
{spawningProjectIds.includes(project.id) ? "Spawning..." : "Spawn Orchestrator"}
</button>
@ -587,7 +622,7 @@ function ProjectOverviewGrid({
function ProjectMetric({ label, value, tone }: { label: string; value: number; tone: string }) {
return (
<div className="min-w-[78px] rounded-[8px] border border-[var(--color-border-subtle)] px-2.5 py-2">
<div className="min-w-[78px] border border-[var(--color-border-subtle)] px-2.5 py-2">
<div className="text-[10px] uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
{label}
</div>
@ -598,42 +633,63 @@ function ProjectMetric({ label, value, tone }: { label: string; value: number; t
);
}
function StatusLine({ stats }: { stats: DashboardStats }) {
function StatusCards({ stats }: { stats: DashboardStats }) {
if (stats.totalSessions === 0) {
return <span className="text-[13px] text-[var(--color-text-muted)]">no sessions</span>;
return (
<div className="dashboard-stat-cards">
<div className="dashboard-stat-card dashboard-stat-card--empty">
<span className="dashboard-stat-card__label">Fleet</span>
<span className="dashboard-stat-card__value">0</span>
<span className="dashboard-stat-card__meta">No live sessions</span>
</div>
</div>
);
}
const parts: Array<{ value: number; label: string; color?: string }> = [
{ value: stats.totalSessions, label: "sessions" },
...(stats.workingSessions > 0
? [{ value: stats.workingSessions, label: "working", color: "var(--color-status-working)" }]
: []),
...(stats.openPRs > 0 ? [{ value: stats.openPRs, label: "PRs" }] : []),
...(stats.needsReview > 0
? [{ value: stats.needsReview, label: "need review", color: "var(--color-status-attention)" }]
: []),
const parts: Array<{ value: number; label: string; meta: string; tone?: string }> = [
{ value: stats.totalSessions, label: "Fleet", meta: "Live sessions" },
{
value: stats.workingSessions,
label: "Active",
meta: "Currently moving",
tone: "var(--color-status-working)",
},
{ value: stats.openPRs, label: "PRs", meta: "Open pull requests" },
{
value: stats.needsReview,
label: "Review",
meta: "Awaiting eyes",
tone: "var(--color-status-attention)",
},
];
return (
<div className="flex items-baseline gap-0.5">
{parts.map((part, index) => (
<span key={part.label} className="flex items-baseline">
{index > 0 && (
<span className="mx-3 text-[11px] text-[var(--color-border-strong)]">·</span>
)}
<div className="dashboard-stat-cards">
{parts.map((part) => (
<div key={part.label} className="dashboard-stat-card">
<span
className="text-[20px] font-bold tabular-nums tracking-tight"
style={{ color: part.color ?? "var(--color-text-primary)" }}
className="dashboard-stat-card__value"
style={{ color: part.tone ?? "var(--color-text-primary)" }}
>
{part.value}
</span>
<span className="ml-1.5 text-[11px] text-[var(--color-text-muted)]">{part.label}</span>
</span>
<span className="dashboard-stat-card__label">{part.label}</span>
<span className="dashboard-stat-card__meta">{part.meta}</span>
</div>
))}
</div>
);
}
function BoardLegendItem({ label, tone }: { label: string; tone: string }) {
return (
<span className="board-legend-item">
<span className="board-legend-item__dot" style={{ background: tone }} />
{label}
</span>
);
}
function mergeScore(
pr: Pick<DashboardPR, "ciStatus" | "reviewDecision" | "mergeability" | "unresolvedThreads">,
): number {

View File

@ -1,14 +1,15 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, useMemo } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { useTheme } from "next-themes";
import { cn } from "@/lib/cn";
// Import xterm CSS (must be imported in client component)
import "xterm/css/xterm.css";
// Dynamically import xterm types for TypeScript
import type { Terminal as TerminalType } from "xterm";
import type { ITheme, Terminal as TerminalType } from "xterm";
import type { FitAddon as FitAddonType } from "@xterm/addon-fit";
interface DirectTerminalProps {
@ -37,6 +38,76 @@ interface DirectTerminalWsUrlOptions {
directTerminalPort?: string;
}
type TerminalVariant = "agent" | "orchestrator";
export function buildTerminalThemes(variant: TerminalVariant): { dark: ITheme; light: ITheme } {
const agentAccent = {
cursor: "#5b7ef8",
selDark: "rgba(91, 126, 248, 0.30)",
selLight: "rgba(91, 126, 248, 0.25)",
};
const orchAccent = {
cursor: "#a371f7",
selDark: "rgba(163, 113, 247, 0.25)",
selLight: "rgba(130, 80, 223, 0.20)",
};
const accent = variant === "orchestrator" ? orchAccent : agentAccent;
const dark: ITheme = {
background: "#0a0a0f",
foreground: "#d4d4d8",
cursor: accent.cursor,
cursorAccent: "#0a0a0f",
selectionBackground: accent.selDark,
selectionInactiveBackground: "rgba(128, 128, 128, 0.2)",
// ANSI colors — slightly warmer than pure defaults
black: "#1a1a24",
red: "#ef4444",
green: "#22c55e",
yellow: "#f59e0b",
blue: "#5b7ef8",
magenta: "#a371f7",
cyan: "#22d3ee",
white: "#d4d4d8",
brightBlack: "#50506a",
brightRed: "#f87171",
brightGreen: "#4ade80",
brightYellow: "#fbbf24",
brightBlue: "#7b9cfb",
brightMagenta: "#c084fc",
brightCyan: "#67e8f9",
brightWhite: "#eeeef5",
};
const light: ITheme = {
background: "#fafafa",
foreground: "#24292f",
cursor: accent.cursor,
cursorAccent: "#fafafa",
selectionBackground: accent.selLight,
selectionInactiveBackground: "rgba(128, 128, 128, 0.15)",
// ANSI colors — darkened for legibility on #fafafa terminal background
black: "#24292f",
red: "#b42318",
green: "#1f7a3d",
yellow: "#8a5a00",
blue: "#175cd3",
magenta: "#8e24aa",
cyan: "#0b7285",
white: "#4b5563",
brightBlack: "#374151",
brightRed: "#912018",
brightGreen: "#176639",
brightYellow: "#6f4a00",
brightBlue: "#1d4ed8",
brightMagenta: "#7b1fa2",
brightCyan: "#155e75",
brightWhite: "#374151",
};
return { dark, light };
}
export function buildDirectTerminalWsUrl({
location,
sessionId,
@ -78,6 +149,8 @@ export function DirectTerminal({
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { resolvedTheme } = useTheme();
const terminalThemes = useMemo(() => buildTerminalThemes(variant), [variant]);
const terminalRef = useRef<HTMLDivElement>(null);
const terminalInstance = useRef<TerminalType | null>(null);
@ -164,45 +237,24 @@ export function DirectTerminal({
import("xterm").then((mod) => mod.Terminal),
import("@xterm/addon-fit").then((mod) => mod.FitAddon),
import("@xterm/addon-web-links").then((mod) => mod.WebLinksAddon),
document.fonts.ready,
])
.then(([Terminal, FitAddon, WebLinksAddon]) => {
if (!mounted || !terminalRef.current) return;
// Cursor and selection color differ by variant:
// agent = blue (#5b7ef8), orchestrator = violet (#a371f7)
const cursorColor = variant === "orchestrator" ? "#a371f7" : "#5b7ef8";
const selectionColor =
variant === "orchestrator" ? "rgba(163, 113, 247, 0.25)" : "rgba(91, 126, 248, 0.3)";
const isDark = resolvedTheme !== "light";
const activeTheme = isDark ? terminalThemes.dark : terminalThemes.light;
// Initialize xterm.js Terminal
const terminal = new Terminal({
cursorBlink: true,
fontSize: 13,
fontFamily: '"IBM Plex Mono", "SF Mono", Menlo, Monaco, "Courier New", monospace',
theme: {
background: "#0a0a0f",
foreground: "#d4d4d8",
cursor: cursorColor,
cursorAccent: "#0a0a0f",
selectionBackground: selectionColor,
// ANSI colors — slightly warmer than pure defaults
black: "#1a1a24",
red: "#ef4444",
green: "#22c55e",
yellow: "#f59e0b",
blue: "#5b7ef8",
magenta: "#a371f7",
cyan: "#22d3ee",
white: "#d4d4d8",
brightBlack: "#50506a",
brightRed: "#f87171",
brightGreen: "#4ade80",
brightYellow: "#fbbf24",
brightBlue: "#7b9cfb",
brightMagenta: "#c084fc",
brightCyan: "#67e8f9",
brightWhite: "#eeeef5",
},
fontFamily:
'var(--font-jetbrains-mono), "JetBrains Mono", "SF Mono", Menlo, Monaco, "Courier New", monospace',
theme: activeTheme,
// Light mode needs an explicit contrast floor because agent UIs often emit
// dim/faint ANSI sequences that become unreadable on a near-white background.
minimumContrastRatio: isDark ? 1 : 7,
scrollback: 10000,
allowProposedApi: true,
fastScrollModifier: "alt",
@ -457,6 +509,15 @@ export function DirectTerminal({
};
}, [sessionId, variant]);
// Live theme switching without terminal recreation
useEffect(() => {
const terminal = terminalInstance.current;
if (!terminal) return;
const isDark = resolvedTheme !== "light";
terminal.options.theme = isDark ? terminalThemes.dark : terminalThemes.light;
terminal.options.minimumContrastRatio = isDark ? 1 : 7;
}, [resolvedTheme, terminalThemes]);
// Re-fit terminal when fullscreen changes
useEffect(() => {
const fit = fitAddon.current;
@ -571,8 +632,8 @@ export function DirectTerminal({
return (
<div
className={cn(
"overflow-hidden rounded-[6px] border border-[var(--color-border-default)]",
"bg-[#0a0a0f]",
"overflow-hidden border border-[var(--color-border-default)]",
resolvedTheme === "light" ? "bg-[#fafafa]" : "bg-[#0a0a0f]",
fullscreen && "fixed inset-0 z-50 rounded-none border-0",
)}
>
@ -589,7 +650,7 @@ export function DirectTerminal({
</span>
{/* XDA clipboard badge */}
<span
className="rounded px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.06em]"
className="px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.06em]"
style={{
color: accentColor,
background: `color-mix(in srgb, ${accentColor} 12%, transparent)`,
@ -603,7 +664,7 @@ export function DirectTerminal({
disabled={reloading || status !== "connected"}
title="Restart OpenCode session (/exit then resume mapped session)"
aria-label="Restart OpenCode session"
className="ml-auto flex items-center gap-1 rounded px-2 py-0.5 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-bg-subtle)] hover:text-[var(--color-text-primary)] disabled:cursor-not-allowed disabled:opacity-70"
className="ml-auto flex items-center gap-1 px-2 py-0.5 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-bg-subtle)] hover:text-[var(--color-text-primary)] disabled:cursor-not-allowed disabled:opacity-70"
>
{reloading ? (
<>
@ -646,7 +707,7 @@ export function DirectTerminal({
<button
onClick={() => setFullscreen(!fullscreen)}
className={cn(
"flex items-center gap-1 rounded px-2 py-0.5 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-bg-subtle)] hover:text-[var(--color-text-primary)]",
"flex items-center gap-1 px-2 py-0.5 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-bg-subtle)] hover:text-[var(--color-text-primary)]",
!isOpenCodeSession && "ml-auto",
)}
>

View File

@ -3,7 +3,7 @@
import { type DashboardPR, isPRRateLimited } from "@/lib/types";
import { CIBadge } from "./CIBadge";
function getSizeLabel(additions: number, deletions: number): string {
export function getSizeLabel(additions: number, deletions: number): string {
const size = additions + deletions;
return size > 1000 ? "XL" : size > 500 ? "L" : size > 200 ? "M" : size > 50 ? "S" : "XS";
}

View File

@ -1,64 +1,383 @@
"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;
}
export function ProjectSidebar({ projects, activeProjectId }: ProjectSidebarProps) {
const router = useRouter();
const pathname = usePathname();
type ProjectHealth = "red" | "yellow" | "green" | "gray";
const handleProjectClick = (projectId: string | null) => {
if (projectId === null) {
router.push(pathname + "?project=all");
} else {
router.push(pathname + `?project=${encodeURIComponent(projectId)}`);
}
};
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";
}
if (projects.length <= 1) {
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 (
<aside className="flex h-full w-[180px] flex-col border-r border-[var(--color-border-subtle)] bg-[var(--color-bg-surface)]">
<div className="border-b border-[var(--color-border-subtle)] px-3 py-3">
<h2 className="text-[10px] font-bold uppercase tracking-[0.10em] text-[var(--color-text-tertiary)]">
Projects
</h2>
</div>
<nav className="flex-1 overflow-y-auto py-2">
return <ProjectSidebarInner {...props} />;
}
function ProjectSidebarInner({
projects,
sessions,
activeProjectId,
activeSessionId,
collapsed = false,
onToggleCollapsed,
}: 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="project-sidebar project-sidebar--collapsed flex h-full w-[56px] flex-col items-center py-3">
<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
onClick={() => handleProjectClick(null)}
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 (
<aside className="project-sidebar flex h-full w-[244px] flex-col">
<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(
"w-full px-3 py-2 text-left text-[13px] transition-colors",
"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"
? "bg-[var(--color-accent-subtle)] text-[var(--color-accent)]"
: "text-[var(--color-text-secondary)] hover:bg-[rgba(255,255,255,0.03)] hover:text-[var(--color-text-primary)]",
? "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>
{projects.map((project) => (
<button
key={project.id}
onClick={() => handleProjectClick(project.id)}
className={cn(
"w-full px-3 py-2 text-left text-[13px] transition-colors",
activeProjectId === project.id
? "bg-[var(--color-accent-subtle)] text-[var(--color-accent)]"
: "text-[var(--color-text-secondary)] hover:bg-[rgba(255,255,255,0.03)] hover:text-[var(--color-text-primary)]",
)}
>
{project.name}
</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>
);
}

View File

@ -3,18 +3,17 @@
import { memo, useState, useEffect, useRef } from "react";
import {
type DashboardSession,
type AttentionLevel,
getAttentionLevel,
isPRRateLimited,
TERMINAL_STATUSES,
TERMINAL_ACTIVITIES,
CI_STATUS,
} from "@/lib/types";
import { CI_STATUS } from "@composio/ao-core/types";
import { cn } from "@/lib/cn";
import { getSessionTitle } from "@/lib/format";
import { PRStatus } from "./PRStatus";
import { CICheckList } from "./CIBadge";
import { ActivityDot } from "./ActivityDot";
import { getSizeLabel } from "./PRStatus";
interface SessionCardProps {
session: DashboardSession;
@ -24,14 +23,73 @@ interface SessionCardProps {
onRestore?: (sessionId: string) => void;
}
const borderColorByLevel: Record<AttentionLevel, string> = {
merge: "border-l-[var(--color-status-ready)]",
respond: "border-l-[var(--color-status-error)]",
review: "border-l-[var(--color-accent-orange)]",
pending: "border-l-[var(--color-status-attention)]",
working: "border-l-[var(--color-status-working)]",
done: "border-l-[var(--color-border-default)]",
};
/**
* Determine the status display info for done cards.
*/
function getDoneStatusInfo(session: DashboardSession): {
label: string;
pillClass: string;
icon: React.ReactNode;
} {
const activity = session.activity;
const status = session.status;
const prState = session.pr?.state;
if (prState === "merged" || status === "merged") {
return {
label: "merged",
pillClass: "done-status-pill--merged",
icon: (
<svg
fill="none"
stroke="currentColor"
strokeWidth="2.5"
viewBox="0 0 24 24"
className="h-3 w-3"
>
<path d="M20 6 9 17l-5-5" />
</svg>
),
};
}
if (status === "killed" || status === "terminated") {
return {
label: status,
pillClass: "done-status-pill--killed",
icon: (
<svg
fill="none"
stroke="currentColor"
strokeWidth="2.5"
viewBox="0 0 24 24"
className="h-3 w-3"
>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
),
};
}
// Default: exited / done / cleanup / closed PR
const label = activity === "exited" ? "exited" : status;
return {
label,
pillClass: "done-status-pill--exited",
icon: (
<svg
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
className="h-3 w-3"
>
<circle cx="12" cy="12" r="9" />
<path d="M9 12h6" />
</svg>
),
};
}
function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: SessionCardProps) {
const [expanded, setExpanded] = useState(false);
@ -62,34 +120,236 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
const isRestorable = isTerminal && session.status !== "merged";
const title = getSessionTitle(session);
const isDone = level === "done";
const secondaryText = session.issueLabel
? `${session.issueLabel}${session.issueTitle ? ` · ${session.issueTitle}` : ""}`
: session.issueTitle ?? (session.summary && session.summary !== title ? session.summary : null);
const cardFrameClass = isReadyToMerge
? "session-card--merge-frame"
: alerts.length > 0
? "session-card--alert-frame"
: "session-card--fixed";
const dynamicCardStyle =
alerts.length > 0
? {
minHeight: `${242 + Math.max(0, alerts.length - 2) * 44}px`,
}
: isReadyToMerge
? { minHeight: "264px" }
: undefined;
/* ── Done card variant ──────────────────────────────────────────── */
if (isDone) {
const statusInfo = getDoneStatusInfo(session);
return (
<div
className={cn("session-card-done", expanded && "done-expanded")}
onClick={(e) => {
if ((e.target as HTMLElement).closest("a, button, textarea")) return;
setExpanded(!expanded);
}}
>
{/* Row 1: Status pill + session id + restore */}
<div className="flex items-center gap-2 px-3.5 pt-3 pb-1.5">
<span className={cn("done-status-pill", statusInfo.pillClass)}>
{statusInfo.icon}
{statusInfo.label}
</span>
<span className="font-[var(--font-mono)] text-[10px] tracking-wide text-[var(--color-text-muted)]">
{session.id}
</span>
<div className="flex-1" />
{isRestorable && (
<button
onClick={(e) => {
e.stopPropagation();
onRestore?.(session.id);
}}
className="done-restore-btn"
>
<svg
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
className="h-3 w-3"
>
<polyline points="1 4 1 10 7 10" />
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
</svg>
restore
</button>
)}
</div>
{/* Row 2: Title */}
<div className="px-3.5 pb-2">
<p
className="text-[13px] font-semibold leading-snug [display:-webkit-box] [-webkit-box-orient:vertical] [-webkit-line-clamp:2] overflow-hidden"
style={{ color: "var(--done-title-color)" }}
>
{title}
</p>
</div>
{/* Row 3: Meta chips */}
<div className="flex flex-wrap items-center gap-1.5 px-3.5 pb-3">
{session.branch && (
<span className="done-meta-chip font-[var(--font-mono)]">
<svg
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
className="h-2.5 w-2.5 opacity-50"
>
<path d="M6 3v12M18 9a3 3 0 0 1-3 3H9a3 3 0 0 0-3 3" />
<circle cx="18" cy="6" r="3" />
</svg>
{session.branch}
</span>
)}
{pr && (
<a
href={pr.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="done-meta-chip font-[var(--font-mono)] font-bold text-[var(--color-text-primary)] no-underline underline-offset-2 hover:underline"
>
#{pr.number}
</a>
)}
{pr && !rateLimited && (
<span className="done-meta-chip font-[var(--font-mono)]">
<span className="text-[var(--color-status-ready)]">+{pr.additions}</span>{" "}
<span className="text-[var(--color-status-error)]">-{pr.deletions}</span>{" "}
{getSizeLabel(pr.additions, pr.deletions)}
</span>
)}
</div>
{/* Expandable detail panel */}
{expanded && (
<div className="done-expand-section px-3.5 py-3">
{session.summary && pr?.title && session.summary !== pr.title && (
<div className="mb-3">
<div className="done-detail-heading">
<svg fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M4 6h16M4 12h16M4 18h10" />
</svg>
Summary
</div>
<p className="text-[12px] leading-relaxed text-[var(--color-text-secondary)]">
{session.summary}
</p>
</div>
)}
{session.issueUrl && (
<div className="mb-3">
<div className="done-detail-heading">
<svg fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</svg>
Issue
</div>
<a
href={session.issueUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-[12px] text-[var(--color-accent)] hover:underline"
>
{session.issueLabel || session.issueUrl}
{session.issueTitle && `: ${session.issueTitle}`}
</a>
</div>
)}
{pr && pr.ciChecks.length > 0 && (
<div className="mb-3">
<div className="done-detail-heading">
<svg fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M9 12l2 2 4-4" />
<circle cx="12" cy="12" r="10" />
</svg>
CI Checks
</div>
<CICheckList checks={pr.ciChecks} />
</div>
)}
{pr && (
<div className="mb-3">
<div className="done-detail-heading">
<svg fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65S8.93 17.38 9 18v4" />
<path d="M9 18c-4.51 2-5-2-7-2" />
</svg>
PR
</div>
<p className="text-[12px] text-[var(--color-text-secondary)]">
<a
href={pr.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="hover:underline"
>
{pr.title}
</a>
<br />
<span className="mt-1 inline-flex items-center gap-2">
<span className="done-meta-chip font-[var(--font-mono)]">
<span className="text-[var(--color-status-ready)]">+{pr.additions}</span>{" "}
<span className="text-[var(--color-status-error)]">-{pr.deletions}</span>
</span>
<span className="text-[var(--color-text-muted)]">·</span>
<span className="text-[10px] text-[var(--color-text-muted)]">
mergeable: {pr.mergeability.mergeable ? "yes" : "no"}
</span>
<span className="text-[var(--color-text-muted)]">·</span>
<span className="text-[10px] text-[var(--color-text-muted)]">
review: {pr.reviewDecision}
</span>
</span>
</p>
</div>
)}
{!pr && (
<p className="text-[12px] text-[var(--color-text-tertiary)]">
No PR associated with this session.
</p>
)}
{/* Action buttons — restore already shown in header row */}
</div>
)}
</div>
);
}
/* ── Standard card (non-done) ────────────────────────────────────── */
return (
<div
className={cn(
"session-card cursor-pointer border border-l-[3px]",
"session-card border",
cardFrameClass,
"hover:border-[var(--color-border-strong)]",
borderColorByLevel[level],
`card-glow-${level}`,
isReadyToMerge
? "card-merge-ready border-[rgba(63,185,80,0.3)]"
? "card-merge-ready border-[color-mix(in_srgb,var(--color-status-ready)_30%,transparent)]"
: "border-[var(--color-border-default)]",
expanded && "border-[var(--color-border-strong)]",
pr?.state === "merged" && "opacity-55",
)}
style={{
borderRadius: 7,
background:
expanded && !isReadyToMerge
? "linear-gradient(175deg, rgba(32,41,53,1) 0%, rgba(22,28,37,1) 100%)"
: undefined,
}}
onClick={(e) => {
if ((e.target as HTMLElement).closest("a, button, textarea")) return;
setExpanded(!expanded);
}}
style={dynamicCardStyle}
>
{/* Header row: dot + session ID + terminal link */}
<div className="flex items-center gap-2 px-4 pt-4 pb-2">
<ActivityDot activity={session.activity} />
<div className="session-card__header flex items-center gap-2 px-4 pt-4 pb-2">
{isReadyToMerge ? <ActivityDot activity="ready" /> : <ActivityDot activity={session.activity} />}
<span className="font-[var(--font-mono)] text-[11px] tracking-wide text-[var(--color-text-muted)]">
{session.id}
</span>
@ -100,8 +360,18 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
e.stopPropagation();
onRestore?.(session.id);
}}
className="rounded border border-[rgba(88,166,255,0.35)] px-2 py-0.5 text-[11px] text-[var(--color-accent)] transition-colors hover:bg-[rgba(88,166,255,0.1)]"
className="inline-flex items-center gap-1 border border-[color-mix(in_srgb,var(--color-accent)_35%,transparent)] px-2 py-0.5 text-[11px] text-[var(--color-accent)] transition-colors hover:bg-[var(--color-tint-blue)]"
>
<svg
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
className="h-3 w-3"
>
<polyline points="1 4 1 10 7 10" />
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
</svg>
restore
</button>
)}
@ -109,106 +379,136 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
<a
href={`/sessions/${encodeURIComponent(session.id)}`}
onClick={(e) => e.stopPropagation()}
className="rounded border border-[var(--color-border-default)] bg-[var(--color-bg-subtle)] px-2.5 py-0.5 text-[11px] text-[var(--color-text-muted)] transition-colors hover:border-[var(--color-accent)] hover:text-[var(--color-accent)] hover:no-underline"
className="session-card__control inline-flex items-center justify-center gap-1.5 border border-[var(--color-border-default)] bg-[var(--color-bg-subtle)] px-2.5 py-1 text-[11px] text-[var(--color-text-muted)] transition-colors hover:border-[var(--color-accent)] hover:text-[var(--color-accent)] hover:no-underline"
>
terminal
</a>
)}
</div>
{/* Title — its own row, bigger, can wrap */}
<div className="px-4 pb-3">
<p
className={cn(
"leading-snug [display:-webkit-box] [-webkit-box-orient:vertical] [-webkit-line-clamp:3] overflow-hidden",
level === "working"
? "text-[13px] font-medium text-[var(--color-text-secondary)]"
: "text-[14px] font-semibold text-[var(--color-text-primary)]",
)}
>
{title}
</p>
</div>
{/* Meta row: branch + PR pills */}
<div className="flex flex-wrap items-center gap-1.5 px-4 pb-2.5">
{session.branch && (
<span className="font-[var(--font-mono)] text-[10px] text-[var(--color-text-muted)]">
{session.branch}
</span>
)}
{session.branch && pr && (
<span className="text-[9px] text-[var(--color-border-strong)]">&middot;</span>
)}
{pr && <PRStatus pr={pr} />}
</div>
{/* Rate limited indicator */}
{rateLimited && pr?.state === "open" && (
<div className="px-4 pb-3">
<span className="inline-flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
<svg
className="h-3 w-3 text-[var(--color-text-tertiary)]"
className="session-card__control-icon"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="M6 10l4 2-4 2" />
<path d="M14 14h4" />
</svg>
PR data rate limited
</span>
</div>
)}
terminal
</a>
)}
</div>
{/* Merge button or alert tags */}
{!rateLimited && (alerts.length > 0 || isReadyToMerge) && (
<div className="px-4 pb-3.5 pt-0.5">
{isReadyToMerge && pr ? (
<button
onClick={(e) => {
e.stopPropagation();
onMerge?.(pr.number);
}}
className="inline-flex items-center gap-1.5 rounded-[5px] border-0 bg-[var(--color-status-ready)] px-3 py-1.5 text-[12px] font-semibold text-[var(--color-text-inverse)] transition-[filter,transform] duration-[100ms] hover:-translate-y-px hover:brightness-110"
<div className="session-card__body flex min-h-0 flex-1 flex-col">
{/* Title — its own row, bigger, can wrap */}
<div className="session-card__title-wrap px-4 pb-2">
<p
className={cn(
"session-card__title leading-snug [display:-webkit-box] [-webkit-box-orient:vertical] overflow-hidden",
level === "working"
? "text-[13px] font-medium text-[var(--color-text-secondary)]"
: "text-[14px] font-semibold text-[var(--color-text-primary)]",
)}
>
{title}
</p>
</div>
{/* Meta row: branch + PR# + diff size (simplified for merge-ready) */}
<div className="session-card__meta flex flex-wrap items-center gap-1.5 px-4 pb-2">
{session.branch && (
<span className="font-[var(--font-mono)] text-[10px] text-[var(--color-text-muted)]">
{session.branch}
</span>
)}
{pr && (
<a
href={pr.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="font-[var(--font-mono)] text-[11px] font-bold text-[var(--color-text-primary)] underline-offset-2 hover:underline"
>
#{pr.number}
</a>
)}
{pr && !rateLimited && (
<span className="inline-flex items-center rounded-full bg-[var(--color-chip-bg)] px-2 py-0.5 font-[var(--font-mono)] text-[10px] font-semibold text-[var(--color-text-muted)]">
+{pr.additions} -{pr.deletions} {getSizeLabel(pr.additions, pr.deletions)}
</span>
)}
</div>
{secondaryText && (
<div className="px-4 pb-2">
<p className="session-card__secondary text-[11px] text-[var(--color-text-muted)]">
{secondaryText}
</p>
</div>
)}
{/* Rate limited indicator */}
{rateLimited && pr?.state === "open" && (
<div className="px-4 pb-2">
<span className="inline-flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
<svg
className="h-3.5 w-3.5"
className="h-3 w-3 text-[var(--color-text-tertiary)]"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M5 12h14M12 5l7 7-7 7" />
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</svg>
Merge PR #{pr.number}
</button>
) : (
<div className="flex flex-wrap gap-1">
{alerts.map((alert) => (
<span key={alert.key} className="inline-flex items-center gap-1">
PR data rate limited
</span>
</div>
)}
{!rateLimited && alerts.length > 0 && (
<div className="session-card__actions px-4 pb-2 pt-0.5">
<div className="session-card__alert-grid">
{alerts.slice(0, 3).map((alert) => (
<span
key={alert.key}
className="session-card__alert-pill inline-flex items-stretch overflow-hidden border"
style={{
borderColor:
alert.borderColor ?? alert.color ?? "var(--color-border-default)",
}}
>
<a
href={alert.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className={cn(
"inline-flex items-center gap-1 rounded border px-2 py-0.5 text-[11px] font-medium hover:brightness-125 hover:no-underline",
"min-w-0 flex-1 truncate whitespace-nowrap px-2 py-0.5 font-[var(--font-mono)] text-[11px] font-medium !underline [text-decoration-skip-ink:none] [text-underline-offset:2px] hover:brightness-125",
alert.className,
)}
style={alert.color ? { color: alert.color } : undefined}
>
{alert.count !== undefined && <span className="font-bold">{alert.count}</span>}
{alert.count !== undefined && (
<>
<span className="font-bold">{alert.count}</span>{" "}
</>
)}
{alert.label}
</a>
{alert.actionLabel && session.activity !== "active" && (
{alert.actionLabel && (
<button
onClick={(e) => {
e.stopPropagation();
handleAction(alert.key, alert.actionMessage ?? "");
}}
disabled={sendingAction === alert.key}
className="rounded border border-[rgba(88,166,255,0.25)] px-2 py-0.5 text-[11px] text-[var(--color-accent)] transition-colors hover:bg-[rgba(88,166,255,0.1)] disabled:opacity-50"
className={cn(
"border-l px-2 py-0.5 font-[var(--font-mono)] text-[11px] font-medium transition-colors disabled:opacity-50",
alert.actionClassName,
)}
style={{
borderColor:
alert.borderColor ?? alert.color ?? "var(--color-border-default)",
}}
>
{sendingAction === alert.key ? "sent!" : alert.actionLabel}
</button>
@ -216,118 +516,73 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
</span>
))}
</div>
)}
</div>
)}
{/* Expandable detail panel */}
{expanded && (
<div className="border-t border-[var(--color-border-subtle)] px-4 py-3.5">
{session.summary && pr?.title && session.summary !== pr.title && (
<DetailSection label="Summary">
<p className="text-[12px] leading-relaxed text-[var(--color-text-secondary)]">
{session.summary}
</p>
</DetailSection>
)}
{session.issueUrl && (
<DetailSection label="Issue">
<a
href={session.issueUrl}
target="_blank"
rel="noopener noreferrer"
className="text-[12px] text-[var(--color-accent)] hover:underline"
>
{session.issueLabel || session.issueUrl}
{session.issueTitle && `: ${session.issueTitle}`}
</a>
</DetailSection>
)}
{pr && pr.ciChecks.length > 0 && (
<DetailSection label="CI Checks">
<CICheckList checks={pr.ciChecks} />
</DetailSection>
)}
{pr && pr.unresolvedComments.length > 0 && (
<DetailSection label="Unresolved Comments">
<div className="space-y-1">
{pr.unresolvedComments.map((c) => (
<div key={c.url} className="flex items-center gap-2 text-[12px]">
<span className="w-3 shrink-0 text-center text-[var(--color-status-error)]">
</span>
<span className="min-w-0 flex-1 truncate font-[var(--font-mono)] text-[10px] text-[var(--color-text-secondary)]">
{c.path}
</span>
<a
href={c.url}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 text-[11px] text-[var(--color-accent)] hover:underline"
>
view
</a>
</div>
))}
</div>
</DetailSection>
)}
{pr && (
<DetailSection label="PR">
<p className="text-[12px] text-[var(--color-text-secondary)]">
<a
href={pr.url}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
{pr.title}
</a>
<br />
<span className="text-[var(--color-status-ready)]">+{pr.additions}</span>{" "}
<span className="text-[var(--color-status-error)]">-{pr.deletions}</span>
{" · "}mergeable: {pr.mergeability.mergeable ? "yes" : "no"}
{" · "}review: {pr.reviewDecision}
</p>
</DetailSection>
)}
{!pr && (
<p className="text-[12px] text-[var(--color-text-tertiary)]">
No PR associated with this session.
</p>
)}
<div className="mt-3 flex gap-2 border-t border-[var(--color-border-subtle)] pt-3">
{isRestorable && (
<button
onClick={(e) => {
e.stopPropagation();
onRestore?.(session.id);
}}
className="rounded border border-[rgba(88,166,255,0.35)] px-2.5 py-1 text-[11px] text-[var(--color-accent)] transition-colors hover:bg-[rgba(88,166,255,0.1)]"
>
restore session
</button>
)}
{!isTerminal && (
<button
onClick={(e) => {
e.stopPropagation();
onKill?.(session.id);
}}
className="rounded border border-[rgba(239,68,68,0.35)] px-2.5 py-1 text-[11px] text-[var(--color-status-error)] transition-colors hover:bg-[rgba(239,68,68,0.1)]"
>
terminate
</button>
)}
</div>
)}
<div className="session-card__footer mt-auto flex items-center justify-between gap-2 border-t border-[var(--color-border-subtle)] px-4 py-2.5">
{session.issueUrl ? (
<a
href={session.issueUrl}
target="_blank"
rel="noopener noreferrer"
className="min-w-0 truncate text-[11px] text-[var(--color-accent)] hover:underline"
>
{session.issueLabel || session.issueUrl}
</a>
) : (
<span className="min-w-0 truncate text-[11px] text-[var(--color-text-tertiary)]">
{session.activity ?? session.status}
</span>
)}
{isReadyToMerge && pr ? (
<button
onClick={(e) => {
e.stopPropagation();
onMerge?.(pr.number);
}}
className="session-card__control session-card__merge-control inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 border px-2.5 py-1 text-[11px] transition-colors"
>
<svg
className="session-card__control-icon"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<circle cx="6" cy="6" r="2" />
<circle cx="18" cy="18" r="2" />
<circle cx="18" cy="6" r="2" />
<path d="M8 6h5a3 3 0 0 1 3 3v7" />
<path d="M8 6h5a3 3 0 0 0 3-3V8" />
</svg>
merge
</button>
) : !isTerminal && (
<button
onClick={(e) => {
e.stopPropagation();
onKill?.(session.id);
}}
aria-label="Terminate session"
className="session-card__control session-card__terminate inline-flex shrink-0 cursor-pointer items-center justify-center border border-[color-mix(in_srgb,var(--color-status-error)_35%,transparent)] px-2.5 py-1 text-[11px] text-[var(--color-status-error)] transition-colors hover:border-[var(--color-status-error)] hover:bg-[var(--color-tint-red)]"
>
<svg
className="session-card__control-icon"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M3 6h18" />
<path d="M8 6V4h8v2" />
<path d="M19 6l-1 14H6L5 6" />
<path d="M10 11v6M14 11v6" />
</svg>
</button>
)}
</div>
)}
</div>
</div>
);
}
@ -344,25 +599,17 @@ function areSessionCardPropsEqual(prev: SessionCardProps, next: SessionCardProps
export const SessionCard = memo(SessionCardView, areSessionCardPropsEqual);
function DetailSection({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="mb-2.5">
<div className="mb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
{label}
</div>
{children}
</div>
);
}
interface Alert {
key: string;
label: string;
className: string;
color?: string;
borderColor?: string;
url: string;
count?: number;
actionLabel?: string;
actionMessage?: string;
actionClassName?: string;
}
function getAlerts(session: DashboardSession): Alert[] {
@ -379,19 +626,21 @@ function getAlerts(session: DashboardSession): Alert[] {
alerts.push({
key: "ci-unknown",
label: "CI unknown",
className:
"border-[rgba(245,158,11,0.3)] bg-[rgba(245,158,11,0.08)] text-[var(--color-status-attention)]",
className: "",
color: "var(--color-alert-ci-unknown)",
url: pr.url + "/checks",
});
} else {
alerts.push({
key: "ci-fail",
label: `${failCount} CI check${failCount > 1 ? "s" : ""} failing`,
className:
"border-[rgba(239,68,68,0.3)] bg-[rgba(239,68,68,0.08)] text-[var(--color-status-error)]",
className: "",
color: "var(--color-alert-ci)",
borderColor: "var(--color-alert-ci)",
url: failedCheck?.url ?? pr.url + "/checks",
actionLabel: "ask to fix",
actionMessage: `Please fix the failing CI checks on ${pr.url}`,
actionClassName: "bg-[var(--color-alert-ci-bg)] text-white hover:brightness-110",
});
}
}
@ -400,19 +649,23 @@ function getAlerts(session: DashboardSession): Alert[] {
alerts.push({
key: "changes",
label: "changes requested",
className:
"border-[rgba(239,68,68,0.3)] bg-[rgba(239,68,68,0.08)] text-[var(--color-status-error)]",
className: "",
color: "var(--color-alert-changes)",
url: pr.url,
actionLabel: "ask to address",
actionMessage: `Please address the requested changes on ${pr.url}`,
actionClassName: "bg-[var(--color-alert-changes-bg)] text-white hover:brightness-110",
});
} else if (!pr.isDraft && (pr.reviewDecision === "pending" || pr.reviewDecision === "none")) {
alerts.push({
key: "review",
label: "needs review",
className:
"border-[rgba(245,158,11,0.3)] bg-[rgba(245,158,11,0.08)] text-[var(--color-status-attention)]",
className: "",
color: "var(--color-alert-review)",
url: pr.url,
actionLabel: "ask to post",
actionMessage: `Post ${pr.url} on slack asking for a review.`,
actionClassName: "bg-[var(--color-alert-review-bg)] text-white hover:brightness-110",
});
}
@ -420,11 +673,12 @@ function getAlerts(session: DashboardSession): Alert[] {
alerts.push({
key: "conflict",
label: "merge conflict",
className:
"border-[rgba(239,68,68,0.3)] bg-[rgba(239,68,68,0.08)] text-[var(--color-status-error)]",
className: "",
color: "var(--color-alert-conflict)",
url: pr.url,
actionLabel: "ask to fix",
actionMessage: `Please resolve the merge conflicts on ${pr.url} by rebasing on the base branch`,
actionClassName: "bg-[var(--color-alert-conflict-bg)] text-white hover:brightness-110",
});
}
@ -434,11 +688,13 @@ function getAlerts(session: DashboardSession): Alert[] {
key: "comments",
label: "unresolved comments",
count: pr.unresolvedThreads,
className:
"border-[rgba(239,68,68,0.3)] bg-[rgba(239,68,68,0.08)] text-[var(--color-status-error)]",
className: "",
color: "var(--color-alert-comment)",
borderColor: "var(--color-alert-comment)",
url: firstUrl,
actionLabel: "ask to resolve",
actionMessage: `Please address all unresolved review comments on ${pr.url}`,
actionClassName: "bg-[var(--color-alert-comment-bg)] text-white hover:brightness-110",
});
}

View File

@ -1,13 +1,12 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useRef, type ReactNode } from "react";
import { useSearchParams } from "next/navigation";
import { type DashboardSession, type DashboardPR, isPRMergeReady } from "@/lib/types";
import { CI_STATUS } from "@composio/ao-core/types";
import { cn } from "@/lib/cn";
import { CICheckList } from "./CIBadge";
import { DirectTerminal } from "./DirectTerminal";
import { ActivityDot } from "./ActivityDot";
interface OrchestratorZones {
merge: number;
@ -35,26 +34,8 @@ const activityMeta: Record<string, { label: string; color: string }> = {
exited: { label: "Exited", color: "var(--color-status-error)" },
};
function humanizeStatus(status: string): string {
return status
.replace(/_/g, " ")
.replace(/\bci\b/gi, "CI")
.replace(/\bpr\b/gi, "PR")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
function relativeTime(iso: string): string {
const ms = new Date(iso).getTime();
if (!iso || isNaN(ms)) return "unknown";
const diff = Date.now() - ms;
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return "just now";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
function getSessionHeadline(session: DashboardSession): string {
return session.issueTitle ?? session.summary ?? session.id;
}
function cleanBugbotComment(body: string): { title: string; description: string } {
@ -75,8 +56,97 @@ function buildGitHubBranchUrl(pr: DashboardPR): string {
return `https://github.com/${pr.owner}/${pr.repo}/tree/${pr.branch}`;
}
function buildGitHubRepoUrl(pr: DashboardPR): string {
return `https://github.com/${pr.owner}/${pr.repo}`;
function SessionTopStrip({
headline,
activityLabel,
activityColor,
branch,
pr,
isOrchestrator = false,
rightSlot,
}: {
headline: string;
activityLabel: string;
activityColor: string;
branch: string | null;
pr: DashboardPR | null;
isOrchestrator?: boolean;
rightSlot?: ReactNode;
}) {
return (
<section className="session-page-header">
<div className="session-page-header__crumbs">
<a
href="/"
className="flex items-center gap-1 text-[11px] font-medium text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text-primary)] hover:no-underline"
>
<svg
className="h-3 w-3 opacity-60"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
viewBox="0 0 24 24"
>
<path d="M15 18l-6-6 6-6" />
</svg>
Orchestrator
</a>
<span className="text-[var(--color-border-strong)]">/</span>
<span className="font-[var(--font-mono)] text-[11px] text-[var(--color-text-tertiary)]">
{headline}
</span>
{isOrchestrator ? <span className="session-page-header__mode">orchestrator</span> : null}
</div>
<div className="session-page-header__main">
<div className="session-page-header__identity">
<h1 className="truncate text-[17px] font-semibold tracking-[-0.03em] text-[var(--color-text-primary)]">
{headline}
</h1>
<div className="session-page-header__meta">
<div
className="flex items-center gap-1.5 border px-2.5 py-1"
style={{
background: `color-mix(in srgb, ${activityColor} 12%, transparent)`,
border: `1px solid color-mix(in srgb, ${activityColor} 20%, transparent)`,
}}
>
<span className="h-1.5 w-1.5 shrink-0" style={{ background: activityColor }} />
<span className="text-[11px] font-semibold" style={{ color: activityColor }}>
{activityLabel}
</span>
</div>
{branch ? (
pr ? (
<a
href={buildGitHubBranchUrl(pr)}
target="_blank"
rel="noopener noreferrer"
className="session-detail-link-pill font-[var(--font-mono)] text-[10px] hover:no-underline"
>
{branch}
</a>
) : (
<span className="session-detail-link-pill font-[var(--font-mono)] text-[10px]">
{branch}
</span>
)
) : null}
{pr ? (
<a
href={pr.url}
target="_blank"
rel="noopener noreferrer"
className="session-detail-link-pill session-detail-link-pill--accent hover:no-underline"
>
PR #{pr.number}
</a>
) : null}
</div>
</div>
{rightSlot ? <div className="session-page-header__side">{rightSlot}</div> : null}
</div>
</section>
);
}
async function askAgentToFix(
@ -106,9 +176,19 @@ async function askAgentToFix(
function OrchestratorStatusStrip({
zones,
createdAt,
headline,
activityLabel,
activityColor,
branch,
pr,
}: {
zones: OrchestratorZones;
createdAt: string;
headline: string;
activityLabel: string;
activityColor: string;
branch: string | null;
pr: DashboardPR | null;
}) {
const [uptime, setUptime] = useState<string>("");
@ -137,52 +217,61 @@ function OrchestratorStatusStrip({
zones.merge + zones.respond + zones.review + zones.working + zones.pending + zones.done;
return (
<div
className="border-b border-[var(--color-border-subtle)] px-8 py-4"
style={{
background: "linear-gradient(to bottom, rgba(88,166,255,0.04) 0%, transparent 100%)",
}}
>
<div className="mx-auto flex max-w-[900px] items-center gap-3 flex-wrap">
{/* Total count */}
<div className="flex items-baseline gap-1.5 mr-2">
<span className="text-[22px] font-bold leading-none tabular-nums text-[var(--color-text-primary)]">
{total}
</span>
<span className="text-[11px] text-[var(--color-text-tertiary)]">agents</span>
</div>
<div className="h-5 w-px bg-[var(--color-border-subtle)] mr-1" />
{/* Per-zone pills */}
{stats.length > 0 ? (
stats.map((s) => (
<div
key={s.label}
className="flex items-center gap-1.5 rounded-full px-2.5 py-1"
style={{ background: s.bg }}
>
<span
className="text-[15px] font-bold leading-none tabular-nums"
style={{ color: s.color }}
>
{s.value}
</span>
<span className="text-[10px] font-medium" style={{ color: s.color, opacity: 0.8 }}>
{s.label}
<div className="mx-auto max-w-[1180px] px-5 pt-5 lg:px-8">
<SessionTopStrip
headline={headline}
activityLabel={activityLabel}
activityColor={activityColor}
branch={branch}
pr={pr}
isOrchestrator
rightSlot={
<div className="flex flex-wrap items-center gap-3 lg:justify-end">
<div className="flex items-baseline gap-1.5 mr-2">
<span className="text-[22px] font-bold leading-none tabular-nums text-[var(--color-text-primary)]">
{total}
</span>
<span className="text-[11px] text-[var(--color-text-tertiary)]">agents</span>
</div>
))
) : (
<span className="text-[12px] text-[var(--color-text-tertiary)]">no active agents</span>
)}
{uptime && (
<span className="ml-auto font-[var(--font-mono)] text-[11px] text-[var(--color-text-tertiary)]">
up {uptime}
</span>
)}
</div>
<div className="h-5 w-px bg-[var(--color-border-subtle)] mr-1" />
{/* Per-zone pills */}
{stats.length > 0 ? (
stats.map((s) => (
<div
key={s.label}
className="flex items-center gap-1.5 px-2.5 py-1"
style={{ background: s.bg }}
>
<span
className="text-[15px] font-bold leading-none tabular-nums"
style={{ color: s.color }}
>
{s.value}
</span>
<span
className="text-[10px] font-medium"
style={{ color: s.color, opacity: 0.8 }}
>
{s.label}
</span>
</div>
))
) : (
<span className="text-[12px] text-[var(--color-text-tertiary)]">
no active agents
</span>
)}
{uptime && (
<span className="ml-auto font-[var(--font-mono)] text-[11px] text-[var(--color-text-tertiary)]">
up {uptime}
</span>
)}
</div>
}
/>
</div>
);
}
@ -201,11 +290,12 @@ export function SessionDetail({
label: session.activity ?? "unknown",
color: "var(--color-text-muted)",
};
const headline = getSessionHeadline(session);
const accentColor = "var(--color-accent)";
const terminalVariant = isOrchestrator ? "orchestrator" : "agent";
const terminalHeight = isOrchestrator ? "calc(100vh - 240px)" : "max(440px, calc(100vh - 440px))";
const terminalHeight = isOrchestrator ? "clamp(560px, 76vh, 920px)" : "clamp(520px, 72vh, 860px)";
const isOpenCodeSession = session.metadata["agent"] === "opencode";
const opencodeSessionId =
typeof session.metadata["opencodeSessionId"] === "string" &&
@ -217,233 +307,62 @@ export function SessionDetail({
: undefined;
return (
<div className="min-h-screen bg-[var(--color-bg-base)]">
{/* Nav bar — glass effect */}
<nav className="nav-glass sticky top-0 z-10 border-b border-[var(--color-border-subtle)]">
<div className="mx-auto flex max-w-[900px] items-center gap-2 px-8 py-2.5">
<a
href="/"
className="flex items-center gap-1 text-[11px] font-medium text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text-primary)] hover:no-underline"
>
<svg
className="h-3 w-3 opacity-60"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
viewBox="0 0 24 24"
>
<path d="M15 18l-6-6 6-6" />
</svg>
Orchestrator
</a>
<span className="text-[var(--color-border-strong)]">/</span>
<span className="font-[var(--font-mono)] text-[11px] text-[var(--color-text-tertiary)]">
{session.id}
</span>
{isOrchestrator && (
<span
className="ml-1 rounded px-2 py-0.5 text-[10px] font-semibold tracking-[0.05em]"
style={{
color: accentColor,
background: `color-mix(in srgb, ${accentColor} 10%, transparent)`,
border: `1px solid color-mix(in srgb, ${accentColor} 20%, transparent)`,
}}
>
orchestrator
</span>
)}
</div>
</nav>
{/* Orchestrator status strip */}
<div className="session-detail-page min-h-screen bg-[var(--color-bg-base)]">
{isOrchestrator && orchestratorZones && (
<OrchestratorStatusStrip zones={orchestratorZones} createdAt={session.createdAt} />
<OrchestratorStatusStrip
zones={orchestratorZones}
createdAt={session.createdAt}
headline={headline}
activityLabel={activity.label}
activityColor={activity.color}
branch={session.branch}
pr={pr}
/>
)}
<div className="mx-auto max-w-[900px] px-8 py-6">
{/* ── Header card ─────────────────────────────────────────── */}
<div
className="detail-card mb-6 rounded-[8px] border border-[var(--color-border-default)] p-5"
style={{
borderLeft: isOrchestrator ? `3px solid ${accentColor}` : `3px solid ${activity.color}`,
}}
>
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2.5">
<h1 className="font-[var(--font-mono)] text-[17px] font-semibold tracking-[-0.01em] text-[var(--color-text-primary)]">
{session.id}
</h1>
{/* Activity badge */}
<div
className="flex items-center gap-1.5 rounded-full px-2.5 py-0.5"
style={{
background: `color-mix(in srgb, ${activity.color} 12%, transparent)`,
border: `1px solid color-mix(in srgb, ${activity.color} 20%, transparent)`,
}}
>
<ActivityDot activity={session.activity} dotOnly size={6} />
<span className="text-[11px] font-semibold" style={{ color: activity.color }}>
{activity.label}
</span>
</div>
</div>
{session.summary && (
<p className="mt-2 text-[13px] leading-relaxed text-[var(--color-text-secondary)]">
{session.summary}
</p>
)}
{/* Meta chips */}
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{session.projectId && (
<>
{pr ? (
<a
href={buildGitHubRepoUrl(pr)}
target="_blank"
rel="noopener noreferrer"
className="rounded-[4px] border border-[var(--color-border-subtle)] bg-[rgba(255,255,255,0.04)] px-2 py-0.5 text-[11px] text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-border-strong)] hover:text-[var(--color-text-primary)] hover:no-underline"
>
{session.projectId}
</a>
) : (
<span className="rounded-[4px] border border-[var(--color-border-subtle)] bg-[rgba(255,255,255,0.04)] px-2 py-0.5 text-[11px] text-[var(--color-text-secondary)]">
{session.projectId}
</span>
)}
<span className="text-[var(--color-text-tertiary)]">&middot;</span>
</>
)}
{pr && (
<>
<a
href={pr.url}
target="_blank"
rel="noopener noreferrer"
className="rounded-[4px] border border-[var(--color-border-subtle)] bg-[rgba(255,255,255,0.04)] px-2 py-0.5 text-[11px] text-[var(--color-accent)] transition-colors hover:border-[var(--color-accent)] hover:no-underline"
>
PR #{pr.number}
</a>
{(session.branch || session.issueUrl) && (
<span className="text-[var(--color-text-tertiary)]">&middot;</span>
)}
</>
)}
{session.branch && (
<>
{pr ? (
<a
href={buildGitHubBranchUrl(pr)}
target="_blank"
rel="noopener noreferrer"
className="rounded-[4px] border border-[var(--color-border-subtle)] bg-[rgba(255,255,255,0.04)] px-2 py-0.5 font-[var(--font-mono)] text-[10px] text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-border-strong)] hover:text-[var(--color-text-primary)] hover:no-underline"
>
{session.branch}
</a>
) : (
<span className="rounded-[4px] border border-[var(--color-border-subtle)] bg-[rgba(255,255,255,0.04)] px-2 py-0.5 font-[var(--font-mono)] text-[10px] text-[var(--color-text-secondary)]">
{session.branch}
</span>
)}
{session.issueUrl && (
<span className="text-[var(--color-text-tertiary)]">&middot;</span>
)}
</>
)}
{session.issueUrl && (
<a
href={session.issueUrl}
target="_blank"
rel="noopener noreferrer"
className="rounded-[4px] border border-[var(--color-border-subtle)] bg-[rgba(255,255,255,0.04)] px-2 py-0.5 text-[11px] text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-border-strong)] hover:text-[var(--color-text-primary)] hover:no-underline"
>
{session.issueLabel || session.issueUrl}
</a>
)}
</div>
<ClientTimestamps
status={session.status}
createdAt={session.createdAt}
lastActivityAt={session.lastActivityAt}
/>
</div>
</div>
</div>
{/* ── PR Card ─────────────────────────────────────────────── */}
{pr && <PRCard pr={pr} sessionId={session.id} />}
{/* ── Terminal ─────────────────────────────────────────────── */}
<div className={pr ? "mt-6" : ""}>
<div className="mb-3 flex items-center gap-2">
<div
className="h-3 w-0.5 rounded-full"
style={{ background: isOrchestrator ? accentColor : activity.color, opacity: 0.7 }}
<div className="mx-auto max-w-[1180px] px-5 py-5 lg:px-8">
<main className="min-w-0">
{!isOrchestrator && (
<SessionTopStrip
headline={headline}
activityLabel={activity.label}
activityColor={activity.color}
branch={session.branch}
pr={pr}
/>
<span className="text-[10px] font-bold uppercase tracking-[0.10em] text-[var(--color-text-tertiary)]">
Terminal
</span>
</div>
<DirectTerminal
sessionId={session.id}
startFullscreen={startFullscreen}
variant={terminalVariant}
height={terminalHeight}
isOpenCodeSession={isOpenCodeSession}
reloadCommand={isOpenCodeSession ? reloadCommand : undefined}
/>
</div>
)}
<section className="mt-5">
<div className="mb-3 flex items-center gap-2">
<div
className="h-3 w-0.5"
style={{ background: isOrchestrator ? accentColor : activity.color, opacity: 0.75 }}
/>
<span className="text-[10px] font-bold uppercase tracking-[0.1em] text-[var(--color-text-tertiary)]">
Live Terminal
</span>
</div>
<DirectTerminal
sessionId={session.id}
startFullscreen={startFullscreen}
variant={terminalVariant}
height={terminalHeight}
isOpenCodeSession={isOpenCodeSession}
reloadCommand={isOpenCodeSession ? reloadCommand : undefined}
/>
</section>
{pr ? (
<section className="mt-6">
<PRCard pr={pr} sessionId={session.id} />
</section>
) : null}
</main>
</div>
</div>
);
}
// ── Client-side timestamps ────────────────────────────────────────────
function ClientTimestamps({
status,
createdAt,
lastActivityAt,
}: {
status: string;
createdAt: string;
lastActivityAt: string;
}) {
const [created, setCreated] = useState<string | null>(null);
const [lastActive, setLastActive] = useState<string | null>(null);
useEffect(() => {
setCreated(relativeTime(createdAt));
setLastActive(relativeTime(lastActivityAt));
}, [createdAt, lastActivityAt]);
return (
<div className="mt-2.5 flex flex-wrap items-center gap-x-1.5 text-[11px] text-[var(--color-text-tertiary)]">
<span className="rounded-[3px] bg-[rgba(255,255,255,0.05)] px-1.5 py-0.5 text-[10px] font-medium">
{humanizeStatus(status)}
</span>
{created && (
<>
<span className="opacity-40">&middot;</span>
<span>created {created}</span>
</>
)}
{lastActive && (
<>
<span className="opacity-40">&middot;</span>
<span>active {lastActive}</span>
</>
)}
</div>
);
}
// ── PR Card ───────────────────────────────────────────────────────────
function PRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: string }) {
@ -527,7 +446,7 @@ function PRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: string }) {
: "var(--color-border-default)";
return (
<div className="detail-card mb-6 overflow-hidden rounded-[8px] border" style={{ borderColor }}>
<div className="detail-card mb-6 overflow-hidden border" style={{ borderColor }}>
{/* Title row */}
<div className="border-b border-[var(--color-border-subtle)] px-5 py-3.5">
<a
@ -553,7 +472,7 @@ function PRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: string }) {
<>
<span className="text-[var(--color-text-tertiary)]">&middot;</span>
<span
className="rounded-full px-2 py-0.5 text-[10px] font-semibold"
className="px-2 py-0.5 text-[10px] font-semibold"
style={{ color: "#a371f7", background: "rgba(163,113,247,0.12)" }}
>
Merged
@ -567,7 +486,7 @@ function PRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: string }) {
<div className="px-5 py-4">
{/* Ready-to-merge banner */}
{allGreen ? (
<div className="flex items-center gap-2 rounded-[5px] border border-[rgba(63,185,80,0.25)] bg-[rgba(63,185,80,0.07)] px-3.5 py-2.5">
<div className="flex items-center gap-2 border border-[rgba(63,185,80,0.25)] bg-[rgba(63,185,80,0.07)] px-3.5 py-2.5">
<svg
className="h-4 w-4 shrink-0 text-[var(--color-status-ready)]"
fill="none"
@ -601,7 +520,7 @@ function PRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: string }) {
<h4 className="mb-2.5 flex items-center gap-2 text-[10px] font-bold uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
Unresolved Comments
<span
className="rounded-full px-1.5 py-0.5 text-[10px] font-bold normal-case tracking-normal"
className="px-1.5 py-0.5 text-[10px] font-bold normal-case tracking-normal"
style={{ color: "#f85149", background: "rgba(248,81,73,0.12)" }}
>
{pr.unresolvedThreads}
@ -612,7 +531,7 @@ function PRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: string }) {
const { title, description } = cleanBugbotComment(c.body);
return (
<details key={c.url} className="group">
<summary className="flex cursor-pointer list-none items-center gap-2 rounded-[5px] px-2 py-1.5 text-[12px] transition-colors hover:bg-[rgba(255,255,255,0.04)]">
<summary className="flex cursor-pointer list-none items-center gap-2 px-2 py-1.5 text-[12px] transition-colors hover:bg-[rgba(255,255,255,0.04)]">
<svg
className="h-3 w-3 shrink-0 text-[var(--color-text-tertiary)] transition-transform group-open:rotate-90"
fill="none"
@ -647,7 +566,7 @@ function PRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: string }) {
onClick={() => handleAskAgentToFix(c)}
disabled={sendingComments.has(c.url)}
className={cn(
"mt-1.5 rounded-[4px] px-3 py-1 text-[11px] font-semibold transition-all",
"mt-1.5 px-3 py-1 text-[11px] font-semibold transition-all",
sentComments.has(c.url)
? "bg-[var(--color-status-ready)] text-white"
: errorComments.has(c.url)

View File

@ -0,0 +1,38 @@
// ── State UI ──────────────────────────────────────────────────────────
interface EmptyStateProps {
message?: string;
}
export function EmptyState({
message,
}: EmptyStateProps) {
const isDefault = !message;
return (
<div className="flex flex-col items-center justify-center py-24 text-center">
{/* Terminal icon */}
<svg
className="mb-4 h-8 w-8 text-[var(--color-border-strong)]"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
viewBox="0 0 24 24"
>
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="M6 9l4 3-4 3M13 15h5" />
</svg>
<p className="text-[13px] text-[var(--color-text-muted)]">
{isDefault ? (
<>
No sessions running. Start one with{" "}
<code className="font-[var(--font-mono)] text-[var(--color-text-secondary)]">
ao start
</code>
</>
) : (
message
)}
</p>
</div>
);
}

View File

@ -0,0 +1,35 @@
"use client";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return <div className="h-9 w-9" />;
const isDark = resolvedTheme === "dark";
return (
<button
onClick={() => setTheme(isDark ? "light" : "dark")}
className="flex h-9 w-9 items-center justify-center rounded-[8px] border border-[var(--color-border-strong)] bg-[var(--color-bg-elevated)] text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-bg-elevated-hover)]"
aria-label={`Switch to ${isDark ? "light" : "dark"} mode`}
title={`Switch to ${isDark ? "light" : "dark"} mode`}
>
{isDark ? (
<svg className="h-[18px] w-[18px]" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="5" />
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
</svg>
) : (
<svg className="h-[18px] w-[18px]" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" />
</svg>
)}
</button>
);
}

View File

@ -0,0 +1,58 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Dashboard } from "../Dashboard";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),
usePathname: () => "/",
useSearchParams: () => new URLSearchParams(),
}));
beforeEach(() => {
const eventSourceMock = {
onmessage: null,
onerror: null,
close: vi.fn(),
};
const eventSourceConstructor = vi.fn(() => eventSourceMock as unknown as EventSource);
global.EventSource = Object.assign(eventSourceConstructor, {
CONNECTING: 0,
OPEN: 1,
CLOSED: 2,
}) as unknown as typeof EventSource;
global.fetch = vi.fn();
});
describe("Dashboard empty state", () => {
it("shows empty state when there are no sessions (single-project view)", () => {
render(<Dashboard initialSessions={[]} />);
expect(screen.getByText(/No sessions running/i)).toBeInTheDocument();
});
it("does not show empty state when sessions exist", () => {
const { queryByText } = render(
<Dashboard
initialSessions={[
{
id: "s1",
projectId: "proj",
status: "working",
activity: "active",
branch: "feat/x",
issueId: null,
issueUrl: null,
issueLabel: null,
issueTitle: null,
summary: "Working on it",
summaryIsFallback: false,
createdAt: new Date().toISOString(),
lastActivityAt: new Date().toISOString(),
pr: null,
metadata: {},
},
]}
/>,
);
expect(queryByText(/No sessions running/i)).not.toBeInTheDocument();
});
});

View File

@ -4,6 +4,12 @@ import { Dashboard } from "@/components/Dashboard";
import type { GlobalPauseState } from "@/lib/types";
import { makeSession } from "@/__tests__/helpers";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),
usePathname: () => "/",
useSearchParams: () => new URLSearchParams(),
}));
describe("Dashboard globalPause banner", () => {
let eventSourceMock: {
onmessage: ((event: MessageEvent) => void) | null;

View File

@ -6,6 +6,7 @@ import { makeSession } from "@/__tests__/helpers";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),
usePathname: () => "/",
useSearchParams: () => new URLSearchParams(),
}));
describe("Dashboard project overview cards", () => {

View File

@ -4,6 +4,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const renderCounts = new Map<string, number>();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),
usePathname: () => "/",
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@/components/SessionCard", () => ({
SessionCard: memo(({ session }: { session: { id: string } }) => {
renderCounts.set(session.id, (renderCounts.get(session.id) ?? 0) + 1);

View File

@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { buildDirectTerminalWsUrl } from "@/components/DirectTerminal";
import { buildDirectTerminalWsUrl, buildTerminalThemes } from "@/components/DirectTerminal";
describe("buildDirectTerminalWsUrl", () => {
it("keeps non-standard port when proxy path override is set", () => {
@ -61,3 +61,79 @@ describe("buildDirectTerminalWsUrl", () => {
expect(wsUrl).toBe("ws://localhost:14888/ws?session=session-4");
});
});
const HEX_RE = /^#[0-9a-fA-F]{6}$/;
const ANSI_KEYS = [
"black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
"brightBlack", "brightRed", "brightGreen", "brightYellow", "brightBlue", "brightMagenta", "brightCyan", "brightWhite",
] as const;
function hexToRgb(hex: string): [number, number, number] {
return [
Number.parseInt(hex.slice(1, 3), 16),
Number.parseInt(hex.slice(3, 5), 16),
Number.parseInt(hex.slice(5, 7), 16),
];
}
function toLinear(channel: number): number {
const normalized = channel / 255;
return normalized <= 0.04045
? normalized / 12.92
: Math.pow((normalized + 0.055) / 1.055, 2.4);
}
function relativeLuminance(hex: string): number {
const [r, g, b] = hexToRgb(hex);
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
}
function contrastRatio(a: string, b: string): number {
const lighter = Math.max(relativeLuminance(a), relativeLuminance(b));
const darker = Math.min(relativeLuminance(a), relativeLuminance(b));
return (lighter + 0.05) / (darker + 0.05);
}
describe("buildTerminalThemes", () => {
it("dark theme has valid hex colors for bg, fg, and all ANSI slots", () => {
const { dark } = buildTerminalThemes("agent");
expect(dark.background).toMatch(HEX_RE);
expect(dark.foreground).toMatch(HEX_RE);
for (const key of ANSI_KEYS) {
expect(dark[key]).toMatch(HEX_RE);
}
});
it("light theme has valid hex colors for bg, fg, and all ANSI slots", () => {
const { light } = buildTerminalThemes("agent");
expect(light.background).toBe("#fafafa");
expect(light.foreground).toBe("#24292f");
for (const key of ANSI_KEYS) {
expect(light[key]).toMatch(HEX_RE);
}
});
it("light theme ANSI colors maintain readable contrast on the terminal background", () => {
const { light } = buildTerminalThemes("agent");
for (const key of ANSI_KEYS) {
expect(contrastRatio(light.background!, light[key]!)).toBeGreaterThanOrEqual(4);
}
});
it("dark theme background is #0a0a0f", () => {
const { dark } = buildTerminalThemes("agent");
expect(dark.background).toBe("#0a0a0f");
});
it("variant changes cursor color between agent and orchestrator", () => {
const agent = buildTerminalThemes("agent");
const orch = buildTerminalThemes("orchestrator");
expect(agent.dark.cursor).not.toBe(orch.dark.cursor);
expect(agent.light.cursor).not.toBe(orch.light.cursor);
});
it("selection colors differ between dark and light themes", () => {
const { dark, light } = buildTerminalThemes("agent");
expect(dark.selectionBackground).not.toBe(light.selectionBackground);
});
});

View File

@ -23,18 +23,18 @@ describe("ProjectSidebar", () => {
it("renders nothing when there is only one project", () => {
const { container } = render(
<ProjectSidebar projects={[projects[0]]} activeProjectId="project-1" />,
<ProjectSidebar projects={[projects[0]]} sessions={[]} activeProjectId="project-1" activeSessionId={undefined} />,
);
expect(container.firstChild).toBeNull();
});
it("renders nothing when there are no projects", () => {
const { container } = render(<ProjectSidebar projects={[]} activeProjectId={undefined} />);
const { container } = render(<ProjectSidebar projects={[]} sessions={[]} activeProjectId={undefined} activeSessionId={undefined} />);
expect(container.firstChild).toBeNull();
});
it("renders sidebar with all projects when there are multiple", () => {
render(<ProjectSidebar projects={projects} activeProjectId="project-1" />);
render(<ProjectSidebar projects={projects} sessions={[]} activeProjectId="project-1" activeSessionId={undefined} />);
expect(screen.getByText("Projects")).toBeInTheDocument();
expect(screen.getByText("All Projects")).toBeInTheDocument();
expect(screen.getByText("Project One")).toBeInTheDocument();
@ -43,25 +43,25 @@ describe("ProjectSidebar", () => {
});
it("highlights active project", () => {
render(<ProjectSidebar projects={projects} activeProjectId="project-2" />);
render(<ProjectSidebar projects={projects} sessions={[]} activeProjectId="project-2" activeSessionId={undefined} />);
const projectTwoButton = screen.getByRole("button", { name: "Project Two" });
expect(projectTwoButton.className).toContain("accent");
});
it("highlights 'All Projects' when no project is active", () => {
render(<ProjectSidebar projects={projects} activeProjectId={undefined} />);
render(<ProjectSidebar projects={projects} sessions={[]} activeProjectId={undefined} activeSessionId={undefined} />);
const allProjectsButton = screen.getByRole("button", { name: "All Projects" });
expect(allProjectsButton.className).toContain("accent");
});
it("navigates to project query param when clicking a project", () => {
render(<ProjectSidebar projects={projects} activeProjectId="project-1" />);
render(<ProjectSidebar projects={projects} sessions={[]} activeProjectId="project-1" activeSessionId={undefined} />);
fireEvent.click(screen.getByRole("button", { name: "Project Two" }));
expect(mockPush).toHaveBeenCalledWith("/?project=project-2");
});
it("navigates to 'all' when clicking 'All Projects'", () => {
render(<ProjectSidebar projects={projects} activeProjectId="project-1" />);
render(<ProjectSidebar projects={projects} sessions={[]} activeProjectId="project-1" activeSessionId={undefined} />);
fireEvent.click(screen.getByRole("button", { name: "All Projects" }));
expect(mockPush).toHaveBeenCalledWith("/?project=all");
});
@ -71,7 +71,7 @@ describe("ProjectSidebar", () => {
{ id: "my-app", name: "My App" },
{ id: "other-project", name: "Other Project" },
];
render(<ProjectSidebar projects={projectsWithSpecialChars} activeProjectId="my-app" />);
render(<ProjectSidebar projects={projectsWithSpecialChars} sessions={[]} activeProjectId="my-app" activeSessionId={undefined} />);
fireEvent.click(screen.getByRole("button", { name: "Other Project" }));
expect(mockPush).toHaveBeenCalledWith("/?project=other-project");
});

View File

@ -36,7 +36,7 @@ import {
} from "@composio/ao-core/types";
// Re-export for use in client components
export { TERMINAL_STATUSES, TERMINAL_ACTIVITIES, NON_RESTORABLE_STATUSES };
export { CI_STATUS, TERMINAL_STATUSES, TERMINAL_ACTIVITIES, NON_RESTORABLE_STATUSES };
/**
* Attention zone priority level, ordered by human action urgency:

View File

@ -583,6 +583,9 @@ importers:
next:
specifier: ^15.1.0
version: 15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react:
specifier: ^19.0.0
version: 19.2.4
@ -3157,6 +3160,12 @@ packages:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
next-themes@0.4.6:
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
peerDependencies:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
next@15.5.12:
resolution: {integrity: sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
@ -3638,6 +3647,7 @@ packages:
tar@7.5.7:
resolution: {integrity: sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==}
engines: {node: '>=18'}
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
term-size@2.2.1:
resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==}
@ -6559,6 +6569,11 @@ snapshots:
negotiator@1.0.0: {}
next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
next@15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@next/env': 15.5.12