feat(dashboard): Command Center Team card - View Board/Agents buttons + heartbeat multiplier slider

- TeamArea heartbeat card gains a heartbeat-multiplier slider (mirrors AgentsView: same range/step/presets, persisted via updateSettings) and a View Board / View Agents nav row (onChangeView threaded from CommandCenter <- App handleChangeTaskView).
- Update CC/TeamArea test mocks to export fetchSettings/updateSettings (220 pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-22 02:37:09 -07:00
parent f531618d0f
commit efd659be0e
7 changed files with 180 additions and 3 deletions

View File

@@ -1858,6 +1858,7 @@ function AppInner() {
onShadcnCustomColorsChange={setShadcnCustomColors}
addToast={addToast}
nodesEnabled={nodesEnabled}
onChangeView={handleChangeTaskView}
/>
</Suspense>
</PageErrorBoundary>

View File

@@ -19,6 +19,7 @@ import { CommandCenterControls } from "./CommandCenterControls";
import { ReliabilityView } from "../ReliabilityView";
import { NodesView } from "../NodesView";
import type { ToastType } from "../../hooks/useToast";
import type { TaskView } from "../../hooks/useViewState";
import { SdlcFunnel } from "./SdlcFunnel";
import { Bar, type BarDatum } from "./charts/Bar";
import { Sparkline } from "./charts/Sparkline";
@@ -106,6 +107,11 @@ interface CommandCenterProps {
onShadcnCustomColorsChange?: (colors: Record<string, string>) => void;
addToast?: (message: string, type?: ToastType) => void;
nodesEnabled?: boolean;
/*
FNXC:CommandCenter 2026-06-22-00:00:
The AI engine card (Team-tab Heartbeat control) offers "View Board"/"View Agents" shortcuts. Navigation is owned by App's view router, so thread an optional onChangeView down to TeamArea rather than letting the Command Center mutate routing state itself.
*/
onChangeView?: (view: TaskView) => void;
}
function OverviewTab({
@@ -456,6 +462,7 @@ export function CommandCenter({
onShadcnCustomColorsChange = () => {},
addToast = () => {},
nodesEnabled = false,
onChangeView,
}: CommandCenterProps = {}) {
const { t } = useTranslation("app");
const subViews = useSubViews(nodesEnabled);
@@ -532,7 +539,7 @@ export function CommandCenter({
case "productivity":
return <ProductivityArea range={range} />;
case "team":
return <TeamArea range={range} projectId={projectId} />;
return <TeamArea range={range} projectId={projectId} addToast={addToast} onChangeView={onChangeView} />;
case "ecosystem":
return <EcosystemArea range={range} />;
case "github":

View File

@@ -9,6 +9,13 @@ import { CommandCenter } from "../CommandCenter";
const apiMock = vi.fn();
vi.mock("../../../api/legacy", () => ({
api: (path: string, opts?: RequestInit) => apiMock(path, opts),
// TeamArea (rendered on the team tab) imports these directly; provide resolving
// mocks so its mount effects (heartbeat-multiplier load/save, org tree, executor
// stats) don't call undefined and throw synchronously.
fetchOrgTree: vi.fn().mockResolvedValue([]),
fetchExecutorStats: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false, maxConcurrent: 2 }),
fetchSettings: vi.fn().mockResolvedValue({ heartbeatMultiplier: 1 }),
updateSettings: vi.fn().mockResolvedValue({}),
}));
/*

View File

@@ -7,8 +7,11 @@ import type { PointerEvent as ReactPointerEvent, MouseEvent as ReactMouseEvent }
import { useTranslation } from "react-i18next";
import { Pause, Play } from "lucide-react";
import type { CostResult, OrgTreeNode, TeamAgentSummary, TeamAnalytics } from "@fusion/core";
import { fetchExecutorStats, fetchOrgTree } from "../../../api/legacy";
import { getErrorMessage } from "@fusion/core";
import { fetchExecutorStats, fetchOrgTree, fetchSettings, updateSettings } from "../../../api/legacy";
import { useAppSettings } from "../../../hooks/useAppSettings";
import type { ToastType } from "../../../hooks/useToast";
import type { TaskView } from "../../../hooks/useViewState";
import { AgentAvatar } from "../../AgentAvatar";
import { LoadingSpinner } from "../../LoadingSpinner";
import type { DateRange } from "../DateRangePicker";
@@ -23,6 +26,11 @@ import { formatCost, formatCount } from "./areaShared";
const TEAM_LIVE_REFRESH_MS = 15_000;
const EXECUTOR_STATUS_POLL_MS = 10_000;
const ORG_CHART_DRAG_THRESHOLD = 4;
/*
FNXC:CommandCenter 2026-06-22-00:00:
Heartbeat-multiplier presets mirror the Agents page (AgentsView) exactly so the Command Center slider scales agent heartbeats identically. Same range/step (0.1–10, step 0.1), same persisted settings.heartbeatMultiplier endpoint via updateSettings — no new state or API.
*/
const HEARTBEAT_MULTIPLIER_PRESETS = [0.1, 0.25, 0.5, 1, 2, 3, 5, 10] as const;
type SortKey = "agent" | "tokens" | "cost" | "filesChanged" | "tasksCompleted" | "tasksInProgress";
type AsyncState<T> =
@@ -157,13 +165,29 @@ function TeamOrgChartNode({ node }: { node: OrgTreeNode }) {
* 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 }) {
export function TeamArea({
range,
projectId,
addToast,
onChangeView,
}: {
range: DateRange;
projectId?: string;
addToast?: (message: string, type?: ToastType) => void;
onChangeView?: (view: TaskView) => void;
}) {
const { t } = useTranslation("app");
const {
globalPaused,
enginePaused,
toggleEnginePause,
} = useAppSettings(projectId);
/*
FNXC:CommandCenter 2026-06-22-00:00:
Heartbeat-speed multiplier replicated from the Agents page so users can scale all agent heartbeat intervals from the dashboard. Wired to the same settings.heartbeatMultiplier persisted via updateSettings; loaded on mount via fetchSettings, defaulting to ×1.0.
*/
const [heartbeatMultiplier, setHeartbeatMultiplier] = useState<number>(1);
const [isSavingMultiplier, setIsSavingMultiplier] = useState(false);
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 orgChartViewportRef = useRef<HTMLDivElement | null>(null);
@@ -248,6 +272,38 @@ export function TeamArea({ range, projectId }: { range: DateRange; projectId?: s
if (timeoutId) clearTimeout(timeoutId);
};
}, [projectId, t]);
// Load heartbeat multiplier from project settings on mount (same source as the Agents page).
useEffect(() => {
let cancelled = false;
void fetchSettings(projectId)
.then((settings) => {
if (!cancelled) setHeartbeatMultiplier(settings.heartbeatMultiplier ?? 1);
})
.catch(() => {
// Use default ×1.0 on error.
});
return () => {
cancelled = true;
};
}, [projectId]);
const handleHeartbeatMultiplierChange = useCallback(
async (multiplier: number) => {
const clampedValue = Number.isFinite(multiplier) && multiplier > 0 ? multiplier : 1;
setHeartbeatMultiplier(clampedValue);
setIsSavingMultiplier(true);
try {
await updateSettings({ heartbeatMultiplier: clampedValue }, projectId);
addToast?.(t("agents.heartbeatSpeedSet", "Heartbeat speed set to ×{{value}}", { value: clampedValue.toFixed(1) }), "success");
} catch (err) {
addToast?.(t("agents.heartbeatSpeedSaveFailed", "Failed to save heartbeat multiplier: {{error}}", { error: getErrorMessage(err) }), "error");
} finally {
setIsSavingMultiplier(false);
}
},
[projectId, addToast, t],
);
const agents = useMemo(() => data?.agents ?? [], [data?.agents]);
const unknownAgent = t("commandCenter.team.unknownAgent", "(unknown agent)");
const unknownRole = t("commandCenter.team.unknownRole", "Unknown role");
@@ -460,6 +516,79 @@ export function TeamArea({ range, projectId }: { range: DateRange; projectId?: s
{effectiveGlobalPaused ? (
<p className="cc-team-muted">{t("commandCenter.controls.heartbeat.disabledByStop", "Start the AI engine before resuming the heartbeat.")}</p>
) : null}
{/*
FNXC:CommandCenter 2026-06-22-00:00:
Heartbeat-speed multiplier slider replicated from the Agents page (range 0.1–10, step 0.1, ×0.1–×10 presets) so users can scale all agent heartbeat intervals from the dashboard's AI engine card. Wired to the same settings.heartbeatMultiplier endpoint.
*/}
<div className="cc-team-heartbeat-multiplier heartbeat-multiplier-group">
<div className="heartbeat-multiplier-controls">
<label htmlFor="ccHeartbeatMultiplier" className="heartbeat-multiplier-label">
{t("agents.heartbeatSpeed", "Heartbeat Speed")}
</label>
<input
id="ccHeartbeatMultiplier"
className="heartbeat-multiplier-slider touch-target"
type="range"
min={0.1}
max={10}
step={0.1}
value={heartbeatMultiplier}
onChange={(e) => {
const val = Number(e.target.value);
void handleHeartbeatMultiplierChange(Number.isFinite(val) && val > 0 ? val : 1);
}}
disabled={isSavingMultiplier}
/>
<span className="heartbeat-multiplier-value">×{heartbeatMultiplier.toFixed(1)}</span>
<select
className="heartbeat-multiplier-preset"
value={String(
HEARTBEAT_MULTIPLIER_PRESETS.reduce((closest, candidate) => {
return Math.abs(candidate - heartbeatMultiplier) < Math.abs(closest - heartbeatMultiplier) ? candidate : closest;
}, HEARTBEAT_MULTIPLIER_PRESETS[0]),
)}
onChange={(e) => {
const val = Number(e.target.value);
void handleHeartbeatMultiplierChange(Number.isFinite(val) && val > 0 ? val : 1);
}}
disabled={isSavingMultiplier}
aria-label={t("agents.heartbeatSpeedPreset", "Heartbeat speed preset")}
>
{HEARTBEAT_MULTIPLIER_PRESETS.map((multiplier) => (
<option key={multiplier} value={String(multiplier)}>
×{multiplier}
</option>
))}
</select>
</div>
<small className="text-secondary">
{t("agents.heartbeatSpeedHint", "Scales all agent heartbeat intervals. ×0.5 = twice as fast, ×2.0 = twice as slow. Default: ×1.0")}
</small>
</div>
{/*
FNXC:CommandCenter 2026-06-22-00:00:
AI engine card shortcuts: jump straight to the board or agents views. Navigation is owned by App (onChangeView), so these only fire when wired up.
*/}
{onChangeView ? (
<div className="cc-team-engine-nav">
<button
type="button"
className="btn btn-sm cc-team-engine-nav-btn"
onClick={() => onChangeView("board")}
>
{t("commandCenter.controls.engine.viewBoard", "View Board")}
</button>
<button
type="button"
className="btn btn-sm cc-team-engine-nav-btn"
onClick={() => onChangeView("agents")}
>
{t("commandCenter.controls.engine.viewAgents", "View Agents")}
</button>
</div>
) : null}
</section>
</div>

View File

@@ -8,6 +8,8 @@ import type { DateRange } from "../DateRangePicker";
const mocks = vi.hoisted(() => ({
fetchOrgTree: vi.fn(),
fetchExecutorStats: vi.fn(),
fetchSettings: vi.fn(),
updateSettings: vi.fn(),
toggleEnginePause: vi.fn(),
useAnalyticsArea: vi.fn(),
resolveOrgChartLayoutMode: vi.fn(),
@@ -16,6 +18,8 @@ const mocks = vi.hoisted(() => ({
vi.mock("../../../../api/legacy", () => ({
fetchOrgTree: mocks.fetchOrgTree,
fetchExecutorStats: mocks.fetchExecutorStats,
fetchSettings: mocks.fetchSettings,
updateSettings: mocks.updateSettings,
}));
vi.mock("../../../../hooks/useAppSettings", () => ({
@@ -106,6 +110,8 @@ describe("TeamArea org chart drag panning", () => {
});
mocks.fetchOrgTree.mockResolvedValue(orgTree);
mocks.fetchExecutorStats.mockResolvedValue({ globalPause: false, enginePaused: false, maxConcurrent: 2 });
mocks.fetchSettings.mockResolvedValue({ heartbeatMultiplier: 1 });
mocks.updateSettings.mockResolvedValue({});
mocks.useAnalyticsArea.mockReturnValue({ data: teamAnalyticsFixture(), isLoading: false, error: null });
});

View File

@@ -12,6 +12,8 @@ const mocks = vi.hoisted(() => ({
backfillGithubSourceIssueClosedAt: vi.fn(),
fetchOrgTree: vi.fn(),
fetchExecutorStats: vi.fn(),
fetchSettings: vi.fn(),
updateSettings: vi.fn(),
toggleEnginePause: vi.fn(),
appSettings: { globalPaused: false, enginePaused: false },
}));
@@ -27,6 +29,8 @@ vi.mock("../../../../api/legacy", () => ({
mocks.backfillGithubSourceIssueClosedAt(options, projectId),
fetchOrgTree: mocks.fetchOrgTree,
fetchExecutorStats: mocks.fetchExecutorStats,
fetchSettings: mocks.fetchSettings,
updateSettings: mocks.updateSettings,
}));
vi.mock("../../../../hooks/useAppSettings", () => ({
@@ -255,6 +259,10 @@ beforeEach(() => {
maxConcurrent: 2,
lastActivityAt: "2026-06-19T12:00:00.000Z",
});
mocks.fetchSettings.mockReset();
mocks.fetchSettings.mockResolvedValue({ heartbeatMultiplier: 1 });
mocks.updateSettings.mockReset();
mocks.updateSettings.mockResolvedValue({});
toggleEnginePauseMock.mockReset();
appSettingsMock.globalPaused = false;
appSettingsMock.enginePaused = false;

View File

@@ -311,6 +311,25 @@ Team owns the Agent org chart and Heartbeat control. Org nodes must be self-styl
gap: var(--space-xs);
}
/*
FNXC:CommandCenter 2026-06-22-00:00:
AI engine card (Team Heartbeat control) gains a heartbeat-speed multiplier and View Board / View Agents shortcuts. Space the multiplier from the pause control and lay the nav buttons in a wrapping row using theme tokens only.
*/
.cc-team-heartbeat-multiplier {
margin-block-start: var(--space-sm);
}
.cc-team-engine-nav {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
margin-block-start: var(--space-sm);
}
.cc-team-engine-nav-btn {
flex: 1 1 auto;
}
/*
FNXC:CommandCenter 2026-06-21-00:00:
FN-6885 adds a grab affordance to the Team org-chart scroll owner. Keep native overflow scrolling as the single pan mechanism and switch to non-selecting grabbing only while the mouse/pen drag handler is actively scrolling.