From acec32cea9007aea8274c60bfa3a0eb0f54620a3 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 10 May 2026 14:05:10 -0400 Subject: [PATCH] feat(web): build live events page in settings Replaces the /settings/live-events placeholder with a working page that streams server-wide events from /api/v1/attach. Shares the leader-owned cross-tab EventSource so additional tabs subscribe without opening parallel connections. The page keeps an in-memory ring buffer (newest first, max 1,000) with id or run_id:seq dedupe and resets on remount; live-only by design, nothing is replayed on connect or persisted in the browser. Reuses the existing event-debug filters, search, and details panel, and links each row's run_id to /runs/:id. The category filter is the static set of DebugCategory values so "All types" always matches. Settings layout is now fullHeight-aware so the events page can fill the viewport alongside the sub-nav. DebugEventDetailsPanel's event prop is broadened to a shared EventDisplayPayload shape so it accepts both EventEnvelope and the live payload. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/fabro-web/app/components/event-debug.tsx | 9 +- apps/fabro-web/app/lib/live-events.test.tsx | 131 ++++++++++ apps/fabro-web/app/lib/live-events.ts | 64 +++++ .../app/routes/settings-live-events.test.tsx | 162 ++++++++++++ .../app/routes/settings-live-events.tsx | 232 +++++++++++++++++- apps/fabro-web/app/routes/settings.tsx | 20 +- .../plans/2026-05-10-live-events-page.md | 57 +++++ 7 files changed, 668 insertions(+), 7 deletions(-) create mode 100644 apps/fabro-web/app/lib/live-events.test.tsx create mode 100644 apps/fabro-web/app/lib/live-events.ts create mode 100644 apps/fabro-web/app/routes/settings-live-events.test.tsx create mode 100644 docs/superpowers/plans/2026-05-10-live-events-page.md diff --git a/apps/fabro-web/app/components/event-debug.tsx b/apps/fabro-web/app/components/event-debug.tsx index d197192ba..28346deb7 100644 --- a/apps/fabro-web/app/components/event-debug.tsx +++ b/apps/fabro-web/app/components/event-debug.tsx @@ -223,11 +223,16 @@ export function DetailsPanel({ ); } +export type EventDisplayPayload = { + event?: string | null; + [key: string]: unknown; +}; + export function DebugEventDetailsPanel({ event, onClose, }: { - event: EventEnvelope | null; + event: EventDisplayPayload | null; onClose: () => void; }) { return ( @@ -241,7 +246,7 @@ export function DebugEventDetailsPanel({ ); } -function DebugEventDetails({ event }: { event: EventEnvelope }) { +function DebugEventDetails({ event }: { event: EventDisplayPayload }) { const text = useMemo(() => JSON.stringify(event, null, 2), [event]); const tokens = useMemo(() => highlightJson(text), [text]); return ( diff --git a/apps/fabro-web/app/lib/live-events.test.tsx b/apps/fabro-web/app/lib/live-events.test.tsx new file mode 100644 index 000000000..433718c1f --- /dev/null +++ b/apps/fabro-web/app/lib/live-events.test.tsx @@ -0,0 +1,131 @@ +import { describe, expect, test } from "bun:test"; + +import { + createCrossTabSseCoordinator, + type BroadcastChannelLike, +} from "./cross-tab-sse"; +import { subscribeToLiveEvents, type LiveEventPayload } from "./live-events"; +import type { EventSourceLike } from "./sse"; + +type MessageHandler = ((event: { data: string }) => void) | null; + +class FakeEventSource { + onmessage: MessageHandler = null; + closed = false; + + emit(payload: unknown) { + this.onmessage?.({ data: JSON.stringify(payload) }); + } + + close() { + this.closed = true; + } +} + +class FakeBroadcastChannel implements BroadcastChannelLike { + onmessage: ((event: { data: unknown }) => void) | null = null; + + postMessage() {} + + close() {} +} + +describe("subscribeToLiveEvents", () => { + test("coordinated mode opens /api/v1/attach and forwards every payload", async () => { + const source = new FakeEventSource(); + const created: string[] = []; + const seen: LiveEventPayload[] = []; + const coordinator = createCoordinator((url) => { + created.push(url); + return source; + }); + + const cleanup = subscribeToLiveEvents( + (payload) => seen.push(payload), + () => { + throw new Error("source should be created by coordinator"); + }, + { coordinator }, + ); + + await waitFor(() => created.length === 1); + + source.emit({ id: "evt-1", event: "stage.started", run_id: "run-a" }); + source.emit({ id: "evt-2", event: "agent.message", run_id: "run-b" }); + + expect(created).toEqual(["/api/v1/attach"]); + expect(seen.map((p) => p.run_id)).toEqual(["run-a", "run-b"]); + + cleanup(); + coordinator.close(); + }); + + test("fallback mode opens /api/v1/attach (not a per-run URL) and forwards payloads", () => { + const source = new FakeEventSource(); + const created: string[] = []; + const seen: LiveEventPayload[] = []; + const coordinator = createFallbackCoordinator(); + + const cleanup = subscribeToLiveEvents( + (payload) => seen.push(payload), + (url) => { + created.push(url); + return source; + }, + { coordinator }, + ); + + source.emit({ id: "evt-1", event: "run.completed", run_id: "run-a" }); + source.emit({ id: "evt-2", event: "run.failed", run_id: "run-b" }); + + expect(created).toEqual(["/api/v1/attach"]); + expect(seen.map((p) => p.run_id)).toEqual(["run-a", "run-b"]); + + cleanup(); + coordinator.close(); + }); + + test("fallback closes the shared source on the final unsubscribe", () => { + const source = new FakeEventSource(); + const coordinator = createFallbackCoordinator(); + const cleanup = subscribeToLiveEvents(() => {}, () => source, { coordinator }); + + expect(source.closed).toBe(false); + cleanup(); + expect(source.closed).toBe(true); + + coordinator.close(); + }); +}); + +function createCoordinator(eventSourceFactory: (url: string) => EventSourceLike) { + return createCrossTabSseCoordinator({ + tabId: "live-events-test", + channelFactory: () => new FakeBroadcastChannel(), + eventSourceFactory, + addVisibilityChangeListener: () => () => {}, + addPagehideListener: () => () => {}, + timing: { + heartbeatMs: 10, + leaderStaleMs: 50, + electionJitterMs: 0, + }, + }); +} + +function createFallbackCoordinator() { + return createCrossTabSseCoordinator({ + channelFactory: () => { + throw new Error("BroadcastChannel unavailable"); + }, + }); +} + +async function waitFor(condition: () => boolean, timeoutMs = 200) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (condition()) return; + await new Promise((resolve) => setTimeout(resolve, 2)); + } + throw new Error("condition did not become true before timeout"); +} diff --git a/apps/fabro-web/app/lib/live-events.ts b/apps/fabro-web/app/lib/live-events.ts new file mode 100644 index 000000000..1de529bf5 --- /dev/null +++ b/apps/fabro-web/app/lib/live-events.ts @@ -0,0 +1,64 @@ +import { + subscribeToCrossTabSse, + type CrossTabSseCoordinator, +} from "./cross-tab-sse"; +import { queryKeys } from "./query-keys"; +import { + createBrowserEventSource, + subscribeToSharedEventSource, + type EventPayload, + type EventSourceLike, + type SharedEventSubscription, + type SseKey, +} from "./sse"; + +export interface LiveEventPayload extends EventPayload { + id?: string; + seq?: number; + event?: string; + ts?: string; + run_id?: string; + node_id?: string; + stage_id?: string; + properties?: Record; +} + +interface LiveEventOptions { + coordinator?: CrossTabSseCoordinator; +} + +const subscriptions = new Map(); +const SUBSCRIPTION_KEY = "live-events"; +const NO_KEYS: SseKey[] = []; +const NOOP_MUTATE = () => Promise.resolve(); + +export function subscribeToLiveEvents( + onEvent: (payload: LiveEventPayload) => void, + eventSourceFactory: (url: string) => EventSourceLike = createBrowserEventSource, + { coordinator }: LiveEventOptions = {}, +): () => void { + return subscribeToCrossTabSse({ + coordinator, + subscriptionKey: SUBSCRIPTION_KEY, + mutate: NOOP_MUTATE, + debounceMs: 0, + resyncKeys: () => NO_KEYS, + resolveInvalidation: (payload) => { + onEvent(payload); + return { keys: NO_KEYS }; + }, + fallbackSubscribe: () => + subscribeToSharedEventSource({ + subscriptions, + subscriptionKey: SUBSCRIPTION_KEY, + url: queryKeys.system.attachUrl(), + mutate: NOOP_MUTATE, + eventSourceFactory, + debounceMs: 0, + resolveInvalidation: (payload) => { + onEvent(payload); + return { keys: NO_KEYS }; + }, + }), + }); +} diff --git a/apps/fabro-web/app/routes/settings-live-events.test.tsx b/apps/fabro-web/app/routes/settings-live-events.test.tsx new file mode 100644 index 000000000..f62210192 --- /dev/null +++ b/apps/fabro-web/app/routes/settings-live-events.test.tsx @@ -0,0 +1,162 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import TestRenderer, { act } from "react-test-renderer"; +import { MemoryRouter, Route, Routes } from "react-router"; + +import type { LiveEventPayload } from "../lib/live-events"; + +let capturedOnEvent: ((payload: LiveEventPayload) => void) | null = null; + +mock.module("../lib/live-events", () => ({ + subscribeToLiveEvents: ( + onEvent: (payload: LiveEventPayload) => void, + ) => { + capturedOnEvent = onEvent; + return () => { + if (capturedOnEvent === onEvent) capturedOnEvent = null; + }; + }, +})); + +const { default: SettingsLiveEvents, appendLiveEvent, MAX_EVENTS } = await import( + "./settings-live-events" +); + +const mountedRenderers: TestRenderer.ReactTestRenderer[] = []; + +function renderSettingsLiveEvents() { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + let renderer: TestRenderer.ReactTestRenderer | undefined; + act(() => { + renderer = TestRenderer.create( + + + } /> + + , + ); + }); + mountedRenderers.push(renderer!); + return renderer!; +} + +function pushEvent(payload: LiveEventPayload) { + act(() => { + capturedOnEvent?.(payload); + }); +} + +function rowsByEventName(renderer: TestRenderer.ReactTestRenderer): string[] { + const rows = renderer.root.findAllByProps({ role: "button" }); + return rows + .map((row) => { + const monoSpans = row.findAllByType("span").filter((s) => { + const cls = s.props.className; + return typeof cls === "string" && cls.includes("font-mono") && cls.includes("text-fg-2"); + }); + return monoSpans[0]?.children?.[0] as string | undefined; + }) + .filter((name): name is string => typeof name === "string"); +} + +describe("appendLiveEvent", () => { + test("prepends new events newest-first", () => { + const a: LiveEventPayload = { id: "a", event: "x" }; + const b: LiveEventPayload = { id: "b", event: "y" }; + const result = appendLiveEvent(appendLiveEvent([], a), b); + expect(result.map((e) => e.id)).toEqual(["b", "a"]); + }); + + test("dedupes by id when present", () => { + const a: LiveEventPayload = { id: "a", event: "x" }; + const result = appendLiveEvent([a], { id: "a", event: "x" }); + expect(result).toHaveLength(1); + }); + + test("dedupes by run_id:seq when id is missing", () => { + const a: LiveEventPayload = { run_id: "run-1", seq: 7, event: "x" }; + const result = appendLiveEvent([a], { run_id: "run-1", seq: 7, event: "x" }); + expect(result).toHaveLength(1); + }); + + test("treats events with neither id nor seq as distinct", () => { + const a: LiveEventPayload = { event: "x" }; + const result = appendLiveEvent([a], { event: "x" }); + expect(result).toHaveLength(2); + }); + + test("caps the buffer at MAX_EVENTS", () => { + const seed = Array.from({ length: MAX_EVENTS }, (_, i) => ({ + id: `seed-${i}`, + event: "x", + })); + const result = appendLiveEvent(seed, { id: "fresh", event: "x" }); + expect(result).toHaveLength(MAX_EVENTS); + expect(result[0]?.id).toBe("fresh"); + expect(result[result.length - 1]?.id).toBe(`seed-${MAX_EVENTS - 2}`); + }); +}); + +describe("SettingsLiveEvents route", () => { + afterEach(() => { + act(() => { + for (const renderer of mountedRenderers.splice(0)) { + renderer.unmount(); + } + }); + capturedOnEvent = null; + delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT; + }); + + test("renders the live-only empty state on mount", () => { + const renderer = renderSettingsLiveEvents(); + const text = JSON.stringify(renderer.toJSON()); + expect(text).toContain("Waiting for events"); + expect(text).toContain("only shows events that arrive after it's opened"); + }); + + test("appends incoming events newest first", () => { + const renderer = renderSettingsLiveEvents(); + pushEvent({ id: "a", event: "stage.started", run_id: "run-1", ts: "2026-05-10T10:00:00Z" }); + pushEvent({ id: "b", event: "agent.message", run_id: "run-2", ts: "2026-05-10T10:00:01Z" }); + + expect(rowsByEventName(renderer)).toEqual(["agent.message", "stage.started"]); + }); + + test("ignores duplicate event ids", () => { + const renderer = renderSettingsLiveEvents(); + pushEvent({ id: "a", event: "stage.started", run_id: "run-1", ts: "2026-05-10T10:00:00Z" }); + pushEvent({ id: "a", event: "stage.started", run_id: "run-1", ts: "2026-05-10T10:00:00Z" }); + + expect(rowsByEventName(renderer)).toEqual(["stage.started"]); + }); + + test("links the run_id cell to the run detail page", () => { + const renderer = renderSettingsLiveEvents(); + pushEvent({ id: "a", event: "stage.started", run_id: "run-1", ts: "2026-05-10T10:00:00Z" }); + + const links = renderer.root.findAllByProps({ to: "/runs/run-1" }); + expect(links.length).toBeGreaterThan(0); + }); + + test("filters events by category and search", () => { + const renderer = renderSettingsLiveEvents(); + pushEvent({ id: "1", event: "stage.started", run_id: "run-1", ts: "2026-05-10T10:00:00Z" }); + pushEvent({ id: "2", event: "agent.message", run_id: "run-2", ts: "2026-05-10T10:00:01Z" }); + pushEvent({ id: "3", event: "command.started", run_id: "run-3", ts: "2026-05-10T10:00:02Z" }); + + expect(rowsByEventName(renderer)).toEqual([ + "command.started", + "agent.message", + "stage.started", + ]); + + const searchInput = renderer.root.findByProps({ name: "event-search" }); + act(() => { + (searchInput.props.onChange as (e: { target: { value: string } }) => void)({ + target: { value: "agent" }, + }); + }); + + expect(rowsByEventName(renderer)).toEqual(["agent.message"]); + }); +}); diff --git a/apps/fabro-web/app/routes/settings-live-events.tsx b/apps/fabro-web/app/routes/settings-live-events.tsx index ed77f700f..38dc4f81f 100644 --- a/apps/fabro-web/app/routes/settings-live-events.tsx +++ b/apps/fabro-web/app/routes/settings-live-events.tsx @@ -1,3 +1,231 @@ -export default function SettingsLiveEvents() { - return
; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Link } from "react-router"; + +import { + DEBUG_CATEGORIES, + DebugEventDetailsPanel, + EventSearchInput, + MultiSelectFilter, + debugCategory, + debugCategoryLabel, + debugCategoryTone, + type DebugCategory, +} from "../components/event-debug"; +import { EmptyState } from "../components/state"; +import { Tooltip } from "../components/ui"; +import { formatAbsoluteTs } from "../lib/format"; +import { + subscribeToLiveEvents, + type LiveEventPayload, +} from "../lib/live-events"; + +export function meta() { + return [{ title: "Live Events — Fabro" }]; +} + +export const handle = { wide: true, fullHeight: true }; + +export const MAX_EVENTS = 1000; + +export function eventDedupeKey(payload: LiveEventPayload): string | null { + if (typeof payload.id === "string") return payload.id; + if (typeof payload.run_id === "string" && typeof payload.seq === "number") { + return `${payload.run_id}:${payload.seq}`; + } + return null; +} + +export function appendLiveEvent( + buffer: LiveEventPayload[], + payload: LiveEventPayload, +): LiveEventPayload[] { + const key = eventDedupeKey(payload); + if (key !== null && buffer.some((event) => eventDedupeKey(event) === key)) { + return buffer; + } + const next = [payload, ...buffer]; + if (next.length > MAX_EVENTS) next.length = MAX_EVENTS; + return next; +} + +export default function SettingsLiveEvents() { + const [events, setEvents] = useState([]); + const [openKey, setOpenKey] = useState(null); + const [selectedCategories, setSelectedCategories] = useState([]); + const [search, setSearch] = useState(""); + + useEffect(() => { + return subscribeToLiveEvents((payload) => { + setEvents((prev) => appendLiveEvent(prev, payload)); + }); + }, []); + + const filtered = useMemo(() => { + const useCategoryFilter = selectedCategories.length > 0; + const cats = new Set(selectedCategories); + const needle = search.toLowerCase(); + return events.filter((event) => { + const name = event.event ?? ""; + if (useCategoryFilter && !cats.has(debugCategory(name))) return false; + if (needle) { + const blob = `${name} ${event.run_id ?? ""} ${event.stage_id ?? ""} ${event.node_id ?? ""} ${JSON.stringify(event.properties ?? {})}`.toLowerCase(); + if (!blob.includes(needle)) return false; + } + return true; + }); + }, [events, selectedCategories, search]); + + const openEvent = useMemo( + () => (openKey != null ? events.find((e) => rowKey(e) === openKey) ?? null : null), + [events, openKey], + ); + + const isFiltering = selectedCategories.length > 0 || search.length > 0; + + const clearFilters = useCallback(() => { + setSelectedCategories([]); + setSearch(""); + }, []); + + return ( +
+
+
+
+
+
+ + selected={selectedCategories} + options={DEBUG_CATEGORIES} + labelOf={debugCategoryLabel} + onChange={setSelectedCategories} + emptyMeansAll + /> + + {isFiltering && ( + + )} +
+ {events.length > 0 && ( + + {isFiltering + ? `${filtered.length.toLocaleString()} of ${events.length.toLocaleString()} events` + : `${events.length.toLocaleString()} events`} + + )} +
+
+
+
+ {events.length === 0 ? ( +
+ +
+ ) : filtered.length === 0 ? ( +
+ No events match these filters. +
+ ) : ( + filtered.map((event) => ( + setOpenKey(rowKey(event))} + /> + )) + )} +
+
+ + setOpenKey(null)} /> +
+ ); +} + +function rowKey(event: LiveEventPayload): string { + return ( + eventDedupeKey(event) ?? + `${event.run_id ?? "?"}:${event.event ?? ""}:${event.ts ?? ""}` + ); +} + +function LiveEventRow({ + event, + selected, + onSelect, +}: { + event: LiveEventPayload; + selected: boolean; + onSelect: () => void; +}) { + const eventName = event.event ?? ""; + const category = debugCategory(eventName); + const stage = event.stage_id ?? event.node_id ?? null; + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect(); + } + } + + return ( +
+ + {debugCategoryLabel(category)} + + + {eventName} + + + {event.run_id ? ( + e.stopPropagation()} + className="text-fg-3 hover:text-fg hover:underline" + > + {event.run_id} + + ) : ( + — + )} + + + {stage ?? ""} + + {event.ts ? ( + + + {formatAbsoluteTs(event.ts)} + + + ) : ( + — + )} +
+ ); } diff --git a/apps/fabro-web/app/routes/settings.tsx b/apps/fabro-web/app/routes/settings.tsx index 1d765dd4d..1173d16f4 100644 --- a/apps/fabro-web/app/routes/settings.tsx +++ b/apps/fabro-web/app/routes/settings.tsx @@ -1,5 +1,5 @@ import { BoltIcon, Cog6ToothIcon } from "@heroicons/react/24/outline"; -import { Link, Outlet, useLocation } from "react-router"; +import { Link, Outlet, useLocation, useMatches } from "react-router"; export function meta({}: any) { return [{ title: "Settings — Fabro" }]; @@ -35,10 +35,19 @@ function classNames(...classes: Array) { export default function SettingsLayout() { const { pathname } = useLocation(); + const matches = useMatches(); const currentName = navItems.find((item) => item.match(pathname))?.name ?? "Settings"; + const fullHeight = matches.some( + (m) => (m.handle as { fullHeight?: boolean } | undefined)?.fullHeight, + ); return ( -
+
-
+

{currentName}

diff --git a/docs/superpowers/plans/2026-05-10-live-events-page.md b/docs/superpowers/plans/2026-05-10-live-events-page.md new file mode 100644 index 000000000..f2e561894 --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-live-events-page.md @@ -0,0 +1,57 @@ +# Live Events Page Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a live-only `/events` page that streams server-wide Fabro run events received after the page is opened. + +**Architecture:** Reuse the existing web SSE coordination layer. One browser tab owns the global `/api/v1/attach` EventSource and rebroadcasts events through `BroadcastChannel`; the new page subscribes to that stream and stores only an in-memory ring buffer for the current page lifetime. + +**Tech Stack:** React, React Router, SWR mutate plumbing, EventSource, BroadcastChannel, Bun tests. + +--- + +## Summary + +Build a live-only `/events` page that shows server-wide Fabro run events received after the page is opened. Do not add backend replay, persistence, or a server-side recent-events buffer. Reuse the existing web SSE coordination: one tab owns `/api/v1/attach`, other tabs receive events via `BroadcastChannel`. + +## Key Changes + +- Add a top-level route `events` in `apps/fabro-web/app/router.tsx`, rendered inside the existing app shell with `wide` and `fullHeight` handles. +- Add a top-level nav item named `Events` in `apps/fabro-web/app/layouts/app-shell.tsx` using an existing outline icon such as `BoltIcon`. +- Add `apps/fabro-web/app/lib/live-events.ts`: + - Export `subscribeToLiveEvents(onEvent, options?)`. + - Use `subscribeToCrossTabSse` with `subscriptionKey: "live-events"`. + - In coordinated mode, rely on the existing leader-owned `/api/v1/attach` stream. + - In fallback mode, use `subscribeToSharedEventSource` with `queryKeys.system.attachUrl()`. + - Return no SWR invalidation keys; call `onEvent(payload)` from the resolver. +- Add `apps/fabro-web/app/routes/live-events.tsx`: + - Keep a local in-memory ring buffer, newest first, max 1,000 events. + - Deduplicate by `id` when present, otherwise by `run_id:seq`. + - Start empty on mount and reset on refresh/navigation remount. + - Reuse existing event debug controls: category filter, search input, details panel. + - Render a global row shape that includes category, event name, `run_id`, optional `node_id`/`stage_id`, and absolute timestamp. + - Link `run_id` to `/runs/:id` when present. + - Empty state text should make live-only behavior clear: events appear only after this page is opened. + +## Testing + +- Add `apps/fabro-web/app/lib/live-events.test.tsx`: + - Coordinated mode opens `/api/v1/attach`. + - Fallback mode opens `/api/v1/attach`, not `/api/v1/runs/:id/attach`. + - `onEvent` receives all run IDs. + - No SWR mutate keys are emitted. +- Add `apps/fabro-web/app/routes/live-events.test.tsx`: + - Renders empty live-only state initially. + - Appends incoming events newest first. + - Deduplicates repeated event IDs. + - Caps retained events at 1,000. + - Filters by category/search and opens details panel. +- Run: + - `cd apps/fabro-web && bun test` + - `cd apps/fabro-web && bun run typecheck` + +## Assumptions + +- No Rust/server changes are needed because `GET /api/v1/attach` already streams global live events. +- “Live-only” means no replay on connect and no persistence in the browser beyond the current page lifetime. +- The page should be discoverable from main navigation as `Events`; the direct URL `/events` remains the canonical route.