FN-6755: move org chart and heartbeat controls to Team

Relocate Command Center team operations out of Overview and into the Team section.

- Render the agent org chart and heartbeat pause/resume controls above Team analytics.
- Keep Overview controls focused on global engine, concurrency, and theme settings.
- Add Team-specific styling and tests for org chart, heartbeat, and responsive placement.
- Update Command Center documentation to describe the new Team operations layout.

Files changed:
 docs/dashboard-guide.md                            |   5 +-
 .../components/command-center/CommandCenter.tsx    |   2 +-
 .../command-center/CommandCenterControls.css       |  61 ------
 .../command-center/CommandCenterControls.tsx       | 188 +------------------
 .../__tests__/CommandCenter.mobile-scroll.test.tsx |   8 +-
 .../__tests__/CommandCenter.tablet-layout.test.tsx |   2 +-
 .../__tests__/CommandCenter.test.tsx               |   4 +
 .../__tests__/CommandCenterControls.test.tsx       |  89 +--------
 .../components/command-center/areas/TeamArea.tsx   | 204 ++++++++++++++++++++-
 .../command-center/areas/__tests__/areas.test.tsx  | 123 ++++++++++++-
 .../app/components/command-center/areas/areas.css  | 181 ++++++++++++++++++
 11 files changed, 526 insertions(+), 341 deletions(-)

Fusion-Task-Id: FN-6755

Fusion-Task-Lineage: 63ceb609-f0e3-4e02-8fea-c97cfc18ccf2
This commit is contained in:
gsxdsm
2026-06-20 00:15:25 -07:00
parent 24246fba2b
commit 9483670d6a
11 changed files with 526 additions and 341 deletions

View File

