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:
@@ -1858,6 +1858,7 @@ function AppInner() {
|
||||
onShadcnCustomColorsChange={setShadcnCustomColors}
|
||||
addToast={addToast}
|
||||
nodesEnabled={nodesEnabled}
|
||||
onChangeView={handleChangeTaskView}
|
||||
/>
|
||||
</Suspense>
|
||||
</PageErrorBoundary>
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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({}),
|
||||
}));
|
||||
|
||||
/*
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user