From d34f097a5cf365dc00e09daf195f90048f95db59 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 17 Feb 2026 15:56:12 -0600 Subject: [PATCH] feat(web): add orchestrator events widget with matrix signal visibility --- apps/web/src/components/hud/HUD.tsx | 9 + .../components/hud/WidgetRenderer.test.tsx | 10 ++ .../web/src/components/hud/WidgetRenderer.tsx | 6 + .../widgets/OrchestratorEventsWidget.tsx | 159 ++++++++++++++++++ .../src/components/widgets/WidgetRegistry.tsx | 12 ++ .../OrchestratorEventsWidget.test.tsx | 69 ++++++++ .../widgets/__tests__/WidgetRegistry.test.tsx | 6 + apps/web/src/components/widgets/index.ts | 1 + docs/tasks.md | 2 + 9 files changed, 274 insertions(+) create mode 100644 apps/web/src/components/widgets/OrchestratorEventsWidget.tsx create mode 100644 apps/web/src/components/widgets/__tests__/OrchestratorEventsWidget.test.tsx diff --git a/apps/web/src/components/hud/HUD.tsx b/apps/web/src/components/hud/HUD.tsx index 33a7428..6341be7 100644 --- a/apps/web/src/components/hud/HUD.tsx +++ b/apps/web/src/components/hud/HUD.tsx @@ -55,6 +55,15 @@ const WIDGET_REGISTRY = { minWidth: 1, minHeight: 1, }, + OrchestratorEventsWidget: { + name: "orchestrator-events", + displayName: "Orchestrator Events", + description: "Recent events and stream health for orchestration", + defaultWidth: 2, + defaultHeight: 2, + minWidth: 1, + minHeight: 1, + }, } as const; type WidgetRegistryKey = keyof typeof WIDGET_REGISTRY; diff --git a/apps/web/src/components/hud/WidgetRenderer.test.tsx b/apps/web/src/components/hud/WidgetRenderer.test.tsx index 00ef010..a5b0b1a 100644 --- a/apps/web/src/components/hud/WidgetRenderer.test.tsx +++ b/apps/web/src/components/hud/WidgetRenderer.test.tsx @@ -12,6 +12,9 @@ vi.mock("@/components/widgets", () => ({ AgentStatusWidget: ({ id }: { id: string }): React.JSX.Element => (
Agent Status Widget {id}
), + OrchestratorEventsWidget: ({ id }: { id: string }): React.JSX.Element => ( +
Orchestrator Events Widget {id}
+ ), })); function createWidgetPlacement(id: string): WidgetPlacement { @@ -34,4 +37,11 @@ describe("WidgetRenderer", () => { render(); expect(screen.getByText("Agent Status Widget agent-status-123")).toBeInTheDocument(); }); + + it("renders hyphenated orchestrator-events widget IDs correctly", () => { + render(); + expect( + screen.getByText("Orchestrator Events Widget orchestrator-events-123") + ).toBeInTheDocument(); + }); }); diff --git a/apps/web/src/components/hud/WidgetRenderer.tsx b/apps/web/src/components/hud/WidgetRenderer.tsx index 83a11b0..d05551b 100644 --- a/apps/web/src/components/hud/WidgetRenderer.tsx +++ b/apps/web/src/components/hud/WidgetRenderer.tsx @@ -10,6 +10,7 @@ import { CalendarWidget, QuickCaptureWidget, AgentStatusWidget, + OrchestratorEventsWidget, } from "@/components/widgets"; import type { WidgetPlacement } from "@mosaic/shared"; @@ -24,6 +25,7 @@ const WIDGET_COMPONENTS = { calendar: CalendarWidget, "quick-capture": QuickCaptureWidget, "agent-status": AgentStatusWidget, + "orchestrator-events": OrchestratorEventsWidget, }; const WIDGET_CONFIG = { @@ -43,6 +45,10 @@ const WIDGET_CONFIG = { displayName: "Agent Status", description: "View running agent sessions", }, + "orchestrator-events": { + displayName: "Orchestrator Events", + description: "Recent orchestration events and stream health", + }, }; export function WidgetRenderer({ diff --git a/apps/web/src/components/widgets/OrchestratorEventsWidget.tsx b/apps/web/src/components/widgets/OrchestratorEventsWidget.tsx new file mode 100644 index 0000000..5384aec --- /dev/null +++ b/apps/web/src/components/widgets/OrchestratorEventsWidget.tsx @@ -0,0 +1,159 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Activity, DatabaseZap, Loader2, Wifi, WifiOff } from "lucide-react"; +import type { WidgetProps } from "@mosaic/shared"; + +interface OrchestratorEvent { + type: string; + timestamp: string; + agentId?: string; + taskId?: string; + data?: Record; +} + +interface RecentEventsResponse { + events: OrchestratorEvent[]; +} + +function isMatrixSignal(event: OrchestratorEvent): boolean { + const text = JSON.stringify(event).toLowerCase(); + return ( + text.includes("matrix") || + text.includes("room") || + text.includes("channel") || + text.includes("thread") + ); +} + +export function OrchestratorEventsWidget({ + id: _id, + config: _config, +}: WidgetProps): React.JSX.Element { + const [events, setEvents] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [streamConnected, setStreamConnected] = useState(false); + + const loadRecentEvents = useCallback(async (): Promise => { + try { + const response = await fetch("/api/orchestrator/events/recent?limit=25"); + if (!response.ok) { + throw new Error(`Unable to load events: HTTP ${String(response.status)}`); + } + const payload = (await response.json()) as RecentEventsResponse; + setEvents(payload.events); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Unable to load events."); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + void loadRecentEvents(); + + const eventSource = + typeof EventSource !== "undefined" ? new EventSource("/api/orchestrator/events") : null; + if (eventSource) { + eventSource.onopen = (): void => { + setStreamConnected(true); + }; + eventSource.onmessage = (): void => { + void loadRecentEvents(); + }; + eventSource.onerror = (): void => { + setStreamConnected(false); + }; + } + + const interval = setInterval(() => { + void loadRecentEvents(); + }, 15000); + + return (): void => { + clearInterval(interval); + eventSource?.close(); + }; + }, [loadRecentEvents]); + + const matrixSignals = useMemo( + () => events.filter((event) => isMatrixSignal(event)).length, + [events] + ); + + if (isLoading) { + return ( +
+ + Loading orchestrator events... +
+ ); + } + + if (error) { + return ( +
+ + {error} +
+ ); + } + + return ( +
+
+
+ {streamConnected ? ( + + ) : ( + + )} + {streamConnected ? "Live stream connected" : "Polling mode"} +
+
+ + Matrix signals: {matrixSignals} +
+
+ +
+ {events.length === 0 ? ( +
+ No recent orchestration events. +
+ ) : ( + events + .slice() + .reverse() + .map((event, index) => ( +
+
+
+ + + {event.type} + + {isMatrixSignal(event) && ( + + matrix + + )} +
+ + {new Date(event.timestamp).toLocaleTimeString()} + +
+
+ {event.taskId ? `Task ${event.taskId}` : "Task n/a"} + {event.agentId ? ` ยท Agent ${event.agentId.slice(0, 8)}` : ""} +
+
+ )) + )} +
+
+ ); +} diff --git a/apps/web/src/components/widgets/WidgetRegistry.tsx b/apps/web/src/components/widgets/WidgetRegistry.tsx index 614f30f..ec811b4 100644 --- a/apps/web/src/components/widgets/WidgetRegistry.tsx +++ b/apps/web/src/components/widgets/WidgetRegistry.tsx @@ -10,6 +10,7 @@ import { QuickCaptureWidget } from "./QuickCaptureWidget"; import { AgentStatusWidget } from "./AgentStatusWidget"; import { ActiveProjectsWidget } from "./ActiveProjectsWidget"; import { TaskProgressWidget } from "./TaskProgressWidget"; +import { OrchestratorEventsWidget } from "./OrchestratorEventsWidget"; export interface WidgetDefinition { name: string; @@ -95,6 +96,17 @@ export const widgetRegistry: Record = { minHeight: 2, maxWidth: 3, }, + OrchestratorEventsWidget: { + name: "OrchestratorEventsWidget", + displayName: "Orchestrator Events", + description: "Recent orchestration events with stream/Matrix visibility", + component: OrchestratorEventsWidget, + defaultWidth: 2, + defaultHeight: 2, + minWidth: 1, + minHeight: 2, + maxWidth: 4, + }, }; /** diff --git a/apps/web/src/components/widgets/__tests__/OrchestratorEventsWidget.test.tsx b/apps/web/src/components/widgets/__tests__/OrchestratorEventsWidget.test.tsx new file mode 100644 index 0000000..7349836 --- /dev/null +++ b/apps/web/src/components/widgets/__tests__/OrchestratorEventsWidget.test.tsx @@ -0,0 +1,69 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { OrchestratorEventsWidget } from "../OrchestratorEventsWidget"; + +describe("OrchestratorEventsWidget", () => { + const mockFetch = vi.fn(); + + beforeEach(() => { + global.fetch = mockFetch as unknown as typeof fetch; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("renders loading state initially", () => { + mockFetch.mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-empty-function + () => new Promise(() => {}) + ); + + render(); + expect(screen.getByText("Loading orchestrator events...")).toBeInTheDocument(); + }); + + it("renders events and matrix signal count", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + events: [ + { + type: "task.completed", + timestamp: "2026-02-17T16:40:00.000Z", + taskId: "TASK-1", + data: { channelId: "room-123" }, + }, + { + type: "agent.running", + timestamp: "2026-02-17T16:41:00.000Z", + taskId: "TASK-2", + agentId: "agent-abc12345", + }, + ], + }), + }); + + render(); + + await waitFor(() => { + expect(screen.getByText("task.completed")).toBeInTheDocument(); + expect(screen.getByText("agent.running")).toBeInTheDocument(); + expect(screen.getByText(/Matrix signals: 1/)).toBeInTheDocument(); + }); + }); + + it("renders error state when API fails", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 503, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/Unable to load events: HTTP 503/)).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/src/components/widgets/__tests__/WidgetRegistry.test.tsx b/apps/web/src/components/widgets/__tests__/WidgetRegistry.test.tsx index c02728d..2fe000a 100644 --- a/apps/web/src/components/widgets/__tests__/WidgetRegistry.test.tsx +++ b/apps/web/src/components/widgets/__tests__/WidgetRegistry.test.tsx @@ -10,6 +10,7 @@ import { widgetRegistry } from "../WidgetRegistry"; import { TasksWidget } from "../TasksWidget"; import { CalendarWidget } from "../CalendarWidget"; import { QuickCaptureWidget } from "../QuickCaptureWidget"; +import { OrchestratorEventsWidget } from "../OrchestratorEventsWidget"; describe("WidgetRegistry", (): void => { it("should have a registry of widgets", (): void => { @@ -32,6 +33,11 @@ describe("WidgetRegistry", (): void => { expect(widgetRegistry.QuickCaptureWidget!.component).toBe(QuickCaptureWidget); }); + it("should include OrchestratorEventsWidget in registry", (): void => { + expect(widgetRegistry.OrchestratorEventsWidget).toBeDefined(); + expect(widgetRegistry.OrchestratorEventsWidget!.component).toBe(OrchestratorEventsWidget); + }); + it("should have correct metadata for TasksWidget", (): void => { const tasksWidget = widgetRegistry.TasksWidget!; expect(tasksWidget.name).toBe("TasksWidget"); diff --git a/apps/web/src/components/widgets/index.ts b/apps/web/src/components/widgets/index.ts index 63aa792..971d8ee 100644 --- a/apps/web/src/components/widgets/index.ts +++ b/apps/web/src/components/widgets/index.ts @@ -6,3 +6,4 @@ export { TasksWidget } from "./TasksWidget"; export { CalendarWidget } from "./CalendarWidget"; export { QuickCaptureWidget } from "./QuickCaptureWidget"; export { AgentStatusWidget } from "./AgentStatusWidget"; +export { OrchestratorEventsWidget } from "./OrchestratorEventsWidget"; diff --git a/docs/tasks.md b/docs/tasks.md index c6cae5f..039e71f 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -383,3 +383,5 @@ | ORCH-OBS-004 | done | Add tests/docs updates for recent events and operator command usage | #411 | orchestrator,docs | feature/mosaic-stack-finalization | ORCH-OBS-001 | | orch | 2026-02-17T16:36Z | 2026-02-17T16:40Z | 8K | 6K | | ORCH-OBS-005 | done | Fix HUD widget ID generation/parsing for hyphenated widget types (`quick-capture`, `agent-status`) | #411 | web | feature/mosaic-stack-finalization | ORCH-OBS-004 | | orch | 2026-02-17T16:42Z | 2026-02-17T16:48Z | 8K | 6K | | ORCH-OBS-006 | done | Add `WidgetRenderer` regression tests for hyphenated widget IDs | #411 | web | feature/mosaic-stack-finalization | ORCH-OBS-005 | | orch | 2026-02-17T16:48Z | 2026-02-17T16:50Z | 5K | 3K | +| ORCH-OBS-007 | done | Add `OrchestratorEventsWidget` for live/recent orchestration visibility with Matrix signal hints | #411 | web | feature/mosaic-stack-finalization | ORCH-OBS-002 | | orch | 2026-02-17T16:55Z | 2026-02-17T17:03Z | 12K | 9K | +| ORCH-OBS-008 | done | Integrate new widget into HUD/WidgetRegistry and extend widget regression coverage | #411 | web | feature/mosaic-stack-finalization | ORCH-OBS-007 | | orch | 2026-02-17T17:03Z | 2026-02-17T17:08Z | 10K | 7K |