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:
parent
178af32682
commit
f9feb4bbf8
File diff suppressed because it is too large
Load Diff
|
|
@ -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 dashboard’s 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)]">·</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",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue