From 90cfbfb4fcfc866d2c143eabdb40790afcf53710 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 15 Jun 2026 19:23:17 -0700 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20U4=20=E2=80=94=20Command=20C?= =?UTF-8?q?enter=20view=20shell,=20nav,=20and=20chart=20primitives?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- AGENTS.md | 3 +- packages/dashboard/app/App.tsx | 12 + .../__tests__/lazy-loaded-views-docs.test.ts | 6 +- packages/dashboard/app/components/Header.tsx | 16 +- .../dashboard/app/components/MobileNavBar.tsx | 12 + .../command-center/CommandCenter.css | 143 +++++++++++ .../command-center/CommandCenter.tsx | 226 ++++++++++++++++++ .../command-center/DateRangePicker.css | 55 +++++ .../command-center/DateRangePicker.tsx | 169 +++++++++++++ .../__tests__/CommandCenter.test.tsx | 85 +++++++ .../command-center/__tests__/charts.test.tsx | 144 +++++++++++ .../components/command-center/charts/Bar.tsx | 58 +++++ .../command-center/charts/Funnel.tsx | 68 ++++++ .../command-center/charts/Sparkline.tsx | 43 ++++ .../command-center/charts/StackedBar.tsx | 60 +++++ .../command-center/charts/charts.css | 179 ++++++++++++++ packages/dashboard/app/hooks/useViewState.ts | 3 +- packages/i18n/locales/en/app.json | 53 +++- 18 files changed, 1319 insertions(+), 16 deletions(-) create mode 100644 packages/dashboard/app/components/command-center/CommandCenter.css create mode 100644 packages/dashboard/app/components/command-center/CommandCenter.tsx create mode 100644 packages/dashboard/app/components/command-center/DateRangePicker.css create mode 100644 packages/dashboard/app/components/command-center/DateRangePicker.tsx create mode 100644 packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx create mode 100644 packages/dashboard/app/components/command-center/__tests__/charts.test.tsx create mode 100644 packages/dashboard/app/components/command-center/charts/Bar.tsx create mode 100644 packages/dashboard/app/components/command-center/charts/Funnel.tsx create mode 100644 packages/dashboard/app/components/command-center/charts/Sparkline.tsx create mode 100644 packages/dashboard/app/components/command-center/charts/StackedBar.tsx create mode 100644 packages/dashboard/app/components/command-center/charts/charts.css diff --git a/AGENTS.md b/AGENTS.md index 6181ff7004..7bdd5c8da8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ``. +These 21 views are lazy-loaded via `React.lazy()` with ``. 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` diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index ce408595b2..c7ecf47c0a 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -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 ( + + + + + + ); + } + if (taskView === "devserver" || taskView === "dev-server") { if (!settingsLoaded || !devServerEnabled) { return null; diff --git a/packages/dashboard/app/__tests__/lazy-loaded-views-docs.test.ts b/packages/dashboard/app/__tests__/lazy-loaded-views-docs.test.ts index bff329f1df..53a86f982c 100644 --- a/packages/dashboard/app/__tests__/lazy-loaded-views-docs.test.ts +++ b/packages/dashboard/app/__tests__/lazy-loaded-views-docs.test.ts @@ -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`"); diff --git a/packages/dashboard/app/components/Header.tsx b/packages/dashboard/app/components/Header.tsx index a649aff430..48a31c166d 100644 --- a/packages/dashboard/app/components/Header.tsx +++ b/packages/dashboard/app/components/Header.tsx @@ -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({ <> + {experimentalFeatures?.devServerView && ( + + {experimentalFeatures?.evalsView && ( + ); + })} + + +
+ {renderActiveTab()} +
+ + ); +} diff --git a/packages/dashboard/app/components/command-center/DateRangePicker.css b/packages/dashboard/app/components/command-center/DateRangePicker.css new file mode 100644 index 0000000000..8a2acb7635 --- /dev/null +++ b/packages/dashboard/app/components/command-center/DateRangePicker.css @@ -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); +} diff --git a/packages/dashboard/app/components/command-center/DateRangePicker.tsx b/packages/dashboard/app/components/command-center/DateRangePicker.tsx new file mode 100644 index 0000000000..126e15528a --- /dev/null +++ b/packages/dashboard/app/components/command-center/DateRangePicker.tsx @@ -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(null); + const triggerRef = useRef(null); + const popoverRef = useRef(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 ( +
+ + {open ? ( +
+
+ {resolvedPresets.map((preset) => ( + + ))} +
+
+ + + {customError ? ( +
+ {customError} +
+ ) : null} +
+
+ ) : null} +
+ ); +} diff --git a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx new file mode 100644 index 0000000000..3dce4fd02a --- /dev/null +++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.test.tsx @@ -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(); + 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(); + expect(screen.getByTestId("command-center-empty")).toBeTruthy(); + }); + + it("exposes the ARIA tabs pattern (tablist + tabs + tabpanel)", () => { + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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); + }); +}); diff --git a/packages/dashboard/app/components/command-center/__tests__/charts.test.tsx b/packages/dashboard/app/components/command-center/__tests__/charts.test.tsx new file mode 100644 index 0000000000..e491642112 --- /dev/null +++ b/packages/dashboard/app/components/command-center/__tests__/charts.test.tsx @@ -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( + , + ); + 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(); + 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(); + 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(); + 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(); + const el = screen.getByLabelText("nan: 0"); + expect(widthOf(el)).toBe("0%"); + }); +}); + +describe("StackedBar", () => { + it("renders proportional segments and a legend", () => { + render( + , + ); + 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(); + 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(); + const sparkline = screen.getByRole("img", { name: "models per day" }); + const bars = sparkline.querySelectorAll(".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(); + const bars = screen.getByRole("img", { name: "empty" }).querySelectorAll(".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( + , + ); + 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(); + 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-/); + } + }); +}); diff --git a/packages/dashboard/app/components/command-center/charts/Bar.tsx b/packages/dashboard/app/components/command-center/charts/Bar.tsx new file mode 100644 index 0000000000..b0f4df840d --- /dev/null +++ b/packages/dashboard/app/components/command-center/charts/Bar.tsx @@ -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 ( +
    + {data.map((d) => { + const width = safeWidthPercent(d.value, computedMax); + const valueText = d.valueLabel ?? String(Number.isFinite(d.value) ? d.value : 0); + return ( +
  • + {d.label} +
    +
    +
    + {valueText} +
  • + ); + })} +
+ ); +} diff --git a/packages/dashboard/app/components/command-center/charts/Funnel.tsx b/packages/dashboard/app/components/command-center/charts/Funnel.tsx new file mode 100644 index 0000000000..2b99e1a5a5 --- /dev/null +++ b/packages/dashboard/app/components/command-center/charts/Funnel.tsx @@ -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 ( +
    + {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 ( +
  1. +
    + {s.label} + {conversion !== null ? {conversion} : null} +
    +
    +
    +
    + {valueText} +
  2. + ); + })} +
