FN-6727: add Command Center operator controls

Add operator controls to the Command Center overview and reuse a shared theme dropdown.

- Add Command Center controls for org chart status, pause toggles, heartbeat, concurrency settings, and theme selection.
- Extract theme option metadata and a reusable ThemeDropdown for app-level and Command Center theme controls.
- Update localization, documentation, and dashboard tests for the new controls and mobile layout behavior.

Files changed:
 docs/dashboard-guide.md                            |   1 +
 docs/settings-reference.md                         |   6 +-
 packages/dashboard/app/App.tsx                     |   8 +-
 .../dashboard/app/components/ThemeDropdown.css     |  96 +++++
 .../dashboard/app/components/ThemeDropdown.tsx     | 175 +++++++++
 .../dashboard/app/components/ThemeSelector.tsx     |  65 +---
 .../components/__tests__/ThemeDropdown.test.tsx    |  76 ++++
 .../components/__tests__/ThemeSelector.test.tsx    |  18 +
 .../components/command-center/CommandCenter.tsx    |  52 ++-
 .../command-center/CommandCenterControls.css       | 193 ++++++++++
 .../command-center/CommandCenterControls.tsx       | 424 +++++++++++++++++++++
 .../__tests__/CommandCenter.mobile-scroll.test.tsx |  17 +
 .../__tests__/CommandCenter.test.tsx               |  26 +-
 .../__tests__/CommandCenterControls.test.tsx       | 241 ++++++++++++
 packages/dashboard/app/components/themeOptions.ts  |  70 ++++
 packages/i18n/locales/en/app.json                  |  47 +++
 16 files changed, 1442 insertions(+), 73 deletions(-)

Fusion-Task-Id: FN-6727

Fusion-Task-Lineage: bd7752cf-9f6e-4359-a5bc-d2a4b68d086b
This commit is contained in:
gsxdsm
2026-06-19 14:22:02 -07:00
parent 2da55a8994
commit 5145e609cc
16 changed files with 1442 additions and 73 deletions

View File

@@ -663,6 +663,7 @@ Navigation:
Features:
- Global date-range picker in the header scopes the analytics tabs; **Mission Control** remains live rather than historical.
- **Overview controls dashboard** sits at the top of the Overview landing surface on desktop and mobile. It includes a read-only agent org chart, heartbeat pause/resume backed by the existing `enginePaused` setting, AI engine stop/start backed by `globalPause`, live scheduler status from executor stats, range sliders for `maxConcurrent`, `maxTriageConcurrent`, and `maxWorktrees` that persist through `/api/settings`, and a compact theme dropdown with the same color-chip swatches as Settings. These controls reuse existing APIs and App-level theme setters; they do not add a new heartbeat interval setting, backend route, or second theme owner.
- **Overview** summarizes token usage/cost, autonomy, active nodes, sessions, agent runs, tasks done, model breadth, and real open signals, and includes the SDLC throughput funnel for the selected range at the bottom of the Overview content in loading, error, empty, and populated states. Its token total and Live activity snapshot token metric refresh on a bounded live cadence and animate number changes while preserving reduced-motion preferences. The sessions card uses the selected-range `ActivityAnalytics.sessions` value already loaded for the overview. The Live activity snapshot also shows the current board-state count for tasks in progress, independent of the selected analytics date range. Overview includes a graph-rich software-factory snapshot with the existing tokens-by-model bar, tool-category bar, real recharts token-share pie, and the daily activity multi-series line chart placed before the daily activity sparkline/trend so the richer line graph sits higher in the chart grid. These reuse the already-loaded tokens, tools, activity, and signals analytics; the signals count comes from `/api/command-center/signals` and renders unavailable (`—`) while the incidents-backed response is loading or unavailable. The chart reveal/glow accents are decorative and disabled when reduced-motion preferences are active. The SDLC completion rate is shown as a radial gauge and is calculated as cohort conversion from in-range triage entrants, so the rate is capped at 100% even when older tasks finish during the range.
- **Tokens** breaks down token totals, estimated cost, tasks, and per-model usage. Per-model and per-provider breakdowns use the task's analytics-only actually-used model snapshot when available, so usage from settings-resolved runs appears under the real runtime model instead of `(unknown)` without changing future model resolution; estimated cost uses the same snapshot-first, legacy-fallback model identity so those resolved runs price normally when the model is in the pricing table. It includes the existing token-usage-over-time chart, an additive recharts multi-series line graph, and a token-share pie backed by the same grouped token analytics; use the granularity control to switch the time-series request between hourly, daily, and weekly buckets. The token total and charts poll on a bounded cadence, keep the previous data visible during refresh, animate decorative count/bar transitions, and disable those animations for reduced-motion users.
- **Tools** shows autonomy ratio, tool-call volume, intervention counts, sessions, and tool categories. The area keeps the existing category bar and adds a recharts category-share pie from `ToolAnalytics.byCategory`. There is intentionally no tools line chart yet because `ToolAnalytics` does not expose a per-day tool trend; the dashboard does not fabricate one or call a new endpoint.

View File

