FN-6657: embed system stats in Command Center

Move system telemetry out of the global header modal and into Command Center as a dedicated System area.

- add a Command Center System tab with live CPU, memory, heap, process, task, agent, and vitest controls
- remove the legacy System Stats modal entry points from desktop and mobile navigation
- update modal manager, docs, and regression coverage for the new embedded System area
- add a patch changeset for the published Fusion package

Files changed:
 .../fn-6657-system-stats-into-command-center.md    |   5 +
 docs/architecture.md                               |   2 +-
 docs/dashboard-guide.md                            |   4 +-
 packages/dashboard/app/App.tsx                     |   7 -
 packages/dashboard/app/components/AppModals.tsx    |  12 -
 packages/dashboard/app/components/Header.tsx       |   9 -
 packages/dashboard/app/components/MobileNavBar.tsx |  12 -
 .../dashboard/app/components/SystemStatsModal.css  | 319 -------------
 .../dashboard/app/components/SystemStatsModal.tsx  | 523 ---------------------
 .../app/components/__tests__/AppModals.test.tsx    |  51 --
 .../app/components/__tests__/Header.test.tsx       |  12 -
 .../app/components/__tests__/MobileNavBar.test.tsx |  13 -
 .../components/__tests__/SystemStatsModal.test.tsx | 329 -------------
 ...-merge-toggle-blank.mobile-integration.test.tsx |   1 -
 .../components/command-center/CommandCenter.tsx    |   5 +
 .../__tests__/CommandCenter.mobile-scroll.test.tsx |  46 ++
 .../__tests__/CommandCenter.test.tsx               |  57 ++-
 .../__tests__/SystemStatsArea.test.tsx             | 229 +++++++++
 .../command-center/areas/SystemStatsArea.css       | 122 +++++
 .../command-center/areas/SystemStatsArea.tsx       | 440 +++++++++++++++++
 .../app/hooks/__tests__/useModalManager.test.ts    |  23 -
 packages/dashboard/app/hooks/useModalManager.ts    |  12 -
 22 files changed, 906 insertions(+), 1327 deletions(-)

Fusion-Task-Id: FN-6657

Fusion-Task-Lineage: 7593ee86-671e-4c95-96f8-a8dc8861e7b7
This commit is contained in:
gsxdsm
2026-06-18 18:58:21 -07:00
parent 94a081f4f3
commit af31f7ddfa
22 changed files with 906 additions and 1327 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Move System Stats into the Command Center as a redesigned graph-rich System area with gauges, trend sparklines, task/agent bars, and relocated Vitest controls; remove the standalone System Stats modal plus its Header and mobile More affordances.

View File

