diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx
index 6c4f396..df9f060 100644
--- a/frontend/src/app/dashboard/page.tsx
+++ b/frontend/src/app/dashboard/page.tsx
@@ -3,26 +3,24 @@
export const dynamic = "force-dynamic";
import { type KeyboardEvent, type MouseEvent, useMemo } from "react";
-import Link from "next/link";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { SignedIn, SignedOut, useAuth } from "@/auth/clerk";
-import {
- Activity,
- ArrowUpRight,
- Bot,
- Info,
- LayoutGrid,
- Shield,
- Timer,
-} from "lucide-react";
+import { Activity, Bot, LayoutGrid, Timer } from "lucide-react";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
-import { Markdown } from "@/components/atoms/Markdown";
import { SignedOutPanel } from "@/components/auth/SignedOutPanel";
import { ForgejoIssueMetricCards } from "@/components/git/ForgejoIssueMetricCards";
+import {
+ DashboardMetricCard,
+ DashboardInfoBlock,
+ DashboardEmptyState,
+ PendingApprovalsSection,
+ SessionsSection,
+ RecentActivitySection,
+} from "@/components/dashboard";
import { ApiError } from "@/api/mutator";
import {
type dashboardMetricsApiV1MetricsDashboardGetResponse,
@@ -386,129 +384,6 @@ const toSessionSummaries = (
});
};
-function TopMetricCard({
- title,
- value,
- secondary,
- infoText,
- icon,
- accent,
-}: {
- title: string;
- value: string;
- secondary?: string;
- infoText?: string;
- icon: React.ReactNode;
- accent: "blue" | "green" | "violet" | "emerald";
-}) {
- const iconTone =
- accent === "blue"
- ? "bg-[color:var(--accent-soft)] text-[color:var(--accent)]"
- : accent === "green"
- ? "bg-[color:rgba(52,211,153,0.15)] text-[color:var(--success)]"
- : accent === "violet"
- ? "bg-[color:rgba(251,191,36,0.15)] text-[color:var(--warning)]"
- : "bg-[color:rgba(52,211,153,0.15)] text-[color:var(--success)]";
-
- return (
-
-
-
-
-
- {title}
-
- {infoText ? (
-
-
-
- ) : null}
-
-
-
- {value}
-
- {secondary ? (
-
{secondary}
- ) : null}
-
-
-
{icon}
-
-
- );
-}
-
-function InfoBlock({
- title,
- badge,
- infoText,
- rows,
-}: {
- title: string;
- badge?: { text: string; tone: "online" | "offline" | "neutral" };
- infoText?: string;
- rows: SummaryRow[];
-}) {
- return (
-
-
-
-
{title}
- {infoText ? (
-
-
-
- ) : null}
-
- {badge ? (
-
- {badge.text}
-
- ) : null}
-
-
- {rows.map((row) => (
-
- {row.label}
-
- {row.value}
-
-
- ))}
-
-
- );
-}
export default function DashboardPage() {
const router = useRouter();
@@ -1031,34 +906,34 @@ export default function DashboardPage() {
) : null}
- }
- accent="blue"
+ tone="accent"
/>
- }
- accent="green"
+ tone="success"
/>
- }
- accent="violet"
+ tone="warning"
/>
- }
- accent="emerald"
+ tone="success"
/>
@@ -1072,222 +947,49 @@ export default function DashboardPage() {
-
-
+
-
-
-
-
- Pending Approvals
-
-
- Open global approvals
-
-
-
-
- {!metrics && metricsQuery.isLoading ? (
-
- Loading pending approvals...
-
- ) : !metrics && metricsQuery.error ? (
-
- Pending approvals are temporarily unavailable.
-
- ) : hasPendingApprovals ? (
-
-
- {pendingApprovalItems.map((item) => (
-
-
-
- {item.task_title || "Pending approval"}
-
-
- {item.board_name} · {item.confidence}% score
-
-
-
- {formatRelativeTimestamp(item.created_at)}
-
-
- ))}
-
- {pendingApprovalsTotal > pendingApprovalItems.length ? (
-
- Showing latest {formatCount(pendingApprovalItems.length)}{" "}
- of {formatCount(pendingApprovalsTotal)} pending approvals.
-
- ) : null}
-
- ) : (
-
- No pending approvals across your boards.
-
- )}
-
+
-
-
-
- Sessions
-
-
- {formatCount(activeSessions)}
-
-
-
- {!hasConfiguredGateways ? (
-
- No gateways are configured for any board yet.
-
- ) : gatewayStatusesQuery.isLoading ? (
-
- Loading sessions...
-
- ) : sessionSummaries.length > 0 ? (
- <>
- {gatewayUnavailableCount > 0 ? (
-
- {formatCount(gatewayUnavailableCount)} gateway
- {gatewayUnavailableCount === 1 ? "" : "s"}{" "}
- unavailable; showing sessions from reachable gateways.
-
- ) : null}
- {sessionSummaries.map((session) => (
-
-
-
-
-
- {session.title}
-
-
- {session.subtitle}
-
-
-
-
- {session.usage === DASH
- ? "Usage unavailable"
- : session.usage}
-
-
- {session.lastSeenAt
- ? formatRelativeTimestamp(session.lastSeenAt)
- : "Activity unavailable"}
-
-
-
-
- ))}
- >
- ) : gatewayUnavailableCount === gatewayTargets.length ? (
-
- Session data is unavailable for all configured gateways.
-
- ) : (
-
- No active sessions detected.
-
- )}
-
-
-
-
-
-
- Recent Activity
-
-
- Open feed
-
-
-
-
- {recentLogs.length > 0 ? (
- recentLogs.map((event) => {
- const eventHref = buildActivityEventHref(event);
- return (
-
- handleLogRowClick(interactionEvent, eventHref)
- }
- onKeyDown={(interactionEvent) =>
- handleLogRowKeyDown(interactionEvent, eventHref)
- }
- className="cursor-pointer overflow-hidden rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-3 py-2 transition hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface-strong)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
- >
-
-
-
-
-
-
- {event.event_type}
-
-
-
-
{formatRelativeTimestamp(event.created_at)}
-
{formatTimestamp(event.created_at)}
-
-
-
- );
- })
- ) : (
-
-
- No activity yet
-
- Activity appears here when events are emitted.
-
-
- )}
-
-
+
+
diff --git a/frontend/src/components/dashboard/DashboardEmptyState.tsx b/frontend/src/components/dashboard/DashboardEmptyState.tsx
new file mode 100644
index 0000000..bb78099
--- /dev/null
+++ b/frontend/src/components/dashboard/DashboardEmptyState.tsx
@@ -0,0 +1,35 @@
+import type { ReactNode } from "react";
+import { toneBanner, type Tone } from "./tokens";
+
+interface DashboardEmptyStateProps {
+ icon?: ReactNode;
+ message: string;
+ sub?: string;
+ tone?: Tone;
+}
+
+/**
+ * Reusable empty / status callout used throughout dashboard sections.
+ * One component replaces five copies of the same inline div pattern.
+ */
+export function DashboardEmptyState({
+ icon,
+ message,
+ sub,
+ tone = "default",
+}: DashboardEmptyStateProps) {
+ if (icon) {
+ return (
+
+
{icon}
+
{message}
+ {sub &&
{sub}
}
+
+ );
+ }
+ return (
+
+ {message}
+
+ );
+}
diff --git a/frontend/src/components/dashboard/DashboardInfoBlock.tsx b/frontend/src/components/dashboard/DashboardInfoBlock.tsx
new file mode 100644
index 0000000..7c37548
--- /dev/null
+++ b/frontend/src/components/dashboard/DashboardInfoBlock.tsx
@@ -0,0 +1,40 @@
+import { toneText, type Tone } from "./tokens";
+import { DashboardSection } from "./DashboardSection";
+import type { BadgeTone } from "./tokens";
+
+export type InfoRow = {
+ label: string;
+ value: string;
+ tone?: Tone;
+};
+
+interface DashboardInfoBlockProps {
+ title: string;
+ infoText?: string;
+ badge?: { text: string; tone: BadgeTone };
+ rows: InfoRow[];
+}
+
+/**
+ * Labeled key/value block used for Workload, Throughput, Gateway Health.
+ * Tone → color is a lookup, not a ternary chain.
+ */
+export function DashboardInfoBlock({ title, infoText, badge, rows }: DashboardInfoBlockProps) {
+ return (
+
+
+ {rows.map((row) => (
+
+ {row.label}
+
+ {row.value}
+
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/components/dashboard/DashboardMetricCard.tsx b/frontend/src/components/dashboard/DashboardMetricCard.tsx
new file mode 100644
index 0000000..9f7adfc
--- /dev/null
+++ b/frontend/src/components/dashboard/DashboardMetricCard.tsx
@@ -0,0 +1,51 @@
+import type { ReactNode } from "react";
+import { Info } from "lucide-react";
+import { toneIcon, type MetricToneKey } from "./tokens";
+
+interface DashboardMetricCardProps {
+ title: string;
+ value: string;
+ secondary?: string;
+ infoText?: string;
+ icon: ReactNode;
+ tone: MetricToneKey;
+}
+
+/**
+ * Top-row metric card. Tone drives the icon container color only —
+ * card background and text always come from design tokens.
+ */
+export function DashboardMetricCard({
+ title,
+ value,
+ secondary,
+ infoText,
+ icon,
+ tone,
+}: DashboardMetricCardProps) {
+ return (
+
+
+
+
+
+ {title}
+
+ {infoText && (
+
+
+
+ )}
+
+
+
{value}
+ {secondary && (
+
{secondary}
+ )}
+
+
+
{icon}
+
+
+ );
+}
diff --git a/frontend/src/components/dashboard/DashboardSection.tsx b/frontend/src/components/dashboard/DashboardSection.tsx
new file mode 100644
index 0000000..18fb077
--- /dev/null
+++ b/frontend/src/components/dashboard/DashboardSection.tsx
@@ -0,0 +1,60 @@
+import type { ReactNode } from "react";
+import Link from "next/link";
+import { ArrowUpRight, Info } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { toneBadge, type BadgeTone } from "./tokens";
+
+interface DashboardSectionProps {
+ title: string;
+ infoText?: string;
+ badge?: { text: string; tone: BadgeTone };
+ action?: { label: string; href: string };
+ children: ReactNode;
+ className?: string;
+}
+
+/**
+ * Standard dashboard card section.
+ * Uses .surface-card so all color values come from design tokens —
+ * no hardcoded palette classes.
+ */
+export function DashboardSection({
+ title,
+ infoText,
+ badge,
+ action,
+ children,
+ className,
+}: DashboardSectionProps) {
+ return (
+
+
+
+
{title}
+ {infoText && (
+
+
+
+ )}
+
+
+ {badge && (
+
+ {badge.text}
+
+ )}
+ {action && (
+
+ {action.label}
+
+
+ )}
+
+
+ {children}
+
+ );
+}
diff --git a/frontend/src/components/dashboard/PendingApprovalsSection.tsx b/frontend/src/components/dashboard/PendingApprovalsSection.tsx
new file mode 100644
index 0000000..f41814d
--- /dev/null
+++ b/frontend/src/components/dashboard/PendingApprovalsSection.tsx
@@ -0,0 +1,80 @@
+import Link from "next/link";
+import { DashboardSection } from "./DashboardSection";
+import { DashboardEmptyState } from "./DashboardEmptyState";
+
+type ApprovalItem = {
+ approval_id: string;
+ board_id: string;
+ board_name: string;
+ task_title?: string | null;
+ confidence: number;
+ created_at: string;
+};
+
+interface PendingApprovalsSectionProps {
+ items: ApprovalItem[];
+ total: number;
+ isLoading: boolean;
+ hasError: boolean;
+ formatCount: (n: number) => string;
+ formatRelative: (ts: string) => string;
+}
+
+export function PendingApprovalsSection({
+ items,
+ total,
+ isLoading,
+ hasError,
+ formatCount,
+ formatRelative,
+}: PendingApprovalsSectionProps) {
+ return (
+
+ {isLoading ? (
+
+ ) : hasError ? (
+
+ ) : items.length > 0 ? (
+
+
+ {items.map((item) => (
+
+
+
+ {item.task_title || "Pending approval"}
+
+
+ {item.board_name} · {item.confidence}% score
+
+
+
+ {formatRelative(item.created_at)}
+
+
+ ))}
+
+ {total > items.length && (
+
+ Showing latest {formatCount(items.length)} of {formatCount(total)} pending approvals.
+
+ )}
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/dashboard/RecentActivitySection.tsx b/frontend/src/components/dashboard/RecentActivitySection.tsx
new file mode 100644
index 0000000..a82991f
--- /dev/null
+++ b/frontend/src/components/dashboard/RecentActivitySection.tsx
@@ -0,0 +1,78 @@
+import { type KeyboardEvent, type MouseEvent } from "react";
+import { Shield } from "lucide-react";
+import { DashboardSection } from "./DashboardSection";
+import { DashboardEmptyState } from "./DashboardEmptyState";
+import { Markdown } from "@/components/atoms/Markdown";
+import type { ActivityEventRead } from "@/api/generated/model";
+
+export type ActivityEvent = ActivityEventRead;
+
+interface RecentActivitySectionProps {
+ events: ActivityEvent[];
+ feedHref: string;
+ onRowClick: (e: MouseEvent, href: string) => void;
+ onRowKeyDown: (e: KeyboardEvent, href: string) => void;
+ buildHref: (event: ActivityEvent) => string;
+ formatRelative: (ts: string) => string;
+ formatTimestamp: (ts: string) => string;
+}
+
+export function RecentActivitySection({
+ events,
+ feedHref,
+ onRowClick,
+ onRowKeyDown,
+ buildHref,
+ formatRelative,
+ formatTimestamp,
+}: RecentActivitySectionProps) {
+ return (
+
+
+ {events.length > 0 ? (
+ events.map((event) => {
+ const href = buildHref(event);
+ return (
+
onRowClick(e, href)}
+ onKeyDown={(e) => onRowKeyDown(e, href)}
+ className="cursor-pointer overflow-hidden rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-3 py-2 transition hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface-strong)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
+ >
+
+
+
+
+
+
+ {event.event_type}
+
+
+
+
{formatRelative(event.created_at)}
+
{formatTimestamp(event.created_at)}
+
+
+
+ );
+ })
+ ) : (
+
}
+ message="No activity yet"
+ sub="Activity appears here when events are emitted."
+ />
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/dashboard/SessionsSection.tsx b/frontend/src/components/dashboard/SessionsSection.tsx
new file mode 100644
index 0000000..ebd0ef0
--- /dev/null
+++ b/frontend/src/components/dashboard/SessionsSection.tsx
@@ -0,0 +1,93 @@
+import { DashboardSection } from "./DashboardSection";
+import { DashboardEmptyState } from "./DashboardEmptyState";
+
+type SessionSummary = {
+ key: string;
+ title: string;
+ subtitle: string;
+ usage: string;
+ lastSeenAt: string | null;
+ isMain: boolean;
+};
+
+interface SessionsSectionProps {
+ sessions: SessionSummary[];
+ activeSessions: number;
+ hasConfiguredGateways: boolean;
+ isLoading: boolean;
+ gatewayUnavailableCount: number;
+ gatewayTargetsCount: number;
+ formatCount: (n: number) => string;
+ formatRelative: (ts: string) => string;
+ dash: string;
+}
+
+export function SessionsSection({
+ sessions,
+ activeSessions,
+ hasConfiguredGateways,
+ isLoading,
+ gatewayUnavailableCount,
+ gatewayTargetsCount,
+ formatCount,
+ formatRelative,
+ dash,
+}: SessionsSectionProps) {
+ return (
+
+
+ {!hasConfiguredGateways ? (
+
+ ) : isLoading ? (
+
+ ) : sessions.length > 0 ? (
+ <>
+ {gatewayUnavailableCount > 0 && (
+
+ )}
+ {sessions.map((session) => (
+
+
+
+
+
+ {session.title}
+
+
{session.subtitle}
+
+
+
+ {session.usage === dash ? "Usage unavailable" : session.usage}
+
+
+ {session.lastSeenAt ? formatRelative(session.lastSeenAt) : "Activity unavailable"}
+
+
+
+
+ ))}
+ >
+ ) : gatewayUnavailableCount === gatewayTargetsCount ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/dashboard/index.ts b/frontend/src/components/dashboard/index.ts
new file mode 100644
index 0000000..e7e5e99
--- /dev/null
+++ b/frontend/src/components/dashboard/index.ts
@@ -0,0 +1,8 @@
+export { DashboardSection } from "./DashboardSection";
+export { DashboardMetricCard } from "./DashboardMetricCard";
+export { DashboardInfoBlock, type InfoRow } from "./DashboardInfoBlock";
+export { DashboardEmptyState } from "./DashboardEmptyState";
+export { PendingApprovalsSection } from "./PendingApprovalsSection";
+export { SessionsSection } from "./SessionsSection";
+export { RecentActivitySection } from "./RecentActivitySection";
+export { toneText, toneBanner, toneBadge, toneIcon, type Tone, type BadgeTone, type MetricToneKey } from "./tokens";
diff --git a/frontend/src/components/dashboard/tokens.ts b/frontend/src/components/dashboard/tokens.ts
new file mode 100644
index 0000000..9cca506
--- /dev/null
+++ b/frontend/src/components/dashboard/tokens.ts
@@ -0,0 +1,43 @@
+/** Central tone → className map. Use this instead of ternary chains throughout dashboard components. */
+
+export type Tone = "default" | "success" | "warning" | "danger";
+export type BadgeTone = "online" | "offline" | "neutral";
+export type MetricToneKey = "accent" | "success" | "warning" | "danger";
+
+/** Inline text color for a data value. */
+export const toneText: Record = {
+ default: "text-strong",
+ success: "text-[color:var(--success)]",
+ warning: "text-[color:var(--warning)]",
+ danger: "text-[color:var(--danger)]",
+};
+
+/** Soft background + text for banners and empty-state callouts. */
+export const toneBanner: Record = {
+ default:
+ "border-[color:var(--border)] bg-[color:var(--surface-muted)] text-muted",
+ success:
+ "border-[color:rgba(52,211,153,0.35)] bg-[color:rgba(52,211,153,0.08)] text-[color:var(--success)]",
+ warning:
+ "border-[color:rgba(251,191,36,0.35)] bg-[color:rgba(251,191,36,0.08)] text-[color:var(--warning)]",
+ danger:
+ "border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] text-[color:var(--danger)]",
+};
+
+/** Small pill / badge background + text. */
+export const toneBadge: Record = {
+ online:
+ "bg-[color:rgba(52,211,153,0.15)] text-[color:var(--success)]",
+ offline:
+ "bg-[color:rgba(248,113,113,0.12)] text-[color:var(--danger)]",
+ neutral:
+ "bg-[color:var(--surface-strong)] text-muted",
+};
+
+/** Icon container background + icon color for metric cards. */
+export const toneIcon: Record = {
+ accent: "bg-[color:var(--accent-soft)] text-[color:var(--accent)]",
+ success: "bg-[color:rgba(52,211,153,0.15)] text-[color:var(--success)]",
+ warning: "bg-[color:rgba(251,191,36,0.15)] text-[color:var(--warning)]",
+ danger: "bg-[color:rgba(248,113,113,0.12)] text-[color:var(--danger)]",
+};