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) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-10 14:05:10 -04:00
parent da368366f0
commit acec32cea9
No known key found for this signature in database
7 changed files with 668 additions and 7 deletions

View file

@ -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 (

View file

@ -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");
}

View file

@ -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<string, unknown>;
}
interface LiveEventOptions {
coordinator?: CrossTabSseCoordinator;
}
const subscriptions = new Map<string, SharedEventSubscription>();
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<LiveEventPayload>({
coordinator,
subscriptionKey: SUBSCRIPTION_KEY,
mutate: NOOP_MUTATE,
debounceMs: 0,
resyncKeys: () => NO_KEYS,
resolveInvalidation: (payload) => {
onEvent(payload);
return { keys: NO_KEYS };
},
fallbackSubscribe: () =>
subscribeToSharedEventSource<LiveEventPayload>({
subscriptions,
subscriptionKey: SUBSCRIPTION_KEY,
url: queryKeys.system.attachUrl(),
mutate: NOOP_MUTATE,
eventSourceFactory,
debounceMs: 0,
resolveInvalidation: (payload) => {
onEvent(payload);
return { keys: NO_KEYS };
},
}),
});
}

View file

@ -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(
<MemoryRouter initialEntries={["/settings/live-events"]}>
<Routes>
<Route path="/settings/live-events" element={<SettingsLiveEvents />} />
</Routes>
</MemoryRouter>,
);
});
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"]);
});
});

View file

@ -1,3 +1,231 @@
export default function SettingsLiveEvents() {
return <div />;
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<LiveEventPayload[]>([]);
const [openKey, setOpenKey] = useState<string | null>(null);
const [selectedCategories, setSelectedCategories] = useState<DebugCategory[]>([]);
const [search, setSearch] = useState("");
useEffect(() => {
return subscribeToLiveEvents((payload) => {
setEvents((prev) => appendLiveEvent(prev, payload));
});
}, []);
const filtered = useMemo<LiveEventPayload[]>(() => {
const useCategoryFilter = selectedCategories.length > 0;
const cats = new Set<DebugCategory>(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<LiveEventPayload | null>(
() => (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 (
<div className="flex min-h-0 flex-1">
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<div className="shrink-0 border-b border-line">
<div className="pr-4 sm:pr-6 lg:pr-8">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 pb-3">
<div className="flex flex-1 flex-wrap items-center gap-2">
<MultiSelectFilter<DebugCategory>
selected={selectedCategories}
options={DEBUG_CATEGORIES}
labelOf={debugCategoryLabel}
onChange={setSelectedCategories}
emptyMeansAll
/>
<EventSearchInput value={search} onChange={setSearch} />
{isFiltering && (
<button
type="button"
onClick={clearFilters}
className="rounded px-2 py-1 text-xs text-fg-muted transition-colors hover:bg-overlay hover:text-fg-2 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-teal-500"
>
Clear
</button>
)}
</div>
{events.length > 0 && (
<span className="text-xs tabular-nums text-fg-muted">
{isFiltering
? `${filtered.length.toLocaleString()} of ${events.length.toLocaleString()} events`
: `${events.length.toLocaleString()} events`}
</span>
)}
</div>
</div>
</div>
<div
data-testid="live-events-list"
className="min-h-0 flex-1 overflow-y-auto pt-2 pb-[calc(1.5rem+var(--fabro-interview-dock-clearance,0px))]"
>
{events.length === 0 ? (
<div className="px-2 py-12">
<EmptyState
title="Waiting for events"
description="This page only shows events that arrive after it's opened. Trigger or wait for activity on any run, and events will stream in here."
/>
</div>
) : filtered.length === 0 ? (
<div className="px-2 py-6 text-sm text-fg-muted">
No events match these filters.
</div>
) : (
filtered.map((event) => (
<LiveEventRow
key={rowKey(event)}
event={event}
selected={openKey === rowKey(event)}
onSelect={() => setOpenKey(rowKey(event))}
/>
))
)}
</div>
</div>
<DebugEventDetailsPanel event={openEvent} onClose={() => setOpenKey(null)} />
</div>
);
}
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<HTMLDivElement>) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect();
}
}
return (
<div
role="button"
tabIndex={0}
onClick={onSelect}
onKeyDown={handleKeyDown}
aria-pressed={selected}
className={`grid w-full grid-cols-[5rem_1fr_10rem_8rem_auto] items-center gap-3 px-5 py-2.5 text-left transition-colors hover:bg-overlay focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-teal-500 ${
selected ? "bg-overlay" : ""
}`}
>
<span
className={`inline-flex w-fit items-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${debugCategoryTone(category)}`}
>
{debugCategoryLabel(category)}
</span>
<span className="min-w-0 truncate font-mono text-xs text-fg-2">
{eventName}
</span>
<span className="min-w-0 truncate font-mono text-xs">
{event.run_id ? (
<Link
to={`/runs/${event.run_id}`}
onClick={(e) => e.stopPropagation()}
className="text-fg-3 hover:text-fg hover:underline"
>
{event.run_id}
</Link>
) : (
<span className="text-fg-muted">—</span>
)}
</span>
<span className="min-w-0 truncate font-mono text-xs text-fg-muted">
{stage ?? ""}
</span>
{event.ts ? (
<Tooltip label={formatAbsoluteTs(event.ts)}>
<span className="font-mono text-xs tabular-nums text-fg-muted">
{formatAbsoluteTs(event.ts)}
</span>
</Tooltip>
) : (
<span className="font-mono text-xs text-fg-muted">—</span>
)}
</div>
);
}

View file

@ -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<string | false | null | undefined>) {
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 (
<div className="flex flex-col gap-6 lg:flex-row">
<div
className={classNames(
"flex flex-col gap-6 lg:flex-row",
fullHeight && "min-h-0 flex-1",
)}
>
<aside className="lg:w-56 lg:shrink-0">
<nav className="sticky top-6">
<ul role="list" className="flex gap-1 overflow-x-auto lg:flex-col lg:gap-0.5">
@ -66,7 +75,12 @@ export default function SettingsLayout() {
</nav>
</aside>
<div className="min-w-0 flex-1">
<div
className={classNames(
"min-w-0 flex-1",
fullHeight && "flex min-h-0 flex-col",
)}
>
<h1 className="mb-2 text-xl font-semibold tracking-tight text-fg">
{currentName}
</h1>

View file

@ -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.