@@ -299,10 +299,10 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`
| `globalPause` | `boolean` | `false` | Hard stop: terminate active engine sessions and pause scheduling immediately. |
| `globalPauseReason` | `string` | `undefined` | Optional reason for `globalPause` (`"rate-limit"` for automatic pauses, `"manual"` for user-triggered pauses). Cleared on unpause. |
| `enginePaused` | `boolean` | `false` | Soft pause: stop dispatching new work while letting active sessions finish. While paused (including shared pause windows with `globalPause`), stuck-task polling/timers are suspended so paused wall-clock time does not count against `taskStuckTimeoutMs`. Clearing pause state resumes runtime scheduling and gives tracked active sessions a fresh stuck-task grace window before normal detection resumes; when `autoMerge` is enabled, eligible `in-review` tasks are re-swept into the auto-merge queue (paused/blocked/failed review tasks remain skipped). |
| `maxConcurrent` | `number` | `2` | Max concurrent task-lane AI agents (planning, executor, merge). |
| `maxTriageConcurrent` | `number` | `2` | Max concurrent planning agents. |
| `maxConcurrent` | `number` | `2` | Max concurrent task-lane AI agents (planning, executor, merge). Editable from Settings and the Command Center Overview controls dashboard. |
| `maxTriageConcurrent` | `number` | `2` | Max concurrent planning agents. Editable from Settings and the Command Center Overview controls dashboard. |
| `globalMaxConcurrent` | `number` | `4` | System-wide max concurrent agents across all projects. |
| `maxWorktrees` | `number` | `4` | Max git worktrees. |
| `maxWorktrees` | `number` | `4` | Max git worktrees. Editable from Settings and the Command Center Overview controls dashboard. |
| `pollIntervalMs` | `number` | `15000` | Scheduler poll interval (ms). |
| `heartbeatMultiplier` | `number` | `1` | Global multiplier applied to agent heartbeat timing: both heartbeat intervals and unresponsive timeout bases. Configured from the Agents screen (not Settings). |
| `heartbeatScopeDiscipline` | `"strict" \| "lite" \| "off"` | `"strict"` | Heartbeat prompt procedure mode. `strict` keeps coordination-heavy scope discipline, `lite` restores pre-2026-05-11 wording, and `off` uses a minimal procedure. Per-agent `runtimeConfig.heartbeatScopeDiscipline` can override this default. |

View File

@@ -1819,7 +1819,13 @@ function AppInner() {
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<CommandCenter />
<CommandCenter
projectId={currentProject?.id}
colorTheme={colorTheme}
themeMode={themeMode}
onColorThemeChange={setColorTheme}
onThemeModeChange={setThemeMode}
/>
</Suspense>
</PageErrorBoundary>
);

View File

@@ -0,0 +1,96 @@
.theme-dropdown {
position: relative;
display: flex;
flex-direction: column;
gap: var(--space-sm);
min-inline-size: 0;
}
.theme-dropdown-trigger {
justify-content: space-between;
gap: var(--space-sm);
inline-size: 100%;
min-inline-size: 0;
}
.theme-dropdown-trigger .theme-option-swatch,
.theme-dropdown-option .theme-option-swatch {
flex: 0 0 auto;
}
.theme-dropdown-trigger-label,
.theme-dropdown-option-label {
min-inline-size: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.theme-dropdown-modes {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
}
.theme-dropdown-mode-btn {
gap: var(--space-xs);
}
.theme-dropdown-mode-btn.active {
border-color: var(--accent);
color: var(--accent);
}
.theme-dropdown-popover {
position: absolute;
z-index: 10;
inset-block-start: calc(100% + var(--space-xs));
inset-inline: 0;
padding: var(--space-sm);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface-1);
box-shadow: var(--shadow-lg);
}
.theme-dropdown-list {
display: grid;
gap: var(--space-xs);
max-block-size: min(60vh, calc(var(--space-2xl) * 12));
overflow-y: auto;
overscroll-behavior: contain;
}
.theme-dropdown-option {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-sm);
inline-size: 100%;
padding: var(--space-sm);
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
cursor: pointer;
text-align: start;
}
.theme-dropdown-option:hover,
.theme-dropdown-option:focus-visible,
.theme-dropdown-option.active {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, transparent);
outline: none;
}
@media (max-width: 768px) {
.theme-dropdown-popover {
position: static;
margin-block-start: var(--space-xs);
}
.theme-dropdown-list {
max-block-size: min(50vh, calc(var(--space-2xl) * 10));
}
}

View File

@@ -0,0 +1,175 @@
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { useTranslation } from "react-i18next";
import { Check, ChevronDown } from "lucide-react";
import type { ColorTheme, ThemeMode } from "@fusion/core";
import { COLOR_THEMES, THEME_MODES } from "./themeOptions";
import "./ThemeSelector.css";
import "./ThemeDropdown.css";
interface ThemeDropdownProps {
colorTheme: ColorTheme;
onColorThemeChange: (theme: ColorTheme) => void;
themeMode?: ThemeMode;
onThemeModeChange?: (mode: ThemeMode) => void;
}
function ThemeSwatch({ className }: { className: string }) {
return (
<span className={`theme-option-swatch ${className}`} aria-hidden="true">
<span className="theme-option-swatch-sample theme-option-swatch-sample-1" />
<span className="theme-option-swatch-sample theme-option-swatch-sample-2" />
<span className="theme-option-swatch-sample theme-option-swatch-sample-3" />
<span className="theme-option-swatch-sample theme-option-swatch-sample-4" />
</span>
);
}
/*
FNXC:Theme 2026-06-19-12:10:
FN-6727 requires Command Center operators to change the global app theme from a compact dropdown that previews each color theme with the same rich swatch chips used by Settings; this component accepts App-threaded setters instead of creating another theme owner.
*/
export function ThemeDropdown({ colorTheme, onColorThemeChange, themeMode, onThemeModeChange }: ThemeDropdownProps) {
const { t } = useTranslation("app");
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(() => Math.max(0, COLOR_THEMES.findIndex((theme) => theme.value === colorTheme)));
const rootRef = useRef<HTMLDivElement | null>(null);
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
const currentTheme = useMemo(
() => COLOR_THEMES.find((theme) => theme.value === colorTheme) ?? COLOR_THEMES[0],
[colorTheme],
);
const listboxId = "theme-dropdown-listbox";
useEffect(() => {
if (!open) return;
const handlePointerDown = (event: PointerEvent) => {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
setOpen(false);
}
};
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [open]);
useEffect(() => {
setActiveIndex(Math.max(0, COLOR_THEMES.findIndex((theme) => theme.value === colorTheme)));
}, [colorTheme]);
useEffect(() => {
if (open) {
optionRefs.current[activeIndex]?.focus();
}
}, [activeIndex, open]);
const chooseTheme = (theme: ColorTheme) => {
onColorThemeChange(theme);
setOpen(false);
};
const handleTriggerKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Enter" || event.key === " ") {
event.preventDefault();
const selectedIndex = Math.max(0, COLOR_THEMES.findIndex((theme) => theme.value === colorTheme));
setActiveIndex(event.key === "ArrowUp" ? Math.max(0, selectedIndex - 1) : selectedIndex);
setOpen(true);
}
};
const handleOptionKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number, theme: ColorTheme) => {
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
return;
}
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
const delta = event.key === "ArrowDown" ? 1 : -1;
setActiveIndex((index + delta + COLOR_THEMES.length) % COLOR_THEMES.length);
return;
}
if (event.key === "Home") {
event.preventDefault();
setActiveIndex(0);
return;
}
if (event.key === "End") {
event.preventDefault();
setActiveIndex(COLOR_THEMES.length - 1);
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
chooseTheme(theme);
}
};
return (
<div className="theme-dropdown" ref={rootRef}>
<button
type="button"
className="theme-dropdown-trigger btn"
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listboxId}
onClick={() => setOpen((value) => !value)}
onKeyDown={handleTriggerKeyDown}
>
<ThemeSwatch className={currentTheme.className} />
<span className="theme-dropdown-trigger-label">
{t(`theme.colorTheme.${currentTheme.value}`, currentTheme.label)}
</span>
<ChevronDown size={16} aria-hidden="true" />
</button>
{themeMode && onThemeModeChange ? (
<div className="theme-dropdown-modes" role="radiogroup" aria-label={t("theme.modeLabel", "Theme mode")}>
{THEME_MODES.map(({ value, label, icon: Icon }) => (
<button
key={value}
type="button"
className={`theme-dropdown-mode-btn btn btn-sm${themeMode === value ? " active" : ""}`}
aria-pressed={themeMode === value}
onClick={() => onThemeModeChange(value)}
title={t(`theme.${value}Mode`, `${label} mode`)}
>
<Icon size={16} aria-hidden="true" />
<span>{t(`theme.${value}`, label)}</span>
</button>
))}
</div>
) : null}
{open ? (
<div className="theme-dropdown-popover" role="presentation">
<div id={listboxId} className="theme-dropdown-list" role="listbox" aria-label={t("theme.colorThemeLabel", "Color theme")}>
{COLOR_THEMES.map(({ value, label, className }, index) => {
const selected = colorTheme === value;
return (
<button
key={value}
ref={(element) => {
optionRefs.current[index] = element;
}}
type="button"
className={`theme-dropdown-option${selected ? " active" : ""}`}
role="option"
aria-selected={selected}
tabIndex={index === activeIndex ? 0 : -1}
onClick={() => chooseTheme(value)}
onKeyDown={(event) => handleOptionKeyDown(event, index, value)}
>
<ThemeSwatch className={className} />
<span className="theme-dropdown-option-label">{t(`theme.colorTheme.${value}`, label)}</span>
{selected ? <Check size={16} aria-hidden="true" /> : null}
</button>
);
})}
</div>
</div>
) : null}
</div>
);
}
export type { ThemeDropdownProps };

View File

@@ -3,6 +3,7 @@ import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Sun, Moon, Monitor } from "lucide-react";
import type { ThemeMode, ColorTheme } from "@fusion/core";
import { COLOR_THEMES, THEME_MODES } from "./themeOptions";
interface ThemeSelectorProps {
themeMode: ThemeMode;
@@ -13,12 +14,6 @@ interface ThemeSelectorProps {
onDashboardFontScaleChange?: (scalePct: number) => void;
}
const THEME_MODES: { value: ThemeMode; label: string; icon: typeof Sun }[] = [
{ value: "light", label: "Light", icon: Sun },
{ value: "dark", label: "Dark", icon: Moon },
{ value: "system", label: "System", icon: Monitor },
];
const FONT_SCALE_OPTIONS = [
{ value: 90, label: "Small" },
{ value: 100, label: "Default" },
@@ -26,64 +21,6 @@ const FONT_SCALE_OPTIONS = [
{ value: 120, label: "Largest" },
] as const;
const COLOR_THEMES: { value: ColorTheme; label: string; className: string }[] = [
{ value: "default", label: "Default", className: "theme-swatch-default" },
{ value: "ocean", label: "Ocean", className: "theme-swatch-ocean" },
{ value: "forest", label: "Forest", className: "theme-swatch-forest" },
{ value: "sunset", label: "Sunset", className: "theme-swatch-sunset" },
{ value: "zen", label: "Zen", className: "theme-swatch-zen" },
{ value: "berry", label: "Berry", className: "theme-swatch-berry" },
{ value: "high-contrast", label: "High Contrast", className: "theme-swatch-high-contrast" },
{ value: "industrial", label: "Industrial", className: "theme-swatch-industrial" },
{ value: "monochrome", label: "Mono", className: "theme-swatch-monochrome" },
{ value: "slate", label: "Slate", className: "theme-swatch-slate" },
{ value: "ash", label: "Ash", className: "theme-swatch-ash" },
{ value: "graphite", label: "Graphite", className: "theme-swatch-graphite" },
{ value: "silver", label: "Silver", className: "theme-swatch-silver" },
{ value: "solarized", label: "Solarized", className: "theme-swatch-solarized" },
{ value: "factory", label: "Factory", className: "theme-swatch-factory" },
{ value: "factory-mono", label: "Factory Mono", className: "theme-swatch-factory-mono" },
{ value: "ayu", label: "Ayu", className: "theme-swatch-ayu" },
{ value: "one-dark", label: "One Dark", className: "theme-swatch-one-dark" },
{ value: "nord", label: "Nord", className: "theme-swatch-nord" },
{ value: "dracula", label: "Dracula", className: "theme-swatch-dracula" },
{ value: "gruvbox", label: "Gruvbox", className: "theme-swatch-gruvbox" },
{ value: "tokyo-night", label: "Tokyo Night", className: "theme-swatch-tokyo-night" },
{ value: "catppuccin-mocha", label: "Catppuccin Mocha", className: "theme-swatch-catppuccin-mocha" },
{ value: "github-dark", label: "GitHub Dark", className: "theme-swatch-github-dark" },
{ value: "everforest", label: "Everforest", className: "theme-swatch-everforest" },
{ value: "rose-pine", label: "Rosé Pine", className: "theme-swatch-rose-pine" },
{ value: "kanagawa", label: "Kanagawa", className: "theme-swatch-kanagawa" },
{ value: "night-owl", label: "Night Owl", className: "theme-swatch-night-owl" },
{ value: "palenight", label: "Palenight", className: "theme-swatch-palenight" },
{ value: "monokai-pro", label: "Monokai Pro", className: "theme-swatch-monokai-pro" },
{ value: "slime", label: "Slime", className: "theme-swatch-slime" },
{ value: "brutalist", label: "Brutalist", className: "theme-swatch-brutalist" },
{ value: "neon-city", label: "Neon City", className: "theme-swatch-neon-city" },
{ value: "parchment", label: "Parchment", className: "theme-swatch-parchment" },
{ value: "terminal", label: "Terminal", className: "theme-swatch-terminal" },
{ value: "glass", label: "Glass", className: "theme-swatch-glass" },
{ value: "horizon", label: "Horizon", className: "theme-swatch-horizon" },
{ value: "vitesse", label: "Vitesse", className: "theme-swatch-vitesse" },
{ value: "outrun", label: "Outrun", className: "theme-swatch-outrun" },
{ value: "snazzy", label: "Snazzy", className: "theme-swatch-snazzy" },
{ value: "porple", label: "Porple", className: "theme-swatch-porple" },
{ value: "espresso", label: "Espresso", className: "theme-swatch-espresso" },
{ value: "mars", label: "Mars", className: "theme-swatch-mars" },
{ value: "poimandres", label: "Poimandres", className: "theme-swatch-poimandres" },
{ value: "ember", label: "Ember", className: "theme-swatch-ember" },
{ value: "rust", label: "Rust", className: "theme-swatch-rust" },
{ value: "copper", label: "Copper", className: "theme-swatch-copper" },
{ value: "foundry", label: "Foundry", className: "theme-swatch-foundry" },
{ value: "carbon", label: "Carbon", className: "theme-swatch-carbon" },
{ value: "sandstone", label: "Sandstone", className: "theme-swatch-sandstone" },
{ value: "lagoon", label: "Lagoon", className: "theme-swatch-lagoon" },
{ value: "frost", label: "Frost", className: "theme-swatch-frost" },
{ value: "lavender", label: "Lavender", className: "theme-swatch-lavender" },
{ value: "neon-bloom", label: "Neon Bloom", className: "theme-swatch-neon-bloom" },
{ value: "sepia", label: "Sepia", className: "theme-swatch-sepia" },
];
/**
* ThemeSelector component for choosing light/dark/system mode and color theme
*/

View File

@@ -0,0 +1,76 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { COLOR_THEMES } from "../themeOptions";
import { ThemeDropdown } from "../ThemeDropdown";
describe("ThemeDropdown", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders the current theme chip and opens all swatched theme options", () => {
render(<ThemeDropdown colorTheme="ocean" onColorThemeChange={vi.fn()} />);
const trigger = screen.getByRole("button", { name: /ocean/i });
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(within(trigger).getByText("Ocean")).toBeDefined();
expect(trigger.querySelector(".theme-swatch-ocean")).toBeTruthy();
fireEvent.click(trigger);
const listbox = screen.getByRole("listbox", { name: /color theme/i });
expect(trigger.getAttribute("aria-expanded")).toBe("true");
for (const theme of COLOR_THEMES) {
const option = within(listbox)
.getAllByRole("option")
.find((element) => element.textContent?.trim().startsWith(theme.label));
expect(option?.querySelector(`.${theme.className}`)).toBeTruthy();
}
});
it("selects themes and closes from click, escape, and outside click", () => {
const onColorThemeChange = vi.fn();
render(<ThemeDropdown colorTheme="default" onColorThemeChange={onColorThemeChange} />);
fireEvent.click(screen.getByRole("button", { name: /default/i }));
fireEvent.click(screen.getAllByRole("option").find((element) => element.textContent?.trim() === "Forest")!);
expect(onColorThemeChange).toHaveBeenCalledWith("forest");
expect(screen.queryByRole("listbox")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: /default/i }));
fireEvent.keyDown(screen.getByRole("option", { name: /default/i }), { key: "Escape" });
expect(screen.queryByRole("listbox")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: /default/i }));
fireEvent.pointerDown(document.body);
expect(screen.queryByRole("listbox")).toBeNull();
});
it("is keyboard-operable with arrows and enter", () => {
const onColorThemeChange = vi.fn();
render(<ThemeDropdown colorTheme="default" onColorThemeChange={onColorThemeChange} />);
const trigger = screen.getByRole("button", { name: /default/i });
fireEvent.keyDown(trigger, { key: "ArrowDown" });
fireEvent.keyDown(screen.getByRole("option", { name: /default/i }), { key: "ArrowDown" });
fireEvent.keyDown(screen.getByRole("option", { name: /ocean/i }), { key: "Enter" });
expect(onColorThemeChange).toHaveBeenCalledWith("ocean");
expect(screen.queryByRole("listbox")).toBeNull();
});
it("renders compact theme mode controls when mode props are supplied", () => {
const onThemeModeChange = vi.fn();
render(
<ThemeDropdown
colorTheme="default"
themeMode="dark"
onColorThemeChange={vi.fn()}
onThemeModeChange={onThemeModeChange}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /light/i }));
expect(onThemeModeChange).toHaveBeenCalledWith("light");
});
});

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { COLOR_THEMES } from "@fusion/core";
import { ThemeSelector } from "../ThemeSelector";
import { COLOR_THEMES as THEME_OPTIONS } from "../themeOptions";
describe("ThemeSelector", () => {
it("renders theme mode toggle buttons", () => {
@@ -128,6 +129,23 @@ describe("ThemeSelector", () => {
expect(screen.getByLabelText("Sepia theme")).toBeDefined();
});
it("renders every shared swatch class from themeOptions", () => {
render(
<ThemeSelector
themeMode="dark"
colorTheme="default"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
for (const theme of THEME_OPTIONS) {
const option = screen.getByLabelText(`${theme.label} theme`);
expect(option.querySelector(`.${theme.className}`)).toBeTruthy();
}
expect(THEME_OPTIONS.map((theme) => theme.value)).toEqual([...COLOR_THEMES]);
});
it("marks current color theme as active", () => {
render(
<ThemeSelector

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertCircle, Gauge } from "lucide-react";
import type { ActivityAnalytics, LiveSnapshot, SignalsAnalytics, TokenAnalytics, ToolAnalytics } from "@fusion/core";
import type { ActivityAnalytics, ColorTheme, LiveSnapshot, SignalsAnalytics, ThemeMode, TokenAnalytics, ToolAnalytics } from "@fusion/core";
import { api } from "../../api/legacy";
import { DateRangePicker, defaultPresets, rangeFromPreset, type DateRange } from "./DateRangePicker";
import { TokensArea } from "./areas/TokensArea";
@@ -14,6 +14,7 @@ import { GithubArea } from "./areas/GithubArea";
import { SignalsArea } from "./areas/SignalsArea";
import { SystemStatsArea } from "./areas/SystemStatsArea";
import { MissionControlPanel } from "./MissionControlPanel";
import { CommandCenterControls } from "./CommandCenterControls";
import { ReliabilityView } from "../ReliabilityView";
import { SdlcFunnel } from "./SdlcFunnel";
import { Bar, type BarDatum } from "./charts/Bar";
@@ -86,7 +87,22 @@ FN-6683 adds real Overview pie and line charts by reusing the already-fetched to
*/
const OVERVIEW_TOKEN_REFRESH_MS = 15_000;
function OverviewTab({ range }: { range: DateRange }) {
interface CommandCenterProps {
projectId?: string;
colorTheme?: ColorTheme;
themeMode?: ThemeMode;
onColorThemeChange?: (theme: ColorTheme) => void;
onThemeModeChange?: (mode: ThemeMode) => void;
}
function OverviewTab({
range,
projectId,
colorTheme = "default",
themeMode = "system",
onColorThemeChange = () => {},
onThemeModeChange = () => {},
}: { range: DateRange } & CommandCenterProps) {
const { t } = useTranslation("app");
const tokens = useAnalyticsArea<TokenAnalytics>("/command-center/tokens?groupBy=model", range, {
pollMs: OVERVIEW_TOKEN_REFRESH_MS,
@@ -228,6 +244,15 @@ function OverviewTab({ range }: { range: DateRange }) {
// The throughput funnel reads its own data (activityLog transitions) and shows
// its own empty state, so it renders even when the stat-card aggregates have no
// data yet.
const controlsSection = (
<CommandCenterControls
projectId={projectId}
colorTheme={colorTheme}
themeMode={themeMode}
onColorThemeChange={onColorThemeChange}
onThemeModeChange={onThemeModeChange}
/>
);
const throughputSection = (
<div className="cc-overview-throughput" data-testid="command-center-throughput">
<SdlcFunnel range={range} />
@@ -237,6 +262,7 @@ function OverviewTab({ range }: { range: DateRange }) {
if (isInitialLoading) {
return (
<div className="cc-overview">
{controlsSection}
<div className="cc-loading" data-testid="command-center-overview-loading">
<div className="cc-chart-skeleton" />
<p>{t("commandCenter.loading", "Loading command center...")}</p>
@@ -249,6 +275,7 @@ function OverviewTab({ range }: { range: DateRange }) {
if (coreError !== null && !hasData) {
return (
<div className="cc-overview">
{controlsSection}
<div className="cc-error" data-testid="command-center-overview-error" role="alert">
<AlertCircle size={24} />
<p>{coreError}</p>
@@ -261,6 +288,7 @@ function OverviewTab({ range }: { range: DateRange }) {
if (!hasData) {
return (
<div className="cc-overview">
{controlsSection}
<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>
@@ -272,6 +300,7 @@ function OverviewTab({ range }: { range: DateRange }) {
return (
<div className="cc-overview">
{controlsSection}
<div className="cc-stat-grid">
{cards.map((card) => (
<div key={card.id} className="card cc-stat-card" data-testid={`command-center-stat-${card.id}`}>
@@ -397,7 +426,13 @@ function PlaceholderTab({ tabId }: { tabId: SubViewId }) {
);
}
export function CommandCenter() {
export function CommandCenter({
projectId,
colorTheme = "default",
themeMode = "system",
onColorThemeChange = () => {},
onThemeModeChange = () => {},
}: CommandCenterProps = {}) {
const { t } = useTranslation("app");
const subViews = useSubViews();
const [activeTab, setActiveTab] = useState<SubViewId>("overview");
@@ -451,7 +486,16 @@ export function CommandCenter() {
function renderActiveTab() {
switch (activeTab) {
case "overview":
return <OverviewTab range={range} />;
return (
<OverviewTab
range={range}
projectId={projectId}
colorTheme={colorTheme}
themeMode={themeMode}
onColorThemeChange={onColorThemeChange}
onThemeModeChange={onThemeModeChange}
/>
);
case "tokens":
return <TokensArea range={range} />;
case "tools":

View File

@@ -0,0 +1,193 @@
.cc-controls {
min-inline-size: 0;
}
.cc-controls-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-md);
align-items: stretch;
}
.cc-controls-card {
min-inline-size: 0;
padding: var(--space-md);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface-1);
}
.cc-controls-card--org,
.cc-controls-card--concurrency {
grid-column: 1 / -1;
}
.cc-controls-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-md);
margin-block-end: var(--space-md);
}
.cc-controls-card-header h3 {
margin: 0;
color: var(--text);
font-size: 0.9375rem;
}
.cc-controls-card-header p,
.cc-controls-muted {
margin: var(--space-xs) 0 0;
color: var(--text-muted);
font-size: 0.8125rem;
}
.cc-controls-error {
margin: 0;
color: var(--color-error);
font-size: 0.8125rem;
}
.cc-controls-status-pill {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: var(--space-xs);
color: var(--text-muted);
font-size: 0.8125rem;
}
.cc-controls-facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-sm);
margin: 0 0 var(--space-md);
}
.cc-controls-facts div {
min-inline-size: 0;
}
.cc-controls-facts dt {
color: var(--text-muted);
font-size: 0.75rem;
}
.cc-controls-facts dd {
margin: var(--space-xs) 0 0;
color: var(--text);
font-size: 0.875rem;
overflow-wrap: anywhere;
}
.cc-controls-action {
gap: var(--space-xs);
}
.cc-controls-save-state {
flex: 0 0 auto;
color: var(--text-muted);
font-size: 0.8125rem;
}
.cc-controls-save-state--saved {
color: var(--color-success);
}
.cc-controls-save-state--error {
color: var(--color-error);
}
.cc-controls-sliders {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-md);
}
.cc-controls-slider {
display: flex;
min-inline-size: 0;
flex-direction: column;
gap: var(--space-sm);
color: var(--text);
font-size: 0.8125rem;
}
.cc-controls-slider-label {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
}
.cc-controls-slider-label strong {
color: var(--accent);
font-variant-numeric: tabular-nums;
}
.cc-controls-slider input[type="range"] {
inline-size: 100%;
accent-color: var(--accent);
}
.cc-controls-org-scroll {
max-block-size: calc(var(--space-2xl) * 10);
overflow: auto;
overscroll-behavior: contain;
}
.cc-controls-org-roots,
.cc-controls-org-children {
display: flex;
flex-direction: column;
gap: var(--space-sm);
margin: 0;
padding: 0;
list-style: none;
}
.cc-controls-org-children {
margin-block-start: var(--space-sm);
margin-inline-start: var(--space-xl);
padding-inline-start: var(--space-md);
border-inline-start: 1px solid var(--border-subtle);
}
.cc-controls-org-item {
min-inline-size: max-content;
}
.cc-controls-org-node {
display: inline-flex;
min-inline-size: calc(var(--space-2xl) * 5);
max-inline-size: calc(var(--space-2xl) * 10);
}
@media (max-width: 768px) {
.cc-controls-grid {
grid-template-columns: minmax(0, 1fr);
}
.cc-controls-card {
padding: var(--space-sm);
}
.cc-controls-card-header {
flex-direction: column;
}
.cc-controls-facts,
.cc-controls-sliders {
grid-template-columns: minmax(0, 1fr);
}
.cc-controls-action {
justify-content: center;
inline-size: 100%;
}
.cc-controls-org-scroll {
max-block-size: calc(var(--space-2xl) * 8);
}
}

View File

@@ -0,0 +1,424 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Pause, Play, Power } from "lucide-react";
import type { ColorTheme, OrgTreeNode, ThemeMode } from "@fusion/core";
import { fetchConfig, fetchExecutorStats, fetchOrgTree, fetchSettings, updateSettings } from "../../api/legacy";
import { useAppSettings } from "../../hooks/useAppSettings";
import { AgentAvatar } from "../AgentAvatar";
import { ThemeDropdown } from "../ThemeDropdown";
import "./CommandCenterControls.css";
export interface CommandCenterControlsProps {
projectId?: string;
colorTheme: ColorTheme;
themeMode: ThemeMode;
onColorThemeChange: (theme: ColorTheme) => void;
onThemeModeChange: (mode: ThemeMode) => void;
}
type AsyncState<T> =
| { status: "loading"; data: T | null; error: null }
| { status: "loaded"; data: T; error: null }
| { status: "error"; data: T | null; error: string };
type ExecutorStats = {
globalPause: boolean;
enginePaused: boolean;
maxConcurrent: number;
lastActivityAt?: string;
};
type ConcurrencyValues = {
maxConcurrent: number;
maxTriageConcurrent: number;
maxWorktrees: number;
};
const EXECUTOR_STATUS_POLL_MS = 10_000;
const CONCURRENCY_SAVE_DEBOUNCE_MS = 500;
const DEFAULT_CONCURRENCY_VALUES: ConcurrencyValues = {
maxConcurrent: 2,
maxTriageConcurrent: 1,
maxWorktrees: 5,
};
function formatAgentMeta(node: OrgTreeNode) {
const title = node.agent.title?.trim();
return title ? `${node.agent.role} · ${title}` : node.agent.role;
}
function OrgChartNode({ node }: { node: OrgTreeNode }) {
const hasChildren = node.children.length > 0;
return (
<li className="cc-controls-org-item">
<div className="org-chart-node cc-controls-org-node">
<div className="org-chart-node__header">
<span className="org-chart-node__icon"><AgentAvatar agent={node.agent} size={20} /></span>
<span className="org-chart-node__name">{node.agent.name}</span>
</div>
<div className="org-chart-node__meta">
<span className="org-chart-node__badge">{node.agent.state}</span>
<span>{formatAgentMeta(node)}</span>
</div>
</div>
{hasChildren ? (
<ul className="org-chart-children cc-controls-org-children">
{node.children.map((child) => (
<OrgChartNode key={child.agent.id} node={child} />
))}
</ul>
) : null}
</li>
);
}
/*
FNXC:CommandCenter 2026-06-19-12:20:
FN-6727 makes the Command Center Overview the operator controls dashboard: org chart, heartbeat, engine, concurrency, and theme controls must live above the existing throughput and analytics surfaces without introducing new backend routes.
*/
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function formatLastActivity(value: string | undefined, fallback: string) {
if (!value) return fallback;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return fallback;
return date.toLocaleString();
}
function StatusPill({ paused, label }: { paused: boolean; label: string }) {
return (
<span className="cc-controls-status-pill">
<span className={`status-dot ${paused ? "status-dot--pending" : "status-dot--online"}`} aria-hidden="true" />
<span>{label}</span>
</span>
);
}
export function CommandCenterControls({ projectId, colorTheme, themeMode, onColorThemeChange, onThemeModeChange }: CommandCenterControlsProps) {
const { t } = useTranslation("app");
const {
globalPaused,
enginePaused,
toggleGlobalPause,
toggleEnginePause,
refresh,
} = useAppSettings(projectId);
const [orgTreeState, setOrgTreeState] = useState<AsyncState<OrgTreeNode[]>>({ status: "loading", data: null, error: null });
const [executorStatsState, setExecutorStatsState] = useState<AsyncState<ExecutorStats>>({ status: "loading", data: null, error: null });
const [concurrencyState, setConcurrencyState] = useState<AsyncState<ConcurrencyValues>>({ status: "loading", data: null, error: null });
const [concurrencyDirty, setConcurrencyDirty] = useState(false);
const [concurrencySaveState, setConcurrencySaveState] = useState<"idle" | "saving" | "saved" | "error">("idle");
useEffect(() => {
let cancelled = false;
setOrgTreeState({ status: "loading", data: null, error: null });
void (async () => {
try {
const result = await fetchOrgTree(projectId);
if (!cancelled) {
setOrgTreeState({ status: "loaded", data: result, error: null });
}
} catch (error) {
if (!cancelled) {
setOrgTreeState({
status: "error",
data: null,
error: error instanceof Error ? error.message : t("commandCenter.controls.orgChart.error", "Unable to load org chart"),
});
}
}
})();
return () => {
cancelled = true;
};
}, [projectId, t]);
useEffect(() => {
let cancelled = false;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const loadExecutorStats = async () => {
try {
const result = await fetchExecutorStats(projectId);
if (!cancelled) {
setExecutorStatsState({ status: "loaded", data: result, error: null });
}
} catch (error) {
if (!cancelled) {
setExecutorStatsState({
status: "error",
data: null,
error: error instanceof Error ? error.message : t("commandCenter.controls.status.error", "Unable to load live scheduler status"),
});
}
} finally {
if (!cancelled) {
timeoutId = setTimeout(loadExecutorStats, EXECUTOR_STATUS_POLL_MS);
}
}
};
setExecutorStatsState({ status: "loading", data: null, error: null });
void loadExecutorStats();
return () => {
cancelled = true;
if (timeoutId) clearTimeout(timeoutId);
};
}, [projectId, t]);
useEffect(() => {
let cancelled = false;
setConcurrencyDirty(false);
setConcurrencySaveState("idle");
setConcurrencyState({ status: "loading", data: null, error: null });
void (async () => {
try {
const [config, settings] = await Promise.all([fetchConfig(projectId), fetchSettings(projectId)]);
if (!cancelled) {
setConcurrencyState({
status: "loaded",
data: {
maxConcurrent: clamp(settings.maxConcurrent ?? config.maxConcurrent ?? DEFAULT_CONCURRENCY_VALUES.maxConcurrent, 1, 10),
maxTriageConcurrent: clamp(settings.maxTriageConcurrent ?? DEFAULT_CONCURRENCY_VALUES.maxTriageConcurrent, 1, 10),
maxWorktrees: clamp(settings.maxWorktrees ?? DEFAULT_CONCURRENCY_VALUES.maxWorktrees, 1, 20),
},
error: null,
});
}
} catch (error) {
if (!cancelled) {
setConcurrencyState({
status: "error",
data: DEFAULT_CONCURRENCY_VALUES,
error: error instanceof Error ? error.message : t("commandCenter.controls.concurrency.error", "Unable to load concurrency settings"),
});
}
}
})();
return () => {
cancelled = true;
};
}, [projectId, t]);
useEffect(() => {
if (!concurrencyDirty || !concurrencyState.data) return;
const values = concurrencyState.data;
const timeoutId = setTimeout(() => {
setConcurrencySaveState("saving");
void updateSettings(values, projectId)
.then(async () => {
await refresh();
setConcurrencyDirty(false);
setConcurrencySaveState("saved");
})
.catch(() => {
setConcurrencySaveState("error");
});
}, CONCURRENCY_SAVE_DEBOUNCE_MS);
return () => clearTimeout(timeoutId);
}, [concurrencyDirty, concurrencyState.data, projectId, refresh]);
const updateConcurrencyValue = (key: keyof ConcurrencyValues, rawValue: string, min: number, max: number) => {
const nextValue = clamp(Number(rawValue), min, max);
setConcurrencyState((current) => ({
status: "loaded",
data: { ...(current.data ?? DEFAULT_CONCURRENCY_VALUES), [key]: nextValue },
error: null,
}));
setConcurrencyDirty(true);
setConcurrencySaveState("idle");
};
const effectiveGlobalPaused = executorStatsState.data?.globalPause ?? globalPaused;
const effectiveEnginePaused = executorStatsState.data?.enginePaused ?? enginePaused;
const lastActivityLabel = formatLastActivity(
executorStatsState.data?.lastActivityAt,
t("commandCenter.controls.status.noActivity", "No recent activity"),
);
const concurrencyValues = concurrencyState.data ?? DEFAULT_CONCURRENCY_VALUES;
/*
FNXC:CommandCenter 2026-06-19-12:35:
The Command Center concurrency sliders mutate live scheduler limits through the existing /api/settings path; after each debounced save, refresh useAppSettings so the running dashboard reflects the new scheduler capacity without local shadow state drifting.
FNXC:CommandCenter 2026-06-19-12:30:
Heartbeat controls pause/resume the scheduling heartbeat via the existing enginePaused setting, while engine controls stop/start all AI work via globalPause. Both controls intentionally reuse useAppSettings toggles so Command Center does not add backend routes or competing scheduler state.
*/
return (
<section className="cc-controls" data-testid="command-center-controls" aria-label={t("commandCenter.controls.title", "Operator controls")}>
<div className="cc-controls-grid">
<section className="card cc-controls-card cc-controls-card--org" data-testid="cc-controls-org-chart">
<div className="cc-controls-card-header">
<div>
<h3>{t("commandCenter.controls.orgChart.title", "Agent org chart")}</h3>
<p>{t("commandCenter.controls.orgChart.description", "Read-only view of the running agent hierarchy.")}</p>
</div>
</div>
<div className="cc-controls-org-scroll" aria-live="polite">
{orgTreeState.status === "loading" ? (
<p className="cc-controls-muted">{t("commandCenter.controls.orgChart.loading", "Loading org chart…")}</p>
) : orgTreeState.status === "error" ? (
<p className="cc-controls-error" role="alert">{orgTreeState.error}</p>
) : orgTreeState.data.length === 0 ? (
<p className="cc-controls-muted">{t("commandCenter.controls.orgChart.empty", "No agents are reporting in yet.")}</p>
) : (
<ul className="cc-controls-org-roots">
{orgTreeState.data.map((node) => (
<OrgChartNode key={node.agent.id} node={node} />
))}
</ul>
)}
</div>
</section>
<section className="card cc-controls-card" data-testid="cc-controls-heartbeat">
<div className="cc-controls-card-header">
<div>
<h3>{t("commandCenter.controls.heartbeat.title", "Heartbeat control")}</h3>
<p>{t("commandCenter.controls.heartbeat.description", "Pause or resume the scheduling heartbeat.")}</p>
</div>
<StatusPill
paused={effectiveEnginePaused}
label={effectiveEnginePaused ? t("commandCenter.controls.status.paused", "Paused") : t("commandCenter.controls.status.running", "Running")}
/>
</div>
<dl className="cc-controls-facts">
<div>
<dt>{t("commandCenter.controls.status.lastActivity", "Last activity")}</dt>
<dd>{executorStatsState.status === "loading" ? t("commandCenter.controls.status.loading", "Loading…") : lastActivityLabel}</dd>
</div>
<div>
<dt>{t("commandCenter.controls.status.maxConcurrent", "Max concurrent")}</dt>
<dd>{executorStatsState.data?.maxConcurrent ?? "—"}</dd>
</div>
</dl>
{executorStatsState.status === "error" ? <p className="cc-controls-error" role="alert">{executorStatsState.error}</p> : null}
<button
type="button"
className="btn btn-secondary cc-controls-action"
onClick={() => void toggleEnginePause()}
disabled={effectiveGlobalPaused}
>
{effectiveEnginePaused ? <Play size={16} aria-hidden="true" /> : <Pause size={16} aria-hidden="true" />}
<span>
{effectiveEnginePaused
? t("commandCenter.controls.heartbeat.resume", "Resume heartbeat")
: t("commandCenter.controls.heartbeat.pause", "Pause heartbeat")}
</span>
</button>
{effectiveGlobalPaused ? (
<p className="cc-controls-muted">{t("commandCenter.controls.heartbeat.disabledByStop", "Start the AI engine before resuming the heartbeat.")}</p>
) : null}
</section>
<section className="card cc-controls-card" data-testid="cc-controls-engine">
<div className="cc-controls-card-header">
<div>
<h3>{t("commandCenter.controls.engine.title", "AI engine")}</h3>
<p>{t("commandCenter.controls.engine.description", "Stopping the engine halts all AI work.")}</p>
</div>
<StatusPill
paused={effectiveGlobalPaused}
label={effectiveGlobalPaused ? t("commandCenter.controls.status.stopped", "Stopped") : t("commandCenter.controls.status.running", "Running")}
/>
</div>
<button
type="button"
className="btn btn-secondary cc-controls-action"
onClick={() => void toggleGlobalPause()}
>
<Power size={16} aria-hidden="true" />
<span>
{effectiveGlobalPaused
? t("header.startAiEngine", "Start AI Engine")
: t("header.stopAiEngine", "Stop AI Engine")}
</span>
</button>
</section>
<section className="card cc-controls-card" data-testid="cc-controls-theme">
<div className="cc-controls-card-header">
<div>
<h3>{t("commandCenter.controls.theme.title", "Theme")}</h3>
<p>{t("commandCenter.controls.theme.description", "Switch the dashboard theme with live color previews.")}</p>
</div>
</div>
<ThemeDropdown
colorTheme={colorTheme}
themeMode={themeMode}
onColorThemeChange={onColorThemeChange}
onThemeModeChange={onThemeModeChange}
/>
</section>
<section className="card cc-controls-card cc-controls-card--concurrency" data-testid="cc-controls-concurrency">
<div className="cc-controls-card-header">
<div>
<h3>{t("commandCenter.controls.concurrency.title", "Concurrency")}</h3>
<p>{t("commandCenter.controls.concurrency.description", "Tune live scheduler capacity.")}</p>
</div>
<span className={`cc-controls-save-state cc-controls-save-state--${concurrencySaveState}`} aria-live="polite">
{concurrencyState.status === "loading"
? t("commandCenter.controls.status.loading", "Loading…")
: concurrencySaveState === "saving"
? t("commandCenter.controls.status.saving", "Saving…")
: concurrencySaveState === "saved"
? t("commandCenter.controls.status.saved", "Saved")
: concurrencySaveState === "error"
? t("commandCenter.controls.status.saveError", "Save failed")
: t("commandCenter.controls.status.ready", "Ready")}
</span>
</div>
<div className="cc-controls-sliders">
<label className="cc-controls-slider" htmlFor="cc-max-concurrent">
<span className="cc-controls-slider-label">
{t("commandCenter.controls.concurrency.maxConcurrent", "Max concurrent tasks")}
<strong>{concurrencyValues.maxConcurrent}</strong>
</span>
<input
id="cc-max-concurrent"
type="range"
min={1}
max={10}
value={concurrencyValues.maxConcurrent}
disabled={concurrencyState.status === "loading"}
onChange={(event) => updateConcurrencyValue("maxConcurrent", event.target.value, 1, 10)}
/>
</label>
<label className="cc-controls-slider" htmlFor="cc-max-triage-concurrent">
<span className="cc-controls-slider-label">
{t("commandCenter.controls.concurrency.maxTriageConcurrent", "Max triage concurrent")}
<strong>{concurrencyValues.maxTriageConcurrent}</strong>
</span>
<input
id="cc-max-triage-concurrent"
type="range"
min={1}
max={10}
value={concurrencyValues.maxTriageConcurrent}
disabled={concurrencyState.status === "loading"}
onChange={(event) => updateConcurrencyValue("maxTriageConcurrent", event.target.value, 1, 10)}
/>
</label>
<label className="cc-controls-slider" htmlFor="cc-max-worktrees">
<span className="cc-controls-slider-label">
{t("commandCenter.controls.concurrency.maxWorktrees", "Max worktrees")}
<strong>{concurrencyValues.maxWorktrees}</strong>
</span>
<input
id="cc-max-worktrees"
type="range"
min={1}
max={20}
value={concurrencyValues.maxWorktrees}
disabled={concurrencyState.status === "loading"}
onChange={(event) => updateConcurrencyValue("maxWorktrees", event.target.value, 1, 20)}
/>
</label>
</div>
{concurrencyState.status === "error" ? <p className="cc-controls-error" role="alert">{concurrencyState.error}</p> : null}
</section>
</div>
</section>
);
}

View File

@@ -9,6 +9,21 @@ import { CommandCenter } from "../CommandCenter";
const apiMock = vi.fn();
vi.mock("../../../api/legacy", () => ({
api: (path: string, opts?: RequestInit) => apiMock(path, opts),
fetchOrgTree: vi.fn().mockResolvedValue([]),
fetchExecutorStats: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false, maxConcurrent: 2 }),
fetchSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxTriageConcurrent: 1, maxWorktrees: 5 }),
fetchConfig: vi.fn().mockResolvedValue({ maxConcurrent: 2, rootDir: "/" }),
updateSettings: vi.fn().mockResolvedValue({}),
}));
vi.mock("../../../hooks/useAppSettings", () => ({
useAppSettings: () => ({
globalPaused: false,
enginePaused: false,
toggleGlobalPause: vi.fn(),
toggleEnginePause: vi.fn(),
refresh: vi.fn().mockResolvedValue(undefined),
}),
}));
vi.mock("../../../api", () => ({
@@ -338,6 +353,8 @@ describe("CommandCenter mobile scroll regression (FN-6595)", () => {
const overviewPanel = screen.getByTestId("command-center-panel-overview");
await screen.findByTestId("command-center-empty");
expect(screen.getByTestId("command-center-controls")).toBeTruthy();
expect(screen.getByTestId("cc-controls-concurrency")).toBeTruthy();
assertScrollOwnerContract(overviewPanel);
fireEvent.click(screen.getByTestId("command-center-tab-tokens"));

View File

@@ -9,6 +9,21 @@ import { CommandCenter } from "../CommandCenter";
const apiMock = vi.fn();
vi.mock("../../../api/legacy", () => ({
api: (path: string, opts?: RequestInit) => apiMock(path, opts),
fetchOrgTree: vi.fn().mockResolvedValue([]),
fetchExecutorStats: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false, maxConcurrent: 2 }),
fetchSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxTriageConcurrent: 1, maxWorktrees: 5 }),
fetchConfig: vi.fn().mockResolvedValue({ maxConcurrent: 2, rootDir: "/" }),
updateSettings: vi.fn().mockResolvedValue({}),
}));
vi.mock("../../../hooks/useAppSettings", () => ({
useAppSettings: () => ({
globalPaused: false,
enginePaused: false,
toggleGlobalPause: vi.fn(),
toggleEnginePause: vi.fn(),
refresh: vi.fn().mockResolvedValue(undefined),
}),
}));
vi.mock("../../../api", () => ({
@@ -319,10 +334,19 @@ afterEach(() => {
describe("CommandCenter shell", () => {
it("renders with the Overview tab active by default", () => {
render(<CommandCenter />);
render(
<CommandCenter
projectId="project-a"
colorTheme="default"
themeMode="dark"
onColorThemeChange={vi.fn()}
onThemeModeChange={vi.fn()}
/>,
);
const overviewTab = screen.getByTestId("command-center-tab-overview");
expect(overviewTab.getAttribute("aria-selected")).toBe("true");
expect(screen.getByTestId("command-center-panel-overview")).toBeTruthy();
expect(screen.getByTestId("command-center-controls")).toBeTruthy();
});
it("renders throughput last while the Overview branch is loading", () => {

View File

@@ -0,0 +1,241 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import type { OrgTreeNode } from "@fusion/core";
import { CommandCenterControls } from "../CommandCenterControls";
const mocks = vi.hoisted(() => ({
fetchOrgTree: vi.fn(),
fetchExecutorStats: vi.fn(),
fetchSettings: vi.fn(),
fetchConfig: vi.fn(),
updateSettings: vi.fn(),
toggleGlobalPause: vi.fn(),
toggleEnginePause: vi.fn(),
refresh: vi.fn(),
appSettings: {
globalPaused: false,
enginePaused: false,
},
}));
vi.mock("../../../api/legacy", () => ({
fetchOrgTree: mocks.fetchOrgTree,
fetchExecutorStats: mocks.fetchExecutorStats,
fetchSettings: mocks.fetchSettings,
fetchConfig: mocks.fetchConfig,
updateSettings: mocks.updateSettings,
}));
vi.mock("../../../hooks/useAppSettings", () => ({
useAppSettings: () => ({
globalPaused: mocks.appSettings.globalPaused,
enginePaused: mocks.appSettings.enginePaused,
toggleGlobalPause: mocks.toggleGlobalPause,
toggleEnginePause: mocks.toggleEnginePause,
refresh: mocks.refresh,
}),
}));
function renderControls(projectId?: string) {
return render(
<CommandCenterControls
projectId={projectId}
colorTheme="default"
themeMode="dark"
onColorThemeChange={vi.fn()}
onThemeModeChange={vi.fn()}
/>,
);
}
function agentNode(id: string, name: string, children: OrgTreeNode[] = []): OrgTreeNode {
return {
agent: {
id,
name,
role: "executor",
state: "idle",
createdAt: "2026-06-19T00:00:00.000Z",
updatedAt: "2026-06-19T00:00:00.000Z",
metadata: {},
},
children,
};
}
async function flushPromises() {
await act(async () => {
await Promise.resolve();
});
}
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
mocks.appSettings.globalPaused = false;
mocks.appSettings.enginePaused = false;
mocks.fetchOrgTree.mockResolvedValue([]);
mocks.fetchExecutorStats.mockResolvedValue({
globalPause: false,
enginePaused: false,
maxConcurrent: 2,
lastActivityAt: "2026-06-19T12:00:00.000Z",
});
mocks.fetchSettings.mockResolvedValue({ maxConcurrent: 2, maxTriageConcurrent: 1, maxWorktrees: 5 });
mocks.fetchConfig.mockResolvedValue({ maxConcurrent: 2, rootDir: "/repo" });
mocks.updateSettings.mockResolvedValue({});
mocks.refresh.mockResolvedValue(undefined);
});
afterEach(() => {
vi.useRealTimers();
});
describe("CommandCenterControls", () => {
it("renders org chart loading, empty, and populated states", async () => {
let resolveOrgTree: (value: OrgTreeNode[]) => void = () => {};
mocks.fetchOrgTree.mockReturnValueOnce(new Promise<OrgTreeNode[]>((resolve) => { resolveOrgTree = resolve; }));
const { rerender } = renderControls("project-a");
expect(screen.getByText("Loading org chart…")).toBeDefined();
await act(async () => resolveOrgTree([]));
expect(screen.getByText("No agents are reporting in yet.")).toBeDefined();
expect(mocks.fetchOrgTree).toHaveBeenCalledWith("project-a");
mocks.fetchOrgTree.mockResolvedValueOnce([agentNode("agent-lead", "Lead Agent", [agentNode("agent-child", "Child Agent")])]);
rerender(
<CommandCenterControls
projectId="project-b"
colorTheme="default"
themeMode="dark"
onColorThemeChange={vi.fn()}
onThemeModeChange={vi.fn()}
/>,
);
await flushPromises();
expect(screen.getByText("Lead Agent")).toBeDefined();
expect(screen.getByText("Child Agent")).toBeDefined();
expect(mocks.fetchOrgTree).toHaveBeenLastCalledWith("project-b");
});
it("renders org chart error state without crashing", async () => {
mocks.fetchOrgTree.mockRejectedValueOnce(new Error("fetch failed"));
renderControls("project-a");
await flushPromises();
const orgSection = screen.getByTestId("cc-controls-org-chart");
expect(within(orgSection).getByRole("alert")).toHaveTextContent("fetch failed");
});
it("supports undefined project ids and renders status sections", async () => {
renderControls(undefined);
await flushPromises();
expect(screen.getByTestId("command-center-controls")).toBeDefined();
expect(screen.getByTestId("cc-controls-heartbeat")).toBeDefined();
expect(screen.getByTestId("cc-controls-engine")).toBeDefined();
expect(screen.getByTestId("cc-controls-concurrency")).toBeDefined();
expect(screen.getByTestId("cc-controls-theme")).toBeDefined();
expect(mocks.fetchOrgTree).toHaveBeenCalledWith(undefined);
expect(mocks.fetchExecutorStats).toHaveBeenCalledWith(undefined);
});
it("heartbeat and engine controls call the existing settings toggles", async () => {
renderControls("project-a");
await flushPromises();
fireEvent.click(screen.getByRole("button", { name: /pause heartbeat/i }));
expect(mocks.toggleEnginePause).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: /stop ai engine/i }));
expect(mocks.toggleGlobalPause).toHaveBeenCalledTimes(1);
});
it("disables heartbeat toggle when the AI engine is stopped", async () => {
mocks.appSettings.globalPaused = true;
mocks.fetchExecutorStats.mockResolvedValue({ globalPause: true, enginePaused: true, maxConcurrent: 2 });
renderControls("project-a");
await flushPromises();
const heartbeatButton = screen.getByRole("button", { name: /resume heartbeat/i });
expect(heartbeatButton).toBeDisabled();
});
it("persists bounded concurrency slider changes and refreshes settings", async () => {
renderControls("project-a");
await flushPromises();
const section = screen.getByTestId("cc-controls-concurrency");
const slider = within(section).getByLabelText(/max concurrent tasks/i);
fireEvent.change(slider, { target: { value: "7" } });
await act(async () => {
vi.advanceTimersByTime(500);
await Promise.resolve();
});
expect(mocks.updateSettings).toHaveBeenCalledWith(
{ maxConcurrent: 7, maxTriageConcurrent: 1, maxWorktrees: 5 },
"project-a",
);
expect(mocks.refresh).toHaveBeenCalledTimes(1);
});
it("persists concurrency slider changes without a project id", async () => {
renderControls(undefined);
await flushPromises();
const section = screen.getByTestId("cc-controls-concurrency");
const slider = within(section).getByLabelText(/max worktrees/i);
fireEvent.change(slider, { target: { value: "12" } });
await act(async () => {
vi.advanceTimersByTime(500);
await Promise.resolve();
});
expect(mocks.updateSettings).toHaveBeenCalledWith(
{ maxConcurrent: 2, maxTriageConcurrent: 1, maxWorktrees: 12 },
undefined,
);
});
it("shows save error indicator when concurrency update fails", async () => {
mocks.updateSettings.mockRejectedValueOnce(new Error("network error"));
renderControls("project-a");
await flushPromises();
const section = screen.getByTestId("cc-controls-concurrency");
const slider = within(section).getByLabelText(/max concurrent tasks/i);
fireEvent.change(slider, { target: { value: "8" } });
await act(async () => {
vi.advanceTimersByTime(500);
await Promise.resolve();
});
expect(within(section).getByText(/save failed/i)).toBeDefined();
});
it("selects a theme from the embedded dropdown", async () => {
const onColorThemeChange = vi.fn();
render(
<CommandCenterControls
colorTheme="default"
themeMode="dark"
onColorThemeChange={onColorThemeChange}
onThemeModeChange={vi.fn()}
/>,
);
await flushPromises();
fireEvent.click(screen.getByRole("button", { name: /default/i }));
fireEvent.click(screen.getAllByRole("option").find((element) => element.textContent?.trim() === "Forest")!);
expect(onColorThemeChange).toHaveBeenCalledWith("forest");
});
});

View File

@@ -0,0 +1,70 @@
import { Monitor, Moon, Sun, type LucideIcon } from "lucide-react";
import type { ColorTheme, ThemeMode } from "@fusion/core";
/*
FNXC:Theme 2026-06-19-12:00:
The Settings theme grid and Command Center theme dropdown must share one source of truth for theme labels and swatch classes so color-chip affordances stay synchronized across both theme selectors.
*/
export const THEME_MODES: { value: ThemeMode; label: string; icon: LucideIcon }[] = [
{ value: "light", label: "Light", icon: Sun },
{ value: "dark", label: "Dark", icon: Moon },
{ value: "system", label: "System", icon: Monitor },
];
export const COLOR_THEMES: { value: ColorTheme; label: string; className: string }[] = [
{ value: "default", label: "Default", className: "theme-swatch-default" },
{ value: "ocean", label: "Ocean", className: "theme-swatch-ocean" },
{ value: "forest", label: "Forest", className: "theme-swatch-forest" },
{ value: "sunset", label: "Sunset", className: "theme-swatch-sunset" },
{ value: "zen", label: "Zen", className: "theme-swatch-zen" },
{ value: "berry", label: "Berry", className: "theme-swatch-berry" },
{ value: "high-contrast", label: "High Contrast", className: "theme-swatch-high-contrast" },
{ value: "industrial", label: "Industrial", className: "theme-swatch-industrial" },
{ value: "monochrome", label: "Mono", className: "theme-swatch-monochrome" },
{ value: "slate", label: "Slate", className: "theme-swatch-slate" },
{ value: "ash", label: "Ash", className: "theme-swatch-ash" },
{ value: "graphite", label: "Graphite", className: "theme-swatch-graphite" },
{ value: "silver", label: "Silver", className: "theme-swatch-silver" },
{ value: "solarized", label: "Solarized", className: "theme-swatch-solarized" },
{ value: "factory", label: "Factory", className: "theme-swatch-factory" },
{ value: "factory-mono", label: "Factory Mono", className: "theme-swatch-factory-mono" },
{ value: "ayu", label: "Ayu", className: "theme-swatch-ayu" },
{ value: "one-dark", label: "One Dark", className: "theme-swatch-one-dark" },
{ value: "nord", label: "Nord", className: "theme-swatch-nord" },
{ value: "dracula", label: "Dracula", className: "theme-swatch-dracula" },
{ value: "gruvbox", label: "Gruvbox", className: "theme-swatch-gruvbox" },
{ value: "tokyo-night", label: "Tokyo Night", className: "theme-swatch-tokyo-night" },
{ value: "catppuccin-mocha", label: "Catppuccin Mocha", className: "theme-swatch-catppuccin-mocha" },
{ value: "github-dark", label: "GitHub Dark", className: "theme-swatch-github-dark" },
{ value: "everforest", label: "Everforest", className: "theme-swatch-everforest" },
{ value: "rose-pine", label: "Rosé Pine", className: "theme-swatch-rose-pine" },
{ value: "kanagawa", label: "Kanagawa", className: "theme-swatch-kanagawa" },
{ value: "night-owl", label: "Night Owl", className: "theme-swatch-night-owl" },
{ value: "palenight", label: "Palenight", className: "theme-swatch-palenight" },
{ value: "monokai-pro", label: "Monokai Pro", className: "theme-swatch-monokai-pro" },
{ value: "slime", label: "Slime", className: "theme-swatch-slime" },
{ value: "brutalist", label: "Brutalist", className: "theme-swatch-brutalist" },
{ value: "neon-city", label: "Neon City", className: "theme-swatch-neon-city" },
{ value: "parchment", label: "Parchment", className: "theme-swatch-parchment" },
{ value: "terminal", label: "Terminal", className: "theme-swatch-terminal" },
{ value: "glass", label: "Glass", className: "theme-swatch-glass" },
{ value: "horizon", label: "Horizon", className: "theme-swatch-horizon" },
{ value: "vitesse", label: "Vitesse", className: "theme-swatch-vitesse" },
{ value: "outrun", label: "Outrun", className: "theme-swatch-outrun" },
{ value: "snazzy", label: "Snazzy", className: "theme-swatch-snazzy" },
{ value: "porple", label: "Porple", className: "theme-swatch-porple" },
{ value: "espresso", label: "Espresso", className: "theme-swatch-espresso" },
{ value: "mars", label: "Mars", className: "theme-swatch-mars" },
{ value: "poimandres", label: "Poimandres", className: "theme-swatch-poimandres" },
{ value: "ember", label: "Ember", className: "theme-swatch-ember" },
{ value: "rust", label: "Rust", className: "theme-swatch-rust" },
{ value: "copper", label: "Copper", className: "theme-swatch-copper" },
{ value: "foundry", label: "Foundry", className: "theme-swatch-foundry" },
{ value: "carbon", label: "Carbon", className: "theme-swatch-carbon" },
{ value: "sandstone", label: "Sandstone", className: "theme-swatch-sandstone" },
{ value: "lagoon", label: "Lagoon", className: "theme-swatch-lagoon" },
{ value: "frost", label: "Frost", className: "theme-swatch-frost" },
{ value: "lavender", label: "Lavender", className: "theme-swatch-lavender" },
{ value: "neon-bloom", label: "Neon Bloom", className: "theme-swatch-neon-bloom" },
{ value: "sepia", label: "Sepia", className: "theme-swatch-sepia" },
];

View File

@@ -7121,6 +7121,53 @@
"from": "From",
"to": "To",
"invalidRange": "Start date must be on or before end date"
},
"controls": {
"title": "Operator controls",
"orgChart": {
"title": "Agent org chart",
"description": "Read-only view of the running agent hierarchy.",
"loading": "Loading org chart…",
"empty": "No agents are reporting in yet.",
"error": "Unable to load org chart"
},
"heartbeat": {
"title": "Heartbeat control",
"description": "Pause or resume the scheduling heartbeat.",
"pause": "Pause heartbeat",
"resume": "Resume heartbeat",
"disabledByStop": "Start the AI engine before resuming the heartbeat."
},
"engine": {
"title": "AI engine",
"description": "Stopping the engine halts all AI work."
},
"concurrency": {
"title": "Concurrency",
"description": "Tune live scheduler capacity.",
"maxConcurrent": "Max concurrent tasks",
"maxTriageConcurrent": "Max triage concurrent",
"maxWorktrees": "Max worktrees",
"error": "Unable to load concurrency settings"
},
"theme": {
"title": "Theme",
"description": "Switch the dashboard theme with live color previews."
},
"status": {
"running": "Running",
"paused": "Paused",
"stopped": "Stopped",
"loading": "Loading…",
"saving": "Saving…",
"saved": "Saved",
"saveError": "Save failed",
"ready": "Ready",
"error": "Unable to load live scheduler status",
"lastActivity": "Last activity",
"maxConcurrent": "Max concurrent",
"noActivity": "No recent activity"
}
}
}
}