feat(dashboard): U4 — Command Center view shell, nav, and chart primitives

New command-center built-in view (lazy-loaded, ARIA-tabbed) with hand-rolled
CSS-bar chart primitives (Bar/StackedBar/Sparkline/Funnel), DateRangePicker,
and Overview tab. Animations use --duration-* tokens (IACVT-safe).
This commit is contained in:
gsxdsm
2026-06-15 19:23:17 -07:00
parent ab9fdc4136
commit 90cfbfb4fc
18 changed files with 1319 additions and 16 deletions

View File

@@ -215,7 +215,7 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme
### Lazy-Loaded Heavy Views
These 20 views are lazy-loaded via `React.lazy()` with `<Suspense fallback={null}>`.
These 21 views are lazy-loaded via `React.lazy()` with `<Suspense fallback={null}>`.
Keep this AGENTS inventory in sync with App lazy imports and `packages/dashboard/app/__tests__/lazy-loaded-views-docs.test.ts`.
- `AgentsView`
@@ -229,6 +229,7 @@ Keep this AGENTS inventory in sync with App lazy imports and `packages/dashboard
- `SkillsView`
- `ResearchView`
- `ReliabilityView`
- `CommandCenter`
- `EvalsView`
- `TodoView`
- `GoalsView`

View File

@@ -116,6 +116,7 @@ const SkillsView = lazy(() => import("./components/SkillsView").then((m) => ({ d
const MemoryView = lazy(() => import("./components/MemoryView").then((m) => ({ default: m.MemoryView })));
const SecretsView = lazy(() => import("./components/SecretsView").then((m) => ({ default: m.SecretsView })));
const ReliabilityView = lazy(() => import("./components/ReliabilityView").then((m) => ({ default: m.ReliabilityView })));
const CommandCenter = lazy(() => import("./components/command-center/CommandCenter").then((m) => ({ default: m.CommandCenter })));
const DevServerView = lazy(() => import("./components/DevServerView").then((m) => ({ default: m.DevServerView })));
const _TodoView = lazy(() => import("./components/TodoView").then((m) => ({ default: m.TodoView })));
const GoalsView = lazy(() => import("./components/GoalsView").then((m) => ({ default: m.GoalsView })));
@@ -146,6 +147,7 @@ function prefetchLazyViews() {
void import("./components/MemoryView");
void import("./components/SecretsView");
void import("./components/ReliabilityView");
void import("./components/command-center/CommandCenter");
void import("./components/DevServerView");
void import("./components/TodoView");
void import("./components/GoalsView");
@@ -1825,6 +1827,16 @@ function AppInner() {
);
}
if (taskView === "command-center") {
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<CommandCenter />
</Suspense>
</PageErrorBoundary>
);
}
if (taskView === "devserver" || taskView === "dev-server") {
if (!settingsLoaded || !devServerEnabled) {
return null;

View File

@@ -14,6 +14,7 @@ const EXPECTED_DOCUMENTED_VIEWS = new Set([
"SkillsView",
"ResearchView",
"ReliabilityView",
"CommandCenter",
"EvalsView",
"TodoView",
"GoalsView",
@@ -37,6 +38,7 @@ const EXPECTED_APP_LEVEL_VIEWS = new Set([
"MemoryView",
"SecretsView",
"ReliabilityView",
"CommandCenter",
"DevServerView",
"TodoView",
"GoalsView",
@@ -83,11 +85,11 @@ describe("AGENTS lazy-loaded views inventory", () => {
const section = extractLazyLoadedSection(agentsDoc);
const countMatch = section.match(/These\s+(\d+)\s+views\s+are lazy-loaded/);
expect(countMatch).toBeTruthy();
expect(Number(countMatch?.[1])).toBe(20);
expect(Number(countMatch?.[1])).toBe(21);
const documentedViews = extractBacktickedNamesFromBullets(section);
expect(new Set(documentedViews)).toEqual(EXPECTED_DOCUMENTED_VIEWS);
expect(documentedViews).toHaveLength(20);
expect(documentedViews).toHaveLength(21);
expect(section).toContain("`ResearchView`");
expect(section).toContain("`TodoView`");

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback, useMemo, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Monitor, Server, Workflow, Bot, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare, ChevronDown, Check, Zap, Sparkles, FileText, Brain, CheckSquare, Lock } from "lucide-react";
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Monitor, Server, Workflow, Bot, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare, ChevronDown, Check, Zap, Sparkles, FileText, Brain, CheckSquare, Lock, Gauge } from "lucide-react";
import "./Header.css";
// ProjectSelector styles used by the imported standalone component.
import "./ProjectSelector.css";
@@ -1092,7 +1092,7 @@ export function Header({
<>
<button
ref={viewOverflowTriggerRef}
className={`view-toggle-btn${["research", "skills", "insights", "memory", "secrets", "reliability", "dev-server", "devserver", "graph", "stash-recovery"].includes(view) || (experimentalFeatures?.evalsView && view === "evals") || (experimentalFeatures?.goalsView && view === "goalsView") || (todosEnabled && todosOpen) || isPluginViewId(view) ? " active" : ""}`}
className={`view-toggle-btn${["research", "skills", "insights", "memory", "secrets", "reliability", "command-center", "dev-server", "devserver", "graph", "stash-recovery"].includes(view) || (experimentalFeatures?.evalsView && view === "evals") || (experimentalFeatures?.goalsView && view === "goalsView") || (todosEnabled && todosOpen) || isPluginViewId(view) ? " active" : ""}`}
onClick={() => setIsViewOverflowOpen((prev) => !prev)}
title={t("header.moreViews", "More views")}
aria-label={t("header.moreViews", "More views")}
@@ -1232,6 +1232,18 @@ export function Header({
<Activity size={14} />
<span>{t("header.reliabilityView", "Reliability")}</span>
</button>
<button
className={`view-toggle-overflow-item${view === "command-center" ? " active" : ""}`}
onClick={() => {
onChangeView("command-center");
setIsViewOverflowOpen(false);
}}
role="menuitem"
data-testid="view-overflow-command-center"
>
<Gauge size={14} />
<span>{t("header.commandCenterView", "Command Center")}</span>
</button>
{experimentalFeatures?.devServerView && (
<button
className={`view-toggle-overflow-item${view === "dev-server" || view === "devserver" ? " active" : ""}`}

View File

@@ -10,6 +10,7 @@ import {
FileCode,
FileText,
Folder,
Gauge,
GitBranch,
Grid3X3,
History,
@@ -290,6 +291,7 @@ export function MobileNavBar({
const isMoreActive =
view === "documents"
|| view === "reliability"
|| view === "command-center"
|| (Boolean(experimentalFeatures?.evalsView) && view === "evals")
|| (Boolean(experimentalFeatures?.goalsView) && view === "goalsView")
|| view === "research"
@@ -680,6 +682,16 @@ export function MobileNavBar({
<Activity />
<span>{t("nav.reliability", "Reliability")}</span>
</button>
<button
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-command-center"
onClick={() => handleMoreAction(() => onChangeView("command-center"))}
>
<Gauge />
<span>{t("nav.commandCenter", "Command Center")}</span>
</button>
{experimentalFeatures?.evalsView && (
<button
type="button"

View File

@@ -0,0 +1,143 @@
/* Command Center shell.
* Animation durations use --duration-* tokens only (never --transition-*).
*/
.command-center {
display: flex;
flex-direction: column;
gap: var(--space-4, 1rem);
padding: var(--space-4, 1rem);
}
.cc-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3, 0.75rem);
}
.cc-title {
display: flex;
align-items: center;
gap: var(--space-2, 0.5rem);
margin: 0;
font-size: var(--font-size-lg, 1.1rem);
}
/* ---- Tabs ---- */
.cc-tablist {
display: flex;
flex-wrap: wrap;
gap: var(--space-1, 0.25rem);
border-bottom: 1px solid var(--border-subtle, rgba(127, 127, 127, 0.25));
}
.cc-tab {
appearance: none;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-secondary, #888);
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
cursor: pointer;
font-size: var(--font-size-sm, 0.85rem);
transition: color var(--transition-fast), border-color var(--transition-fast);
}
.cc-tab:hover {
color: var(--text-primary, #ddd);
}
.cc-tab.active {
color: var(--text-primary, #ddd);
border-bottom-color: var(--color-accent, #4f8cff);
}
.cc-tabpanel {
outline: none;
}
/* ---- Overview ---- */
.cc-overview {
display: flex;
flex-direction: column;
gap: var(--space-4, 1rem);
}
.cc-stat-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
gap: var(--space-3, 0.75rem);
}
.cc-stat-card {
display: flex;
flex-direction: column;
gap: var(--space-1, 0.25rem);
padding: var(--space-3, 0.75rem);
}
.cc-stat-label {
font-size: var(--font-size-sm, 0.85rem);
color: var(--text-secondary, #888);
}
.cc-stat-value {
font-size: var(--font-size-xl, 1.5rem);
font-variant-numeric: tabular-nums;
color: var(--text-primary, #ddd);
}
.cc-live-strip {
display: flex;
align-items: center;
gap: var(--space-2, 0.5rem);
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
border: 1px dashed var(--border-subtle, rgba(127, 127, 127, 0.25));
border-radius: var(--radius-md, 8px);
font-size: var(--font-size-sm, 0.85rem);
}
.cc-live-strip-label {
color: var(--text-primary, #ddd);
font-weight: 600;
}
.cc-live-strip-placeholder {
color: var(--text-secondary, #888);
}
/* ---- States ---- */
.cc-loading,
.cc-error,
.cc-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-2, 0.5rem);
padding: var(--space-6, 2rem);
color: var(--text-secondary, #888);
text-align: center;
}
.cc-error {
color: var(--color-error, #e5484d);
}
.cc-loading .cc-chart-skeleton {
height: 1rem;
border-radius: var(--radius-sm, 4px);
background: var(--surface-2, rgba(127, 127, 127, 0.12));
animation: cc-shell-pulse var(--duration-slow) ease-in-out infinite;
}
@keyframes cc-shell-pulse {
0%,
100% {
opacity: 0.4;
}
50% {
opacity: 0.9;
}
}

View File

@@ -0,0 +1,226 @@
import { useCallback, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertCircle, Gauge } from "lucide-react";
import { DateRangePicker, defaultPresets, rangeFromPreset, type DateRange } from "./DateRangePicker";
import "./CommandCenter.css";
type SubViewId =
| "overview"
| "tokens"
| "tools"
| "activity"
| "productivity"
| "ecosystem"
| "mission-control";
interface SubView {
id: SubViewId;
label: string;
}
function useSubViews(): SubView[] {
const { t } = useTranslation("app");
return [
{ id: "overview", label: t("commandCenter.tabs.overview", "Overview") },
{ id: "tokens", label: t("commandCenter.tabs.tokens", "Tokens") },
{ id: "tools", label: t("commandCenter.tabs.tools", "Tools") },
{ id: "activity", label: t("commandCenter.tabs.activity", "Activity") },
{ id: "productivity", label: t("commandCenter.tabs.productivity", "Productivity") },
{ id: "ecosystem", label: t("commandCenter.tabs.ecosystem", "Ecosystem") },
{ id: "mission-control", label: t("commandCenter.tabs.missionControl", "Mission Control") },
];
}
interface OverviewStatCard {
id: string;
label: string;
}
/**
* Headline stat cards (one per measurement area). Values land once Phase A's
* analytics endpoints exist; until then each card shows the shared empty state.
*/
function OverviewTab({ hasData }: { hasData: boolean }) {
const { t } = useTranslation("app");
const cards: OverviewStatCard[] = [
{ id: "tokens", label: t("commandCenter.overview.tokensCost", "Tokens & cost") },
{ id: "autonomy", label: t("commandCenter.overview.autonomy", "Autonomy ratio") },
{ id: "nodes", label: t("commandCenter.overview.activeNodes", "Active nodes") },
{ id: "tasksDone", label: t("commandCenter.overview.tasksDone", "Tasks done") },
{ id: "models", label: t("commandCenter.overview.uniqueModels", "Unique models") },
{ id: "signals", label: t("commandCenter.overview.openSignals", "Open signals") },
];
if (!hasData) {
return (
<div className="cc-empty" data-testid="command-center-empty">
<Gauge size={28} />
<p>{t("commandCenter.empty", "No usage data yet. Run some agents to populate the Command Center.")}</p>
</div>
);
}
return (
<div className="cc-overview">
<div className="cc-stat-grid">
{cards.map((card) => (
<div key={card.id} className="card cc-stat-card" data-testid={`command-center-stat-${card.id}`}>
<div className="cc-stat-label">{card.label}</div>
<div className="cc-stat-value">—</div>
</div>
))}
</div>
<div className="cc-live-strip" data-testid="command-center-live-strip">
<span className="cc-live-strip-label">{t("commandCenter.overview.liveStrip", "Live activity")}</span>
<span className="cc-live-strip-placeholder">
{t("commandCenter.overview.liveStripPending", "Live Mission Control loads with active sessions.")}
</span>
</div>
</div>
);
}
function PlaceholderTab({ tabId }: { tabId: SubViewId }) {
const { t } = useTranslation("app");
return (
<div className="cc-empty" data-testid={`command-center-placeholder-${tabId}`}>
<Gauge size={28} />
<p>{t("commandCenter.areaPending", "This area renders once metrics data is available.")}</p>
</div>
);
}
export function CommandCenter() {
const { t } = useTranslation("app");
const subViews = useSubViews();
const [activeTab, setActiveTab] = useState<SubViewId>("overview");
// Shell-only state: real loading/error wiring lands with the Phase A endpoints.
const [isLoading] = useState(false);
const [error] = useState<string | null>(null);
// No analytics endpoints yet, so there is no data to show — drives the empty state.
const hasData = false;
const [range, setRange] = useState<DateRange>(() => rangeFromPreset(defaultPresets((k, f) => f)[1]));
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
const focusTab = useCallback(
(index: number) => {
const clamped = (index + subViews.length) % subViews.length;
setActiveTab(subViews[clamped].id);
tabRefs.current[clamped]?.focus();
},
[subViews],
);
const onTabKeyDown = useCallback(
(e: React.KeyboardEvent, index: number) => {
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
e.preventDefault();
focusTab(index + 1);
break;
case "ArrowLeft":
case "ArrowUp":
e.preventDefault();
focusTab(index - 1);
break;
case "Home":
e.preventDefault();
focusTab(0);
break;
case "End":
e.preventDefault();
focusTab(subViews.length - 1);
break;
case "Enter":
case " ":
e.preventDefault();
setActiveTab(subViews[index].id);
break;
default:
break;
}
},
[focusTab, subViews],
);
function renderActiveTab() {
if (activeTab === "overview") {
return <OverviewTab hasData={hasData} />;
}
return <PlaceholderTab tabId={activeTab} />;
}
if (isLoading) {
return (
<div className="cc-loading" data-testid="command-center-loading">
<div className="cc-chart-skeleton" style={{ width: "60%" }} />
<p>{t("commandCenter.loading", "Loading command center...")}</p>
</div>
);
}
if (error !== null) {
return (
<div className="cc-error" data-testid="command-center-error" role="alert">
<AlertCircle size={24} />
<p>{error}</p>
</div>
);
}
return (
<section className="command-center" data-testid="command-center">
<header className="cc-header">
<h2 className="cc-title">
<Gauge size={18} />
{t("commandCenter.heading", "Command Center")}
</h2>
<DateRangePicker value={range} onChange={setRange} />
</header>
<div
className="cc-tablist"
role="tablist"
aria-label={t("commandCenter.tablistLabel", "Command Center sections")}
>
{subViews.map((sub, index) => {
const selected = sub.id === activeTab;
return (
<button
key={sub.id}
ref={(el) => {
tabRefs.current[index] = el;
}}
role="tab"
id={`cc-tab-${sub.id}`}
aria-selected={selected}
aria-controls={`cc-tabpanel-${sub.id}`}
tabIndex={selected ? 0 : -1}
className={`cc-tab${selected ? " active" : ""}`}
onClick={() => setActiveTab(sub.id)}
onKeyDown={(e) => onTabKeyDown(e, index)}
data-testid={`command-center-tab-${sub.id}`}
>
{sub.label}
</button>
);
})}
</div>
<div
role="tabpanel"
id={`cc-tabpanel-${activeTab}`}
aria-labelledby={`cc-tab-${activeTab}`}
tabIndex={0}
className="cc-tabpanel"
data-testid={`command-center-panel-${activeTab}`}
>
{renderActiveTab()}
</div>
</section>
);
}

View File

@@ -0,0 +1,55 @@
.cc-date-range {
position: relative;
display: inline-block;
}
.cc-date-range-trigger {
display: inline-flex;
align-items: center;
gap: var(--space-1, 0.25rem);
}
.cc-date-range-popover {
position: absolute;
top: calc(100% + var(--space-1, 0.25rem));
right: 0;
z-index: 20;
min-width: 16rem;
padding: var(--space-3, 0.75rem);
background: var(--surface-1, #1c1c1c);
border: 1px solid var(--border-subtle, rgba(127, 127, 127, 0.25));
border-radius: var(--radius-md, 8px);
box-shadow: var(--shadow-md, 0 6px 24px rgba(0, 0, 0, 0.35));
display: flex;
flex-direction: column;
gap: var(--space-3, 0.75rem);
}
.cc-date-range-presets {
display: flex;
flex-wrap: wrap;
gap: var(--space-2, 0.5rem);
}
.cc-date-range-custom {
display: flex;
flex-direction: column;
gap: var(--space-2, 0.5rem);
}
.cc-date-range-field {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2, 0.5rem);
font-size: var(--font-size-sm, 0.85rem);
color: var(--text-secondary, #888);
}
.cc-date-range-field input {
background: var(--surface-2, rgba(127, 127, 127, 0.12));
border: 1px solid var(--border-subtle, rgba(127, 127, 127, 0.25));
border-radius: var(--radius-sm, 4px);
color: var(--text-primary, #ddd);
padding: var(--space-1, 0.25rem) var(--space-2, 0.5rem);
}

View File

@@ -0,0 +1,169 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Calendar } from "lucide-react";
import "./DateRangePicker.css";
export interface DateRange {
/** ISO date string (YYYY-MM-DD) or null for an open lower bound. */
from: string | null;
/** ISO date string (YYYY-MM-DD) or null for an open upper bound (now). */
to: string | null;
/** Identifier for the active preset, or "custom". */
preset: string;
}
export interface DateRangePreset {
id: string;
label: string;
/** Days back from now; null = all time. */
days: number | null;
}
export interface DateRangePickerProps {
value: DateRange;
onChange: (range: DateRange) => void;
presets?: DateRangePreset[];
}
export function defaultPresets(t: (key: string, fallback: string) => string): DateRangePreset[] {
return [
{ id: "24h", label: t("commandCenter.range.last24h", "Last 24h"), days: 1 },
{ id: "7d", label: t("commandCenter.range.last7d", "Last 7 days"), days: 7 },
{ id: "30d", label: t("commandCenter.range.last30d", "Last 30 days"), days: 30 },
{ id: "all", label: t("commandCenter.range.allTime", "All time"), days: null },
];
}
export function rangeFromPreset(preset: DateRangePreset): DateRange {
if (preset.days === null) {
return { from: null, to: null, preset: preset.id };
}
const from = new Date(Date.now() - preset.days * 86_400_000);
return { from: from.toISOString().slice(0, 10), to: null, preset: preset.id };
}
export function DateRangePicker({ value, onChange, presets }: DateRangePickerProps) {
const { t } = useTranslation("app");
const resolvedPresets = presets ?? defaultPresets(t);
const [open, setOpen] = useState(false);
const [customError, setCustomError] = useState<string | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const close = useCallback(() => {
setOpen(false);
// Return focus to the trigger on dismiss.
triggerRef.current?.focus();
}, []);
useEffect(() => {
if (!open) {
return;
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
close();
}
};
const onPointerDown = (e: PointerEvent) => {
if (
popoverRef.current &&
!popoverRef.current.contains(e.target as Node) &&
triggerRef.current &&
!triggerRef.current.contains(e.target as Node)
) {
setOpen(false);
}
};
document.addEventListener("keydown", onKeyDown);
document.addEventListener("pointerdown", onPointerDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
document.removeEventListener("pointerdown", onPointerDown);
};
}, [open, close]);
const activeLabel =
resolvedPresets.find((p) => p.id === value.preset)?.label ??
t("commandCenter.range.custom", "Custom range");
const applyCustom = useCallback(
(from: string | null, to: string | null) => {
if (from && to && from > to) {
setCustomError(t("commandCenter.range.invalidRange", "Start date must be on or before end date"));
return;
}
setCustomError(null);
onChange({ from, to, preset: "custom" });
},
[onChange, t],
);
return (
<div className="cc-date-range">
<button
ref={triggerRef}
type="button"
className="btn btn-sm cc-date-range-trigger"
onClick={() => setOpen((v) => !v)}
aria-haspopup="dialog"
aria-expanded={open}
data-testid="cc-date-range-trigger"
>
<Calendar size={14} />
<span>{activeLabel}</span>
</button>
{open ? (
<div
ref={popoverRef}
className="cc-date-range-popover"
role="dialog"
aria-label={t("commandCenter.range.dialogLabel", "Select date range")}
data-testid="cc-date-range-popover"
>
<div className="cc-date-range-presets">
{resolvedPresets.map((preset) => (
<button
key={preset.id}
type="button"
className={`btn btn-sm${value.preset === preset.id ? " active" : ""}`}
onClick={() => {
onChange(rangeFromPreset(preset));
close();
}}
data-testid={`cc-date-range-preset-${preset.id}`}
>
{preset.label}
</button>
))}
</div>
<div className="cc-date-range-custom">
<label className="cc-date-range-field">
<span>{t("commandCenter.range.from", "From")}</span>
<input
type="date"
value={value.from ?? ""}
onChange={(e) => applyCustom(e.target.value || null, value.to)}
data-testid="cc-date-range-from"
/>
</label>
<label className="cc-date-range-field">
<span>{t("commandCenter.range.to", "To")}</span>
<input
type="date"
value={value.to ?? ""}
onChange={(e) => applyCustom(value.from, e.target.value || null)}
data-testid="cc-date-range-to"
/>
</label>
{customError ? (
<div className="form-error" role="alert" data-testid="cc-date-range-error">
{customError}
</div>
) : null}
</div>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,85 @@
import { describe, it, expect } from "vitest";
import { render, screen, fireEvent, within } from "@testing-library/react";
import { CommandCenter } from "../CommandCenter";
describe("CommandCenter shell", () => {
it("renders with the Overview tab active by default", () => {
render(<CommandCenter />);
const overviewTab = screen.getByTestId("command-center-tab-overview");
expect(overviewTab.getAttribute("aria-selected")).toBe("true");
expect(screen.getByTestId("command-center-panel-overview")).toBeTruthy();
});
it("renders the documented empty state when there is no data (no crash)", () => {
render(<CommandCenter />);
expect(screen.getByTestId("command-center-empty")).toBeTruthy();
});
it("exposes the ARIA tabs pattern (tablist + tabs + tabpanel)", () => {
render(<CommandCenter />);
const tablist = screen.getByRole("tablist");
const tabs = within(tablist).getAllByRole("tab");
expect(tabs.length).toBe(7);
// roving tabindex: exactly one tab is focusable.
const focusable = tabs.filter((tab) => tab.getAttribute("tabindex") === "0");
expect(focusable.length).toBe(1);
expect(screen.getByRole("tabpanel")).toBeTruthy();
});
it("activates a tab on click and updates aria-selected", () => {
render(<CommandCenter />);
fireEvent.click(screen.getByTestId("command-center-tab-tokens"));
expect(screen.getByTestId("command-center-tab-tokens").getAttribute("aria-selected")).toBe("true");
expect(screen.getByTestId("command-center-tab-overview").getAttribute("aria-selected")).toBe("false");
expect(screen.getByTestId("command-center-panel-tokens")).toBeTruthy();
});
it("supports arrow-key navigation between tabs (roving tabindex)", () => {
render(<CommandCenter />);
const overviewTab = screen.getByTestId("command-center-tab-overview");
overviewTab.focus();
fireEvent.keyDown(overviewTab, { key: "ArrowRight" });
const tokensTab = screen.getByTestId("command-center-tab-tokens");
expect(tokensTab.getAttribute("aria-selected")).toBe("true");
expect(document.activeElement).toBe(tokensTab);
});
it("wraps with ArrowLeft from the first tab to the last", () => {
render(<CommandCenter />);
const overviewTab = screen.getByTestId("command-center-tab-overview");
overviewTab.focus();
fireEvent.keyDown(overviewTab, { key: "ArrowLeft" });
const last = screen.getByTestId("command-center-tab-mission-control");
expect(last.getAttribute("aria-selected")).toBe("true");
expect(document.activeElement).toBe(last);
});
it("activates with Enter and Space", () => {
render(<CommandCenter />);
const toolsTab = screen.getByTestId("command-center-tab-tools");
fireEvent.keyDown(toolsTab, { key: "Enter" });
expect(toolsTab.getAttribute("aria-selected")).toBe("true");
const activityTab = screen.getByTestId("command-center-tab-activity");
fireEvent.keyDown(activityTab, { key: " " });
expect(activityTab.getAttribute("aria-selected")).toBe("true");
});
it("makes the active tabpanel focusable (Tab moves into the panel)", () => {
render(<CommandCenter />);
const panel = screen.getByTestId("command-center-panel-overview");
expect(panel.getAttribute("tabindex")).toBe("0");
expect(panel.getAttribute("role")).toBe("tabpanel");
});
it("renders a date-range picker that returns focus to its trigger on dismiss", () => {
render(<CommandCenter />);
const trigger = screen.getByTestId("cc-date-range-trigger");
fireEvent.click(trigger);
expect(screen.getByTestId("cc-date-range-popover")).toBeTruthy();
// Escape dismisses and returns focus to the trigger.
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByTestId("cc-date-range-popover")).toBeNull();
expect(document.activeElement).toBe(trigger);
});
});

View File

@@ -0,0 +1,144 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { readFileSync } from "fs";
import { resolve } from "path";
import { Bar } from "../charts/Bar";
import { StackedBar } from "../charts/StackedBar";
import { Sparkline } from "../charts/Sparkline";
import { Funnel } from "../charts/Funnel";
function widthOf(el: HTMLElement): string {
return el.style.width;
}
function heightOf(el: HTMLElement): string {
return el.style.height;
}
describe("Bar", () => {
it("renders a fill per datum and an accessible label", () => {
render(
<Bar
ariaLabel="tokens by model"
data={[
{ label: "gpt-4", value: 100 },
{ label: "sonnet", value: 50 },
]}
/>,
);
expect(screen.getByRole("list", { name: "tokens by model" })).toBeTruthy();
expect(screen.getByLabelText("gpt-4: 100")).toBeTruthy();
expect(screen.getByLabelText("sonnet: 50")).toBeTruthy();
});
it("renders the largest value at 100% and scales the rest", () => {
render(<Bar data={[{ label: "a", value: 100 }, { label: "b", value: 25 }]} />);
expect(widthOf(screen.getByLabelText("a: 100"))).toBe("100%");
expect(widthOf(screen.getByLabelText("b: 25"))).toBe("25%");
});
it("renders a 0-width bar for a zero value, never NaN", () => {
render(<Bar data={[{ label: "zero", value: 0 }, { label: "x", value: 10 }]} />);
const zero = screen.getByLabelText("zero: 0");
expect(widthOf(zero)).toBe("0%");
expect(widthOf(zero)).not.toContain("NaN");
});
it("renders 0-width bars for an all-zero dataset without dividing by zero", () => {
render(<Bar data={[{ label: "a", value: 0 }, { label: "b", value: 0 }]} />);
expect(widthOf(screen.getByLabelText("a: 0"))).toBe("0%");
expect(widthOf(screen.getByLabelText("b: 0"))).toBe("0%");
});
it("treats a non-finite value as zero width", () => {
render(<Bar data={[{ label: "nan", value: Number.NaN }]} />);
const el = screen.getByLabelText("nan: 0");
expect(widthOf(el)).toBe("0%");
});
});
describe("StackedBar", () => {
it("renders proportional segments and a legend", () => {
render(
<StackedBar
ariaLabel="status split"
segments={[
{ label: "done", value: 75 },
{ label: "open", value: 25 },
]}
/>,
);
expect(screen.getByRole("img", { name: "status split" })).toBeTruthy();
expect(widthOf(screen.getByLabelText("done: 75"))).toBe("75%");
expect(widthOf(screen.getByLabelText("open: 25"))).toBe("25%");
});
it("renders 0-width slices for an all-zero set, never NaN", () => {
render(<StackedBar segments={[{ label: "a", value: 0 }, { label: "b", value: 0 }]} />);
expect(widthOf(screen.getByLabelText("a: 0"))).toBe("0%");
expect(widthOf(screen.getByLabelText("b: 0"))).toBe("0%");
});
});
describe("Sparkline", () => {
it("renders one bar per value with proportional heights", () => {
render(<Sparkline ariaLabel="models per day" values={[2, 4, 0]} />);
const sparkline = screen.getByRole("img", { name: "models per day" });
const bars = sparkline.querySelectorAll<HTMLElement>(".cc-sparkline-bar");
expect(bars.length).toBe(3);
expect(heightOf(bars[0])).toBe("50%");
expect(heightOf(bars[1])).toBe("100%");
expect(heightOf(bars[2])).toBe("0%");
});
it("renders 0-height bars for all-zero values without NaN", () => {
render(<Sparkline ariaLabel="empty" values={[0, 0]} />);
const bars = screen.getByRole("img", { name: "empty" }).querySelectorAll<HTMLElement>(".cc-sparkline-bar");
expect(heightOf(bars[0])).toBe("0%");
expect(heightOf(bars[1])).toBe("0%");
});
});
describe("Funnel", () => {
it("renders stages with conversion from the prior stage", () => {
render(
<Funnel
ariaLabel="sdlc"
stages={[
{ label: "triage", value: 100 },
{ label: "todo", value: 50 },
{ label: "done", value: 25 },
]}
/>,
);
expect(widthOf(screen.getByLabelText("triage: 100"))).toBe("100%");
expect(widthOf(screen.getByLabelText("todo: 50"))).toBe("50%");
// first stage has no conversion label; subsequent stages do (todo=50%, done=50%)
expect(screen.getAllByText("50%").length).toBe(2);
});
it("shows a — conversion when the prior stage is zero, never NaN%", () => {
render(<Funnel stages={[{ label: "a", value: 0 }, { label: "b", value: 5 }]} />);
expect(screen.getByText("—")).toBeTruthy();
expect(widthOf(screen.getByLabelText("a: 0"))).toBe("0%");
});
});
/**
* Real-browser-style guard for the IACVT token trap: the chart CSS must drive
* its loader animation off a bare --duration-* token, never a --transition-*
* duration+easing pair (which silently resolves to animation: none).
*/
describe("chart CSS animation tokens", () => {
const cssPath = resolve(__dirname, "../charts/charts.css");
const css = readFileSync(cssPath, "utf8");
it("uses a --duration-* token in the loader animation, not --transition-*", () => {
const animationLines = css.split("\n").filter((line) => /animation\s*:/.test(line));
expect(animationLines.length).toBeGreaterThan(0);
for (const line of animationLines) {
expect(line).not.toMatch(/var\(--transition-/);
expect(line).toMatch(/var\(--duration-/);
}
});
});

View File

@@ -0,0 +1,58 @@
import "./charts.css";
export interface BarDatum {
label: string;
value: number;
/** Optional display string for the value (defaults to the number). */
valueLabel?: string;
}
export interface BarProps {
data: BarDatum[];
/**
* Max value mapped to 100% width. Defaults to the largest datum value.
* Always coerced to at least 1 so a zero-only dataset never divides by zero.
*/
max?: number;
/** Accessible label for the whole chart. */
ariaLabel?: string;
}
function safeWidthPercent(value: number, max: number): number {
if (!Number.isFinite(value) || value <= 0) {
return 0;
}
const denom = Number.isFinite(max) && max > 0 ? max : 1;
return Math.max(0, Math.min(100, (value / denom) * 100));
}
/**
* Hand-rolled CSS horizontal bar chart. Zero-value bars render a 0-width bar
* with an accessible label rather than NaN.
*/
export function Bar({ data, max, ariaLabel }: BarProps) {
const computedMax = max ?? data.reduce((m, d) => (d.value > m ? d.value : m), 0);
return (
<ul className="cc-bar-chart" role="list" aria-label={ariaLabel}>
{data.map((d) => {
const width = safeWidthPercent(d.value, computedMax);
const valueText = d.valueLabel ?? String(Number.isFinite(d.value) ? d.value : 0);
return (
<li key={d.label} className="cc-bar-row">
<span className="cc-bar-label">{d.label}</span>
<div className="cc-bar-track">
<div
className="cc-bar-fill"
style={{ width: `${width}%` }}
role="img"
aria-label={`${d.label}: ${valueText}`}
/>
</div>
<span className="cc-bar-value">{valueText}</span>
</li>
);
})}
</ul>
);
}

View File

@@ -0,0 +1,68 @@
import "./charts.css";
export interface FunnelStage {
label: string;
value: number;
}
export interface FunnelProps {
stages: FunnelStage[];
/** Accessible label for the whole funnel. */
ariaLabel?: string;
}
function safeWidthPercent(value: number, max: number): number {
if (!Number.isFinite(value) || value <= 0) {
return 0;
}
const denom = Number.isFinite(max) && max > 0 ? max : 1;
return Math.max(0, Math.min(100, (value / denom) * 100));
}
function conversionLabel(value: number, prev: number | null): string | null {
if (prev === null) {
return null;
}
if (!Number.isFinite(prev) || prev <= 0) {
return "—";
}
const pct = Math.max(0, Math.min(100, (value / prev) * 100));
return `${pct.toFixed(0)}%`;
}
/**
* Hand-rolled CSS funnel. The first (largest) stage anchors 100% width; each
* stage shows its count and conversion from the prior stage. Zero values render
* a 0-width bar and a "—" conversion, never NaN.
*/
export function Funnel({ stages, ariaLabel }: FunnelProps) {
const max = stages.reduce((m, s) => (s.value > m ? s.value : m), 0);
return (
<ol className="cc-funnel" aria-label={ariaLabel}>
{stages.map((s, i) => {
const width = safeWidthPercent(s.value, max);
const prev = i > 0 ? stages[i - 1].value : null;
const conversion = conversionLabel(s.value, prev);
const valueText = String(Number.isFinite(s.value) ? s.value : 0);
return (
<li key={s.label} className="cc-funnel-stage">
<div className="cc-funnel-header">
<span className="cc-funnel-label">{s.label}</span>
{conversion !== null ? <span className="cc-funnel-conversion">{conversion}</span> : null}
</div>
<div className="cc-funnel-track">
<div
className="cc-funnel-fill"
style={{ width: `${width}%` }}
role="img"
aria-label={`${s.label}: ${valueText}`}
/>
</div>
<span className="cc-funnel-value">{valueText}</span>
</li>
);
})}
</ol>
);
}

View File

@@ -0,0 +1,43 @@
import "./charts.css";
export interface SparklineProps {
values: number[];
/** Accessible label for the whole sparkline. */
ariaLabel?: string;
/** Max value mapped to full height. Defaults to the largest value. */
max?: number;
}
function safeHeightPercent(value: number, max: number): number {
if (!Number.isFinite(value) || value <= 0) {
return 0;
}
const denom = Number.isFinite(max) && max > 0 ? max : 1;
return Math.max(0, Math.min(100, (value / denom) * 100));
}
/**
* Hand-rolled CSS-bar sparkline (mini vertical bar chart). Zero / non-finite
* values render a 0-height bar, never a NaN height.
*/
export function Sparkline({ values, ariaLabel, max }: SparklineProps) {
const computedMax = max ?? values.reduce((m, v) => (v > m ? v : m), 0);
return (
<div className="cc-sparkline" role="img" aria-label={ariaLabel}>
{values.map((v, i) => {
const height = safeHeightPercent(v, computedMax);
return (
<div
// Sparkline points are positional and may repeat values, so the
// index is the only stable key.
key={i}
className="cc-sparkline-bar"
style={{ height: `${height}%` }}
aria-hidden="true"
/>
);
})}
</div>
);
}

View File

@@ -0,0 +1,60 @@
import "./charts.css";
export interface StackedSegment {
label: string;
value: number;
/** CSS color (e.g. a var(--...) token). */
color?: string;
}
export interface StackedBarProps {
segments: StackedSegment[];
/** Accessible label for the whole bar. */
ariaLabel?: string;
}
function safeShare(value: number, total: number): number {
if (!Number.isFinite(value) || value <= 0) {
return 0;
}
if (!Number.isFinite(total) || total <= 0) {
return 0;
}
return Math.max(0, Math.min(100, (value / total) * 100));
}
/**
* Hand-rolled single horizontal stacked bar. A zero-value segment renders a
* 0-width slice (still keyed + labelled) rather than NaN. An all-zero set
* renders an empty track.
*/
export function StackedBar({ segments, ariaLabel }: StackedBarProps) {
const total = segments.reduce((sum, s) => (Number.isFinite(s.value) && s.value > 0 ? sum + s.value : sum), 0);
return (
<div className="cc-stacked-bar" role="img" aria-label={ariaLabel}>
<div className="cc-stacked-track">
{segments.map((s) => {
const share = safeShare(s.value, total);
return (
<div
key={s.label}
className="cc-stacked-segment"
style={{ width: `${share}%`, backgroundColor: s.color }}
aria-label={`${s.label}: ${Number.isFinite(s.value) ? s.value : 0}`}
/>
);
})}
</div>
<ul className="cc-stacked-legend" role="list">
{segments.map((s) => (
<li key={s.label} className="cc-stacked-legend-item">
<span className="cc-stacked-swatch" style={{ backgroundColor: s.color }} aria-hidden="true" />
<span>{s.label}</span>
<strong>{Number.isFinite(s.value) ? s.value : 0}</strong>
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,179 @@
/* Command Center hand-rolled CSS-bar chart primitives.
*
* Animation durations MUST use --duration-* tokens (bare durations).
* NEVER use --transition-* here: those are duration+easing pairs and silently
* invalidate animation declarations (see animation-duration-tokens.css.test.ts).
*/
/* ---- Bar ---- */
.cc-bar-chart {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-2, 0.5rem);
}
.cc-bar-row {
display: grid;
grid-template-columns: minmax(6rem, 12rem) 1fr auto;
align-items: center;
gap: var(--space-2, 0.5rem);
}
.cc-bar-label {
font-size: var(--font-size-sm, 0.85rem);
color: var(--text-secondary, #888);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cc-bar-track {
position: relative;
height: 0.75rem;
background: var(--surface-2, rgba(127, 127, 127, 0.12));
border-radius: var(--radius-sm, 4px);
overflow: hidden;
}
.cc-bar-fill {
height: 100%;
background: var(--color-accent, #4f8cff);
border-radius: var(--radius-sm, 4px);
transition: width var(--transition-normal);
}
.cc-bar-value {
font-size: var(--font-size-sm, 0.85rem);
font-variant-numeric: tabular-nums;
color: var(--text-primary, #ddd);
}
/* ---- StackedBar ---- */
.cc-stacked-bar {
display: flex;
flex-direction: column;
gap: var(--space-2, 0.5rem);
}
.cc-stacked-track {
display: flex;
height: 0.75rem;
background: var(--surface-2, rgba(127, 127, 127, 0.12));
border-radius: var(--radius-sm, 4px);
overflow: hidden;
}
.cc-stacked-segment {
height: 100%;
background: var(--color-accent, #4f8cff);
transition: width var(--transition-normal);
}
.cc-stacked-legend {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: var(--space-3, 0.75rem);
}
.cc-stacked-legend-item {
display: flex;
align-items: center;
gap: var(--space-1, 0.25rem);
font-size: var(--font-size-sm, 0.85rem);
color: var(--text-secondary, #888);
}
.cc-stacked-swatch {
width: 0.6rem;
height: 0.6rem;
border-radius: 2px;
background: var(--color-accent, #4f8cff);
display: inline-block;
}
/* ---- Sparkline ---- */
.cc-sparkline {
display: flex;
align-items: flex-end;
gap: 1px;
height: 2rem;
}
.cc-sparkline-bar {
flex: 1 1 0;
min-width: 1px;
background: var(--color-accent, #4f8cff);
border-radius: 1px;
transition: height var(--transition-normal);
}
/* ---- Funnel ---- */
.cc-funnel {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-2, 0.5rem);
}
.cc-funnel-stage {
display: flex;
flex-direction: column;
gap: var(--space-1, 0.25rem);
}
.cc-funnel-header {
display: flex;
justify-content: space-between;
font-size: var(--font-size-sm, 0.85rem);
color: var(--text-secondary, #888);
}
.cc-funnel-conversion {
font-variant-numeric: tabular-nums;
}
.cc-funnel-track {
height: 1rem;
background: var(--surface-2, rgba(127, 127, 127, 0.12));
border-radius: var(--radius-sm, 4px);
overflow: hidden;
}
.cc-funnel-fill {
height: 100%;
background: var(--color-accent, #4f8cff);
border-radius: var(--radius-sm, 4px);
transition: width var(--transition-normal);
}
.cc-funnel-value {
font-size: var(--font-size-sm, 0.85rem);
font-variant-numeric: tabular-nums;
color: var(--text-primary, #ddd);
}
/* ---- Loading shimmer (used by chart skeletons) ---- */
.cc-chart-skeleton {
height: 0.75rem;
border-radius: var(--radius-sm, 4px);
background: var(--surface-2, rgba(127, 127, 127, 0.12));
animation: cc-chart-pulse var(--duration-slow) ease-in-out infinite;
}
@keyframes cc-chart-pulse {
0%,
100% {
opacity: 0.4;
}
50% {
opacity: 0.9;
}
}

View File

@@ -5,7 +5,7 @@ import { getScopedItem, setScopedItem } from "../utils/projectStorage";
import { getPluginViewId, isPluginViewId, isPluginViewRegistered } from "../plugins/pluginViewRegistry";
export type ViewMode = "overview" | "project";
export type BuiltInTaskView = "board" | "list" | "graph" | "agents" | "missions" | "chat" | "documents" | "research" | "evals" | "goalsView" | "skills" | "mailbox" | "insights" | "memory" | "reliability" | "secrets" | "devserver" | "dev-server" | "stash-recovery" | "pull-requests";
export type BuiltInTaskView = "board" | "list" | "graph" | "agents" | "missions" | "chat" | "documents" | "research" | "evals" | "goalsView" | "skills" | "mailbox" | "insights" | "memory" | "reliability" | "command-center" | "secrets" | "devserver" | "dev-server" | "stash-recovery" | "pull-requests";
export type PluginTaskView = `plugin:${string}:${string}`;
export type TaskView = BuiltInTaskView | PluginTaskView;
@@ -26,6 +26,7 @@ const BUILT_IN_TASK_VIEWS: readonly BuiltInTaskView[] = [
"insights",
"memory",
"reliability",
"command-center",
"secrets",
"devserver",
"dev-server",

View File

@@ -2454,7 +2454,8 @@
"viewProjects": "View Projects",
"viewUsage": "View usage",
"workflows": "Workflows",
"workingBranch": "Working branch"
"workingBranch": "Working branch",
"commandCenterView": "Command Center"
},
"health": {
"activeTasks": "Active Tasks",
@@ -3469,7 +3470,8 @@
"terminal": "Terminal",
"todos": "Todos",
"usage": "Usage",
"workflows": "Workflows"
"workflows": "Workflows",
"commandCenter": "Command Center"
},
"newTaskModal": {
"addDependencies": "Add dependencies",
@@ -6187,10 +6189,10 @@
"yes": "Yes"
},
"taskFields": {
"unset": "—",
"moreFields": "Additional fields",
"orphaned": "Orphaned fields",
"saveFailed": "Failed to save field",
"unset": "—"
"saveFailed": "Failed to save field"
},
"taskForm": {
"addDependencies": "Add dependencies",
@@ -7008,12 +7010,6 @@
"sha256": "SHA-256",
"version": "Version"
},
"taskFields": {
"unset": "—",
"moreFields": "Additional fields",
"orphaned": "Orphaned fields",
"saveFailed": "Failed to save field"
},
"workflowEditor": {
"cliAgent": {
"executorOption": "CLI agent",
@@ -7055,5 +7051,42 @@
"mobileKeyArrowDown": "Cursor down",
"mobileKeyArrowLeft": "Cursor left",
"mobileKeyArrowRight": "Cursor right"
},
"commandCenter": {
"heading": "Command Center",
"loading": "Loading command center...",
"empty": "No usage data yet. Run some agents to populate the Command Center.",
"areaPending": "This area renders once metrics data is available.",
"tablistLabel": "Command Center sections",
"tabs": {
"overview": "Overview",
"tokens": "Tokens",
"tools": "Tools",
"activity": "Activity",
"productivity": "Productivity",
"ecosystem": "Ecosystem",
"missionControl": "Mission Control"
},
"overview": {
"tokensCost": "Tokens & cost",
"autonomy": "Autonomy ratio",
"activeNodes": "Active nodes",
"tasksDone": "Tasks done",
"uniqueModels": "Unique models",
"openSignals": "Open signals",
"liveStrip": "Live activity",
"liveStripPending": "Live Mission Control loads with active sessions."
},
"range": {
"last24h": "Last 24h",
"last7d": "Last 7 days",
"last30d": "Last 30 days",
"allTime": "All time",
"custom": "Custom range",
"dialogLabel": "Select date range",
"from": "From",
"to": "To",
"invalidRange": "Start date must be on or before end date"
}
}
}