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 |