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:
@@ -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`
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`");
|
||||
|
||||
@@ -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" : ""}`}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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-/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user