@@ -674,13 +674,14 @@ 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.
<!-- FNXC:CommandCenter 2026-06-19-23:54: FN-6755 moved team-specific operations out of Overview: org hierarchy and heartbeat pause/resume live in Team, while Overview keeps global AI engine, concurrency, and theme controls. -->
- **Overview controls dashboard** sits at the top of the Overview landing surface on desktop and mobile. It includes 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 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.
- **Activity** tracks sessions, messages, active nodes, active agents, agent heartbeat runs, and stickiness. Agent-run sheets show total, active, completed, and failed runs for the selected range, and the Agent runs/day sparkline trends runs by `agentRuns.startedAt`. The area keeps the existing live animated line charts for messages/day, active agents/day, active nodes/day, and combined throughput/day (`messages + active agents + active nodes`), and adds a recharts multi-series line graph for messages, active agents, and agent runs plus an agent-run outcome pie from the existing `agentRuns` split. These charts reuse the existing activity analytics endpoint, refresh on a bounded 15-second cadence while mounted, keep the previous data visible during refreshes, and disable decorative draw-on motion for reduced-motion users.
- **Productivity** separates outcome counters (commits and pull requests), task-duration stats, and volume proxies such as modified files, lines changed, and files by language. The task-duration block counts done tasks completed in the selected range and shows average, median, p90, and total active execution time from `cumulativeActiveMs`; when no qualifying duration data exists, duration values render the unavailable `—` sentinel rather than `0`. It keeps the files-by-language bar and adds a language-share pie from `ProductivityAnalytics.byLanguage`. There is intentionally no productivity line chart because the current productivity response has no per-day throughput or completion time series; no new endpoint is called.
- **Team** shows a per-agent analytics table plus tokens-by-agent and tasks-done-by-agent charts, and adds a real token-share pie from the same per-agent token totals. Metrics come only from the project-scoped `tasks` and `agents` tables: token totals and estimated cost are summed from the `tokenUsage*` columns by `assignedAgentId`, files changed counts parsed `tasks.modifiedFiles` paths, tasks done counts `column = 'done'` moves in the selected range, and in-progress / in-review values reflect current task columns. Agent name, role, and live state come from the `agents` table; deleted-agent task history falls back to the raw agent id instead of crashing. The tab uses `/api/command-center/team`, adds no schema, never calls GitHub, and intentionally leaves per-agent issues filed/fixed to FN-6653. Team has no per-day analytics series today, so it intentionally does not render a line chart or fabricate a trend. Decorative chart reveal motion uses duration tokens and is disabled for reduced-motion users.
- **Team** shows the read-only agent org chart, heartbeat pause/resume backed by the existing `enginePaused` setting, a per-agent analytics table, tokens-by-agent and tasks-done-by-agent charts, and a real token-share pie from the same per-agent token totals. The org chart is styled by Command Center's Team CSS, not lazy Agents view CSS, and org nodes show only agent names so role/title description/meta text does not clutter Team operations. Metrics come only from the project-scoped `tasks` and `agents` tables: token totals and estimated cost are summed from the `tokenUsage*` columns by `assignedAgentId`, files changed counts parsed `tasks.modifiedFiles` paths, tasks done counts `column = 'done'` moves in the selected range, and in-progress / in-review values reflect current task columns. Agent name, role, and live state come from the `agents` table; deleted-agent task history falls back to the raw agent id instead of crashing. The tab uses `/api/command-center/team`, adds no schema, never calls GitHub, and intentionally leaves per-agent issues filed/fixed to FN-6653. Team has no per-day analytics series today, so it intentionally does not render a line chart or fabricate a trend. Decorative chart reveal motion uses duration tokens and is disabled for reduced-motion users.
- **Ecosystem** shows active model breadth, per-model task activity, and real plugin activations for the selected range. Plugin activation counts come from project-scoped plugin/extension load events via `/api/command-center/plugin-activations`; if no activation rows exist in range, the metric renders unavailable (`—`) rather than fabricating zero. The tab still reuses the tokens analytics endpoint grouped by model, adds a task-share-by-model pie from `TokenAnalytics.groups`, and renders a tokens/tasks trend line when `TokenAnalytics.series` buckets are present; if series buckets are absent, no synthetic trend is shown.
- **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. To make historical fixed dates exact, use **Backfill exact close times** in the Fixed by Fusion card; the dashboard calls the project-scoped manual `POST /api/git/github/backfill-source-issue-closed-at` endpoint in `{ offset, limit }` batches until `hasMore` is false, then surfaces the accumulated `scanned`, `filled`, `skipped`, and `errors` counts. The endpoint fetches real GitHub `closed_at` values once, fills only missing `sourceIssueClosedAt` values, and never runs automatically or from analytics-time rendering. The area shows filed/fixed/net stat cards, a filed-vs-fixed pie, a filed/fixed recharts trend line, existing daily sparklines, and a by-repository bar breakdown.
- **Signals** is backed by the project-scoped `/api/command-center/signals` endpoint, which aggregates real rows from the local `incidents` table. It shows total/open/resolved counts, MTTR when resolved incidents have enough timestamps, and source/severity/status breakdowns; an empty incidents table renders honest zero counts with MTTR unavailable rather than fabricated signal volume. It adds an open-vs-resolved status pie from the same response. Signals has no per-day series today, so it intentionally does not render a line chart or fabricate a trend. External connectors that ingest third-party signals into incidents are tracked separately in FN-6706.

View File

@@ -516,7 +516,7 @@ export function CommandCenter({
case "productivity":
return <ProductivityArea range={range} />;
case "team":
return <TeamArea range={range} />;
return <TeamArea range={range} projectId={projectId} />;
case "ecosystem":
return <EcosystemArea range={range} />;
case "github":

View File

@@ -17,7 +17,6 @@
background: var(--surface-1);
}
.cc-controls-card--org,
.cc-controls-card--concurrency {
grid-column: 1 / -1;
}
@@ -58,29 +57,6 @@
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);
}
@@ -131,39 +107,6 @@
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);
@@ -177,7 +120,6 @@
flex-direction: column;
}
.cc-controls-facts,
.cc-controls-sliders {
grid-template-columns: minmax(0, 1fr);
}
@@ -187,7 +129,4 @@
inline-size: 100%;
}
.cc-controls-org-scroll {
max-block-size: calc(var(--space-2xl) * 8);
}
}

View File