+ ); +} diff --git a/packages/dashboard/app/components/command-center/charts/Sparkline.tsx b/packages/dashboard/app/components/command-center/charts/Sparkline.tsx new file mode 100644 index 0000000000..b346e37484 --- /dev/null +++ b/packages/dashboard/app/components/command-center/charts/Sparkline.tsx @@ -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 ( +
+ {values.map((v, i) => { + const height = safeHeightPercent(v, computedMax); + return ( + + ); +} diff --git a/packages/dashboard/app/components/command-center/charts/StackedBar.tsx b/packages/dashboard/app/components/command-center/charts/StackedBar.tsx new file mode 100644 index 0000000000..8cc2398749 --- /dev/null +++ b/packages/dashboard/app/components/command-center/charts/StackedBar.tsx @@ -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 ( +
+
+ {segments.map((s) => { + const share = safeShare(s.value, total); + return ( +
+ ); + })} +
+
    + {segments.map((s) => ( +
  • +
  • + ))} +
+
+ ); +} diff --git a/packages/dashboard/app/components/command-center/charts/charts.css b/packages/dashboard/app/components/command-center/charts/charts.css new file mode 100644 index 0000000000..fc99dab519 --- /dev/null +++ b/packages/dashboard/app/components/command-center/charts/charts.css @@ -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; + } +} diff --git a/packages/dashboard/app/hooks/useViewState.ts b/packages/dashboard/app/hooks/useViewState.ts index 9dee09b820..51b0f2ad12 100644 --- a/packages/dashboard/app/hooks/useViewState.ts +++ b/packages/dashboard/app/hooks/useViewState.ts @@ -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", diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 3d97e8d4dc..f765f0ea80 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -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" + } } }