From 3c4fe2fd5ab738abdd2916eeb2dfe245f146bc85 Mon Sep 17 00:00:00 2001 From: Kristaps Karlsons Date: Fri, 7 Aug 2026 21:58:31 +0300 Subject: [PATCH] fix(serve): the run list notices a run appearing or disappearing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `refresh()` is the only thing that reads the run list, and it ran once per project selection plus once when the dashboard itself started a run. Every other way the set changes went unnoticed: a run started with `ratatoskr run` in a terminal never appeared, and one deleted by `ratatoskr runs rm` stayed on screen indefinitely. A reload or a project switch was the only cure. Polled rather than streamed because there is no event for "the set of runs changed". The per-run stream exists only once you know a run to subscribe to, which is precisely the thing being missed. Ten seconds, and only while the tab is visible, with an immediate read when it becomes visible again — a backgrounded dashboard costs nothing and a returning one is current at once. The selection is fixed with it. `refresh` kept the current `runId` unconditionally, so deleting the selected run left its row gone from the list while the detail pane went on showing it. It now keeps a selection that still exists and falls back to the newest when it does not. Verified against a browser held open across the change: 74 rows, delete one, 73 rows fourteen seconds later, no reload. --- crates/ratatoskr-serve/web/src/App.tsx | 37 +++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/ratatoskr-serve/web/src/App.tsx b/crates/ratatoskr-serve/web/src/App.tsx index 401d871..4bb35a5 100644 --- a/crates/ratatoskr-serve/web/src/App.tsx +++ b/crates/ratatoskr-serve/web/src/App.tsx @@ -23,6 +23,14 @@ import { const STALE_MS = 120_000; /** How many live events to keep on screen. Old ones scroll away; the log file keeps everything. */ const FEED_LIMIT = 250; +/** + * How often to re-read the run list while the tab is visible. + * + * A run started or deleted outside the dashboard produces no event to subscribe to, so this is + * the only way the list learns about it. Short enough that a run started in a terminal appears + * while you are still looking for it, long enough that a handful of rows is not worth streaming. + */ +const RUN_LIST_POLL_MS = 10_000; const short = (id: string | null) => (id ? id.slice(0, 8) : "—"); const clock = (ts: string | null) => (ts ? ts.slice(11, 19) : "—"); @@ -650,7 +658,12 @@ export default function App() { const list = await listRuns(project); setRuns(list); setError(null); - setRunId((cur) => cur ?? list[0]?.run_id ?? null); + // Keep the current selection when it still exists, fall back to the newest when it does + // not. A run deleted by `ratatoskr runs rm` would otherwise stay selected: its row is gone + // from the list while the detail pane goes on showing it. + setRunId((cur) => + cur && list.some((r) => r.run_id === cur) ? cur : (list[0]?.run_id ?? null), + ); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } @@ -660,6 +673,28 @@ export default function App() { void refresh(); }, [refresh]); + // The run list changes outside this tab and nothing tells us: `ratatoskr run` in a terminal adds + // one, `runs rm` removes one, and neither goes through the dashboard. Without this the list is + // whatever it was when the project was selected, so a run in flight is invisible until a reload + // and a deleted run lingers indefinitely. + // + // Polled rather than streamed because there is no event for "the set of runs changed" — the + // per-run stream only exists once you know a run to subscribe to, which is the thing being + // missed. Only while the tab is visible, and immediately on becoming visible again, so a + // backgrounded dashboard costs nothing and a returning one is current at once. + useEffect(() => { + if (!project) return; + const tick = () => { + if (document.visibilityState === "visible") void refresh(); + }; + const id = window.setInterval(tick, RUN_LIST_POLL_MS); + document.addEventListener("visibilitychange", tick); + return () => { + window.clearInterval(id); + document.removeEventListener("visibilitychange", tick); + }; + }, [project, refresh]); + // Switching project means a different store entirely, so nothing selected carries over. This // has to happen in the same update as the project change: as a reactive effect it would run // *after* the fetch effects had already re-run, asking the new project for the old run's id.