@@ -1,10 +1,9 @@
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 { Power } from "lucide-react";
import type { ColorTheme, ThemeMode } from "@fusion/core";
import { fetchConfig, fetchSettings, updateSettings } from "../../api/legacy";
import { useAppSettings } from "../../hooks/useAppSettings";
import { AgentAvatar } from "../AgentAvatar";
import { ThemeDropdown } from "../ThemeDropdown";
import "./CommandCenterControls.css";
@@ -21,20 +20,12 @@ type AsyncState<T> =
| { 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,
@@ -42,51 +33,14 @@ const DEFAULT_CONCURRENCY_VALUES: ConcurrencyValues = {
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.
FNXC:CommandCenter 2026-06-19-13:45:
Overview controls keep only global AI engine, Theme, and Concurrency controls. Agent org chart and Heartbeat control belong to the Team tab so team-specific hierarchy and scheduler heartbeat affordances are not duplicated across Command Center sections.
*/
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">
@@ -100,72 +54,13 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, onColo
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);
@@ -229,12 +124,7 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, onColo
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 effectiveGlobalPaused = globalPaused;
const concurrencyValues = concurrencyState.data ?? DEFAULT_CONCURRENCY_VALUES;
/*
@@ -242,75 +132,11 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, onColo
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.
Engine controls stop/start all AI work via globalPause. Heartbeat pause/resume moved to TeamArea but still reuses useAppSettings there 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>

View File

@@ -117,7 +117,7 @@ function populatedProductivityFixture() {
commits: 2,
pullRequests: 1,
loc: { value: 42, unavailable: false },
hoursSaved: { value: 2.8, unavailable: false },
hoursSaved: { value: 1, unavailable: false },
taskDuration: {
completedTasks: 2,
averageMs: 1_800_000,
@@ -338,7 +338,7 @@ function assertScrollOwnerContract(panel: HTMLElement) {
function assertNoChartScrollSteal(panel: HTMLElement) {
// FN-6680: jsdom does not compute real flex/grid layout, so this guards rule presence and scroll-owner structure only; the CSS-string regression plus Blink audit cover actual mobile pixel layout.
const chartContainers = panel.querySelectorAll<HTMLElement>(
".cc-bar-chart, .cc-bar-row, .cc-sparkline, .cc-line-chart, .cc-recharts-chart, .cc-recharts-empty, .cc-radial-gauge, .cc-funnel, .cc-token-series, .cc-token-series-plot, .cc-overview-chart-card, .cc-team-chart-panel, .cc-stat-card",
".cc-bar-chart, .cc-bar-row, .cc-sparkline, .cc-line-chart, .cc-recharts-chart, .cc-recharts-empty, .cc-radial-gauge, .cc-funnel, .cc-token-series, .cc-token-series-plot, .cc-overview-chart-card, .cc-team-ops-card, .cc-team-chart-panel, .cc-stat-card",
);
expect(chartContainers.length).toBeGreaterThan(0);
for (const container of chartContainers) {
@@ -373,6 +373,8 @@ describe("CommandCenter mobile scroll regression (FN-6595)", () => {
await screen.findByTestId("command-center-empty");
expect(screen.getByTestId("command-center-controls")).toBeTruthy();
expect(screen.getByTestId("cc-controls-concurrency")).toBeTruthy();
expect(screen.queryByTestId("cc-controls-org-chart")).toBeNull();
expect(screen.queryByTestId("cc-controls-heartbeat")).toBeNull();
assertScrollOwnerContract(overviewPanel);
fireEvent.click(screen.getByTestId("command-center-tab-tokens"));
@@ -383,6 +385,8 @@ describe("CommandCenter mobile scroll regression (FN-6595)", () => {
fireEvent.click(screen.getByTestId("command-center-tab-team"));
const teamPanel = screen.getByTestId("command-center-panel-team");
expect(teamPanel).toBe(screen.getByRole("tabpanel"));
expect(screen.getByTestId("cc-team-org-chart")).toBeTruthy();
expect(screen.getByTestId("cc-team-heartbeat")).toBeTruthy();
assertScrollOwnerContract(teamPanel);
fireEvent.click(screen.getByTestId("command-center-tab-github"));

View File

@@ -109,7 +109,7 @@ function populatedProductivityFixture() {
commits: 2,
pullRequests: 1,
loc: { value: 42, unavailable: false },
hoursSaved: { value: 2.8, unavailable: false },
hoursSaved: { value: 1, unavailable: false },
taskDuration: {
completedTasks: 2,
averageMs: 1_800_000,

View File

@@ -357,6 +357,8 @@ describe("CommandCenter shell", () => {
expect(overviewTab.getAttribute("aria-selected")).toBe("true");
expect(screen.getByTestId("command-center-panel-overview")).toBeTruthy();
expect(screen.getByTestId("command-center-controls")).toBeTruthy();
expect(screen.queryByTestId("cc-controls-org-chart")).toBeNull();
expect(screen.queryByTestId("cc-controls-heartbeat")).toBeNull();
});
it("renders throughput last while the Overview branch is loading", () => {
@@ -813,6 +815,8 @@ describe("CommandCenter shell", () => {
await screen.findByTestId("cc-area-team");
expect(screen.getByTestId("command-center-tab-team").getAttribute("aria-selected")).toBe("true");
expect(screen.getByTestId("cc-team-org-chart")).toBeTruthy();
expect(screen.getByTestId("cc-team-heartbeat")).toBeTruthy();
const alphaRow = screen.getByTestId("cc-team-row-agent-alpha");
expect(alphaRow).toBeTruthy();
expect(within(alphaRow).getByText("Alpha Agent")).toBeTruthy();

View File

@@ -1,11 +1,8 @@
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(),
@@ -19,8 +16,6 @@ const mocks = vi.hoisted(() => ({
}));
vi.mock("../../../api/legacy", () => ({
fetchOrgTree: mocks.fetchOrgTree,
fetchExecutorStats: mocks.fetchExecutorStats,
fetchSettings: mocks.fetchSettings,
fetchConfig: mocks.fetchConfig,
updateSettings: mocks.updateSettings,
@@ -48,21 +43,6 @@ function renderControls(projectId?: string) {
);
}
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();
@@ -74,13 +54,6 @@ beforeEach(() => {
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({});
@@ -92,77 +65,25 @@ afterEach(() => {
});
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 () => {
it("renders only overview controls after team affordances move", async () => {
renderControls(undefined);
await flushPromises();
expect(screen.getByTestId("command-center-controls")).toBeDefined();
expect(screen.getByTestId("cc-controls-heartbeat")).toBeDefined();
expect(screen.queryByTestId("cc-controls-org-chart")).toBeNull();
expect(screen.queryByTestId("cc-controls-heartbeat")).toBeNull();
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 () => {
it("engine controls call the existing settings toggle", 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();
expect(mocks.toggleEnginePause).not.toHaveBeenCalled();
});
it("persists bounded concurrency slider changes and refreshes settings", async () => {

View File

@@ -4,7 +4,11 @@ Team tab shows each agent's tokens/cost/files-changed/tasks-completed with live
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type { CostResult, TeamAgentSummary, TeamAnalytics } from "@fusion/core";
import { Pause, Play } from "lucide-react";
import type { CostResult, OrgTreeNode, TeamAgentSummary, TeamAnalytics } from "@fusion/core";
import { fetchExecutorStats, fetchOrgTree } from "../../../api/legacy";
import { useAppSettings } from "../../../hooks/useAppSettings";
import { AgentAvatar } from "../../AgentAvatar";
import type { DateRange } from "../DateRangePicker";
import { Bar, type BarDatum } from "../charts/Bar";
import { Sparkline } from "../charts/Sparkline";
@@ -14,8 +18,21 @@ import { useAnalyticsArea } from "./useAnalyticsArea";
import { formatCost, formatCount } from "./areaShared";
const TEAM_LIVE_REFRESH_MS = 15_000;
const EXECUTOR_STATUS_POLL_MS = 10_000;
type SortKey = "agent" | "tokens" | "cost" | "filesChanged" | "tasksCompleted" | "tasksInProgress";
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;
};
function costSortValue(cost: CostResult): number {
return cost.unavailable || cost.usd === null ? -1 : cost.usd;
}
@@ -85,12 +102,115 @@ function buildBarData(
});
}
/** Render per-agent team analytics from the project-scoped `/command-center/team` endpoint. */
export function TeamArea({ range }: { range: DateRange }) {
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 TeamStatusPill({ paused, label }: { paused: boolean; label: string }) {
return (
<span className="cc-team-status-pill">
<span className={`status-dot ${paused ? "status-dot--pending" : "status-dot--online"}`} aria-hidden="true" />
<span>{label}</span>
</span>
);
}
function TeamOrgChartNode({ node }: { node: OrgTreeNode }) {
const hasChildren = node.children.length > 0;
return (
<li className="cc-team-org-item">
<div className="cc-team-org-card">
<div className="cc-team-org-card-header">
<span className="cc-team-org-avatar"><AgentAvatar agent={node.agent} size={20} /></span>
<span className="cc-team-org-name">{node.agent.name}</span>
<span className="cc-team-org-badge">{node.agent.state}</span>
</div>
</div>
{hasChildren ? (
<ul className="cc-team-org-children">
{node.children.map((child) => (
<TeamOrgChartNode key={child.agent.id} node={child} />
))}
</ul>
) : null}
</li>
);
}
/**
* FNXC:CommandCenter 2026-06-19-13:45:
* Org chart and heartbeat control are Team-tab responsibilities, not Overview controls. Keep them outside AreaShell so project-level team operations remain visible while analytics load, error, or return empty, remove org-node role/title descriptions, and style org cards locally so Command Center never depends on lazy AgentsView.css.
*/
export function TeamArea({ range, projectId }: { range: DateRange; projectId?: string }) {
const { t } = useTranslation("app");
const {
globalPaused,
enginePaused,
toggleEnginePause,
} = 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 { data, isLoading, error } = useAnalyticsArea<TeamAnalytics>("/command-center/team", range, {
pollMs: TEAM_LIVE_REFRESH_MS,
});
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 (orgError) {
if (!cancelled) {
setOrgTreeState({
status: "error",
data: null,
error: orgError instanceof Error ? orgError.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 (statsError) {
if (!cancelled) {
setExecutorStatsState({
status: "error",
data: null,
error: statsError instanceof Error ? statsError.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]);
const agents = useMemo(() => data?.agents ?? [], [data?.agents]);
const unknownAgent = t("commandCenter.team.unknownAgent", "(unknown agent)");
const unknownRole = t("commandCenter.team.unknownRole", "Unknown role");
@@ -154,8 +274,81 @@ export function TeamArea({ range }: { range: DateRange }) {
return <span className="cc-sort-caret">{sortDir === 1 ? "▲" : "▼"}</span>;
}
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"),
);
return (
<AreaShell
<div className="cc-team-area">
<div className="cc-team-ops-grid" data-testid="cc-team-ops">
<section className="card cc-team-ops-card cc-team-ops-card--org" data-testid="cc-team-org-chart">
<div className="cc-team-ops-card-header">
<div>
<h3>{t("commandCenter.controls.orgChart.title", "Agent org chart")}</h3>
</div>
</div>
<div className="cc-team-org-scroll" aria-live="polite">
{orgTreeState.status === "loading" ? (
<p className="cc-team-muted">{t("commandCenter.controls.orgChart.loading", "Loading org chart…")}</p>
) : orgTreeState.status === "error" ? (
<p className="cc-team-error" role="alert">{orgTreeState.error}</p>
) : orgTreeState.data.length === 0 ? (
<p className="cc-team-muted">{t("commandCenter.controls.orgChart.empty", "No agents are reporting in yet.")}</p>
) : (
<ul className="cc-team-org-roots">
{orgTreeState.data.map((node) => (
<TeamOrgChartNode key={node.agent.id} node={node} />
))}
</ul>
)}
</div>
</section>
<section className="card cc-team-ops-card" data-testid="cc-team-heartbeat">
<div className="cc-team-ops-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>
<TeamStatusPill
paused={effectiveEnginePaused}
label={effectiveEnginePaused ? t("commandCenter.controls.status.paused", "Paused") : t("commandCenter.controls.status.running", "Running")}
/>
</div>
<dl className="cc-team-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-team-error" role="alert">{executorStatsState.error}</p> : null}
<button
type="button"
className="btn btn-secondary cc-team-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-team-muted">{t("commandCenter.controls.heartbeat.disabledByStop", "Start the AI engine before resuming the heartbeat.")}</p>
) : null}
</section>
</div>
<AreaShell
testId="team"
isLoading={isLoading}
error={error}
@@ -275,6 +468,7 @@ export function TeamArea({ range }: { range: DateRange }) {
</table>
</div>
</div>
</AreaShell>
</AreaShell>
</div>
);
}

View File

@@ -4,14 +4,37 @@ Command Center area component tests (PR #1683). Pin loading/error/unavailable-vs
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, within, act, renderHook } from "@testing-library/react";
import type { OrgTreeNode } from "@fusion/core";
// Mock the api() helper so the areas fetch deterministic fixtures.
const apiMock = vi.fn();
const backfillGithubSourceIssueClosedAtMock = vi.fn();
const mocks = vi.hoisted(() => ({
api: vi.fn(),
backfillGithubSourceIssueClosedAt: vi.fn(),
fetchOrgTree: vi.fn(),
fetchExecutorStats: vi.fn(),
toggleEnginePause: vi.fn(),
appSettings: { globalPaused: false, enginePaused: false },
}));
const apiMock = mocks.api;
const backfillGithubSourceIssueClosedAtMock = mocks.backfillGithubSourceIssueClosedAt;
const fetchOrgTreeMock = mocks.fetchOrgTree;
const fetchExecutorStatsMock = mocks.fetchExecutorStats;
const toggleEnginePauseMock = mocks.toggleEnginePause;
const appSettingsMock = mocks.appSettings;
vi.mock("../../../../api/legacy", () => ({
api: (path: string, opts?: RequestInit) => apiMock(path, opts),
api: (path: string, opts?: RequestInit) => mocks.api(path, opts),
apiBackfillGithubSourceIssueClosedAt: (options?: { offset?: number; limit?: number }, projectId?: string) =>
backfillGithubSourceIssueClosedAtMock(options, projectId),
mocks.backfillGithubSourceIssueClosedAt(options, projectId),
fetchOrgTree: mocks.fetchOrgTree,
fetchExecutorStats: mocks.fetchExecutorStats,
}));
vi.mock("../../../../hooks/useAppSettings", () => ({
useAppSettings: () => ({
globalPaused: mocks.appSettings.globalPaused,
enginePaused: mocks.appSettings.enginePaused,
toggleEnginePause: mocks.toggleEnginePause,
}),
}));
import { TokensArea } from "../TokensArea";
@@ -107,6 +130,22 @@ function githubFixture() {
};
}
function agentNode(id: string, name: string, children: OrgTreeNode[] = [], title = "Team Lead"): OrgTreeNode {
return {
agent: {
id,
name,
title,
role: "executor",
state: "idle",
createdAt: "2026-06-19T00:00:00.000Z",
updatedAt: "2026-06-19T00:00:00.000Z",
metadata: {},
},
children,
};
}
function emptyTeamFixture() {
return {
from: null,
@@ -202,6 +241,18 @@ function activityFixture() {
beforeEach(() => {
apiMock.mockReset();
backfillGithubSourceIssueClosedAtMock.mockReset();
fetchOrgTreeMock.mockReset();
fetchOrgTreeMock.mockResolvedValue([]);
fetchExecutorStatsMock.mockReset();
fetchExecutorStatsMock.mockResolvedValue({
globalPause: false,
enginePaused: false,
maxConcurrent: 2,
lastActivityAt: "2026-06-19T12:00:00.000Z",
});
toggleEnginePauseMock.mockReset();
appSettingsMock.globalPaused = false;
appSettingsMock.enginePaused = false;
});
afterEach(() => {
@@ -918,6 +969,70 @@ describe("ProductivityArea", () => {
});
describe("TeamArea", () => {
it("renders relocated org chart and heartbeat outside analytics gating", async () => {
apiMock.mockResolvedValueOnce(emptyTeamFixture());
fetchOrgTreeMock.mockResolvedValueOnce([
agentNode("agent-lead", "Lead Agent", [agentNode("agent-child", "Child Agent", [], "Child Title")]),
]);
render(<TeamArea range={range7d} projectId="project-a" />);
const orgSection = await screen.findByTestId("cc-team-org-chart");
const heartbeatSection = screen.getByTestId("cc-team-heartbeat");
expect(screen.getByTestId("cc-area-team-empty")).toBeTruthy();
expect(within(orgSection).getByText("Lead Agent")).toBeTruthy();
expect(within(orgSection).getByText("Child Agent")).toBeTruthy();
expect(within(orgSection).queryByText("executor · Team Lead")).toBeNull();
expect(within(orgSection).queryByText("executor · Child Title")).toBeNull();
expect(orgSection.querySelector(".cc-team-org-card")).toBeTruthy();
expect(orgSection.querySelector(".org-chart-node-card")).toBeNull();
expect(within(heartbeatSection).getByRole("button", { name: /pause heartbeat/i })).toBeEnabled();
expect(fetchOrgTreeMock).toHaveBeenCalledWith("project-a");
expect(fetchExecutorStatsMock).toHaveBeenCalledWith("project-a");
});
it("keeps relocated team controls visible for loading, empty, error, and undefined-project states", async () => {
apiMock.mockImplementationOnce(() => new Promise(() => undefined));
fetchOrgTreeMock.mockResolvedValueOnce([]);
const loading = render(<TeamArea range={range7d} />);
expect(await screen.findByTestId("cc-team-org-chart")).toBeTruthy();
expect(screen.getByTestId("cc-team-heartbeat")).toBeTruthy();
expect(screen.getByText("No agents are reporting in yet.")).toBeTruthy();
expect(fetchOrgTreeMock).toHaveBeenCalledWith(undefined);
expect(fetchExecutorStatsMock).toHaveBeenCalledWith(undefined);
loading.unmount();
apiMock.mockResolvedValueOnce(emptyTeamFixture());
fetchOrgTreeMock.mockRejectedValueOnce(new Error("org failed"));
const empty = render(<TeamArea range={range7d} />);
expect(await screen.findByTestId("cc-area-team-empty")).toBeTruthy();
expect(within(screen.getByTestId("cc-team-org-chart")).getByRole("alert")).toHaveTextContent("org failed");
empty.unmount();
apiMock.mockRejectedValueOnce(new Error("team failed"));
fetchOrgTreeMock.mockResolvedValueOnce([agentNode("agent-one", "One Agent")]);
render(<TeamArea range={range7d} />);
expect(await screen.findByTestId("cc-area-team-error")).toBeTruthy();
expect(screen.getByTestId("cc-team-org-chart")).toBeTruthy();
expect(screen.getByTestId("cc-team-heartbeat")).toBeTruthy();
});
it("toggles heartbeat and disables it when the AI engine is stopped", async () => {
apiMock.mockResolvedValueOnce(emptyTeamFixture());
fetchExecutorStatsMock.mockResolvedValueOnce({ globalPause: false, enginePaused: false, maxConcurrent: 3 });
const running = render(<TeamArea range={range7d} projectId="project-a" />);
fireEvent.click(await screen.findByRole("button", { name: /pause heartbeat/i }));
expect(toggleEnginePauseMock).toHaveBeenCalledTimes(1);
running.unmount();
apiMock.mockResolvedValueOnce(emptyTeamFixture());
fetchExecutorStatsMock.mockResolvedValueOnce({ globalPause: true, enginePaused: true, maxConcurrent: 3 });
render(<TeamArea range={range7d} projectId="project-a" />);
const resume = await screen.findByRole("button", { name: /resume heartbeat/i });
expect(resume).toBeDisabled();
expect(screen.getByText("Start the AI engine before resuming the heartbeat.")).toBeTruthy();
});
it("renders the per-agent pie for populated team analytics", async () => {
apiMock.mockResolvedValue({
...populatedTeamFixture(),

View File

@@ -218,7 +218,168 @@ FN-6680 keeps table-heavy chart areas on the same --border-width/--border-subtle
/*
FNXC:CommandCenterStyling 2026-06-18-22:38:
The Team view must use dashboard design tokens only, preserve .cc-tabpanel as the scroll owner on mobile, and match the shared card rhythm (--space-md gap/padding, 1px solid --border-subtle, --radius-md, --surface-1) while keeping decorative motion duration-token based with reduced-motion disabled.
FNXC:CommandCenter 2026-06-19-13:45:
Team owns the Agent org chart and Heartbeat control. Org nodes must be self-styled Command Center cards so they render correctly before lazy AgentsView.css is loaded, and the moved controls must stay usable above Team analytics loading/error/empty states.
*/
.cc-team-area {
display: flex;
flex-direction: column;
gap: var(--space-lg);
min-inline-size: 0;
}
.cc-team-ops-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: var(--space-md);
min-inline-size: 0;
}
.cc-team-ops-card {
min-inline-size: 0;
padding: var(--space-md);
border: thin solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface-1);
}
.cc-team-ops-card--org {
grid-column: 1 / -1;
}
.cc-team-ops-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-md);
margin-block-end: var(--space-md);
}
.cc-team-ops-card-header h3 {
margin: 0;
color: var(--text);
font-size: 0.9375rem;
}
.cc-team-ops-card-header p,
.cc-team-muted {
margin: var(--space-xs) 0 0;
color: var(--text-muted);
font-size: 0.8125rem;
}
.cc-team-error {
margin: 0;
color: var(--color-error);
font-size: 0.8125rem;
}
.cc-team-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-team-facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-sm);
margin: 0 0 var(--space-md);
}
.cc-team-facts div {
min-inline-size: 0;
}
.cc-team-facts dt {
color: var(--text-muted);
font-size: 0.75rem;
}
.cc-team-facts dd {
margin: var(--space-xs) 0 0;
color: var(--text);
font-size: 0.875rem;
overflow-wrap: anywhere;
}
.cc-team-action {
gap: var(--space-xs);
}
.cc-team-org-scroll {
max-block-size: calc(var(--space-2xl) * 10);
overflow: auto;
overscroll-behavior: contain;
}
.cc-team-org-roots,
.cc-team-org-children {
display: flex;
flex-direction: column;
gap: var(--space-sm);
margin: 0;
padding: 0;
list-style: none;
}
.cc-team-org-children {
margin-block-start: var(--space-sm);
margin-inline-start: var(--space-xl);
padding-inline-start: var(--space-md);
border-inline-start: thin solid var(--border-subtle);
}
.cc-team-org-item {
min-inline-size: max-content;
}
.cc-team-org-card {
display: inline-flex;
min-inline-size: calc(var(--space-2xl) * 5);
max-inline-size: calc(var(--space-2xl) * 10);
padding: var(--space-sm);
border: thin solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface-2);
box-shadow: var(--shadow-sm);
}
.cc-team-org-card-header {
display: flex;
align-items: center;
gap: var(--space-sm);
min-inline-size: 0;
}
.cc-team-org-avatar {
display: inline-flex;
flex: 0 0 auto;
}
.cc-team-org-name {
color: var(--text);
font-size: 0.8125rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cc-team-org-badge {
flex: 0 0 auto;
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--accent) 12%, var(--surface-1));
color: var(--text-muted);
font-size: 0.75rem;
text-transform: capitalize;
}
.cc-team-chart-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -310,16 +471,36 @@ Tablet Command Center areas share the FN-6679 overflow fix with the shell: area
}
@media (max-width: 768px) {
.cc-team-ops-grid,
.cc-team-chart-grid {
min-inline-size: 0;
grid-template-columns: minmax(0, 1fr);
}
.cc-team-ops-card--org,
.cc-team-spark-panel {
grid-column: auto;
}
.cc-team-ops-card,
.cc-team-chart-panel {
padding: var(--space-sm);
}
.cc-team-ops-card-header {
flex-direction: column;
}
.cc-team-facts {
grid-template-columns: minmax(0, 1fr);
}
.cc-team-action {
justify-content: center;
inline-size: 100%;
}
.cc-team-org-scroll {
max-block-size: calc(var(--space-2xl) * 8);
}
}