diff --git a/packages/dashboard/app/components/command-center/CommandCenter.tsx b/packages/dashboard/app/components/command-center/CommandCenter.tsx index e5d5003ab1..03a8b7e89e 100644 --- a/packages/dashboard/app/components/command-center/CommandCenter.tsx +++ b/packages/dashboard/app/components/command-center/CommandCenter.tsx @@ -8,6 +8,7 @@ import { ActivityArea } from "./areas/ActivityArea"; import { ProductivityArea } from "./areas/ProductivityArea"; import { EcosystemArea } from "./areas/EcosystemArea"; import { SignalsArea } from "./areas/SignalsArea"; +import { MissionControlPanel } from "./MissionControlPanel"; import "./CommandCenter.css"; type SubViewId = @@ -171,8 +172,8 @@ export function CommandCenter() { return ; case "signals": return ; - // Mission Control (U6b) is wired in its own unit; placeholder until then. case "mission-control": + return ; default: return ; } diff --git a/packages/dashboard/app/components/command-center/MissionControlPanel.css b/packages/dashboard/app/components/command-center/MissionControlPanel.css new file mode 100644 index 0000000000..dc9c721760 --- /dev/null +++ b/packages/dashboard/app/components/command-center/MissionControlPanel.css @@ -0,0 +1,98 @@ +/* + * Mission-Control live panel (U6b). Component-local styles. + * Animation durations use --duration-* tokens only (never --transition-*). + */ + +.cc-mission-control { + display: flex; + flex-direction: column; + gap: var(--space-4, 16px); +} + +.cc-mc-columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-4, 16px); +} + +.cc-mc-section { + display: flex; + flex-direction: column; + gap: var(--space-2, 8px); + min-width: 0; +} + +.cc-mc-muted { + color: var(--text-secondary, #888); + font-size: 0.85rem; + margin: 0; +} + +.cc-mc-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-1, 4px); +} + +.cc-mc-session, +.cc-mc-node { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2, 8px); + padding: var(--space-2, 8px); + border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08)); + border-radius: var(--radius-sm, 6px); + background: var(--surface-1, rgba(255, 255, 255, 0.02)); + min-width: 0; +} + +.cc-mc-node.inactive { + opacity: 0.55; +} + +.cc-mc-session-purpose, +.cc-mc-node-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.cc-mc-session-meta, +.cc-mc-node-meta { + display: flex; + align-items: center; + gap: var(--space-2, 8px); + flex-shrink: 0; +} + +.cc-mc-badge { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.03em; + padding: 2px 6px; + border-radius: var(--radius-sm, 6px); + background: var(--surface-2, rgba(59, 130, 246, 0.15)); + color: var(--text-primary, #ddd); +} + +.cc-mc-badge.inactive { + background: var(--surface-2, rgba(255, 255, 255, 0.06)); + color: var(--text-secondary, #999); +} + +.cc-mc-task, +.cc-mc-node-count { + font-size: 0.75rem; + color: var(--text-secondary, #999); +} + +@media (max-width: 768px), (max-height: 480px) { + .cc-mc-columns { + grid-template-columns: 1fr; + } +} diff --git a/packages/dashboard/app/components/command-center/MissionControlPanel.tsx b/packages/dashboard/app/components/command-center/MissionControlPanel.tsx new file mode 100644 index 0000000000..aee96db444 --- /dev/null +++ b/packages/dashboard/app/components/command-center/MissionControlPanel.tsx @@ -0,0 +1,352 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { AlertCircle, Loader2, Radio } from "lucide-react"; +import type { LiveSnapshot, LiveSession, ColumnCount } from "@fusion/core"; +import { api } from "../../api/legacy"; +import { subscribeSse } from "../../sse-bus"; +import { Funnel, type FunnelStage } from "./charts/Funnel"; +import "./MissionControlPanel.css"; + +/** Poll cadence while work is in-flight (KTD5). */ +export const LIVE_POLL_INTERVAL_MS = 5_000; + +/** + * A node whose most recent active session was last updated longer ago than this + * is rendered as "inactive" rather than dropped from the list, so a node that + * goes quiet stays visible (greyed) until the next authoritative snapshot + * removes it. + */ +export const NODE_STALE_THRESHOLD_MS = 30_000; + +/** SSE events that should trigger an immediate refetch (push half of KTD5). */ +const LIVE_REFETCH_EVENTS = [ + "session:updated", + "session:completed", + "run:created", + "run:updated", + "run:completed", + "run:cancelled", + "run:failed", + "agent:stateChanged", + "task:moved", + "task:updated", + "task:created", + "task:deleted", +] as const; + +/** + * The ordered SDLC funnel stages. Columns are matched case-insensitively against + * these canonical stage ids; any column that does not map to a known stage is + * folded into an "other" bucket so custom workflow columns still contribute a + * count rather than being silently dropped. + */ +const FUNNEL_STAGES: Array<{ id: string; match: (column: string) => boolean }> = [ + { id: "triage", match: (c) => c === "triage" || c === "signal" || c === "backlog" }, + { id: "todo", match: (c) => c === "todo" || c === "to-do" || c === "to do" || c === "ready" }, + { id: "in-progress", match: (c) => c === "in-progress" || c === "in progress" || c === "doing" }, + { id: "in-review", match: (c) => c === "in-review" || c === "in review" || c === "review" }, + { id: "done", match: (c) => c === "done" || c === "complete" || c === "completed" || c === "shipped" }, +]; + +interface NodeView { + path: string; + label: string; + sessionCount: number; + inactive: boolean; +} + +export interface LiveSnapshotState { + snapshot: LiveSnapshot | null; + isLoading: boolean; + /** Non-null only for a hard error with no prior snapshot to fall back on. */ + error: string | null; + /** True while a poll interval is scheduled (work in-flight). Exposed for tests. */ + polling: boolean; + reload: () => void; +} + +/** + * Live snapshot hook implementing the push + poll convergence pattern (KTD5): + * + * - **Push:** subscribes to the shared SSE bus and refetches immediately on any + * session/run/task event — so a change lands within one event, not one poll. + * - **Poll:** schedules a 5s interval as a fallback, but **only while work is + * in-flight** (any active session or run). When the latest snapshot shows no + * active work, the interval is cleared and no new one is scheduled — so an idle + * panel does no background polling. The SSE subscription stays live so the next + * started session pushes in and re-arms polling. + * + * The decision to poll is derived from the freshest snapshot (kept in a ref so the + * interval callback always sees current state), re-evaluated after every fetch. + */ +export function useLiveSnapshot(): LiveSnapshotState { + const [snapshot, setSnapshot] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [polling, setPolling] = useState(false); + + const snapshotRef = useRef(null); + const pollTimerRef = useRef | null>(null); + const inFlightRef = useRef(false); + const mountedRef = useRef(true); + + // Stable callbacks below close over only refs + setState, so `load` (and the + // SSE subscription / poll interval that call it) never need to be recreated. + + const stopPolling = useCallback(() => { + if (pollTimerRef.current !== null) { + clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + setPolling(false); + } + }, []); + + const load = useCallback(async () => { + // Coalesce overlapping fetches (a poll tick and an SSE push racing). + if (inFlightRef.current) return; + inFlightRef.current = true; + try { + const result = await api("/command-center/live"); + if (!mountedRef.current) return; + snapshotRef.current = result; + setSnapshot(result); + setError(null); + } catch (loadError: unknown) { + if (!mountedRef.current) return; + setError(loadError instanceof Error ? loadError.message : "Failed to load live snapshot"); + } finally { + inFlightRef.current = false; + if (mountedRef.current) { + setIsLoading(false); + // Re-evaluate polling against the freshest snapshot after every fetch. + // "In-flight" = any active session or run. Idle → no interval exists. + const snap = snapshotRef.current; + const inFlight = !!snap && (snap.activeSessions > 0 || snap.activeRuns > 0); + if (inFlight) { + // Start the poll interval iff one is not already running. + if (pollTimerRef.current === null) { + pollTimerRef.current = setInterval(() => { + void load(); + }, LIVE_POLL_INTERVAL_MS); + setPolling(true); + } + } else { + stopPolling(); + } + } + } + }, [stopPolling]); + + useEffect(() => { + mountedRef.current = true; + void load(); + + const unsubscribe = subscribeSse("/api/events", { + events: Object.fromEntries( + LIVE_REFETCH_EVENTS.map((name) => [name, () => void load()]), + ), + // On reconnect we may have missed events while the stream was down — + // refetch authoritative state. + onReconnect: () => void load(), + }); + + return () => { + mountedRef.current = false; + unsubscribe(); + stopPolling(); + }; + }, [load, stopPolling]); + + const reload = useCallback(() => { + void load(); + }, [load]); + + return { + snapshot, + isLoading, + error: error !== null && snapshot === null ? error : null, + polling, + reload, + }; +} + +function nodeLabelFromPath(path: string): string { + const parts = path.split(/[/\\]/).filter(Boolean); + return parts.length > 0 ? parts[parts.length - 1] : path; +} + +/** Derive per-node views, marking nodes whose sessions are all stale as inactive. */ +function deriveNodes(sessions: LiveSession[], capturedAt: string): NodeView[] { + const capturedMs = Date.parse(capturedAt); + const byPath = new Map(); + for (const s of sessions) { + if (!s.worktreePath) continue; + const prev = byPath.get(s.worktreePath) ?? { count: 0, freshestMs: 0 }; + const ms = Date.parse(s.updatedAt); + byPath.set(s.worktreePath, { + count: prev.count + 1, + freshestMs: Number.isFinite(ms) ? Math.max(prev.freshestMs, ms) : prev.freshestMs, + }); + } + return Array.from(byPath.entries()) + .map(([path, info]) => { + const age = Number.isFinite(capturedMs) && info.freshestMs > 0 ? capturedMs - info.freshestMs : 0; + return { + path, + label: nodeLabelFromPath(path), + sessionCount: info.count, + inactive: age > NODE_STALE_THRESHOLD_MS, + }; + }) + .sort((a, b) => b.sessionCount - a.sessionCount); +} + +/** Map raw column counts onto the ordered SDLC funnel stages. */ +function deriveFunnelStages(columns: ColumnCount[], label: (id: string, fallback: string) => string): FunnelStage[] { + const totals = new Map(); + for (const stage of FUNNEL_STAGES) totals.set(stage.id, 0); + let other = 0; + for (const c of columns) { + const normalized = c.column.trim().toLowerCase(); + const stage = FUNNEL_STAGES.find((s) => s.match(normalized)); + if (stage) { + totals.set(stage.id, (totals.get(stage.id) ?? 0) + c.count); + } else { + other += c.count; + } + } + const stages: FunnelStage[] = FUNNEL_STAGES.map((s) => ({ + label: label(`commandCenter.missionControl.stage.${s.id}`, s.id), + value: totals.get(s.id) ?? 0, + })); + if (other > 0) { + stages.push({ label: label("commandCenter.missionControl.stage.other", "Other"), value: other }); + } + return stages; +} + +/** + * Live Mission-Control panel (U6b). Renders the live snapshot from + * `GET /api/command-center/live` with push + poll convergence (KTD5): SSE events + * trigger an immediate refetch, and a 5s poll runs only while work is in-flight. + */ +export function MissionControlPanel() { + const { t } = useTranslation("app"); + const { snapshot, isLoading, error } = useLiveSnapshot(); + + const sessions = useMemo(() => snapshot?.sessions ?? [], [snapshot?.sessions]); + const nodes = useMemo( + () => (snapshot ? deriveNodes(snapshot.sessions, snapshot.capturedAt) : []), + [snapshot], + ); + const stages = useMemo( + () => (snapshot ? deriveFunnelStages(snapshot.columns, t) : []), + [snapshot, t], + ); + + if (isLoading && !snapshot) { + return ( +
+ + {t("commandCenter.missionControl.loading", "Loading live activity…")} +
+ ); + } + + if (error !== null) { + return ( +
+ +

{error}

+
+ ); + } + + const hasActivity = (snapshot?.activeSessions ?? 0) > 0 || (snapshot?.activeRuns ?? 0) > 0; + + return ( +
+
+
+
{t("commandCenter.missionControl.activeSessions", "Active sessions")}
+
{snapshot?.activeSessions ?? 0}
+
+
+
{t("commandCenter.missionControl.activeRuns", "Active runs")}
+
{snapshot?.activeRuns ?? 0}
+
+
+
{t("commandCenter.missionControl.activeNodes", "Active nodes")}
+
{snapshot?.activeNodes ?? 0}
+
+
+ + {!hasActivity ? ( +
+ +

{t("commandCenter.missionControl.idle", "No active sessions. Live updates resume when work starts.")}

+
+ ) : null} + +
+
+

{t("commandCenter.missionControl.sessionsTitle", "Sessions")}

+ {sessions.length === 0 ? ( +

+ {t("commandCenter.missionControl.noSessions", "No active sessions.")} +

+ ) : ( +
    + {sessions.map((s) => ( +
  • + {s.purpose || s.adapterId} + + {s.agentState} + {s.taskId ? {s.taskId} : null} + +
  • + ))} +
+ )} +
+ +
+

{t("commandCenter.missionControl.nodesTitle", "Nodes")}

+ {nodes.length === 0 ? ( +

+ {t("commandCenter.missionControl.noNodes", "No active nodes.")} +

+ ) : ( +
    + {nodes.map((n) => ( +
  • + {n.label} + + {n.inactive ? ( + + {t("commandCenter.missionControl.inactive", "inactive")} + + ) : null} + + {t("commandCenter.missionControl.sessionCount", "{{count}} session", { count: n.sessionCount })} + + +
  • + ))} +
+ )} +
+
+ +
+

{t("commandCenter.missionControl.funnelTitle", "SDLC funnel (live)")}

+ +
+
+ ); +} diff --git a/packages/dashboard/app/components/command-center/__tests__/MissionControlPanel.test.tsx b/packages/dashboard/app/components/command-center/__tests__/MissionControlPanel.test.tsx new file mode 100644 index 0000000000..fe0a3ea046 --- /dev/null +++ b/packages/dashboard/app/components/command-center/__tests__/MissionControlPanel.test.tsx @@ -0,0 +1,232 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, act } from "@testing-library/react"; +import type { LiveSnapshot } from "@fusion/core"; + +// Mock the api() helper so the panel fetches deterministic snapshots. +const apiMock = vi.fn(); +vi.mock("../../../api/legacy", () => ({ + api: (path: string, opts?: RequestInit) => apiMock(path, opts), +})); + +// Mock the SSE bus, capturing the subscription so tests can fire events and +// assert subscribe/unsubscribe behavior. +type SseEvents = Record void>; +let sseHandlers: SseEvents = {}; +let sseOnReconnect: (() => void) | undefined; +const unsubscribeMock = vi.fn(); +const subscribeMock = vi.fn((_url: string, sub: { events?: SseEvents; onReconnect?: () => void }) => { + sseHandlers = sub.events ?? {}; + sseOnReconnect = sub.onReconnect; + return unsubscribeMock; +}); +vi.mock("../../../sse-bus", () => ({ + subscribeSse: (url: string, sub: { events?: SseEvents; onReconnect?: () => void }) => subscribeMock(url, sub), +})); + +import { MissionControlPanel, LIVE_POLL_INTERVAL_MS, NODE_STALE_THRESHOLD_MS } from "../MissionControlPanel"; + +function snapshot(overrides: Partial = {}): LiveSnapshot { + return { + capturedAt: "2026-06-15T12:00:00.000Z", + activeSessions: 0, + activeRuns: 0, + activeNodes: 0, + sessions: [], + runs: [], + columns: [], + ...overrides, + }; +} + +function activeSession(id: string, overrides: Partial = {}) { + return { + id, + taskId: `task-${id}`, + purpose: `purpose-${id}`, + adapterId: "claude-local", + agentState: "active", + worktreePath: `/repo/wt-${id}`, + updatedAt: "2026-06-15T12:00:00.000Z", + ...overrides, + }; +} + +beforeEach(() => { + apiMock.mockReset(); + subscribeMock.mockClear(); + unsubscribeMock.mockClear(); + sseHandlers = {}; + sseOnReconnect = undefined; + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); +}); + +/** Flush microtasks (awaited promises) under fake timers. */ +async function flush() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe("MissionControlPanel — KTD5 push + poll convergence", () => { + it("renders an active session and removes it when it ends", async () => { + // Initial: one active session. + apiMock.mockResolvedValueOnce( + snapshot({ activeSessions: 1, activeNodes: 1, sessions: [activeSession("s1")] }), + ); + render(); + await flush(); + + expect(screen.getByTestId("mission-control-session-s1")).toBeTruthy(); + expect(screen.getByTestId("mission-control-active-sessions").textContent).toContain("1"); + + // Next fetch (via SSE push): the session ended → empty snapshot. + apiMock.mockResolvedValueOnce(snapshot()); + await act(async () => { + sseHandlers["session:completed"]?.({}); + }); + await flush(); + + expect(screen.queryByTestId("mission-control-session-s1")).toBeNull(); + expect(screen.getByTestId("mission-control-idle")).toBeTruthy(); + }); + + it("does NOT schedule a poll interval when idle (zero active sessions)", async () => { + apiMock.mockResolvedValue(snapshot()); // idle from the start + const setInterval = vi.spyOn(globalThis, "setInterval"); + render(); + await flush(); + + // No interval was ever scheduled because there is no work in-flight. + expect(setInterval).not.toHaveBeenCalled(); + + // Advancing well past the poll cadence triggers no further fetches. + apiMock.mockClear(); + await act(async () => { + vi.advanceTimersByTime(LIVE_POLL_INTERVAL_MS * 3); + }); + await flush(); + expect(apiMock).not.toHaveBeenCalled(); + + setInterval.mockRestore(); + }); + + it("schedules a poll interval only while work is in-flight and stops it when idle", async () => { + // In-flight snapshot → interval should arm. + apiMock.mockResolvedValueOnce( + snapshot({ activeSessions: 1, activeNodes: 1, sessions: [activeSession("s1")] }), + ); + render(); + await flush(); + + // Poll tick fires while in-flight → another fetch (now idle). + apiMock.mockResolvedValueOnce(snapshot()); + await act(async () => { + vi.advanceTimersByTime(LIVE_POLL_INTERVAL_MS); + }); + await flush(); + expect(screen.getByTestId("mission-control-idle")).toBeTruthy(); + + // Now idle: further ticks must NOT fetch (interval was cleared). + apiMock.mockClear(); + await act(async () => { + vi.advanceTimersByTime(LIVE_POLL_INTERVAL_MS * 3); + }); + await flush(); + expect(apiMock).not.toHaveBeenCalled(); + }); + + it("refetches immediately on an SSE push, even between poll ticks", async () => { + apiMock.mockResolvedValueOnce(snapshot()); // idle → no poll interval + render(); + await flush(); + apiMock.mockClear(); + + // A push event arrives with NO timer advance: it must trigger a refetch. + apiMock.mockResolvedValueOnce( + snapshot({ activeSessions: 1, activeNodes: 1, sessions: [activeSession("s2")] }), + ); + await act(async () => { + sseHandlers["run:created"]?.({}); + }); + await flush(); + + expect(apiMock).toHaveBeenCalledTimes(1); + expect(screen.getByTestId("mission-control-session-s2")).toBeTruthy(); + }); + + it("marks a node with no recent heartbeat as inactive rather than dropping it", async () => { + const capturedAt = "2026-06-15T12:00:00.000Z"; + const capturedMs = Date.parse(capturedAt); + const staleAt = new Date(capturedMs - NODE_STALE_THRESHOLD_MS - 5_000).toISOString(); + const freshAt = new Date(capturedMs - 1_000).toISOString(); + + apiMock.mockResolvedValueOnce( + snapshot({ + capturedAt, + activeSessions: 2, + activeNodes: 2, + sessions: [ + activeSession("fresh", { worktreePath: "/repo/fresh-node", updatedAt: freshAt }), + activeSession("stale", { worktreePath: "/repo/stale-node", updatedAt: staleAt }), + ], + }), + ); + render(); + await flush(); + + // Both nodes are still present (stale one not dropped). + const freshNode = screen.getByTestId("mission-control-node-fresh-node"); + const staleNode = screen.getByTestId("mission-control-node-stale-node"); + expect(freshNode.getAttribute("data-inactive")).toBe("false"); + expect(staleNode.getAttribute("data-inactive")).toBe("true"); + }); + + it("subscribes to the SSE bus on mount and unsubscribes on unmount", async () => { + apiMock.mockResolvedValue(snapshot()); + const { unmount } = render(); + await flush(); + + expect(subscribeMock).toHaveBeenCalledTimes(1); + expect(subscribeMock.mock.calls[0][0]).toBe("/api/events"); + expect(typeof sseOnReconnect).toBe("function"); + + unmount(); + expect(unsubscribeMock).toHaveBeenCalledTimes(1); + }); + + it("renders the live SDLC funnel from current column counts", async () => { + apiMock.mockResolvedValueOnce( + snapshot({ + activeSessions: 1, + sessions: [activeSession("s1")], + columns: [ + { column: "triage", count: 4 }, + { column: "in-progress", count: 2 }, + { column: "done", count: 7 }, + { column: "custom-column", count: 3 }, + ], + }), + ); + render(); + await flush(); + + const funnel = screen.getByTestId("mission-control-funnel"); + expect(funnel).toBeTruthy(); + // The unmapped "custom-column" folds into an "Other" stage, not dropped. + expect(funnel.textContent).toContain("3"); + expect(funnel.textContent).toContain("7"); + }); + + it("surfaces a hard error when the first fetch fails with no prior data", async () => { + apiMock.mockRejectedValueOnce(new Error("boom")); + render(); + await flush(); + expect(screen.getByTestId("mission-control-error")).toBeTruthy(); + }); +});