@@ -854,7 +854,7 @@ Operator setup + troubleshooting guide: **[Remote Access runbook](./remote-acces
Key server capabilities:
- REST APIs for tasks, git, GitHub, agents, missions, planning, automations/routines, settings
- System stats snapshot and vitest process controls APIs (`GET /api/system-stats`, `POST /api/kill-vitest`) exposing dashboard process/system telemetry (including app CPU percentage and host memory rendered as numeric values with visual usage bars in the System Stats modal), task/agent aggregates, and manual vitest process termination
- System stats snapshot and vitest process controls APIs (`GET /api/system-stats`, `POST /api/kill-vitest`) exposing dashboard process/system telemetry (including app CPU percentage and host memory rendered as numeric values, radial gauges, and trend sparklines in the Command Center System area), task/agent aggregates, and manual vitest process termination
- Remote access APIs (`/api/remote/*`) for provider config, activation, tunnel lifecycle, status, token issuance, authenticated URL generation, and QR payload generation
- Operational runbook (prereqs/security/troubleshooting): [`docs/remote-access.md`](./remote-access.md)
- `/api/remote/tunnel/start`, `/api/remote/tunnel/stop`, and `/api/remote/tunnel/kill-external` cover tunnel lifecycle and external funnel cleanup.

View File

@@ -654,7 +654,7 @@ Features:
## Command Center
Command Center is the combined analytics and live-operations surface for a project: it pairs historical usage, cost, and throughput analytics with a live Mission Control panel.
Command Center is the combined analytics and live-operations surface for a project: it pairs historical usage, cost, throughput analytics, live system telemetry, and a live Mission Control panel.
Navigation:
- Desktop: **Header → More views → Command Center**
@@ -672,6 +672,7 @@ Features:
- **Ecosystem** shows active model breadth and per-model task activity; unavailable plugin-activation metrics render as unavailable rather than zero.
- **GitHub** shows local GitHub issue flow for the selected range: **Filed by Fusion** counts tasks with a persisted `githubTracking.issue`, **Fixed by Fusion** counts tasks imported from GitHub source issues (`sourceIssueProvider = "github"`) that are currently in `done`, using the persisted `sourceIssueClosedAt` / `TaskSourceIssue.closedAt` close time when the reconciler has observed it. Rows that predate the field or have not been observed closed fall back to task `updatedAt` as the documented completion-time approximation; Fusion never fabricates a close timestamp and this analytics path never calls GitHub, the `gh` CLI, or any external network source. The area shows filed/fixed/net stat cards, filed-vs-fixed daily sparklines, and a by-repository bar breakdown.
- **Signals** shows external signal totals, open/resolved counts, MTTR, and source/severity breakdowns when signal sources are connected.
- **System** is the canonical system-telemetry destination. It reuses `GET /api/system-stats` with no new endpoint, renders live radial gauges for app CPU, host memory, and heap usage, keeps a small client-side rolling buffer for CPU/memory trend sparklines, and charts tasks by column plus agents by state with the shared Command Center chart primitives. The Vitest process count, manual kill confirmation, auto-kill toggle, threshold controls, and last-auto-kill timestamp moved here unchanged; the standalone System Stats modal and its desktop Header/mobile More affordances were removed.
- **Mission Control** shows live active sessions/runs/nodes, current sessions and nodes, an animated live activity snapshot, and a live SDLC funnel; when idle it reports that live updates resume when work starts. Motion-heavy accents respect reduced-motion preferences.
- CSV exports are available from the analytics endpoints with `?format=csv`. The Activity CSV includes daily `agentRuns` values plus summary rows for `(agentRuns.total)`, `(agentRuns.active)`, `(agentRuns.completed)`, and `(agentRuns.failed)`.
@@ -679,6 +680,7 @@ Data states:
- Overview shows a loading state while core analytics settle, then shows `No usage data yet. Run some agents to populate the Command Center.` only after the selected range has settled with no core usage data.
- GitHub issue analytics is local and additive: empty filed/fixed totals render the GitHub area's empty state; malformed historical `githubTracking` JSON is skipped instead of breaking the Command Center.
- Team analytics renders its shared loading/error/empty states for null or zero-agent responses, omits empty chart shells for zero-value datasets, and keeps the Command Center tab panel as the mobile scroll owner.
- System telemetry keeps the previous snapshot visible during refresh failures, renders a first-sample CPU `Sampling…` state without NaN values, shows zero-value task/agent bars for empty collections, and keeps the Command Center tab panel as the mobile scroll owner.
- Signals is best-effort: if the Signals endpoint is absent or no signal source is connected, the Signals area falls back to its empty state and other Command Center metrics remain valid.
## Reliability View

View File

@@ -1335,11 +1335,6 @@ function AppInner() {
pushNav({ type: "modal", close: modalManager.closeGitManager });
}, [modalManager, pushNav]);
const openSystemStatsWithNav = useCallback(() => {
modalManager.openSystemStats();
pushNav({ type: "modal", close: modalManager.closeSystemStats });
}, [modalManager, pushNav]);
const openSchedulesWithNav = useCallback(() => {
modalManager.openSchedules();
pushNav({ type: "modal", close: modalManager.closeSchedules });
@@ -1973,7 +1968,6 @@ function AppInner() {
activePlanningSessionCount={bgPlanningSessions.length}
onOpenUsage={openUsageWithNav}
onOpenActivityLog={openActivityLogWithNav}
onOpenSystemStats={openSystemStatsWithNav}
onOpenMailbox={() => handleTaskViewChange("mailbox")}
mailboxUnreadCount={mailboxUnreadCount}
mailboxPendingApprovalCount={mailboxPendingApprovalCount}
@@ -2181,7 +2175,6 @@ function AppInner() {
keyboardOpen={mobileNavKeyboardOpen}
onOpenSettings={openSettingsWithNav}
onOpenActivityLog={openActivityLogWithNav}
onOpenSystemStats={openSystemStatsWithNav}
onOpenMailbox={() => handleTaskViewChange("mailbox")}
onOpenNodes={handleOpenNodesWithNav}
mailboxUnreadCount={mailboxUnreadCount}

View File

@@ -17,7 +17,6 @@ import { TodoModal } from "./TodoModal";
import { UsageIndicator } from "./UsageIndicator";
import { ScheduledTasksModal } from "./ScheduledTasksModal";
import { NewTaskModal } from "./NewTaskModal";
import { SystemStatsModal } from "./SystemStatsModal";
import { ActivityLogModal } from "./ActivityLogModal";
import { GitManagerModal } from "./GitManagerModal";
import { AgentListModal } from "./AgentListModal";
@@ -187,11 +186,6 @@ export function AppModals({
modalManager.closeUsage();
}, [modalManager.closeUsage, removeNav]);
const closeSystemStatsWithNav = useCallback(() => {
removeNav(modalManager.closeSystemStats);
modalManager.closeSystemStats();
}, [modalManager.closeSystemStats, removeNav]);
const closeSchedulesWithNav = useCallback(() => {
removeNav(modalManager.closeSchedules);
modalManager.closeSchedules();
@@ -426,12 +420,6 @@ export function AppModals({
anchorRect={modalManager.usageAnchorRect}
/>
<SystemStatsModal
isOpen={modalManager.systemStatsOpen}
onClose={closeSystemStatsWithNav}
projectId={projectId}
/>
{modalManager.schedulesOpen && (
<ScheduledTasksModal
onClose={closeSchedulesWithNav}

View File

@@ -65,7 +65,6 @@ export interface HeaderProps {
activePlanningSessionCount?: number;
onOpenUsage?: (anchorRect?: DOMRect | null) => void;
onOpenActivityLog?: () => void;
onOpenSystemStats?: () => void;
/** Opens the mailbox view */
onOpenMailbox?: () => void;
/** Unread message count for badge display */
@@ -140,7 +139,6 @@ export function Header({
activePlanningSessionCount = 0,
onOpenUsage,
onOpenActivityLog,
onOpenSystemStats,
onOpenMailbox,
mailboxUnreadCount = 0,
mailboxPendingApprovalCount = 0,
@@ -1314,13 +1312,6 @@ export function Header({
</button>
)}
{/* System Stats button - desktop only */}
{!isCompact && onOpenSystemStats && (
<button className="btn-icon" onClick={onOpenSystemStats} title={t("header.systemStats", "System Stats")} data-testid="desktop-header-system-stats-btn">
<Monitor size={16} />
</button>
)}
{/* Activity Log button - desktop only (moved to overflow on mobile/tablet) */}
{!isCompact && onOpenActivityLog && (
<button className="btn-icon" onClick={onOpenActivityLog} title={t("header.viewActivityLog", "View Activity Log")}>

View File

@@ -75,7 +75,6 @@ export interface MobileNavBarProps {
// Navigation handlers
onOpenSettings?: () => void;
onOpenActivityLog?: () => void;
onOpenSystemStats?: () => void;
onOpenMailbox?: () => void;
mailboxUnreadCount?: number;
mailboxPendingApprovalCount?: number;
@@ -142,7 +141,6 @@ export function MobileNavBar({
keyboardOpen = false,
onOpenSettings,
onOpenActivityLog,
onOpenSystemStats,
onOpenMailbox,
mailboxUnreadCount = 0,
mailboxPendingApprovalCount = 0,
@@ -484,16 +482,6 @@ export function MobileNavBar({
<span>{t("nav.activityLog", "Activity Log")}</span>
</button>
<button
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-system-stats"
onClick={() => handleMoreAction(onOpenSystemStats)}
>
<Monitor />
<span>{t("nav.systemStats", "System Stats")}</span>
</button>
<button
type="button"
className="mobile-more-item"

View File

@@ -1,319 +0,0 @@
.system-stats-modal {
display: flex;
flex-direction: column;
gap: var(--space-md);
max-height: min(80vh, 56rem);
}
.system-stats-modal__header {
align-items: center;
}
.system-stats-modal__title {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
margin: 0;
font-size: 1rem;
}
.system-stats-modal__header-actions {
display: inline-flex;
gap: var(--space-sm);
align-items: center;
}
.system-stats-modal__auto-refresh {
display: inline-flex;
flex-direction: column;
align-items: flex-end;
color: var(--text-dim);
font-size: 0.8rem;
line-height: 1.25;
}
.system-stats-modal__auto-refresh-time {
color: var(--text-muted);
transition: color var(--transition-fast);
}
.system-stats-modal__header-actions .btn-icon {
flex-shrink: 0;
}
.system-stats-modal__header-actions .btn-icon svg {
display: block;
width: var(--btn-icon-size);
height: var(--btn-icon-size);
}
.system-stats-modal__refresh--spinning {
animation: system-stats-modal-refresh-spin var(--duration-slow) linear infinite;
}
@keyframes system-stats-modal-refresh-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.system-stats-modal__state {
margin: 0 var(--space-xl);
padding: var(--space-md);
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-md);
color: var(--text-muted);
background: var(--surface);
}
.system-stats-modal__state--error,
.system-stats-modal__footer-error {
color: var(--color-error);
background: var(--status-error-bg);
border-color: var(--color-error);
}
.system-stats-modal__content {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-md);
overflow-y: auto;
padding: 0 var(--space-xl) var(--space-xl);
}
.system-stats-modal__section {
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
padding: var(--space-md);
}
.system-stats-modal__section-title {
margin: 0 0 var(--space-md);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--text-muted);
}
.system-stats-modal__grid {
margin: 0;
display: grid;
gap: var(--space-sm);
}
.system-stats-modal__row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: var(--space-md);
}
.system-stats-modal__row--memory-used,
.system-stats-modal__row--cpu-used {
align-items: stretch;
flex-direction: column;
}
.system-stats-modal__row--memory-used dd,
.system-stats-modal__row--cpu-used dd {
width: 100%;
justify-content: flex-end;
}
.system-stats-modal__memory-progress-wrapper {
width: 100%;
}
.system-stats-modal__memory-progress-track {
width: 100%;
height: var(--space-sm);
border-radius: var(--radius-sm);
background: var(--border);
overflow: hidden;
}
.system-stats-modal__memory-progress-track--warning {
box-shadow: inset 0 0 0 var(--btn-border-width) var(--color-warning);
}
.system-stats-modal__memory-progress-track--critical {
box-shadow: inset 0 0 0 var(--btn-border-width) var(--color-error);
}
.system-stats-modal__memory-progress-fill {
height: 100%;
border-radius: var(--radius-sm);
transition: width var(--transition-normal), background-color var(--transition-normal);
}
.system-stats-modal__memory-progress-fill--normal {
background: var(--color-success);
}
.system-stats-modal__memory-progress-fill--warning {
background: var(--color-warning);
}
.system-stats-modal__memory-progress-fill--critical {
background: var(--color-error);
}
.system-stats-modal__row dt {
margin: 0;
font-size: 0.85rem;
color: var(--text-muted);
}
.system-stats-modal__row dd {
margin: 0;
display: inline-flex;
gap: var(--space-sm);
align-items: baseline;
text-align: right;
}
.system-stats-modal__value {
color: var(--text);
}
.system-stats-modal__detail {
color: var(--text-dim);
font-size: 0.85rem;
}
.system-stats-modal__value--warning {
color: var(--color-warning);
}
.system-stats-modal__value--critical {
color: var(--color-error);
}
.system-stats-modal__footer-error {
margin: 0 var(--space-xl) var(--space-xl);
padding: var(--space-sm) var(--space-md);
border: var(--btn-border-width) solid var(--color-error);
border-radius: var(--radius-md);
font-size: 0.85rem;
}
.system-stats-modal__section-title--with-icon {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
}
.system-stats-modal__vitest-controls {
display: grid;
gap: var(--space-sm);
}
.system-stats-modal__kill-row {
display: flex;
justify-content: flex-start;
align-items: center;
}
.system-stats-modal__toggle-row,
.system-stats-modal__threshold-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
color: var(--text);
}
.system-stats-modal__threshold-controls {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
}
.system-stats-modal__threshold-controls input[type="range"] {
accent-color: var(--todo);
}
.system-stats-modal__toggle-row input[type="checkbox"] {
accent-color: var(--todo);
}
.system-stats-modal__threshold-row .input {
width: 6ch;
min-width: 6ch;
}
.system-stats-modal__last-kill {
margin: 0;
color: var(--text-dim);
font-size: 0.85rem;
}
.system-stats-modal__kill-result {
margin: 0;
font-size: 0.85rem;
}
.system-stats-modal__kill-result--success {
color: var(--color-success);
}
.system-stats-modal__kill-result--error {
color: var(--color-error);
}
@media (max-width: 768px) {
.system-stats-modal {
max-height: min(84vh, 60rem);
}
.system-stats-modal__content {
grid-template-columns: minmax(0, 1fr);
padding: 0 var(--space-lg) var(--space-lg);
}
.system-stats-modal__state,
.system-stats-modal__footer-error {
margin-left: var(--space-lg);
margin-right: var(--space-lg);
}
.system-stats-modal__auto-refresh {
align-items: flex-start;
}
.system-stats-modal__header-actions .btn-icon {
width: calc(var(--space-md) * 3);
height: calc(var(--space-md) * 3);
min-width: calc(var(--space-md) * 3);
min-height: calc(var(--space-md) * 3);
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.system-stats-modal__toggle-row,
.system-stats-modal__threshold-row {
align-items: flex-start;
flex-direction: column;
}
.system-stats-modal__threshold-controls {
width: 100%;
}
.system-stats-modal__threshold-controls input[type="range"] {
flex: 1;
}
.system-stats-modal__threshold-row .input {
width: 7ch;
min-width: 7ch;
flex: 0 0 auto;
}
}

View File

@@ -1,523 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Monitor, RefreshCw, ShieldAlert, Skull, X } from "lucide-react";
import {
fetchGlobalSettings,
fetchSystemStats,
killVitestProcesses,
updateGlobalSettings,
type KillVitestResponse,
type SystemStatsResponse,
} from "../api";
import "./SystemStatsModal.css";
interface SystemStatsModalProps {
isOpen: boolean;
onClose: () => void;
projectId?: string;
}
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return "—";
const mb = bytes / (1024 * 1024);
if (mb < 1024) return `${mb.toFixed(0)} MB`;
return `${(mb / 1024).toFixed(2)} GB`;
}
function toPercent(used: number, total: number): string {
if (!Number.isFinite(used) || !Number.isFinite(total) || total <= 0) return "—";
return `${((used / total) * 100).toFixed(1)}%`;
}
type Severity = "normal" | "warning" | "critical";
function heapSeverity(used: number, limit: number): Severity {
if (limit <= 0) return "normal";
const pct = used / limit;
if (pct >= 0.85) return "critical";
if (pct >= 0.65) return "warning";
return "normal";
}
function rssSeverity(rss: number, totalSystemMem: number): Severity {
if (totalSystemMem <= 0) return "normal";
const pct = rss / totalSystemMem;
if (pct >= 0.5) return "critical";
if (pct >= 0.25) return "warning";
return "normal";
}
function systemMemSeverity(used: number, total: number): Severity {
if (total <= 0) return "normal";
const pct = used / total;
if (pct >= 0.9) return "critical";
if (pct >= 0.75) return "warning";
return "normal";
}
function cpuSeverity(percent: number | null, cores: number): Severity {
if (percent === null || !Number.isFinite(percent) || percent < 0) return "normal";
const normalized = cores > 0 ? percent / cores : percent;
if (normalized >= 80) return "critical";
if (normalized >= 50) return "warning";
return "normal";
}
function severityClassName(severity: Severity): string {
if (severity === "critical") return "system-stats-modal__value--critical";
if (severity === "warning") return "system-stats-modal__value--warning";
return "";
}
function formatTimestamp(value: string | null | undefined, notYetLabel: string): string {
if (!value) return notYetLabel;
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return notYetLabel;
return parsed.toLocaleString();
}
/**
* SystemStatsModal groups dashboard runtime telemetry into five sections:
* process memory metrics, CPU/load information, host memory usage, task counts
* by column, and agent state counts.
*/
export function SystemStatsModal({ isOpen, onClose, projectId }: SystemStatsModalProps) {
const { t } = useTranslation("app");
const [stats, setStats] = useState<SystemStatsResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [autoKillEnabled, setAutoKillEnabled] = useState(true);
const [killThreshold, setKillThreshold] = useState(90);
const [isKilling, setIsKilling] = useState(false);
const [confirmKill, setConfirmKill] = useState(false);
const [killResult, setKillResult] = useState<KillVitestResponse | null>(null);
const [settingsError, setSettingsError] = useState<string | null>(null);
const [lastRefreshedAt, setLastRefreshedAt] = useState<number | null>(null);
const loadStats = useCallback(async (options?: { preserveKillResult?: boolean }) => {
setLoading(true);
try {
const response = await fetchSystemStats(projectId);
setStats(response);
setError(null);
setLastRefreshedAt(Date.now());
if (!options?.preserveKillResult) {
setKillResult(null);
}
} catch (err) {
setError(err instanceof Error ? err.message : t("systemStats.errorLoadStats", "Failed to load system stats"));
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
if (!isOpen) return;
void loadStats();
const timer = window.setInterval(() => {
void loadStats();
}, 5_000);
return () => {
window.clearInterval(timer);
};
}, [isOpen, loadStats]);
useEffect(() => {
if (!isOpen) return;
const loadSettings = async () => {
try {
const settings = await fetchGlobalSettings();
setAutoKillEnabled(settings.vitestAutoKillEnabled ?? true);
setKillThreshold(settings.vitestKillThresholdPct ?? 90);
setSettingsError(null);
} catch (err) {
setSettingsError(err instanceof Error ? err.message : t("systemStats.errorLoadVitestSettings", "Failed to load vitest settings"));
}
};
void loadSettings();
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
const onKeydown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
document.addEventListener("keydown", onKeydown);
return () => document.removeEventListener("keydown", onKeydown);
}, [isOpen, onClose]);
const processRows = useMemo(() => {
if (!stats) return [];
const system = stats.systemStats;
const heapClassName = severityClassName(heapSeverity(system.heapUsed, system.heapLimit));
const rssClassName = severityClassName(rssSeverity(system.rss, system.systemTotalMem));
return [
{ label: t("systemStats.rowRss", "RSS"), value: formatBytes(system.rss), detail: toPercent(system.rss, system.systemTotalMem), className: rssClassName },
{ label: t("systemStats.rowHeapUsed", "Heap Used"), value: formatBytes(system.heapUsed), detail: t("systemStats.rowHeapUsedDetail", "of {{total}}", { total: formatBytes(system.heapTotal) }), className: heapClassName },
{ label: t("systemStats.rowHeapLimit", "Heap Limit"), value: formatBytes(system.heapLimit), detail: t("systemStats.rowHeapLimitDetail", "V8 limit") },
{ label: t("systemStats.rowExternal", "External"), value: formatBytes(system.external) },
{ label: t("systemStats.rowArrayBuffers", "Array Buffers"), value: formatBytes(system.arrayBuffers) },
];
}, [stats]);
const persistAutoKill = useCallback(async (enabled: boolean) => {
setAutoKillEnabled(enabled);
try {
await updateGlobalSettings({ vitestAutoKillEnabled: enabled });
setSettingsError(null);
} catch (err) {
setSettingsError(err instanceof Error ? err.message : t("systemStats.errorSaveVitestSettings", "Failed to save vitest settings"));
}
}, []);
const persistKillThreshold = useCallback(async (nextThreshold: number) => {
const clamped = Math.min(99, Math.max(50, Number.isFinite(nextThreshold) ? Math.round(nextThreshold) : 90));
setKillThreshold(clamped);
try {
await updateGlobalSettings({ vitestKillThresholdPct: clamped });
setSettingsError(null);
} catch (err) {
setSettingsError(err instanceof Error ? err.message : t("systemStats.errorSaveVitestSettings", "Failed to save vitest settings"));
}
}, []);
const handleKillVitest = useCallback(async () => {
if (isKilling) return;
if (!confirmKill) {
setConfirmKill(true);
return;
}
setIsKilling(true);
try {
const result = await killVitestProcesses(projectId);
setKillResult(result);
setConfirmKill(false);
await loadStats({ preserveKillResult: true });
} catch (err) {
setError(err instanceof Error ? err.message : t("systemStats.errorKillVitest", "Failed to kill vitest processes"));
} finally {
setIsKilling(false);
}
}, [confirmKill, isKilling, loadStats, projectId]);
if (!isOpen) return null;
const system = stats?.systemStats;
const isBackgroundRefreshing = loading && Boolean(stats);
const refreshLabel = lastRefreshedAt
? t("systemStats.updatedAt", "Updated {{time}}", {
time: new Date(lastRefreshedAt).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}),
})
: t("systemStats.waitingFirstUpdate", "Waiting for first update");
const taskStats = stats?.taskStats;
const usedSystemMem = system ? system.systemTotalMem - system.systemFreeMem : 0;
const usedSystemMemSeverity = system ? systemMemSeverity(usedSystemMem, system.systemTotalMem) : "normal";
const usedSystemClassName = system ? severityClassName(usedSystemMemSeverity) : "";
const usedSystemMemPercent =
system && Number.isFinite(system.systemTotalMem) && system.systemTotalMem > 0
? Math.max(0, Math.min(100, (usedSystemMem / system.systemTotalMem) * 100))
: 0;
const usedSystemMemProgressLabel = system
? t("systemStats.systemMemProgressLabel", "System memory used: {{percent}}% ({{used}} of {{total}})", {
percent: usedSystemMemPercent.toFixed(1),
used: formatBytes(usedSystemMem),
total: formatBytes(system.systemTotalMem),
})
: t("systemStats.systemMemUnavailable", "System memory usage unavailable");
const vitestProcessCount = stats?.vitestProcessCount;
const cpuLoadSeverity = cpuSeverity(system?.cpuPercent ?? null, system?.cpuCount ?? 0);
const cpuClassName = severityClassName(cpuLoadSeverity);
const cpuPercentValue = system?.cpuPercent ?? null;
const cpuBarPercent = cpuPercentValue === null ? 0 : Math.max(0, Math.min(100, cpuPercentValue));
const cpuPercentLabel = cpuPercentValue === null ? t("systemStats.cpuSampling", "Sampling…") : `${cpuPercentValue.toFixed(1)}%`;
const cpuProgressLabel =
cpuPercentValue === null
? t("systemStats.cpuProgressUnavailable", "App CPU usage unavailable: waiting for another sample")
: t("systemStats.cpuProgressLabel", "App CPU usage: {{percent}}%", { percent: cpuPercentValue.toFixed(1) });
const killResultClassName = killResult
? killResult.killed > 0
? "system-stats-modal__kill-result system-stats-modal__kill-result--success"
: "system-stats-modal__kill-result system-stats-modal__kill-result--error"
: "";
const lastAutoKillLabel = formatTimestamp(stats?.vitestLastAutoKillAt, t("systemStats.notYet", "Not yet"));
return (
<div
className="modal-overlay open"
role="dialog"
aria-modal="true"
aria-labelledby="system-stats-modal-title"
onClick={(event) => {
if (event.target === event.currentTarget) onClose();
}}
data-testid="system-stats-modal-overlay"
>
<div className="modal modal-lg system-stats-modal" data-testid="system-stats-modal">
<div className="modal-header system-stats-modal__header">
<h2 id="system-stats-modal-title" className="system-stats-modal__title">
<Monitor />
<span>{t("systemStats.title", "System Stats")}</span>
</h2>
<div className="system-stats-modal__header-actions">
<span className="system-stats-modal__auto-refresh" aria-live="polite">
<span>{t("systemStats.autoRefresh", "Auto-refresh · 5s")}</span>
<span className="system-stats-modal__auto-refresh-time">{refreshLabel}</span>
</span>
<button
type="button"
className="btn-icon"
onClick={() => void loadStats()}
title={t("systemStats.refreshTitle", "Refresh")}
aria-label={t("systemStats.refreshAriaLabel", "Refresh system stats")}
>
<RefreshCw
size={16}
className={isBackgroundRefreshing ? "system-stats-modal__refresh--spinning" : undefined}
/>
</button>
<button type="button" className="modal-close" onClick={onClose} aria-label={t("systemStats.closeAriaLabel", "Close")}>
<X />
</button>
</div>
</div>
{loading && !stats && <div className="system-stats-modal__state">{t("systemStats.loading", "Loading system stats…")}</div>}
{error && !stats && (
<div className="system-stats-modal__state system-stats-modal__state--error" role="alert">
{error}
</div>
)}
{stats && (
<div className="system-stats-modal__content">
<section className="system-stats-modal__section" aria-label={t("systemStats.sectionProcessAriaLabel", "Process stats")}>
<h3 className="system-stats-modal__section-title">{t("systemStats.sectionProcess", "Process")}</h3>
<dl className="system-stats-modal__grid">
{processRows.map((row) => (
<div key={row.label} className="system-stats-modal__row">
<dt>{row.label}</dt>
<dd>
<span className={`system-stats-modal__value ${row.className}`.trim()}>{row.value}</span>
{row.detail ? <span className="system-stats-modal__detail">{row.detail}</span> : null}
</dd>
</div>
))}
</dl>
</section>
<section className="system-stats-modal__section" aria-label={t("systemStats.sectionCpuAriaLabel", "CPU and load stats")}>
<h3 className="system-stats-modal__section-title">{t("systemStats.sectionCpu", "CPU & Load")}</h3>
<dl className="system-stats-modal__grid">
<div className="system-stats-modal__row system-stats-modal__row--cpu-used">
<dt>{t("systemStats.rowAppCpu", "App CPU")}</dt>
<dd>
<span className={`system-stats-modal__value ${cpuClassName}`.trim()}>{cpuPercentLabel}</span>
<span className="system-stats-modal__detail">{cpuPercentValue === null ? t("systemStats.cpuFirstSamplePending", "First sample pending") : t("systemStats.cpuProcessUsage", "process usage")}</span>
</dd>
<div className="system-stats-modal__memory-progress-wrapper">
<div
className={`system-stats-modal__memory-progress-track system-stats-modal__memory-progress-track--${cpuLoadSeverity}`}
role="progressbar"
aria-valuenow={Math.round(cpuBarPercent)}
aria-valuemin={0}
aria-valuemax={100}
aria-label={cpuProgressLabel}
>
<div
className={`system-stats-modal__memory-progress-fill system-stats-modal__memory-progress-fill--${cpuLoadSeverity}`}
style={{ width: `${cpuBarPercent}%` }}
/>
</div>
</div>
</div>
<div className="system-stats-modal__row">
<dt>{t("systemStats.rowLoadAvg", "Load Avg")}</dt>
<dd>{system?.loadAvg.map((value) => value.toFixed(2)).join(" ") ?? "—"}</dd>
</div>
<div className="system-stats-modal__row">
<dt>{t("systemStats.rowCores", "Cores")}</dt>
<dd>{system?.cpuCount ?? "—"}</dd>
</div>
<div className="system-stats-modal__row">
<dt>{t("systemStats.rowPlatform", "Platform")}</dt>
<dd>{system?.platform ?? "—"}</dd>
</div>
<div className="system-stats-modal__row">
<dt>{t("systemStats.rowNode", "Node")}</dt>
<dd>{system?.nodeVersion ?? "—"}</dd>
</div>
<div className="system-stats-modal__row">
<dt>{t("systemStats.rowPid", "PID")}</dt>
<dd>{system?.pid ?? "—"}</dd>
</div>
</dl>
</section>
<section className="system-stats-modal__section" aria-label={t("systemStats.sectionSystemMemAriaLabel", "System memory stats")}>
<h3 className="system-stats-modal__section-title">{t("systemStats.sectionSystem", "System")}</h3>
<dl className="system-stats-modal__grid">
<div className="system-stats-modal__row system-stats-modal__row--memory-used">
<dt>{t("systemStats.rowMemoryUsed", "Memory Used")}</dt>
<dd>
<span className={`system-stats-modal__value ${usedSystemClassName}`.trim()}>
{system ? formatBytes(usedSystemMem) : "—"}
</span>
<span className="system-stats-modal__detail">
{system ? `${toPercent(usedSystemMem, system.systemTotalMem)} of ${formatBytes(system.systemTotalMem)}` : ""}
</span>
</dd>
<div className="system-stats-modal__memory-progress-wrapper">
<div
className={`system-stats-modal__memory-progress-track system-stats-modal__memory-progress-track--${usedSystemMemSeverity}`}
role="progressbar"
aria-valuenow={Math.round(usedSystemMemPercent)}
aria-valuemin={0}
aria-valuemax={100}
aria-label={usedSystemMemProgressLabel}
>
<div
className={`system-stats-modal__memory-progress-fill system-stats-modal__memory-progress-fill--${usedSystemMemSeverity}`}
style={{ width: `${usedSystemMemPercent}%` }}
/>
</div>
</div>
</div>
<div className="system-stats-modal__row">
<dt>{t("systemStats.rowMemoryFree", "Memory Free")}</dt>
<dd>{system ? formatBytes(system.systemFreeMem) : "—"}</dd>
</div>
</dl>
</section>
<section className="system-stats-modal__section" aria-label={t("systemStats.sectionTasksAriaLabel", "Task stats")}>
<h3 className="system-stats-modal__section-title">{t("systemStats.sectionTasks", "Tasks")}</h3>
<dl className="system-stats-modal__grid">
<div className="system-stats-modal__row">
<dt>{t("systemStats.rowTotal", "Total")}</dt>
<dd>{taskStats?.total ?? 0}</dd>
</div>
{Object.entries(taskStats?.byColumn ?? {}).map(([column, count]) => (
<div key={column} className="system-stats-modal__row">
<dt>{column}</dt>
<dd>{count}</dd>
</div>
))}
</dl>
</section>
<section className="system-stats-modal__section" aria-label={t("systemStats.sectionAgentsAriaLabel", "Agent stats")}>
<h3 className="system-stats-modal__section-title">{t("systemStats.sectionAgents", "Agents")}</h3>
<dl className="system-stats-modal__grid">
<div className="system-stats-modal__row">
<dt>{t("systemStats.agentIdle", "idle")}</dt>
<dd>{taskStats?.agents.idle ?? 0}</dd>
</div>
<div className="system-stats-modal__row">
<dt>{t("systemStats.agentActive", "active")}</dt>
<dd>{taskStats?.agents.active ?? 0}</dd>
</div>
<div className="system-stats-modal__row">
<dt>{t("systemStats.agentRunning", "running")}</dt>
<dd>{taskStats?.agents.running ?? 0}</dd>
</div>
<div className="system-stats-modal__row">
<dt>{t("systemStats.agentError", "error")}</dt>
<dd>{taskStats?.agents.error ?? 0}</dd>
</div>
</dl>
</section>
<section className="system-stats-modal__section" aria-label={t("systemStats.sectionVitestAriaLabel", "Vitest controls")}>
<h3 className="system-stats-modal__section-title system-stats-modal__section-title--with-icon">
<ShieldAlert />
<span>{t("systemStats.sectionVitest", "Vitest Controls")}</span>
</h3>
<dl className="system-stats-modal__grid system-stats-modal__vitest-controls">
<div className="system-stats-modal__row">
<dt>{t("systemStats.vitestProcesses", "Vitest Processes")}</dt>
<dd>{vitestProcessCount ?? "—"}</dd>
</div>
</dl>
<div className="system-stats-modal__vitest-controls">
<div className="system-stats-modal__kill-row">
<button
type="button"
className="btn btn-danger"
onClick={() => void handleKillVitest()}
disabled={isKilling || vitestProcessCount === 0}
>
<Skull />
<span>{confirmKill ? t("systemStats.confirmKill", "Confirm Kill?") : t("systemStats.killVitest", "Kill Vitest Processes")}</span>
</button>
</div>
<label className="system-stats-modal__toggle-row">
<input
type="checkbox"
checked={autoKillEnabled}
onChange={(event) => {
void persistAutoKill(event.target.checked);
}}
/>
<span>{t("systemStats.autoKillLabel", "Auto-kill vitest on memory pressure")}</span>
</label>
<div className="system-stats-modal__threshold-row">
<label htmlFor="vitest-threshold-number">{t("systemStats.killThresholdLabel", "Kill threshold (%)")}</label>
<div className="system-stats-modal__threshold-controls">
<input
id="vitest-threshold-range"
type="range"
min={50}
max={99}
value={killThreshold}
aria-label={t("systemStats.killThresholdSliderAriaLabel", "Kill threshold slider (%)")}
onChange={(event) => {
const nextValue = Number.parseInt(event.target.value, 10);
void persistKillThreshold(Number.isNaN(nextValue) ? 90 : nextValue);
}}
/>
<input
id="vitest-threshold-number"
type="number"
className="input"
min={50}
max={99}
value={killThreshold}
aria-label={t("systemStats.killThresholdInputAriaLabel", "Kill threshold (%)")}
onChange={(event) => {
const nextValue = Number.parseInt(event.target.value, 10);
void persistKillThreshold(Number.isNaN(nextValue) ? 90 : nextValue);
}}
onBlur={() => {
void persistKillThreshold(killThreshold);
}}
/>
</div>
</div>
{killResult && <p className={killResultClassName}>{t("systemStats.killedProcesses", "Killed {{count}} processes", { count: killResult.killed })}</p>}
<p className="system-stats-modal__last-kill">{t("systemStats.lastAutoKill", "Last auto-kill: {{time}}", { time: lastAutoKillLabel })}</p>
{settingsError && <p className="system-stats-modal__kill-result system-stats-modal__kill-result--error">{settingsError}</p>}
</div>
</section>
</div>
)}
{error && stats && <div className="system-stats-modal__footer-error">{t("systemStats.footerRefreshFailed", "Latest refresh failed: {{error}}", { error })}</div>}
</div>
</div>
);
}

View File

@@ -76,14 +76,6 @@ vi.mock("../NewTaskModal", () => ({
NewTaskModal: () => null,
}));
const mockSystemStatsModalProps = vi.fn();
vi.mock("../SystemStatsModal", () => ({
SystemStatsModal: (props: any) => {
mockSystemStatsModalProps(props);
return null;
},
}));
const mockActivityLogModalProps = vi.fn();
vi.mock("../ActivityLogModal", () => ({
ActivityLogModal: (props: any) => {
@@ -182,7 +174,6 @@ describe("AppModals", () => {
fileBrowserInitialFile: null,
usageOpen: false,
usageAnchorRect: null,
systemStatsOpen: false,
schedulesOpen: false,
newTaskModalOpen: false,
activityLogOpen: false,
@@ -220,8 +211,6 @@ describe("AppModals", () => {
setFileWorkspace: vi.fn(),
openUsage: vi.fn(),
closeUsage: vi.fn(),
openSystemStats: vi.fn(),
closeSystemStats: vi.fn(),
openSchedules: vi.fn(),
closeSchedules: vi.fn(),
openNewTask: vi.fn(),
@@ -257,7 +246,6 @@ describe("AppModals", () => {
mockModelOnboardingModalProps.mockClear();
mockActivityLogModalProps.mockClear();
mockSettingsModalProps.mockClear();
mockSystemStatsModalProps.mockClear();
mockTodoModalProps.mockClear();
});
@@ -530,45 +518,6 @@ describe("AppModals", () => {
});
});
describe("SystemStatsModal wiring", () => {
const commonProps = {
tasks: [],
projects: [],
currentProject: null,
toasts: mockToasts,
removeToast: vi.fn(),
projectActions: { handleAddProject: vi.fn(), handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() },
taskHandlers: { handleModalCreate: vi.fn(), handlePlanningTaskCreated: vi.fn(), handlePlanningTasksCreated: vi.fn(), handleSubtaskTasksCreated: vi.fn(), handleGitHubImport: vi.fn() },
taskOperations: { moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), duplicateTask: vi.fn() },
deepLink: { handleDetailClose: vi.fn() },
settings: mockSettings,
};
it("passes modal manager state and projectId through to SystemStatsModal", () => {
const closeSystemStats = vi.fn();
render(
<AppModals
{...commonProps}
projectId="proj-system"
addToast={vi.fn()}
modalManager={{ ...mockModalManager, systemStatsOpen: true, closeSystemStats }}
/>,
);
expect(mockSystemStatsModalProps).toHaveBeenCalledTimes(1);
expect(mockSystemStatsModalProps).toHaveBeenCalledWith(
expect.objectContaining({
isOpen: true,
onClose: expect.any(Function),
projectId: "proj-system",
}),
);
mockSystemStatsModalProps.mock.calls[0][0].onClose();
expect(closeSystemStats).toHaveBeenCalledTimes(1);
});
});
describe("task detail history wiring", () => {
const commonProps = {
projectId: "proj-1",

View File

@@ -113,18 +113,6 @@ describe("Header", () => {
expect(screen.getByTitle("Import from GitHub")).toBeDefined();
});
it("renders system stats button on desktop when handler is provided", () => {
renderHeader({ onOpenSystemStats: vi.fn() }, "desktop");
expect(screen.getByTitle("System Stats")).toBeDefined();
});
it("calls onOpenSystemStats when system stats button is clicked", () => {
const onOpenSystemStats = vi.fn();
renderHeader({ onOpenSystemStats }, "desktop");
fireEvent.click(screen.getByTitle("System Stats"));
expect(onOpenSystemStats).toHaveBeenCalled();
});
it("calls onOpenSettings when settings button is clicked", () => {
const onOpenSettings = vi.fn();
renderHeader({ onOpenSettings });

View File

@@ -33,7 +33,6 @@ const createDefaultProps = () => ({
modalOpen: false,
onOpenSettings: vi.fn(),
onOpenActivityLog: vi.fn(),
onOpenSystemStats: vi.fn(),
onOpenMailbox: vi.fn(),
onOpenNodes: vi.fn(),
mailboxUnreadCount: 0,
@@ -393,7 +392,6 @@ describe("MobileNavBar", () => {
expect(screen.getByTestId("mobile-more-item-mailbox")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-activity")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-system-stats")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-git")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-terminal")).toBeDefined();
expect(screen.getByTestId("mobile-more-item-files")).toBeDefined();
@@ -563,17 +561,6 @@ describe("MobileNavBar", () => {
expect(props.onOpenActivityLog).toHaveBeenCalledOnce();
});
it("system stats item in more sheet calls onOpenSystemStats", () => {
const props = createDefaultProps();
const { container } = render(<MobileNavBar {...props} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
fireEvent.click(screen.getByTestId("mobile-more-item-system-stats"));
expect(container.querySelector(".mobile-more-sheet")).toBeNull();
expect(props.onOpenSystemStats).toHaveBeenCalledOnce();
});
it("closes sheet and calls handler when item is clicked", () => {
const props = createDefaultProps();
const { container } = render(<MobileNavBar {...props} />);

View File

@@ -1,329 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { SystemStatsModal } from "../SystemStatsModal";
vi.mock("lucide-react", () => ({
Monitor: (props: { className?: string }) => <span data-testid="icon-monitor" {...props} />,
RefreshCw: (props: { className?: string }) => <span data-testid="icon-refresh" {...props} />,
ShieldAlert: (props: { className?: string }) => <span data-testid="icon-shield-alert" {...props} />,
Skull: (props: { className?: string }) => <span data-testid="icon-skull" {...props} />,
X: (props: { className?: string }) => <span data-testid="icon-x" {...props} />,
}));
const mockFetchSystemStats = vi.fn();
const mockFetchGlobalSettings = vi.fn();
const mockKillVitestProcesses = vi.fn();
const mockUpdateGlobalSettings = vi.fn();
vi.mock("../../api", () => ({
fetchSystemStats: (...args: unknown[]) => mockFetchSystemStats(...args),
fetchGlobalSettings: (...args: unknown[]) => mockFetchGlobalSettings(...args),
killVitestProcesses: (...args: unknown[]) => mockKillVitestProcesses(...args),
updateGlobalSettings: (...args: unknown[]) => mockUpdateGlobalSettings(...args),
}));
const sampleStats = {
systemStats: {
rss: 5 * 1024 * 1024 * 1024,
heapUsed: 900 * 1024 * 1024,
heapTotal: 1200 * 1024 * 1024,
heapLimit: 1000 * 1024 * 1024,
external: 50 * 1024 * 1024,
arrayBuffers: 20 * 1024 * 1024,
cpuPercent: 68.4,
loadAvg: [1.2, 0.8, 0.5] as [number, number, number],
cpuCount: 8,
systemTotalMem: 10 * 1024 * 1024 * 1024,
systemFreeMem: 1024 * 1024 * 1024,
pid: 12345,
nodeVersion: "v22.0.0",
platform: "darwin/arm64",
},
taskStats: {
total: 6,
byColumn: {
triage: 1,
todo: 2,
"in-progress": 1,
"in-review": 1,
done: 1,
archived: 0,
},
active: 2,
agents: {
idle: 1,
active: 2,
running: 0,
error: 1,
},
},
vitestProcessCount: 2,
vitestLastAutoKillAt: "2026-04-27T12:00:00.000Z",
};
describe("SystemStatsModal", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchSystemStats.mockResolvedValue(sampleStats);
mockFetchGlobalSettings.mockResolvedValue({
vitestAutoKillEnabled: true,
vitestKillThresholdPct: 90,
});
mockKillVitestProcesses.mockResolvedValue({ killed: 2, pids: [111, 222] });
mockUpdateGlobalSettings.mockResolvedValue({});
});
afterEach(() => {
vi.useRealTimers();
});
it("shows loading state while initial stats are fetched", async () => {
mockFetchSystemStats.mockReturnValue(new Promise(() => undefined));
render(<SystemStatsModal isOpen={true} onClose={vi.fn()} />);
expect(await screen.findByText("Loading system stats…")).toBeDefined();
});
it("renders fetched metrics across all sections", async () => {
render(<SystemStatsModal isOpen={true} onClose={vi.fn()} projectId="proj-1" />);
await waitFor(() => {
expect(mockFetchSystemStats).toHaveBeenCalledWith("proj-1");
expect(mockFetchGlobalSettings).toHaveBeenCalledTimes(1);
});
expect(screen.getByText("System Stats")).toBeDefined();
expect(await screen.findByText("Process")).toBeDefined();
expect(screen.getByText("CPU & Load")).toBeDefined();
expect(screen.getByText("System")).toBeDefined();
expect(screen.getByText("Tasks")).toBeDefined();
expect(screen.getByText("Agents")).toBeDefined();
expect(screen.getByText("Vitest Controls")).toBeDefined();
expect(screen.getByText("5.00 GB")).toBeDefined();
expect(screen.getByText("900 MB")).toBeDefined();
expect(screen.getByText("9.00 GB")).toBeDefined();
expect(screen.getByText("90.0% of 10.00 GB")).toBeDefined();
const memoryUsageProgress = screen.getByRole("progressbar", {
name: "System memory used: 90.0% (9.00 GB of 10.00 GB)",
});
expect(memoryUsageProgress).toHaveAttribute("aria-valuenow", "90");
expect(memoryUsageProgress).toHaveAttribute("aria-valuemin", "0");
expect(memoryUsageProgress).toHaveAttribute("aria-valuemax", "100");
expect(memoryUsageProgress.className).toContain("system-stats-modal__memory-progress-track--critical");
const criticalFill = memoryUsageProgress.querySelector(".system-stats-modal__memory-progress-fill");
expect(criticalFill?.className).toContain("system-stats-modal__memory-progress-fill--critical");
expect(screen.getByText("68.4%")).toBeDefined();
const cpuUsageProgress = screen.getByRole("progressbar", {
name: "App CPU usage: 68.4%",
});
expect(cpuUsageProgress).toHaveAttribute("aria-valuenow", "68");
expect(cpuUsageProgress.className).toContain("system-stats-modal__memory-progress-track--normal");
expect(screen.getByText("1.20 0.80 0.50")).toBeDefined();
expect(screen.getByText("Vitest Processes")).toBeDefined();
expect(screen.getByText(/Last auto-kill:/)).toBeDefined();
const criticalValues = document.querySelectorAll(".system-stats-modal__value--critical");
expect(criticalValues.length).toBeGreaterThan(0);
});
it("keeps memory value and bar severity aligned for warning thresholds", async () => {
mockFetchSystemStats.mockResolvedValue({
...sampleStats,
systemStats: {
...sampleStats.systemStats,
systemFreeMem: 2 * 1024 * 1024 * 1024,
},
});
render(<SystemStatsModal isOpen={true} onClose={vi.fn()} />);
const memoryUsageProgress = await screen.findByRole("progressbar", {
name: "System memory used: 80.0% (8.00 GB of 10.00 GB)",
});
expect(memoryUsageProgress.className).toContain("system-stats-modal__memory-progress-track--warning");
const warningFill = memoryUsageProgress.querySelector(".system-stats-modal__memory-progress-fill");
expect(warningFill?.className).toContain("system-stats-modal__memory-progress-fill--warning");
const memoryValue = screen.getByText("8.00 GB");
expect(memoryValue.className).toContain("system-stats-modal__value--warning");
});
it("shows refresh button icon when the modal is open", async () => {
render(<SystemStatsModal isOpen={true} onClose={vi.fn()} />);
const refreshButton = await screen.findByRole("button", { name: "Refresh system stats" });
const refreshIcon = screen.getByTestId("icon-refresh");
expect(refreshButton).toBeDefined();
expect(refreshIcon).toBeDefined();
expect(refreshButton.contains(refreshIcon)).toBe(true);
});
it("shows auto-refresh indicator when stats are loaded", async () => {
render(<SystemStatsModal isOpen={true} onClose={vi.fn()} />);
expect(await screen.findByText("Auto-refresh · 5s")).toBeDefined();
expect(screen.getByText(/Updated|Waiting for first update/)).toBeDefined();
});
it("applies spinning class to refresh icon during background refresh", async () => {
vi.useFakeTimers();
let callCount = 0;
let resolveBackgroundRefresh: (() => void) | undefined;
const backgroundRefreshPromise = new Promise<typeof sampleStats>((resolve) => {
resolveBackgroundRefresh = () => resolve(sampleStats);
});
mockFetchSystemStats.mockImplementation(() => {
callCount += 1;
if (callCount === 1) {
return Promise.resolve(sampleStats);
}
return backgroundRefreshPromise;
});
render(<SystemStatsModal isOpen={true} onClose={vi.fn()} />);
await act(async () => {
await Promise.resolve();
});
expect(screen.getByText("Auto-refresh · 5s")).toBeDefined();
act(() => {
vi.advanceTimersByTime(5_000);
});
await act(async () => {
await Promise.resolve();
});
expect(screen.getByTestId("icon-refresh").className).toContain("system-stats-modal__refresh--spinning");
resolveBackgroundRefresh?.();
await act(async () => {
await Promise.resolve();
});
});
it("shows error state when initial fetch fails", async () => {
mockFetchSystemStats.mockRejectedValue(new Error("stats unavailable"));
render(<SystemStatsModal isOpen={true} onClose={vi.fn()} />);
expect(await screen.findByRole("alert")).toHaveTextContent("stats unavailable");
});
it("requires a confirmation click before killing vitest processes", async () => {
render(<SystemStatsModal isOpen={true} onClose={vi.fn()} projectId="proj-1" />);
const killButton = await screen.findByRole("button", { name: /Kill Vitest Processes/i });
fireEvent.click(killButton);
expect(mockKillVitestProcesses).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: /Confirm Kill\?/i })).toBeDefined();
fireEvent.click(screen.getByRole("button", { name: /Confirm Kill\?/i }));
await waitFor(() => {
expect(mockKillVitestProcesses).toHaveBeenCalledWith("proj-1");
expect(screen.getByText("Killed 2 processes")).toBeDefined();
});
});
it("persists auto-kill toggle changes", async () => {
render(<SystemStatsModal isOpen={true} onClose={vi.fn()} />);
const toggle = (await screen.findByLabelText("Auto-kill vitest on memory pressure")) as HTMLInputElement;
expect(toggle.checked).toBe(true);
fireEvent.click(toggle);
await waitFor(() => {
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({ vitestAutoKillEnabled: false });
});
});
it("clamps threshold input to allowed range", async () => {
render(<SystemStatsModal isOpen={true} onClose={vi.fn()} />);
const thresholdInput = (await screen.findByLabelText("Kill threshold (%)")) as HTMLInputElement;
fireEvent.change(thresholdInput, { target: { value: "20" } });
await waitFor(() => {
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({ vitestKillThresholdPct: 50 });
});
fireEvent.change(thresholdInput, { target: { value: "120" } });
await waitFor(() => {
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({ vitestKillThresholdPct: 99 });
});
});
it("persists threshold changes from the slider control", async () => {
render(<SystemStatsModal isOpen={true} onClose={vi.fn()} />);
const thresholdSlider = (await screen.findByLabelText("Kill threshold slider (%)")) as HTMLInputElement;
fireEvent.change(thresholdSlider, { target: { value: "95" } });
await waitFor(() => {
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({ vitestKillThresholdPct: 95 });
});
});
it("shows deterministic fallback copy when app CPU percentage is unavailable", async () => {
mockFetchSystemStats.mockResolvedValue({
...sampleStats,
systemStats: {
...sampleStats.systemStats,
cpuPercent: null,
},
});
render(<SystemStatsModal isOpen={true} onClose={vi.fn()} />);
expect(await screen.findByText("Sampling…")).toBeDefined();
const cpuUsageProgress = screen.getByRole("progressbar", {
name: "App CPU usage unavailable: waiting for another sample",
});
expect(cpuUsageProgress).toHaveAttribute("aria-valuenow", "0");
});
it("shows fallback text when last auto-kill timestamp is unavailable", async () => {
mockFetchSystemStats.mockResolvedValue({
...sampleStats,
vitestLastAutoKillAt: null,
});
render(<SystemStatsModal isOpen={true} onClose={vi.fn()} />);
expect(await screen.findByText("Last auto-kill: Not yet")).toBeDefined();
});
it("refreshes every 5 seconds while open and stops when closed", async () => {
vi.useFakeTimers();
const { rerender } = render(<SystemStatsModal isOpen={true} onClose={vi.fn()} />);
await act(async () => {
await Promise.resolve();
});
expect(mockFetchSystemStats).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(5_000);
});
expect(mockFetchSystemStats).toHaveBeenCalledTimes(2);
rerender(<SystemStatsModal isOpen={false} onClose={vi.fn()} />);
await act(async () => {
await vi.advanceTimersByTimeAsync(10_000);
});
expect(mockFetchSystemStats).toHaveBeenCalledTimes(2);
});
});

View File

@@ -318,7 +318,6 @@ function AppShellMobileHarness({ tasks }: { tasks: Task[] }) {
keyboardOpen={keyboardOpen}
onOpenSettings={vi.fn()}
onOpenActivityLog={vi.fn()}
onOpenSystemStats={vi.fn()}
onOpenMailbox={vi.fn()}
onOpenGitManager={vi.fn()}
onOpenWorkflowEditor={vi.fn()}

View File

@@ -12,6 +12,7 @@ import { TeamArea } from "./areas/TeamArea";
import { EcosystemArea } from "./areas/EcosystemArea";
import { GithubArea } from "./areas/GithubArea";
import { SignalsArea } from "./areas/SignalsArea";
import { SystemStatsArea } from "./areas/SystemStatsArea";
import { MissionControlPanel } from "./MissionControlPanel";
import { SdlcFunnel } from "./SdlcFunnel";
import { Bar, type BarDatum } from "./charts/Bar";
@@ -31,6 +32,7 @@ type SubViewId =
| "ecosystem"
| "github"
| "signals"
| "system"
| "mission-control";
interface SubView {
@@ -54,6 +56,7 @@ function useSubViews(): SubView[] {
{ id: "ecosystem", label: t("commandCenter.tabs.ecosystem", "Ecosystem") },
{ id: "github", label: t("commandCenter.tabs.github", "GitHub") },
{ id: "signals", label: t("commandCenter.tabs.signals", "Signals") },
{ id: "system", label: t("commandCenter.tabs.system", "System") },
{ id: "mission-control", label: t("commandCenter.tabs.missionControl", "Mission Control") },
];
}
@@ -441,6 +444,8 @@ export function CommandCenter() {
return <GithubArea range={range} />;
case "signals":
return <SignalsArea range={range} />;
case "system":
return <SystemStatsArea />;
case "mission-control":
return <MissionControlPanel />;
default:

View File

@@ -11,6 +11,13 @@ vi.mock("../../../api/legacy", () => ({
api: (path: string, opts?: RequestInit) => apiMock(path, opts),
}));
vi.mock("../../../api", () => ({
fetchSystemStats: () => Promise.resolve(systemStatsFixture()),
fetchGlobalSettings: () => Promise.resolve({ vitestAutoKillEnabled: true, vitestKillThresholdPct: 90 }),
killVitestProcesses: () => Promise.resolve({ killed: 0, pids: [] }),
updateGlobalSettings: () => Promise.resolve({}),
}));
function emptyTokenFixture() {
return {
totals: { inputTokens: 0, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 0, nTasks: 0 },
@@ -128,6 +135,37 @@ function populatedActivityFixture() {
};
}
function systemStatsFixture() {
const gb = 1024 * 1024 * 1024;
const mb = 1024 * 1024;
return {
systemStats: {
rss: 2 * gb,
heapUsed: 500 * mb,
heapTotal: 700 * mb,
heapLimit: 1 * gb,
external: 20 * mb,
arrayBuffers: 8 * mb,
cpuPercent: 12,
loadAvg: [0.1, 0.2, 0.3] as [number, number, number],
cpuCount: 8,
systemTotalMem: 8 * gb,
systemFreeMem: 4 * gb,
pid: 456,
nodeVersion: "v22.0.0",
platform: "darwin/arm64",
},
taskStats: {
total: 1,
byColumn: { todo: 1 },
active: 0,
agents: { idle: 1, active: 0, running: 0, error: 0 },
},
vitestProcessCount: 0,
vitestLastAutoKillAt: null,
};
}
function mockOverviewApi({ populated = false }: { populated?: boolean } = {}) {
apiMock.mockImplementation((path: string) => {
if (path.startsWith("/command-center/tokens")) return Promise.resolve(populated ? populatedTokenFixture() : emptyTokenFixture());
@@ -136,6 +174,8 @@ function mockOverviewApi({ populated = false }: { populated?: boolean } = {}) {
if (path.startsWith("/command-center/github")) return Promise.resolve(emptyGithubFixture());
if (path.startsWith("/command-center/team")) return Promise.resolve(emptyTeamFixture());
if (path.startsWith("/command-center/signals")) return Promise.resolve({ totalSignals: 0, open: 0, resolved: 0, mttr: { value: null, unavailable: true }, bySource: [], bySeverity: [] });
if (path === "/system-stats") return Promise.resolve(systemStatsFixture());
if (path === "/settings/global") return Promise.resolve({ vitestAutoKillEnabled: true, vitestKillThresholdPct: 90 });
if (path === "/command-center/live") {
return Promise.resolve({
capturedAt: "2026-06-18T00:00:00.000Z",
@@ -224,6 +264,12 @@ describe("CommandCenter mobile scroll regression (FN-6595)", () => {
const githubPanel = screen.getByTestId("command-center-panel-github");
expect(githubPanel).toBe(screen.getByRole("tabpanel"));
assertScrollOwnerContract(githubPanel);
fireEvent.click(screen.getByTestId("command-center-tab-system"));
const systemPanel = screen.getByTestId("command-center-panel-system");
expect(systemPanel).toBe(screen.getByRole("tabpanel"));
await screen.findByTestId("cc-area-system");
assertScrollOwnerContract(systemPanel);
});
it("preserves the mobile scroll owner when the populated Overview charts render", async () => {

View File

@@ -11,6 +11,13 @@ vi.mock("../../../api/legacy", () => ({
api: (path: string, opts?: RequestInit) => apiMock(path, opts),
}));
vi.mock("../../../api", () => ({
fetchSystemStats: () => Promise.resolve(systemStatsFixture()),
fetchGlobalSettings: () => Promise.resolve({ vitestAutoKillEnabled: true, vitestKillThresholdPct: 90 }),
killVitestProcesses: () => Promise.resolve({ killed: 0, pids: [] }),
updateGlobalSettings: () => Promise.resolve({}),
}));
function tokenFixture(totalTokens = 1_500) {
return {
from: "2026-06-08",
@@ -182,6 +189,37 @@ function liveFixture(columns: Array<{ column: string; count: number }> = [{ colu
};
}
function systemStatsFixture() {
const gb = 1024 * 1024 * 1024;
const mb = 1024 * 1024;
return {
systemStats: {
rss: 2 * gb,
heapUsed: 500 * mb,
heapTotal: 700 * mb,
heapLimit: 1 * gb,
external: 20 * mb,
arrayBuffers: 8 * mb,
cpuPercent: 12,
loadAvg: [0.1, 0.2, 0.3] as [number, number, number],
cpuCount: 8,
systemTotalMem: 8 * gb,
systemFreeMem: 4 * gb,
pid: 456,
nodeVersion: "v22.0.0",
platform: "darwin/arm64",
},
taskStats: {
total: 1,
byColumn: { todo: 1 },
active: 0,
agents: { idle: 1, active: 0, running: 0, error: 0 },
},
vitestProcessCount: 0,
vitestLastAutoKillAt: null,
};
}
function mockOverviewApi({
tokens = tokenFixture(),
tools = toolsFixture(),
@@ -211,6 +249,8 @@ function mockOverviewApi({
if (path === "/command-center/live") {
return live instanceof Error ? Promise.reject(live) : Promise.resolve(live);
}
if (path === "/system-stats") return Promise.resolve(systemStatsFixture());
if (path === "/settings/global") return Promise.resolve({ vitestAutoKillEnabled: true, vitestKillThresholdPct: 90 });
return Promise.reject(new Error(`Unhandled api path: ${path}`));
});
}
@@ -520,8 +560,8 @@ describe("CommandCenter shell", () => {
render(<CommandCenter />);
const tablist = screen.getByRole("tablist");
const tabs = within(tablist).getAllByRole("tab");
// Overview, Tokens, Tools, Activity, Productivity, Team, Ecosystem, GitHub, Signals, Mission Control.
expect(tabs.length).toBe(10);
// Overview, Tokens, Tools, Activity, Productivity, Team, Ecosystem, GitHub, Signals, System, Mission Control.
expect(tabs.length).toBe(11);
// roving tabindex: exactly one tab is focusable.
const focusable = tabs.filter((tab) => tab.getAttribute("tabindex") === "0");
expect(focusable.length).toBe(1);
@@ -536,6 +576,18 @@ describe("CommandCenter shell", () => {
expect(screen.getByTestId("command-center-panel-tokens")).toBeTruthy();
});
it("renders and routes the System tab exactly once", async () => {
mockOverviewApi();
render(<CommandCenter />);
expect(screen.getAllByTestId("command-center-tab-system")).toHaveLength(1);
fireEvent.click(screen.getByTestId("command-center-tab-system"));
expect(screen.getByTestId("command-center-tab-system").getAttribute("aria-selected")).toBe("true");
expect(screen.getByTestId("command-center-panel-system")).toBeTruthy();
await screen.findByTestId("cc-area-system");
expect(screen.getByTestId("cc-system-cpu-gauge")).toBeTruthy();
});
it("renders and routes the GitHub tab exactly once", async () => {
mockOverviewApi({ github: githubFixture(4, 2) });
render(<CommandCenter />);
@@ -642,6 +694,7 @@ describe("CommandCenter shell", () => {
"ecosystem",
"github",
"signals",
"system",
"mission-control",
"team",
]) {

View File

@@ -0,0 +1,229 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import "@testing-library/jest-dom";
import { SystemStatsArea } from "../areas/SystemStatsArea";
const mockFetchSystemStats = vi.fn();
const mockFetchGlobalSettings = vi.fn();
const mockKillVitestProcesses = vi.fn();
const mockUpdateGlobalSettings = vi.fn();
vi.mock("../../../api", () => ({
fetchSystemStats: (...args: unknown[]) => mockFetchSystemStats(...args),
fetchGlobalSettings: (...args: unknown[]) => mockFetchGlobalSettings(...args),
killVitestProcesses: (...args: unknown[]) => mockKillVitestProcesses(...args),
updateGlobalSettings: (...args: unknown[]) => mockUpdateGlobalSettings(...args),
}));
const gb = 1024 * 1024 * 1024;
const mb = 1024 * 1024;
type SystemStatsFixture = ReturnType<typeof baseStats>;
type SystemStatsFixtureOverrides = Partial<Omit<SystemStatsFixture, "systemStats" | "taskStats">> & {
systemStats?: Partial<SystemStatsFixture["systemStats"]>;
taskStats?: Partial<Omit<SystemStatsFixture["taskStats"], "agents">> & {
agents?: Partial<SystemStatsFixture["taskStats"]["agents"]>;
};
};
function sampleStats(overrides: SystemStatsFixtureOverrides = {}) {
return {
...baseStats(),
...overrides,
systemStats: {
...baseStats().systemStats,
...overrides.systemStats,
},
taskStats: {
...baseStats().taskStats,
...overrides.taskStats,
agents: {
...baseStats().taskStats.agents,
...overrides.taskStats?.agents,
},
},
};
}
function baseStats() {
return {
systemStats: {
rss: 5 * gb,
heapUsed: 900 * mb,
heapTotal: 1200 * mb,
heapLimit: 1000 * mb,
external: 50 * mb,
arrayBuffers: 20 * mb,
cpuPercent: 68.4,
loadAvg: [1.2, 0.8, 0.5] as [number, number, number],
cpuCount: 8,
systemTotalMem: 10 * gb,
systemFreeMem: 1 * gb,
pid: 12345,
nodeVersion: "v22.0.0",
platform: "darwin/arm64",
},
taskStats: {
total: 6,
byColumn: {
triage: 1,
todo: 2,
"in-progress": 1,
"in-review": 1,
done: 1,
},
active: 2,
agents: {
idle: 1,
active: 2,
running: 0,
error: 1,
},
},
vitestProcessCount: 2,
vitestLastAutoKillAt: "2026-04-27T12:00:00.000Z",
};
}
describe("SystemStatsArea", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchSystemStats.mockResolvedValue(sampleStats());
mockFetchGlobalSettings.mockResolvedValue({ vitestAutoKillEnabled: true, vitestKillThresholdPct: 90 });
mockKillVitestProcesses.mockResolvedValue({ killed: 2, pids: [111, 222] });
mockUpdateGlobalSettings.mockResolvedValue({});
});
afterEach(() => {
vi.useRealTimers();
});
it("renders gauges, trends, bars, details, and Vitest controls for populated stats", async () => {
render(<SystemStatsArea projectId="proj-1" />);
await waitFor(() => {
expect(mockFetchSystemStats).toHaveBeenCalledWith("proj-1");
expect(mockFetchGlobalSettings).toHaveBeenCalledTimes(1);
});
expect(await screen.findByTestId("cc-area-system")).toBeInTheDocument();
expect(screen.getByTestId("cc-system-cpu-gauge")).toHaveTextContent("68%");
expect(screen.getByTestId("cc-system-mem-gauge")).toHaveTextContent("90%");
expect(screen.getByTestId("cc-system-heap-gauge")).toHaveTextContent("90%");
expect(screen.getByTestId("cc-system-cpu-trend")).toBeInTheDocument();
expect(screen.getByTestId("cc-system-memory-trend")).toBeInTheDocument();
expect(screen.getByTestId("cc-system-tasks-bar")).toHaveTextContent("in-progress");
expect(screen.getByTestId("cc-system-agents-bar")).toHaveTextContent("active");
expect(screen.getByTestId("cc-system-details-grid")).toHaveTextContent("RSS");
expect(screen.getByTestId("cc-system-details-grid")).toHaveTextContent("5.00 GB");
expect(screen.getByTestId("cc-system-vitest-controls")).toHaveTextContent("Vitest Processes");
});
it("renders the first-sample CPU state safely without NaN", async () => {
mockFetchSystemStats.mockResolvedValue(sampleStats({ systemStats: { cpuPercent: null } }));
render(<SystemStatsArea />);
await screen.findByTestId("cc-area-system");
expect(screen.getByTestId("cc-system-cpu-gauge")).toHaveTextContent("—");
expect(screen.getByTestId("cc-system-cpu-gauge")).toHaveTextContent("Sampling");
expect(screen.getByTestId("cc-area-system")).not.toHaveTextContent("NaN");
});
it("renders zero-value task and agent bars when collections are empty", async () => {
mockFetchSystemStats.mockResolvedValue(sampleStats({
taskStats: {
total: 0,
byColumn: {},
active: 0,
agents: { idle: 0, active: 0, running: 0, error: 0 },
},
}));
render(<SystemStatsArea />);
await screen.findByTestId("cc-area-system");
const taskBars = screen.getByTestId("cc-system-tasks-bar");
const agentBars = screen.getByTestId("cc-system-agents-bar");
expect(within(taskBars).getByText("triage")).toBeInTheDocument();
expect(within(agentBars).getByText("idle")).toBeInTheDocument();
expect(within(taskBars).getAllByText("0").length).toBeGreaterThan(0);
expect(within(agentBars).getAllByText("0").length).toBeGreaterThan(0);
expect(screen.getByTestId("cc-area-system")).not.toHaveTextContent("NaN");
});
it("keeps the last stats visible when a later poll fails", async () => {
vi.useFakeTimers();
mockFetchSystemStats.mockResolvedValueOnce(sampleStats()).mockRejectedValueOnce(new Error("poll failed"));
render(<SystemStatsArea />);
await act(async () => {
await Promise.resolve();
});
expect(screen.getByTestId("cc-area-system")).toBeInTheDocument();
await act(async () => {
vi.advanceTimersByTime(5_000);
await Promise.resolve();
});
expect(screen.getByTestId("cc-area-system")).toHaveTextContent("Latest refresh failed: poll failed");
expect(screen.getByTestId("cc-system-details-grid")).toHaveTextContent("RSS");
});
it("shows the initial error state when the first fetch fails", async () => {
mockFetchSystemStats.mockRejectedValue(new Error("initial failure"));
render(<SystemStatsArea />);
expect(await screen.findByTestId("cc-area-system-error")).toHaveTextContent("initial failure");
});
it("confirms before killing Vitest and persists settings changes", async () => {
render(<SystemStatsArea projectId="proj-1" />);
await screen.findByTestId("cc-area-system");
const killButton = screen.getByTestId("cc-system-kill-vitest");
fireEvent.click(killButton);
expect(killButton).toHaveTextContent("Confirm Kill?");
fireEvent.click(killButton);
await waitFor(() => {
expect(mockKillVitestProcesses).toHaveBeenCalledWith("proj-1");
});
expect(await screen.findByText("Killed 2 processes")).toBeInTheDocument();
fireEvent.click(screen.getByLabelText("Auto-kill vitest on memory pressure"));
await waitFor(() => {
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({ vitestAutoKillEnabled: false });
});
fireEvent.change(screen.getByLabelText("Kill threshold (%)"), { target: { value: "120" } });
await waitFor(() => {
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({ vitestKillThresholdPct: 99 });
});
});
it("polls every five seconds and clears the interval on unmount", async () => {
vi.useFakeTimers();
const { unmount } = render(<SystemStatsArea />);
await act(async () => {
await Promise.resolve();
});
expect(screen.getByTestId("cc-area-system")).toBeInTheDocument();
expect(mockFetchSystemStats).toHaveBeenCalledTimes(1);
await act(async () => {
vi.advanceTimersByTime(5_000);
await Promise.resolve();
});
expect(mockFetchSystemStats).toHaveBeenCalledTimes(2);
unmount();
await act(async () => {
vi.advanceTimersByTime(5_000);
await Promise.resolve();
});
expect(mockFetchSystemStats).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,122 @@
/*
FNXC:CommandCenter 2026-06-18-00:00:
The Command Center System area replaces the standalone System Stats modal with graph-heavy telemetry while preserving the Command Center mobile scroll contract; keep area-specific styling layout-only and avoid nested overflow containers so .cc-tabpanel remains the sole vertical scroller.
*/
.cc-system-refresh {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-2);
color: var(--text-muted);
font-size: var(--font-size-sm);
}
.cc-system-refresh .btn-icon {
flex: 0 0 auto;
}
.cc-system-gauges .cc-stat-card {
min-block-size: 100%;
}
.cc-system-chart-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
.cc-system-chart-grid .cc-stat-card {
gap: var(--space-3);
}
.cc-system-section-title-with-icon {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.cc-system-section-title-with-icon svg {
color: var(--text-muted);
}
.cc-system-vitest-card {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-3);
padding: var(--space-3);
}
.cc-system-vitest-card .btn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.cc-system-toggle-row,
.cc-system-threshold-row,
.cc-system-threshold-controls {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.cc-system-toggle-row,
.cc-system-threshold-row {
color: var(--text-primary);
font-size: var(--font-size-sm);
}
.cc-system-threshold-controls input[type="range"] {
accent-color: var(--color-accent);
}
.cc-system-threshold-controls .input {
inline-size: 5rem;
}
.cc-system-note {
margin: 0;
color: var(--text-muted);
font-size: var(--font-size-sm);
}
.cc-system-note--error,
.cc-system-value--critical {
color: var(--color-error);
}
.cc-system-value--warning {
color: var(--color-warning);
}
.cc-system-note--success {
color: var(--color-success);
}
@media (max-width: 768px) {
.cc-system-refresh {
align-items: flex-start;
justify-content: flex-start;
flex-direction: column;
}
.cc-system-chart-grid {
grid-template-columns: 1fr;
}
.cc-system-vitest-card,
.cc-system-toggle-row,
.cc-system-threshold-row,
.cc-system-threshold-controls {
align-items: stretch;
flex-direction: column;
inline-size: 100%;
}
.cc-system-vitest-card .btn,
.cc-system-threshold-controls .input {
inline-size: 100%;
}
}

View File

@@ -0,0 +1,440 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { RefreshCw, ShieldAlert, Skull } from "lucide-react";
import {
fetchGlobalSettings,
fetchSystemStats,
killVitestProcesses,
updateGlobalSettings,
type KillVitestResponse,
type SystemStatsResponse,
} from "../../../api";
import { Bar, type BarDatum } from "../charts/Bar";
import { RadialGauge } from "../charts/RadialGauge";
import { Sparkline } from "../charts/Sparkline";
import { AreaShell } from "./AreaShell";
import { formatCount } from "./areaShared";
import "./SystemStatsArea.css";
type Severity = "normal" | "warning" | "critical";
interface SystemSample {
cpuPercent: number;
usedSystemMemPercent: number;
heapUsedPercent: number;
}
const SYSTEM_STATS_POLL_MS = 5_000;
const MAX_SYSTEM_SAMPLES = 30;
const DEFAULT_TASK_COLUMNS = ["triage", "todo", "in-progress", "in-review", "done"];
const AGENT_STATES = ["idle", "active", "running", "error"] as const;
/*
FNXC:CommandCenter 2026-06-18-00:00:
System telemetry now lives in the Command Center System area; it polls /api/system-stats (no new endpoint) and keeps a bounded rolling sample buffer to render live CPU/memory trend sparklines.
*/
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return "—";
const mb = bytes / (1024 * 1024);
if (mb < 1024) return `${mb.toFixed(0)} MB`;
return `${(mb / 1024).toFixed(2)} GB`;
}
function toPercent(used: number, total: number): string {
if (!Number.isFinite(used) || !Number.isFinite(total) || total <= 0) return "—";
return `${((used / total) * 100).toFixed(1)}%`;
}
function heapSeverity(used: number, limit: number): Severity {
if (limit <= 0) return "normal";
const pct = used / limit;
if (pct >= 0.85) return "critical";
if (pct >= 0.65) return "warning";
return "normal";
}
function rssSeverity(rss: number, totalSystemMem: number): Severity {
if (totalSystemMem <= 0) return "normal";
const pct = rss / totalSystemMem;
if (pct >= 0.5) return "critical";
if (pct >= 0.25) return "warning";
return "normal";
}
function systemMemSeverity(used: number, total: number): Severity {
if (total <= 0) return "normal";
const pct = used / total;
if (pct >= 0.9) return "critical";
if (pct >= 0.75) return "warning";
return "normal";
}
function cpuSeverity(percent: number | null, cores: number): Severity {
if (percent === null || !Number.isFinite(percent) || percent < 0) return "normal";
const normalized = cores > 0 ? percent / cores : percent;
if (normalized >= 80) return "critical";
if (normalized >= 50) return "warning";
return "normal";
}
function severityClassName(severity: Severity): string {
if (severity === "critical") return "cc-system-value--critical";
if (severity === "warning") return "cc-system-value--warning";
return "";
}
function formatTimestamp(value: string | null | undefined, notYetLabel: string): string {
if (!value) return notYetLabel;
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return notYetLabel;
return parsed.toLocaleString();
}
function safeRatio(used: number, total: number): number {
if (!Number.isFinite(used) || !Number.isFinite(total) || total <= 0) return 0;
return Math.max(0, Math.min(1, used / total));
}
function clampPercent(value: number | null | undefined): number {
if (value === null || value === undefined || !Number.isFinite(value)) return 0;
return Math.max(0, Math.min(100, value));
}
function sampleFromStats(stats: SystemStatsResponse): SystemSample {
const system = stats.systemStats;
const usedSystemMem = system.systemTotalMem - system.systemFreeMem;
return {
cpuPercent: clampPercent(system.cpuPercent),
usedSystemMemPercent: safeRatio(usedSystemMem, system.systemTotalMem) * 100,
heapUsedPercent: safeRatio(system.heapUsed, system.heapLimit) * 100,
};
}
export function SystemStatsArea({ projectId }: { projectId?: string }) {
const { t } = useTranslation("app");
const [stats, setStats] = useState<SystemStatsResponse | null>(null);
const [samples, setSamples] = useState<SystemSample[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [autoKillEnabled, setAutoKillEnabled] = useState(true);
const [killThreshold, setKillThreshold] = useState(90);
const [isKilling, setIsKilling] = useState(false);
const [confirmKill, setConfirmKill] = useState(false);
const [killResult, setKillResult] = useState<KillVitestResponse | null>(null);
const [settingsError, setSettingsError] = useState<string | null>(null);
const [lastRefreshedAt, setLastRefreshedAt] = useState<number | null>(null);
const loadStats = useCallback(async (options?: { preserveKillResult?: boolean }) => {
setLoading(true);
try {
const response = await fetchSystemStats(projectId);
setStats(response);
setSamples((prev) => [...prev, sampleFromStats(response)].slice(-MAX_SYSTEM_SAMPLES));
setError(null);
setLastRefreshedAt(Date.now());
if (!options?.preserveKillResult) {
setKillResult(null);
}
} catch (err) {
setError(err instanceof Error ? err.message : t("systemStats.errorLoadStats", "Failed to load system stats"));
} finally {
setLoading(false);
}
}, [projectId, t]);
useEffect(() => {
void loadStats();
const timer = window.setInterval(() => {
void loadStats();
}, SYSTEM_STATS_POLL_MS);
return () => {
window.clearInterval(timer);
};
}, [loadStats]);
useEffect(() => {
let cancelled = false;
const loadSettings = async () => {
try {
const settings = await fetchGlobalSettings();
if (cancelled) return;
setAutoKillEnabled(settings.vitestAutoKillEnabled ?? true);
setKillThreshold(settings.vitestKillThresholdPct ?? 90);
setSettingsError(null);
} catch (err) {
if (!cancelled) {
setSettingsError(err instanceof Error ? err.message : t("systemStats.errorLoadVitestSettings", "Failed to load vitest settings"));
}
}
};
void loadSettings();
return () => {
cancelled = true;
};
}, [t]);
const persistAutoKill = useCallback(async (enabled: boolean) => {
setAutoKillEnabled(enabled);
try {
await updateGlobalSettings({ vitestAutoKillEnabled: enabled });
setSettingsError(null);
} catch (err) {
setSettingsError(err instanceof Error ? err.message : t("systemStats.errorSaveVitestSettings", "Failed to save vitest settings"));
}
}, [t]);
const persistKillThreshold = useCallback(async (nextThreshold: number) => {
const clamped = Math.min(99, Math.max(50, Number.isFinite(nextThreshold) ? Math.round(nextThreshold) : 90));
setKillThreshold(clamped);
try {
await updateGlobalSettings({ vitestKillThresholdPct: clamped });
setSettingsError(null);
} catch (err) {
setSettingsError(err instanceof Error ? err.message : t("systemStats.errorSaveVitestSettings", "Failed to save vitest settings"));
}
}, [t]);
const handleKillVitest = useCallback(async () => {
if (isKilling) return;
if (!confirmKill) {
setConfirmKill(true);
return;
}
setIsKilling(true);
try {
const result = await killVitestProcesses(projectId);
setKillResult(result);
setConfirmKill(false);
await loadStats({ preserveKillResult: true });
} catch (err) {
setError(err instanceof Error ? err.message : t("systemStats.errorKillVitest", "Failed to kill vitest processes"));
} finally {
setIsKilling(false);
}
}, [confirmKill, isKilling, loadStats, projectId, t]);
const system = stats?.systemStats;
const taskStats = stats?.taskStats;
const usedSystemMem = system ? system.systemTotalMem - system.systemFreeMem : 0;
const usedSystemMemRatio = system ? safeRatio(usedSystemMem, system.systemTotalMem) : 0;
const heapRatio = system ? safeRatio(system.heapUsed, system.heapLimit) : 0;
const cpuRatio = system?.cpuPercent === null || system?.cpuPercent === undefined ? null : clampPercent(system.cpuPercent) / 100;
const cpuPercentLabel = system?.cpuPercent === null || system?.cpuPercent === undefined ? t("systemStats.cpuSampling", "Sampling…") : `${system.cpuPercent.toFixed(1)}%`;
const refreshLabel = lastRefreshedAt
? t("systemStats.updatedAt", "Updated {{time}}", {
time: new Date(lastRefreshedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }),
})
: t("systemStats.waitingFirstUpdate", "Waiting for first update");
const taskBarData = useMemo<BarDatum[]>(() => {
const byColumn = taskStats?.byColumn ?? {};
const labels = Object.keys(byColumn).length > 0 ? Object.keys(byColumn) : DEFAULT_TASK_COLUMNS;
return labels.map((label) => ({ label, value: byColumn[label] ?? 0, valueLabel: formatCount(byColumn[label] ?? 0) }));
}, [taskStats?.byColumn]);
const agentBarData = useMemo<BarDatum[]>(() => {
const agents = taskStats?.agents;
return AGENT_STATES.map((state) => ({
label: t(`systemStats.agent${state[0].toUpperCase()}${state.slice(1)}`, state),
value: agents?.[state] ?? 0,
valueLabel: formatCount(agents?.[state] ?? 0),
}));
}, [taskStats?.agents, t]);
const detailRows = useMemo(() => {
const heapClassName = system ? severityClassName(heapSeverity(system.heapUsed, system.heapLimit)) : "";
const rssClassName = system ? severityClassName(rssSeverity(system.rss, system.systemTotalMem)) : "";
const systemMemClassName = system ? severityClassName(systemMemSeverity(usedSystemMem, system.systemTotalMem)) : "";
const cpuClassName = system ? severityClassName(cpuSeverity(system.cpuPercent, system.cpuCount)) : "";
return [
{ label: t("systemStats.rowAppCpu", "App CPU"), value: cpuPercentLabel, detail: system?.cpuPercent === null ? t("systemStats.cpuFirstSamplePending", "First sample pending") : t("systemStats.cpuProcessUsage", "process usage"), className: cpuClassName },
{ label: t("systemStats.rowRss", "RSS"), value: system ? formatBytes(system.rss) : "—", detail: system ? toPercent(system.rss, system.systemTotalMem) : "—", className: rssClassName },
{ label: t("systemStats.rowHeapUsed", "Heap Used"), value: system ? formatBytes(system.heapUsed) : "—", detail: system ? t("systemStats.rowHeapUsedDetail", "of {{total}}", { total: formatBytes(system.heapTotal) }) : "—", className: heapClassName },
{ label: t("systemStats.rowHeapLimit", "Heap Limit"), value: system ? formatBytes(system.heapLimit) : "—", detail: t("systemStats.rowHeapLimitDetail", "V8 limit") },
{ label: t("systemStats.rowExternal", "External"), value: system ? formatBytes(system.external) : "—" },
{ label: t("systemStats.rowArrayBuffers", "Array Buffers"), value: system ? formatBytes(system.arrayBuffers) : "—" },
{ label: t("systemStats.rowLoadAvg", "Load Avg"), value: system?.loadAvg.map((value) => value.toFixed(2)).join(" ") ?? "—" },
{ label: t("systemStats.rowCores", "Cores"), value: system?.cpuCount ?? "—" },
{ label: t("systemStats.rowPlatform", "Platform"), value: system?.platform ?? "—" },
{ label: t("systemStats.rowNode", "Node"), value: system?.nodeVersion ?? "—" },
{ label: t("systemStats.rowPid", "PID"), value: system?.pid ?? "—" },
{ label: t("systemStats.rowMemoryUsed", "Memory Used"), value: system ? formatBytes(usedSystemMem) : "—", detail: system ? `${toPercent(usedSystemMem, system.systemTotalMem)} of ${formatBytes(system.systemTotalMem)}` : "—", className: systemMemClassName },
{ label: t("systemStats.rowMemoryFree", "Memory Free"), value: system ? formatBytes(system.systemFreeMem) : "—" },
];
}, [cpuPercentLabel, system, t, usedSystemMem]);
const vitestProcessCount = stats?.vitestProcessCount;
const lastAutoKillLabel = formatTimestamp(stats?.vitestLastAutoKillAt, t("systemStats.notYet", "Not yet"));
const isBackgroundRefreshing = loading && Boolean(stats);
return (
<AreaShell testId="system" isLoading={loading && !stats} error={!stats ? error : null} isEmpty={false}>
<div className="cc-area-section">
<div className="cc-area-section-header">
<h3 className="cc-area-section-title">{t("commandCenter.system.healthTitle", "Live system health")}</h3>
<div className="cc-system-refresh" aria-live="polite">
<span>{t("systemStats.autoRefresh", "Auto-refresh · 5s")}</span>
<span>{refreshLabel}</span>
<button
type="button"
className="btn-icon"
onClick={() => void loadStats()}
title={t("systemStats.refreshTitle", "Refresh")}
aria-label={t("systemStats.refreshAriaLabel", "Refresh system stats")}
>
<RefreshCw size={16} className={isBackgroundRefreshing ? "spin" : undefined} />
</button>
</div>
</div>
{error && stats ? (
<p className="cc-system-note cc-system-note--error" role="status">
{t("systemStats.footerRefreshFailed", "Latest refresh failed: {{error}}", { error })}
</p>
) : null}
<div className="cc-stat-grid cc-system-gauges">
<div className="card cc-stat-card cc-stat-card--gauge" data-testid="cc-system-cpu-gauge">
<RadialGauge value={cpuRatio} label={t("systemStats.rowAppCpu", "App CPU")} ariaLabel={t("commandCenter.system.cpuGauge", "App CPU usage")} />
<span className="cc-stat-sub">{cpuPercentLabel}</span>
</div>
<div className="card cc-stat-card cc-stat-card--gauge" data-testid="cc-system-mem-gauge">
<RadialGauge value={usedSystemMemRatio} label={t("systemStats.rowMemoryUsed", "Memory Used")} ariaLabel={t("commandCenter.system.memoryGauge", "System memory used")} />
<span className="cc-stat-sub">{system ? `${formatBytes(usedSystemMem)} / ${formatBytes(system.systemTotalMem)}` : "—"}</span>
</div>
<div className="card cc-stat-card cc-stat-card--gauge" data-testid="cc-system-heap-gauge">
<RadialGauge value={heapRatio} label={t("systemStats.rowHeapUsed", "Heap Used")} ariaLabel={t("commandCenter.system.heapGauge", "Heap used")} />
<span className="cc-stat-sub">{system ? `${formatBytes(system.heapUsed)} / ${formatBytes(system.heapLimit)}` : "—"}</span>
</div>
</div>
</div>
<div className="cc-area-section">
<h3 className="cc-area-section-title">{t("commandCenter.system.trendsTitle", "Live trends")}</h3>
<div className="cc-stat-grid">
<div className="card cc-stat-card" data-testid="cc-system-cpu-trend">
<div className="cc-stat-label">{t("commandCenter.system.cpuTrend", "CPU over time")}</div>
<Sparkline values={samples.map((sample) => sample.cpuPercent)} max={100} ariaLabel={t("commandCenter.system.cpuTrend", "CPU over time")} />
</div>
<div className="card cc-stat-card" data-testid="cc-system-memory-trend">
<div className="cc-stat-label">{t("commandCenter.system.memoryTrend", "Memory over time")}</div>
<Sparkline values={samples.map((sample) => sample.usedSystemMemPercent)} max={100} ariaLabel={t("commandCenter.system.memoryTrend", "Memory over time")} />
</div>
<div className="card cc-stat-card" data-testid="cc-system-heap-trend">
<div className="cc-stat-label">{t("commandCenter.system.heapTrend", "Heap over time")}</div>
<Sparkline values={samples.map((sample) => sample.heapUsedPercent)} max={100} ariaLabel={t("commandCenter.system.heapTrend", "Heap over time")} />
</div>
</div>
</div>
<div className="cc-area-section">
<h3 className="cc-area-section-title">{t("commandCenter.system.workloadTitle", "Workload")}</h3>
<div className="cc-system-chart-grid">
<div className="card cc-stat-card" data-testid="cc-system-tasks-bar">
<div className="cc-stat-label">{t("systemStats.sectionTasks", "Tasks")}</div>
<Bar data={taskBarData} ariaLabel={t("systemStats.sectionTasksAriaLabel", "Task stats")} />
</div>
<div className="card cc-stat-card" data-testid="cc-system-agents-bar">
<div className="cc-stat-label">{t("systemStats.sectionAgents", "Agents")}</div>
<Bar data={agentBarData} ariaLabel={t("systemStats.sectionAgentsAriaLabel", "Agent stats")} />
</div>
</div>
</div>
<div className="cc-area-section">
<h3 className="cc-area-section-title">{t("commandCenter.system.detailsTitle", "Runtime details")}</h3>
<div className="cc-stat-grid" data-testid="cc-system-details-grid">
{detailRows.map((row) => (
<div key={row.label} className="card cc-stat-card">
<div className="cc-stat-label">{row.label}</div>
<div className={`cc-stat-value ${row.className ?? ""}`.trim()}>{row.value}</div>
{row.detail ? <span className="cc-stat-sub">{row.detail}</span> : null}
</div>
))}
</div>
</div>
<div className="cc-area-section" data-testid="cc-system-vitest-controls">
<h3 className="cc-area-section-title cc-system-section-title-with-icon">
<ShieldAlert />
<span>{t("systemStats.sectionVitest", "Vitest Controls")}</span>
</h3>
<div className="cc-stat-grid">
<div className="card cc-stat-card">
<div className="cc-stat-label">{t("systemStats.vitestProcesses", "Vitest Processes")}</div>
<div className="cc-stat-value">{vitestProcessCount ?? "—"}</div>
</div>
<div className="card cc-stat-card">
<div className="cc-stat-label">{t("systemStats.lastAutoKill", "Last auto-kill: {{time}}", { time: "" }).trim()}</div>
<div className="cc-stat-value">{lastAutoKillLabel}</div>
</div>
</div>
<div className="card cc-system-vitest-card">
<button
type="button"
className="btn btn-danger"
data-testid="cc-system-kill-vitest"
onClick={() => void handleKillVitest()}
disabled={isKilling || vitestProcessCount === 0}
>
<Skull />
<span>{confirmKill ? t("systemStats.confirmKill", "Confirm Kill?") : t("systemStats.killVitest", "Kill Vitest Processes")}</span>
</button>
<label className="cc-system-toggle-row">
<input
type="checkbox"
checked={autoKillEnabled}
onChange={(event) => {
void persistAutoKill(event.target.checked);
}}
/>
<span>{t("systemStats.autoKillLabel", "Auto-kill vitest on memory pressure")}</span>
</label>
<div className="cc-system-threshold-row">
<label htmlFor="cc-system-vitest-threshold-number">{t("systemStats.killThresholdLabel", "Kill threshold (%)")}</label>
<div className="cc-system-threshold-controls">
<input
id="cc-system-vitest-threshold-range"
type="range"
min={50}
max={99}
value={killThreshold}
aria-label={t("systemStats.killThresholdSliderAriaLabel", "Kill threshold slider (%)")}
onChange={(event) => {
const nextValue = Number.parseInt(event.target.value, 10);
void persistKillThreshold(Number.isNaN(nextValue) ? 90 : nextValue);
}}
/>
<input
id="cc-system-vitest-threshold-number"
type="number"
className="input"
min={50}
max={99}
value={killThreshold}
aria-label={t("systemStats.killThresholdInputAriaLabel", "Kill threshold (%)")}
onChange={(event) => {
const nextValue = Number.parseInt(event.target.value, 10);
void persistKillThreshold(Number.isNaN(nextValue) ? 90 : nextValue);
}}
onBlur={() => {
void persistKillThreshold(killThreshold);
}}
/>
</div>
</div>
{killResult ? (
<p className={`cc-system-note ${killResult.killed > 0 ? "cc-system-note--success" : "cc-system-note--error"}`}>
{t("systemStats.killedProcesses", "Killed {{count}} processes", { count: killResult.killed })}
</p>
) : null}
{settingsError ? <p className="cc-system-note cc-system-note--error">{settingsError}</p> : null}
</div>
</div>
</AreaShell>
);
}

View File

@@ -205,29 +205,6 @@ describe("useModalManager", () => {
expect(result.current.settingsInitialSection).toBeUndefined();
});
it("tracks system stats modal state and includes it in anyModalOpen", () => {
const { result } = renderHook(() =>
useModalManager({ projectId: "proj_1", planningSessions: [] }),
);
expect(result.current.systemStatsOpen).toBe(false);
expect(result.current.anyModalOpen).toBe(false);
act(() => {
result.current.openSystemStats();
});
expect(result.current.systemStatsOpen).toBe(true);
expect(result.current.anyModalOpen).toBe(true);
act(() => {
result.current.closeSystemStats();
});
expect(result.current.systemStatsOpen).toBe(false);
expect(result.current.anyModalOpen).toBe(false);
});
it("accepts plain Task object for optimistic modal opening", () => {
const task = createTask("FN-456");
const { result } = renderHook(() =>

View File

@@ -46,7 +46,6 @@ export interface ModalManager {
githubImportOpen: boolean;
usageOpen: boolean;
usageAnchorRect: DOMRect | null;
systemStatsOpen: boolean;
terminalOpen: boolean;
terminalInitialCommand: string | undefined;
terminalInitialCommandGeneration: number;
@@ -108,9 +107,6 @@ export interface ModalManager {
openUsage: (anchorRect?: DOMRect | null) => void;
closeUsage: () => void;
openSystemStats: () => void;
closeSystemStats: () => void;
toggleTerminal: () => void;
closeTerminal: () => void;
@@ -180,7 +176,6 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
const [githubImportOpen, setGitHubImportOpen] = useState(false);
const [usageOpen, setUsageOpen] = useState(false);
const [usageAnchorRect, setUsageAnchorRect] = useState<DOMRect | null>(null);
const [systemStatsOpen, setSystemStatsOpen] = useState(false);
const [terminalOpen, setTerminalOpen] = useState(false);
const [terminalInitialCommand, setTerminalInitialCommand] = useState<string | undefined>(undefined);
const [terminalInitialCommandGeneration, setTerminalInitialCommandGeneration] = useState(0);
@@ -215,7 +210,6 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
scriptsOpen ||
agentsOpen ||
usageOpen ||
systemStatsOpen ||
schedulesOpen ||
githubImportOpen ||
setupWizardOpen ||
@@ -333,9 +327,6 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
setUsageAnchorRect(null);
}, []);
const openSystemStats = useCallback(() => setSystemStatsOpen(true), []);
const closeSystemStats = useCallback(() => setSystemStatsOpen(false), []);
const toggleTerminal = useCallback(() => {
setTerminalOpen((prev) => !prev);
}, []);
@@ -445,7 +436,6 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
githubImportOpen,
usageOpen,
usageAnchorRect,
systemStatsOpen,
terminalOpen,
terminalInitialCommand,
terminalInitialCommandGeneration,
@@ -489,8 +479,6 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
closeGitHubImport,
openUsage,
closeUsage,
openSystemStats,
closeSystemStats,
toggleTerminal,
closeTerminal,
openFiles,