From 3e8a1f7cdc0c9ccbcdbad243c198409206bc3689 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Wed, 15 Apr 2026 08:54:08 -0400 Subject: [PATCH] feat(web): live-update runs board via SSE Subscribe to the global event stream (GET /api/v1/attach) on the runs board page. When a status-changing event arrives (run.submitted, run.starting, run.running, run.paused, run.completed, run.failed), debounce 500ms then revalidate the loader to refresh the board. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/fabro-web/app/routes/runs.tsx | 36 ++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/apps/fabro-web/app/routes/runs.tsx b/apps/fabro-web/app/routes/runs.tsx index 85c6e9bfe..9f802fb8e 100644 --- a/apps/fabro-web/app/routes/runs.tsx +++ b/apps/fabro-web/app/routes/runs.tsx @@ -1,5 +1,5 @@ -import { useState, useCallback, useRef } from "react"; -import { Link } from "react-router"; +import { useState, useCallback, useEffect, useRef } from "react"; +import { Link, useRevalidator } from "react-router"; import { ChevronDownIcon, ChevronRightIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline"; import { DndContext, @@ -523,6 +523,38 @@ export default function Runs({ loaderData }: any) { const [collapsed, setCollapsed] = useState>(new Set()); const [columns, setColumns] = useState(initialColumns); const lowerQuery = query.toLowerCase(); + const revalidator = useRevalidator(); + + const STATUS_EVENTS = new Set([ + "run.submitted", "run.starting", "run.running", + "run.paused", "run.completed", "run.failed", + ]); + + useEffect(() => { + const source = new EventSource("/api/v1/attach"); + let debounceTimer: ReturnType | undefined; + + source.onmessage = (msg) => { + try { + const payload = JSON.parse(msg.data); + if (STATUS_EVENTS.has(payload.event)) { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => revalidator.revalidate(), 500); + } + } catch { + // ignore malformed events + } + }; + + return () => { + clearTimeout(debounceTimer); + source.close(); + }; + }, []); + + useEffect(() => { + setColumns(initialColumns); + }, [initialColumns]); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),