diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 5d23eb84ad..4c13b6b8aa 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -1858,6 +1858,7 @@ function AppInner() { onShadcnCustomColorsChange={setShadcnCustomColors} addToast={addToast} nodesEnabled={nodesEnabled} + onChangeView={handleChangeTaskView} /> diff --git a/packages/dashboard/app/components/command-center/CommandCenter.tsx b/packages/dashboard/app/components/command-center/CommandCenter.tsx index 28777f9f74..731c6f13f6 100644 --- a/packages/dashboard/app/components/command-center/CommandCenter.tsx +++ b/packages/dashboard/app/components/command-center/CommandCenter.tsx @@ -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) => 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 ; case "team": - return ; + return ; case "ecosystem": return ; case "github": diff --git a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.tablet-layout.test.tsx b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.tablet-layout.test.tsx index ba0c6fc8ec..adcd26a78f 100644 --- a/packages/dashboard/app/components/command-center/__tests__/CommandCenter.tablet-layout.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenter.tablet-layout.test.tsx @@ -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({}), })); /* diff --git a/packages/dashboard/app/components/command-center/areas/TeamArea.tsx b/packages/dashboard/app/components/command-center/areas/TeamArea.tsx index b9dd690df3..c3ede4beea 100644 --- a/packages/dashboard/app/components/command-center/areas/TeamArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/TeamArea.tsx @@ -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 = @@ -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(1); + const [isSavingMultiplier, setIsSavingMultiplier] = useState(false); const [orgTreeState, setOrgTreeState] = useState>({ status: "loading", data: null, error: null }); const [executorStatsState, setExecutorStatsState] = useState>({ status: "loading", data: null, error: null }); const orgChartViewportRef = useRef(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 ? (

{t("commandCenter.controls.heartbeat.disabledByStop", "Start the AI engine before resuming the heartbeat.")}

) : 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. + */} +
+
+ + { + const val = Number(e.target.value); + void handleHeartbeatMultiplierChange(Number.isFinite(val) && val > 0 ? val : 1); + }} + disabled={isSavingMultiplier} + /> + ×{heartbeatMultiplier.toFixed(1)} + +
+ + {t("agents.heartbeatSpeedHint", "Scales all agent heartbeat intervals. ×0.5 = twice as fast, ×2.0 = twice as slow. Default: ×1.0")} + +
+ + {/* + 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 ? ( +
+ + +
+ ) : null} diff --git a/packages/dashboard/app/components/command-center/areas/__tests__/TeamArea.orgchart-pan.test.tsx b/packages/dashboard/app/components/command-center/areas/__tests__/TeamArea.orgchart-pan.test.tsx index 89e7334862..ed795b145a 100644 --- a/packages/dashboard/app/components/command-center/areas/__tests__/TeamArea.orgchart-pan.test.tsx +++ b/packages/dashboard/app/components/command-center/areas/__tests__/TeamArea.orgchart-pan.test.tsx @@ -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 }); }); diff --git a/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx b/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx index 000c698ab9..e65a28ae10 100644 --- a/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx +++ b/packages/dashboard/app/components/command-center/areas/__tests__/areas.test.tsx @@ -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; diff --git a/packages/dashboard/app/components/command-center/areas/areas.css b/packages/dashboard/app/components/command-center/areas/areas.css index 3f196c6c16..d5009876b5 100644 --- a/packages/dashboard/app/components/command-center/areas/areas.css +++ b/packages/dashboard/app/components/command-center/areas/areas.css @@ -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.