chore(web): kanban dashboard redesign — Linear-inspired design system

Redesign the kanban dashboard with a Linear-inspired aesthetic:
- New design tokens (neutral charcoal darks, white-alpha borders, muted status colors, indigo accent)
- Tighter spacing, visible column backgrounds, dark mode weight reduction
- Redesigned SessionCard, ProjectSidebar, and AttentionZone components
- Removed list view toggle, reordered columns (respond → merge → done)
- Added ThemeToggle, Skeleton/EmptyState components, and showcase page
- Exported CI_STATUS and getSizeLabel for component consumption

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ashish Huddar 2026-03-21 11:01:31 +05:30
parent 178af32682
commit f9feb4bbf8
11 changed files with 3400 additions and 428 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,990 @@
"use client";
import { useState } from "react";
import type { DashboardSession, DashboardPR, AttentionLevel, DashboardCICheck } from "@/lib/types";
import { AttentionZone } from "@/components/AttentionZone";
import { SessionCard } from "@/components/SessionCard";
import { ActivityDot } from "@/components/ActivityDot";
import { CIBadge } from "@/components/CIBadge";
import { ThemeToggle } from "@/components/ThemeToggle";
import { ProjectSidebar } from "@/components/ProjectSidebar";
import type { ProjectInfo } from "@/lib/project-name";
// ── Mock Data ────────────────────────────────────────────────────────────
function makePR(overrides: Partial<DashboardPR> = {}): DashboardPR {
return {
number: 142,
url: "#",
title: "Add user authentication flow with OAuth2",
owner: "acme",
repo: "agent-orchestrator",
branch: "feat/auth-flow",
baseBranch: "main",
isDraft: false,
state: "open",
additions: 234,
deletions: 48,
ciStatus: "passing",
ciChecks: [
{ name: "build", status: "passed" },
{ name: "lint", status: "passed" },
{ name: "test", status: "passed" },
],
reviewDecision: "approved",
mergeability: {
mergeable: true,
ciPassing: true,
approved: true,
noConflicts: true,
blockers: [],
},
unresolvedThreads: 0,
unresolvedComments: [],
...overrides,
};
}
function makeSession(overrides: Partial<DashboardSession> = {}): DashboardSession {
return {
id: `ses_${Math.random().toString(36).slice(2, 8)}`,
projectId: "proj_alpha",
status: "working",
activity: "active",
branch: "feat/add-feature",
issueId: null,
issueUrl: null,
issueLabel: null,
issueTitle: null,
summary: null,
summaryIsFallback: false,
createdAt: new Date().toISOString(),
lastActivityAt: new Date().toISOString(),
pr: null,
metadata: {},
...overrides,
};
}
// ── Merge Ready sessions
const mergeSessions: DashboardSession[] = [
makeSession({
id: "ses_m1",
status: "approved",
activity: "ready",
branch: "feat/auth-flow",
pr: makePR({
number: 142,
title: "Add user authentication flow with OAuth2",
additions: 234,
deletions: 48,
}),
}),
makeSession({
id: "ses_m2",
status: "approved",
activity: "ready",
branch: "fix/rate-limiter",
pr: makePR({
number: 156,
title: "Fix rate limiter edge case on burst traffic",
additions: 18,
deletions: 5,
}),
}),
];
// ── Respond sessions
const respondSessions: DashboardSession[] = [
makeSession({
id: "ses_r1",
status: "needs_input",
activity: "waiting_input",
branch: "feat/billing-integration",
summary: "Waiting for Stripe API keys to proceed with billing integration",
pr: makePR({
number: 160,
title: "Integrate Stripe billing with subscription tiers",
ciStatus: "passing",
reviewDecision: "pending",
mergeability: {
mergeable: false,
ciPassing: true,
approved: false,
noConflicts: true,
blockers: [],
},
additions: 890,
deletions: 120,
}),
}),
];
// ── Review sessions
const reviewSessions: DashboardSession[] = [
makeSession({
id: "ses_v1",
status: "ci_failed",
activity: "idle",
branch: "feat/search-index",
pr: makePR({
number: 151,
title: "Implement full-text search with Elasticsearch",
ciStatus: "failing",
ciChecks: [
{ name: "build", status: "passed" },
{ name: "lint", status: "passed" },
{ name: "test", status: "failed" },
{ name: "e2e", status: "failed" },
],
reviewDecision: "pending",
mergeability: {
mergeable: false,
ciPassing: false,
approved: false,
noConflicts: true,
blockers: [],
},
additions: 542,
deletions: 89,
}),
}),
makeSession({
id: "ses_v2",
status: "changes_requested",
activity: "idle",
branch: "refactor/api-layer",
pr: makePR({
number: 148,
title: "Refactor API layer to use tRPC",
ciStatus: "passing",
reviewDecision: "changes_requested",
mergeability: {
mergeable: false,
ciPassing: true,
approved: false,
noConflicts: false,
blockers: [],
},
unresolvedThreads: 3,
unresolvedComments: [
{
url: "#",
path: "src/api/router.ts",
author: "reviewer1",
body: "This breaks backward compat",
},
{
url: "#",
path: "src/api/client.ts",
author: "reviewer1",
body: "Missing error handling",
},
{ url: "#", path: "src/api/types.ts", author: "reviewer2", body: "Use zod schema instead" },
],
additions: 1240,
deletions: 680,
}),
}),
];
// ── Pending sessions
const pendingSessions: DashboardSession[] = [
makeSession({
id: "ses_p1",
status: "review_pending",
activity: "idle",
branch: "feat/dark-mode",
pr: makePR({
number: 155,
title: "Add comprehensive dark mode support",
ciStatus: "pending",
ciChecks: [
{ name: "build", status: "running" },
{ name: "test", status: "pending" },
],
reviewDecision: "pending",
mergeability: {
mergeable: false,
ciPassing: false,
approved: false,
noConflicts: true,
blockers: [],
},
additions: 380,
deletions: 95,
}),
}),
];
// ── Working sessions
const workingSessions: DashboardSession[] = [
makeSession({
id: "ses_w1",
status: "working",
activity: "active",
branch: "feat/kanban-filters",
summary: "Implementing column filters for kanban board views",
pr: makePR({
number: 162,
title: "Add column filter controls to kanban board",
ciStatus: "pending",
reviewDecision: "none",
mergeability: {
mergeable: false,
ciPassing: false,
approved: false,
noConflicts: true,
blockers: [],
},
additions: 156,
deletions: 22,
}),
}),
makeSession({
id: "ses_w2",
status: "working",
activity: "active",
branch: "feat/sidebar-tree",
summary: "Building collapsible project sidebar with health indicators",
}),
makeSession({
id: "ses_w3",
status: "working",
activity: "active",
branch: "feat/sse-events",
summary: "Wiring real-time SSE event streaming for live updates",
}),
];
// ── Done sessions
const doneSessions: DashboardSession[] = [
makeSession({
id: "ses_d1",
status: "merged",
activity: "exited",
branch: "feat/theme-toggle",
pr: makePR({
number: 138,
title: "Light/dark theme toggle with CSS custom properties",
state: "merged",
additions: 420,
deletions: 180,
}),
}),
makeSession({
id: "ses_d2",
status: "killed",
activity: "exited",
branch: "experiment/webgl-chart",
summary: "Experimental WebGL chart rendering (abandoned)",
}),
makeSession({
id: "ses_d3",
status: "done",
activity: "exited",
branch: "fix/ssr-hydration",
pr: makePR({
number: 135,
title: "Fix SSR hydration mismatch in theme provider",
state: "merged",
additions: 24,
deletions: 8,
}),
}),
];
const allSessions = [
...mergeSessions,
...respondSessions,
...reviewSessions,
...pendingSessions,
...workingSessions,
...doneSessions,
];
const mockProjects: ProjectInfo[] = [
{ id: "proj_alpha", name: "Alpha" },
{ id: "proj_beta", name: "Beta" },
{ id: "proj_gamma", name: "Gamma" },
];
// ── Showcase Page ────────────────────────────────────────────────────────
const ZONE_ORDER: AttentionLevel[] = ["respond", "review", "pending", "working", "merge", "done"];
const zoneMap: Record<AttentionLevel, DashboardSession[]> = {
merge: mergeSessions,
respond: respondSessions,
review: reviewSessions,
pending: pendingSessions,
working: workingSessions,
done: doneSessions,
};
const ACTIVITY_STATES = ["active", "ready", "idle", "waiting_input", "blocked", "exited"] as const;
const CI_STATUSES: Array<{
status: "passing" | "failing" | "pending" | "none";
checks?: DashboardCICheck[];
}> = [
{
status: "passing",
checks: [
{ name: "build", status: "passed" },
{ name: "test", status: "passed" },
],
},
{
status: "failing",
checks: [
{ name: "build", status: "passed" },
{ name: "test", status: "failed" },
],
},
{
status: "pending",
checks: [
{ name: "build", status: "running" },
{ name: "test", status: "pending" },
],
},
{ status: "none" },
];
export default function ShowcasePage() {
const [sidebarVisible, setSidebarVisible] = useState(true);
const noop = () => {};
const totalSessions = allSessions.length;
const workingCount = workingSessions.length;
const reviewLoad = respondSessions.length + reviewSessions.length + pendingSessions.length;
const mergeReadyCount = mergeSessions.length;
return (
<div className="min-h-screen bg-[var(--color-bg-base)]">
<div className="mx-auto max-w-[1480px] px-4 py-4 md:px-6 md:py-5">
<section className="dashboard-hero mb-8">
<div className="dashboard-hero__backdrop" />
<div className="dashboard-hero__content">
<div className="dashboard-hero__heading">
<div className="dashboard-eyebrow">
<span className="dashboard-eyebrow__dot" />
Showcase reference
</div>
<div>
<h1 className="dashboard-title">Dashboard UI Overhaul</h1>
<p className="dashboard-subtitle">
Reference surface for the redesigned dark-mode kanban dashboard, including the
mission-control header, project rail, lane treatment, and card hierarchy.
</p>
</div>
<div className="mt-2 flex flex-wrap gap-2">
{[
"Mission Control Hero",
"Dark Kanban Lanes",
"Project Rail",
"Session Card States",
"CI / PR Signals",
"Theme Tokens",
].map((tag) => (
<span
key={tag}
className="rounded-full border border-[var(--color-border-subtle)] bg-[var(--color-tint-blue)] px-2.5 py-1 text-[11px] font-medium text-[var(--color-accent)]"
>
{tag}
</span>
))}
</div>
</div>
<div className="dashboard-hero__meta">
<div className="dashboard-stats">
<span className="dashboard-stat">
<span className="dashboard-stat__value">{totalSessions}</span>
<span className="dashboard-stat__label">sessions</span>
</span>
<span className="dashboard-stat">
<span
className="dashboard-stat__value"
style={{ color: "var(--color-status-working)" }}
>
{workingCount}
</span>
<span className="dashboard-stat__label">working</span>
</span>
<span className="dashboard-stat">
<span
className="dashboard-stat__value"
style={{ color: "var(--color-status-attention)" }}
>
{reviewLoad}
</span>
<span className="dashboard-stat__label">attention load</span>
</span>
<span className="dashboard-stat">
<span
className="dashboard-stat__value"
style={{ color: "var(--color-status-ready)" }}
>
{mergeReadyCount}
</span>
<span className="dashboard-stat__label">merge ready</span>
</span>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => setSidebarVisible(!sidebarVisible)}
className="orchestrator-btn rounded-[7px] px-4 py-2 text-[12px] font-semibold"
>
{sidebarVisible ? "Hide" : "Show"} sidebar
</button>
<ThemeToggle />
</div>
</div>
</div>
</section>
{/* ── Section: Kanban Board ──────────────────────────── */}
<Section
title="Kanban Board"
subtitle="Primary reference view for the redesign, matching the live dashboards dark-mode structure"
>
<div className="overflow-hidden border border-[var(--color-border-default)] bg-[var(--color-bg-surface)]">
<div className="flex items-center justify-between gap-4 border-b border-[var(--color-border-subtle)] px-5 py-3">
<div>
<div className="text-[10px] font-semibold uppercase tracking-[0.14em] text-[var(--color-accent)]">
Live board shell
</div>
<div className="mt-1 text-[13px] text-[var(--color-text-secondary)]">
Session lanes, sidebar, and card hierarchy as they should look in the app.
</div>
</div>
<div className="flex flex-wrap gap-2 text-[11px] text-[var(--color-text-muted)]">
<span className="rounded-full border border-[var(--color-border-subtle)] px-2.5 py-1">
Graphite surface
</span>
<span className="rounded-full border border-[var(--color-border-subtle)] px-2.5 py-1">
Bright status accents
</span>
<span className="rounded-full border border-[var(--color-border-subtle)] px-2.5 py-1">
Independent lane scroll
</span>
</div>
</div>
<div className="dashboard-shell flex min-h-[760px]">
{sidebarVisible && (
<div className="shrink-0">
<ProjectSidebar
projects={mockProjects}
sessions={allSessions}
activeProjectId="proj_alpha"
activeSessionId={undefined}
/>
</div>
)}
<div className="dashboard-main flex-1 overflow-hidden px-4 py-4 md:px-6 md:py-5">
<div className="kanban-board-wrap">
<div className="kanban-board">
{ZONE_ORDER.map((level) => (
<AttentionZone
key={level}
level={level}
sessions={zoneMap[level]}
onSend={noop}
onKill={noop}
onMerge={noop}
onRestore={noop}
/>
))}
</div>
</div>
</div>
</div>
</div>
</Section>
{/* ── Section: Attention Zones ───────────────────────── */}
<Section
title="Attention Zones"
subtitle="The redesigned lane language: explicit title, count, and short operator caption"
>
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6">
{(
[
{
level: "merge",
label: "Merge Ready",
color: "var(--color-status-ready)",
desc: "PR approved + CI green",
},
{
level: "respond",
label: "Respond",
color: "var(--color-status-error)",
desc: "Agent waiting for input",
},
{
level: "review",
label: "Review",
color: "var(--color-accent-orange)",
desc: "CI failed / changes requested",
},
{
level: "pending",
label: "Pending",
color: "var(--color-status-attention)",
desc: "Waiting on reviewer / CI",
},
{
level: "working",
label: "Working",
color: "var(--color-status-working)",
desc: "Agent actively coding",
},
{
level: "done",
label: "Done",
color: "var(--color-text-tertiary)",
desc: "Merged or terminated",
},
] as const
).map((zone) => (
<div
key={zone.level}
className="border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] p-4"
>
<div className="mb-3 flex items-center gap-2">
<div
className="h-2.5 w-2.5 rounded-full shadow-[0_0_18px_currentColor]"
style={{ background: zone.color }}
/>
<span className="text-[12px] font-semibold text-[var(--color-text-primary)]">
{zone.label}
</span>
</div>
<div className="mb-2 text-[10px] uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">
Lane rule
</div>
<p className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
{zone.desc}
</p>
</div>
))}
</div>
</Section>
{/* ── Section: Activity States ───────────────────────── */}
<Section
title="Activity Indicators"
subtitle="Real-time activity dots and pills showing agent state"
>
<div className="flex flex-wrap items-center gap-4">
{ACTIVITY_STATES.map((activity) => (
<div key={activity} className="flex items-center gap-3">
<ActivityDot activity={activity} />
<ActivityDot activity={activity} dotOnly size={8} />
</div>
))}
</div>
</Section>
{/* ── Section: CI Badges ─────────────────────────────── */}
<Section
title="CI Status Badges"
subtitle="CI check status indicators with inline and expanded views"
>
<div className="flex flex-wrap items-center gap-4">
{CI_STATUSES.map((ci) => (
<CIBadge key={ci.status} status={ci.status} checks={ci.checks} />
))}
</div>
</Section>
{/* ── Section: Card Variants ─────────────────────────── */}
<Section
title="Session Card Variants"
subtitle="Reference cards for each major state in the redesigned board"
>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{/* Merge-ready card */}
<div>
<Label>Merge Ready</Label>
<SessionCard
session={mergeSessions[0]}
onSend={noop}
onKill={noop}
onMerge={noop}
onRestore={noop}
/>
</div>
{/* Respond card */}
<div>
<Label>Needs Response</Label>
<SessionCard
session={respondSessions[0]}
onSend={noop}
onKill={noop}
onMerge={noop}
onRestore={noop}
/>
</div>
{/* Review card with CI failures */}
<div>
<Label>Review (CI Failing)</Label>
<SessionCard
session={reviewSessions[0]}
onSend={noop}
onKill={noop}
onMerge={noop}
onRestore={noop}
/>
</div>
{/* Review card with unresolved comments */}
<div>
<Label>Review (Changes Requested)</Label>
<SessionCard
session={reviewSessions[1]}
onSend={noop}
onKill={noop}
onMerge={noop}
onRestore={noop}
/>
</div>
{/* Working card */}
<div>
<Label>Working</Label>
<SessionCard
session={workingSessions[0]}
onSend={noop}
onKill={noop}
onMerge={noop}
onRestore={noop}
/>
</div>
{/* Done card (merged) */}
<div>
<Label>Done (Merged)</Label>
<SessionCard
session={doneSessions[0]}
onSend={noop}
onKill={noop}
onMerge={noop}
onRestore={noop}
/>
</div>
</div>
</Section>
{/* ── Section: Design Tokens ─────────────────────────── */}
<Section
title="Design System"
subtitle="CSS custom property tokens powering the entire UI — toggle theme to see both palettes"
>
{/* Colors */}
<div className="mb-6">
<Label>Status Colors</Label>
<div className="flex flex-wrap gap-3">
{[
{ name: "Working", var: "--color-status-working" },
{ name: "Ready", var: "--color-status-ready" },
{ name: "Attention", var: "--color-status-attention" },
{ name: "Error", var: "--color-status-error" },
{ name: "Done", var: "--color-status-done" },
{ name: "Idle", var: "--color-status-idle" },
].map((c) => (
<div key={c.var} className="flex items-center gap-2">
<div
className="h-6 w-6 rounded-full border border-[var(--color-border-default)]"
style={{ background: `var(${c.var})` }}
/>
<div>
<div className="text-[11px] font-medium text-[var(--color-text-primary)]">
{c.name}
</div>
<div className="font-mono text-[9px] text-[var(--color-text-muted)]">
{c.var}
</div>
</div>
</div>
))}
</div>
</div>
<div className="mb-6">
<Label>Accent Colors</Label>
<div className="flex flex-wrap gap-3">
{[
{ name: "Blue", var: "--color-accent-blue" },
{ name: "Green", var: "--color-accent-green" },
{ name: "Yellow", var: "--color-accent-yellow" },
{ name: "Orange", var: "--color-accent-orange" },
{ name: "Red", var: "--color-accent-red" },
{ name: "Violet", var: "--color-accent-violet" },
].map((c) => (
<div key={c.var} className="flex items-center gap-2">
<div
className="h-6 w-6 rounded-full border border-[var(--color-border-default)]"
style={{ background: `var(${c.var})` }}
/>
<div>
<div className="text-[11px] font-medium text-[var(--color-text-primary)]">
{c.name}
</div>
<div className="font-mono text-[9px] text-[var(--color-text-muted)]">
{c.var}
</div>
</div>
</div>
))}
</div>
</div>
<div className="mb-6">
<Label>Surfaces</Label>
<div className="flex flex-wrap gap-3">
{[
{ name: "Base", var: "--color-bg-base" },
{ name: "Surface", var: "--color-bg-surface" },
{ name: "Elevated", var: "--color-bg-elevated" },
{ name: "Subtle", var: "--color-bg-subtle" },
].map((c) => (
<div key={c.var} className="flex items-center gap-2">
<div
className="h-6 w-12 rounded border border-[var(--color-border-default)]"
style={{ background: `var(${c.var})` }}
/>
<div>
<div className="text-[11px] font-medium text-[var(--color-text-primary)]">
{c.name}
</div>
<div className="font-mono text-[9px] text-[var(--color-text-muted)]">
{c.var}
</div>
</div>
</div>
))}
</div>
</div>
<div className="mb-6">
<Label>Tint Backgrounds</Label>
<div className="flex flex-wrap gap-3">
{[
{ name: "Blue", var: "--color-tint-blue" },
{ name: "Green", var: "--color-tint-green" },
{ name: "Yellow", var: "--color-tint-yellow" },
{ name: "Red", var: "--color-tint-red" },
{ name: "Violet", var: "--color-tint-violet" },
{ name: "Orange", var: "--color-tint-orange" },
{ name: "Neutral", var: "--color-tint-neutral" },
].map((c) => (
<div key={c.var} className="flex items-center gap-2">
<div
className="h-6 w-12 rounded border border-[var(--color-border-default)]"
style={{ background: `var(${c.var})` }}
/>
<div>
<div className="text-[11px] font-medium text-[var(--color-text-primary)]">
{c.name}
</div>
<div className="font-mono text-[9px] text-[var(--color-text-muted)]">
{c.var}
</div>
</div>
</div>
))}
</div>
</div>
{/* Typography */}
<div className="mb-6">
<Label>Typography</Label>
<div className="space-y-3 rounded-[8px] border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] p-5">
<div>
<span className="text-[17px] font-bold tracking-[-0.02em] text-[var(--color-text-primary)]">
Heading XL IBM Plex Sans 17px/700
</span>
</div>
<div>
<span className="text-[14px] font-semibold text-[var(--color-text-primary)]">
Heading IBM Plex Sans 14px/600
</span>
</div>
<div>
<span className="text-[13px] text-[var(--color-text-primary)]">
Body IBM Plex Sans 13px/400
</span>
</div>
<div>
<span className="text-[11px] font-semibold uppercase tracking-[0.06em] text-[var(--color-text-secondary)]">
Label IBM Plex Sans 11px/600 uppercase
</span>
</div>
<div>
<span className="font-mono text-[11px] text-[var(--color-text-muted)]">
Monospace IBM Plex Mono 11px
</span>
</div>
</div>
</div>
</Section>
{/* ── Section: Animations ────────────────────────────── */}
<Section
title="Animations"
subtitle="Smooth transitions and micro-interactions throughout the UI"
>
<div className="grid gap-6 md:grid-cols-3">
{/* Activity pulse */}
<div className="rounded-[8px] border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] p-5">
<Label>Activity Pulse</Label>
<p className="mb-3 text-[11px] text-[var(--color-text-muted)]">
Active agents show a pulsing glow effect
</p>
<div className="flex items-center gap-4">
<div
className="h-3 w-3 rounded-full animate-[activity-pulse_2s_ease-in-out_infinite]"
style={{ background: "var(--color-status-working)" }}
/>
<div
className="h-3 w-3 rounded-full animate-[activity-pulse_2s_ease-in-out_infinite]"
style={{ background: "var(--color-status-error)" }}
/>
<div
className="h-3 w-3 rounded-full animate-[activity-pulse_2s_ease-in-out_infinite]"
style={{ background: "var(--color-status-ready)" }}
/>
</div>
</div>
{/* Page enter */}
<div className="rounded-[8px] border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] p-5">
<Label>Page Enter</Label>
<p className="mb-3 text-[11px] text-[var(--color-text-muted)]">
Pages slide up on entry for smooth navigation
</p>
<div className="page-enter text-[12px] text-[var(--color-text-secondary)]">
Content slides in from below
</div>
</div>
{/* Skeleton loading */}
<div className="rounded-[8px] border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] p-5">
<Label>Loading Skeleton</Label>
<p className="mb-3 text-[11px] text-[var(--color-text-muted)]">
Shimmer effect for loading states
</p>
<div className="space-y-2">
<div className="h-3 w-3/4 animate-pulse rounded bg-[var(--color-border-subtle)]" />
<div className="h-3 w-1/2 animate-pulse rounded bg-[var(--color-border-subtle)]" />
<div className="h-3 w-5/6 animate-pulse rounded bg-[var(--color-border-subtle)]" />
</div>
</div>
</div>
</Section>
{/* ── Section: Alert Banners ─────────────────────────── */}
<Section title="Alert Banners" subtitle="Contextual alerts for system-wide events">
<div className="space-y-4">
<div className="flex items-center gap-2.5 rounded 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"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</svg>
<span className="flex-1">
<strong>Orchestrator paused:</strong> Rate limit exceeded. Resume after 2:30 PM
</span>
</div>
<div className="flex items-center gap-2.5 rounded 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"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</svg>
<span className="flex-1">
GitHub API rate limited PR data (CI status, review state, sizes) may be stale.
</span>
</div>
</div>
</Section>
{/* ── Section: Theme Comparison ──────────────────────── */}
<Section
title="Theme Support"
subtitle="Toggle the theme button in the top-right to switch between light and dark modes"
>
<div className="rounded-[8px] border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] p-6">
<div className="flex items-center gap-4">
<ThemeToggle />
<div>
<p className="text-[13px] font-medium text-[var(--color-text-primary)]">
Full Light / Dark Mode
</p>
<p className="text-[11px] text-[var(--color-text-muted)]">
50+ CSS custom property tokens with smooth 0.25s transitions. Theme persists via
localStorage. FOUC prevention with beforeInteractive script.
</p>
</div>
</div>
</div>
</Section>
{/* ── Footer ─────────────────────────────────────────── */}
<footer className="mt-16 border-t border-[var(--color-border-subtle)] pt-6 pb-12 text-center">
<p className="text-[11px] text-[var(--color-text-muted)]">
Agent Orchestrator Dashboard UI Overhaul Showcase
</p>
</footer>
</div>
</div>
);
}
// ── Helper Components ────────────────────────────────────────────────────
function Section({
title,
subtitle,
children,
}: {
title: string;
subtitle: string;
children: React.ReactNode;
}) {
return (
<section className="mb-14">
<div className="mb-5">
<h2 className="text-[17px] font-bold tracking-[-0.02em] text-[var(--color-text-primary)]">
{title}
</h2>
<p className="mt-1 text-[13px] text-[var(--color-text-muted)]">{subtitle}</p>
</div>
{children}
</section>
);
}
function Label({ children }: { children: React.ReactNode }) {
return (
<div className="mb-2 text-[10px] font-bold uppercase tracking-[0.10em] text-[var(--color-text-tertiary)]">
{children}
</div>
);
}

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: "Merge 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,6 +61,8 @@ 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] =
@ -67,6 +72,11 @@ export function Dashboard({
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 +90,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 +110,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 +224,7 @@ export function Dashboard({
}
};
const hasKanbanSessions = KANBAN_LEVELS.some((level) => grouped[level].length > 0);
const hasAnySessions = sessions.length > 0;
const anyRateLimited = useMemo(
() => sessions.some((session) => session.pr && isPRRateLimited(session.pr)),
@ -245,22 +255,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}
/>
)}
<div className="dashboard-main flex-1 overflow-y-auto px-4 py-4 md:px-6 md:py-5">
<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__heading">
<div className="dashboard-eyebrow">
<span className="dashboard-eyebrow__dot" />
Overview
</div>
<div>
<h1 className="dashboard-title">{projectName ?? "Orchestrator"}</h1>
<p className="dashboard-subtitle">
Live sessions, review pressure, and merge readiness.
</p>
</div>
</div>
<div className="dashboard-hero__meta">
<StatusLine stats={liveStats} />
<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 rounded 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 +332,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 rounded 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,39 +374,25 @@ 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="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]">
@ -615,19 +634,17 @@ function StatusLine({ stats }: { stats: DashboardStats }) {
];
return (
<div className="flex items-baseline gap-0.5">
<div className="dashboard-stats">
{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>
)}
<span key={part.label} className="dashboard-stat">
<span
className="text-[20px] font-bold tabular-nums tracking-tight"
className="dashboard-stat__value"
style={{ color: part.color ?? "var(--color-text-primary)" }}
>
{part.value}
</span>
<span className="ml-1.5 text-[11px] text-[var(--color-text-muted)]">{part.label}</span>
<span className="dashboard-stat__label">{part.label}</span>
{index < parts.length - 1 && <span className="dashboard-stat__divider" />}
</span>
))}
</div>

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,63 +1,283 @@
"use client";
import { useState, useEffect } 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;
}
export function ProjectSidebar({ projects, activeProjectId }: ProjectSidebarProps) {
type ProjectHealth = "red" | "yellow" | "green" | "gray";
function computeProjectHealth(sessions: DashboardSession[]): ProjectHealth {
const workers = sessions.filter((s) => !isOrchestratorSession(s));
if (workers.length === 0) return "gray";
for (const s of workers) {
if (getAttentionLevel(s) === "respond") return "red";
}
for (const s of workers) {
const lvl = getAttentionLevel(s);
if (lvl === "review" || lvl === "pending") return "yellow";
}
return "green";
}
const healthDotColor: Record<ProjectHealth, string> = {
red: "var(--color-status-error)",
yellow: "var(--color-status-attention)",
green: "var(--color-status-ready)",
gray: "var(--color-text-tertiary)",
};
const sessionDotColor: Record<AttentionLevel, string> = {
merge: "var(--color-status-ready)",
respond: "var(--color-status-error)",
review: "var(--color-accent-orange)",
pending: "var(--color-status-attention)",
working: "var(--color-status-working)",
done: "var(--color-text-tertiary)",
};
const sessionToneLabel: Record<AttentionLevel, string> = {
merge: "merge",
respond: "reply",
review: "review",
pending: "wait",
working: "live",
done: "done",
};
function SessionDot({ level }: { level: AttentionLevel }) {
return (
<div
className={cn(
"h-[7px] w-[7px] shrink-0 rounded-full",
level === "respond" && "animate-[activity-pulse_2s_ease-in-out_infinite]",
)}
style={{ background: sessionDotColor[level] }}
/>
);
}
function HealthDot({ health }: { health: ProjectHealth }) {
return (
<div
className={cn(
"h-2 w-2 shrink-0 rounded-full",
health === "red" && "animate-[activity-pulse_2s_ease-in-out_infinite]",
)}
style={{ background: healthDotColor[health] }}
/>
);
}
export function ProjectSidebar({
projects,
sessions,
activeProjectId,
activeSessionId,
}: ProjectSidebarProps) {
const router = useRouter();
const pathname = usePathname();
const handleProjectClick = (projectId: string | null) => {
if (projectId === null) {
router.push(pathname + "?project=all");
} else {
router.push(pathname + `?project=${encodeURIComponent(projectId)}`);
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)}`);
};
if (projects.length <= 1) {
return null;
}
const totalWorkerSessions = sessions.filter((s) => !isOrchestratorSession(s)).length;
const needsInputCount = sessions.filter((s) => getAttentionLevel(s) === "respond").length;
const reviewLoadCount = sessions.filter((s) => {
const level = getAttentionLevel(s);
return level === "review" || level === "pending";
}).length;
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>
<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 py-2">
<nav className="flex-1 overflow-y-auto px-2 pb-3">
<button
onClick={() => handleProjectClick(null)}
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 projectSessions = sessions.filter((s) => s.projectId === project.id);
const workerSessions = projectSessions.filter((s) => !isOrchestratorSession(s));
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.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>
</aside>
);

View File

@ -8,13 +8,13 @@ import {
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;
@ -33,6 +33,74 @@ const borderColorByLevel: Record<AttentionLevel, string> = {
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);
const [sendingAction, setSendingAction] = useState<string | null>(null);
@ -62,25 +130,219 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
const isRestorable = isTerminal && session.status !== "merged";
const title = getSessionTitle(session);
const isDone = level === "done";
/* ── 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]",
"hover:border-[var(--color-border-strong)]",
borderColorByLevel[level],
`card-glow-${level}`,
"rounded-none",
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,
background: expanded && !isReadyToMerge ? "var(--card-expanded-bg)" : undefined,
}}
onClick={(e) => {
if ((e.target as HTMLElement).closest("a, button, textarea")) return;
@ -88,8 +350,15 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
}}
>
{/* 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 ? (
<span className="merge-ready-pill">
<span className="merge-ready-pill__dot" />
merge ready
</span>
) : (
<ActivityDot activity={session.activity} />
)}
<span className="font-[var(--font-mono)] text-[11px] tracking-wide text-[var(--color-text-muted)]">
{session.id}
</span>
@ -100,8 +369,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,15 +388,26 @@ 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="inline-flex items-center gap-1 border border-[var(--color-border-default)] bg-[var(--color-bg-subtle)] px-2 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"
>
<svg
className="h-3 w-3"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="M6 10l4 2-4 2" />
<path d="M14 14h4" />
</svg>
terminal
</a>
)}
</div>
{/* Title — its own row, bigger, can wrap */}
<div className="px-4 pb-3">
<div className="session-card__title-wrap px-4 pb-3">
<p
className={cn(
"leading-snug [display:-webkit-box] [-webkit-box-orient:vertical] [-webkit-line-clamp:3] overflow-hidden",
@ -130,17 +420,39 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
</p>
</div>
{/* Meta row: branch + PR pills */}
<div className="flex flex-wrap items-center gap-1.5 px-4 pb-2.5">
{session.branch && (
{/* 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.5">
{isReadyToMerge && session.branch && (
<span className="merge-ready-chip font-[var(--font-mono)]">{session.branch}</span>
)}
{!isReadyToMerge && 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 && (
<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 && isReadyToMerge && (
<span className="merge-ready-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>
)}
{pr && !rateLimited && !isReadyToMerge && (
<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>
)}
{pr && <PRStatus pr={pr} />}
</div>
{/* Rate limited indicator */}
@ -164,14 +476,14 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
{/* Merge button or alert tags */}
{!rateLimited && (alerts.length > 0 || isReadyToMerge) && (
<div className="px-4 pb-3.5 pt-0.5">
<div className="session-card__actions 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"
className="merge-ready-action inline-flex items-center gap-1.5 rounded-[5px] border px-3 py-1.5 text-[12px] font-semibold transition-[filter,transform,background,border-color,box-shadow] duration-[120ms]"
>
<svg
className="h-3.5 w-3.5"
@ -182,33 +494,51 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
>
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
Merge PR #{pr.number}
Merge PR
</button>
) : (
<div className="flex flex-wrap gap-1">
<div className="flex flex-wrap gap-1.5">
{alerts.map((alert) => (
<span key={alert.key} className="inline-flex items-center gap-1">
<span
key={alert.key}
className="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",
"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>
@ -220,114 +550,124 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
</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>
)}
{/* Expandable detail panel — animated via CSS grid-template-rows */}
<div
style={{
display: "grid",
gridTemplateRows: expanded ? "1fr" : "0fr",
transition: "grid-template-rows 200ms ease",
}}
>
<div inert={!expanded} style={{ overflow: "hidden" }}>
{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>
)}
{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.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>
{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={c.url}
href={pr.url}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 text-[11px] text-[var(--color-accent)] hover:underline"
className="hover:underline"
>
view
{pr.title}
</a>
</div>
))}
<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="border border-[color-mix(in_srgb,var(--color-accent)_35%,transparent)] px-2.5 py-1 text-[11px] text-[var(--color-accent)] transition-colors hover:bg-[var(--color-tint-blue)]"
>
restore session
</button>
)}
{!isTerminal && (
<button
onClick={(e) => {
e.stopPropagation();
onKill?.(session.id);
}}
className="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:bg-[var(--color-tint-red)]"
>
terminate
</button>
)}
</div>
</DetailSection>
</div>
)}
{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>
)}
</div>
</div>
);
}
@ -359,10 +699,13 @@ 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 +722,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 +745,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 +769,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 +784,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

@ -0,0 +1,185 @@
// ── Skeleton primitives & state UI ────────────────────────────────────
interface SkeletonBlockProps {
className?: string;
}
export function SkeletonBlock({ className = "" }: SkeletonBlockProps) {
return (
<div
className={`animate-pulse rounded bg-[var(--color-border-subtle)] ${className}`}
/>
);
}
// ── Empty State ────────────────────────────────────────────────────────
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>
);
}
// ── Error State ────────────────────────────────────────────────────────
interface ErrorStateProps {
message: string;
onRetry?: () => void;
}
export function ErrorState({ message, onRetry }: ErrorStateProps) {
return (
<div className="flex flex-col items-center justify-center py-24 text-center">
{/* Error icon */}
<svg
className="mb-4 h-8 w-8 text-[var(--color-status-error)]"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
viewBox="0 0 24 24"
>
<circle cx="12" cy="12" r="10" />
<path d="M15 9l-6 6M9 9l6 6" />
</svg>
<p className="mb-4 text-[13px] text-[var(--color-status-error)]">{message}</p>
{onRetry && (
<button
onClick={onRetry}
className="rounded-[6px] border border-[var(--color-border-default)] px-4 py-1.5 text-[12px] font-medium text-[var(--color-text-secondary)] hover:bg-[var(--color-hover-overlay)] hover:text-[var(--color-text-primary)]"
>
Retry
</button>
)}
<a
href="/"
className="mt-3 text-[12px] text-[var(--color-accent)] hover:underline"
>
Back to dashboard
</a>
</div>
);
}
// ── Dashboard Skeleton ─────────────────────────────────────────────────
export function DashboardSkeleton() {
return (
<div className="flex h-screen">
<div className="flex-1 overflow-y-auto px-8 py-7">
{/* Header bar */}
<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">
<SkeletonBlock className="h-4 w-28" />
<SkeletonBlock className="h-6 w-40" />
</div>
<SkeletonBlock className="h-7 w-24 rounded-[7px]" />
</div>
{/* Kanban-style card columns */}
<div className="mb-8 flex gap-4">
{[1, 2, 3].map((i) => (
<div key={i} className="min-w-[200px] flex-1 space-y-3">
<SkeletonBlock className="h-3 w-20" />
{[1, 2].map((j) => (
<div
key={j}
className="rounded-[8px] border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] p-4 space-y-2"
>
<SkeletonBlock className="h-3 w-3/4" />
<SkeletonBlock className="h-3 w-1/2" />
<SkeletonBlock className="h-3 w-5/6" />
</div>
))}
</div>
))}
</div>
</div>
</div>
);
}
// ── SessionDetail Skeleton ─────────────────────────────────────────────
export function SessionDetailSkeleton() {
return (
<div className="min-h-screen bg-[var(--color-bg-base)]">
{/* Nav bar */}
<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">
<SkeletonBlock className="h-3 w-20" />
<span className="text-[var(--color-border-strong)]">/</span>
<SkeletonBlock className="h-3 w-32" />
</div>
</nav>
<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">
<div className="space-y-3">
<div className="flex items-center gap-2.5">
<SkeletonBlock className="h-5 w-48" />
<SkeletonBlock className="h-5 w-20 rounded-full" />
</div>
<SkeletonBlock className="h-3 w-3/4" />
<div className="flex items-center gap-2">
<SkeletonBlock className="h-4 w-16 rounded-[4px]" />
<SkeletonBlock className="h-4 w-12 rounded-[4px]" />
<SkeletonBlock className="h-4 w-24 rounded-[4px]" />
</div>
<div className="flex items-center gap-2">
<SkeletonBlock className="h-3 w-16 rounded-[3px]" />
<SkeletonBlock className="h-3 w-20" />
</div>
</div>
</div>
{/* PR card skeleton */}
<div className="detail-card mb-6 overflow-hidden rounded-[8px] border border-[var(--color-border-default)]">
<div className="border-b border-[var(--color-border-subtle)] px-5 py-3.5">
<SkeletonBlock className="h-4 w-2/3" />
<div className="mt-2 flex gap-2">
<SkeletonBlock className="h-3 w-16" />
<SkeletonBlock className="h-3 w-12" />
</div>
</div>
<div className="px-5 py-4 space-y-2">
<SkeletonBlock className="h-8 w-full rounded-[5px]" />
<SkeletonBlock className="h-3 w-1/2" />
<SkeletonBlock className="h-3 w-3/4" />
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,35 @@
"use client";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return <div className="h-9 w-9" />;
const isDark = theme === "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,57 @@
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,
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

@ -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: