diff --git a/ui/src/context/LiveUpdatesProvider.test.ts b/ui/src/context/LiveUpdatesProvider.test.ts index 9d13bfe50a..c1336ab280 100644 --- a/ui/src/context/LiveUpdatesProvider.test.ts +++ b/ui/src/context/LiveUpdatesProvider.test.ts @@ -1085,3 +1085,54 @@ describe("LiveUpdatesProvider run lifecycle toasts", () => { }); }); }); + +describe("applyRunLifecycleToCompanyLiveRuns", () => { + function makeClient(initial: Array<{ id: string; status: string }>) { + const cache = new Map([ + [JSON.stringify(queryKeys.liveRuns("company-1")), initial], + ]); + const client = { + getQueryData: (key: unknown) => cache.get(JSON.stringify(key)), + setQueryData: (key: unknown, updater: unknown) => { + const cacheKey = JSON.stringify(key); + const current = cache.get(cacheKey); + cache.set(cacheKey, typeof updater === "function" ? updater(current) : updater); + }, + }; + const read = () => cache.get(JSON.stringify(queryKeys.liveRuns("company-1"))); + return { client, read }; + } + + it("removes a run on a terminal status (patched, no refetch needed)", () => { + const { client, read } = makeClient([{ id: "run-1", status: "running" }, { id: "run-2", status: "running" }]); + const patched = __liveUpdatesTestUtils.applyRunLifecycleToCompanyLiveRuns( + client as never, + "company-1", + { runId: "run-1", status: "succeeded" }, + ); + expect(patched).toBe(true); + expect(read()).toEqual([{ id: "run-2", status: "running" }]); + }); + + it("patches status in place for a run already in the list", () => { + const { client, read } = makeClient([{ id: "run-1", status: "queued" }]); + const patched = __liveUpdatesTestUtils.applyRunLifecycleToCompanyLiveRuns( + client as never, + "company-1", + { runId: "run-1", status: "running" }, + ); + expect(patched).toBe(true); + expect(read()).toEqual([{ id: "run-1", status: "running" }]); + }); + + it("reports not-patched for a genuinely new run so the caller refetches", () => { + const { client, read } = makeClient([{ id: "run-1", status: "running" }]); + const patched = __liveUpdatesTestUtils.applyRunLifecycleToCompanyLiveRuns( + client as never, + "company-1", + { runId: "run-new", status: "running" }, + ); + expect(patched).toBe(false); + expect(read()).toEqual([{ id: "run-1", status: "running" }]); // unchanged + }); +}); diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index f864e45a6c..59124be446 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, type ReactNode } from "react"; import { useQuery, useQueryClient, type InfiniteData, type QueryClient } from "@tanstack/react-query"; import { createCoalescingQueryClient, createInvalidationBatcher } from "../lib/query-invalidation-batcher"; +import { patchRunStatusInList, removeRunFromList } from "../lib/live-runs-cache"; import type { Agent, Issue, IssueComment, LiveEvent } from "@paperclipai/shared"; import type { RunForIssue } from "../api/activity"; import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats"; @@ -797,12 +798,51 @@ function buildRunStatusToast( }; } +/** + * Event-source the company live-runs list from run-lifecycle events instead of + * invalidating + refetching it. Returns true when the cache was fully patched; + * false means a genuinely new run appeared that can't be reconstructed from the + * event, so the caller should refetch once to pick it up. + */ +function applyRunLifecycleToCompanyLiveRuns( + queryClient: QueryClient, + companyId: string, + payload: Record, +): boolean { + const runId = readString(payload.runId); + const status = readString(payload.status); + if (!runId || !status) return false; + + if (TERMINAL_RUN_STATUSES.has(status)) { + queryClient.setQueryData( + queryKeys.liveRuns(companyId), + (current: LiveRunForIssue[] | undefined) => removeRunFromList(current, runId), + ); + // Always "handled": a terminal run must never be in the live list, so if it + // wasn't present there is deliberately nothing to refetch (removeRunFromList + // was a no-op and we must not re-add it). + return true; + } + + let present = false; + queryClient.setQueryData( + queryKeys.liveRuns(companyId), + (current: LiveRunForIssue[] | undefined) => { + const result = patchRunStatusInList(current, runId, status); + present = result.present; + return result.next; + }, + ); + return present; +} + function invalidateHeartbeatQueries( queryClient: ReturnType, companyId: string, payload: Record, ) { - queryClient.invalidateQueries({ queryKey: queryKeys.liveRuns(companyId) }); + // Note: liveRuns(companyId) is intentionally NOT invalidated here — it is + // event-sourced via applyRunLifecycleToCompanyLiveRuns in the caller. queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(companyId) }); @@ -1062,7 +1102,12 @@ function handleLiveEvent( event.type === "heartbeat.run.queued" || event.type === "heartbeat.run.status" ) { + const liveRunsPatched = applyRunLifecycleToCompanyLiveRuns(queryClient, expectedCompanyId, payload); invalidateHeartbeatQueries(queryClient, expectedCompanyId, payload); + if (!liveRunsPatched) { + // A new run we couldn't reconstruct from the event — refetch once to add it. + queryClient.invalidateQueries({ queryKey: queryKeys.liveRuns(expectedCompanyId) }); + } invalidateVisibleIssueRunQueries(queryClient, pathname, payload); if (event.type === "heartbeat.run.status") { const toast = buildRunStatusToast(payload, nameOf); @@ -1160,6 +1205,7 @@ function closeSocketQuietly(target: LiveUpdatesSocketLike | null, reason: string } export const __liveUpdatesTestUtils = { + applyRunLifecycleToCompanyLiveRuns, buildAgentStatusToast, buildRunStatusToast, closeSocketQuietly, @@ -1259,6 +1305,9 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) { } if (reconnectAttempt > 0) { gateRef.current.suppressUntil = Date.now() + RECONNECT_SUPPRESS_MS; + // Reconcile after a gap: events missed while disconnected can't be + // replayed yet, so refetch the event-sourced live-runs list once. + queryClient.invalidateQueries({ queryKey: queryKeys.liveRuns(liveCompanyId) }); } reconnectAttempt = 0; }; diff --git a/ui/src/lib/live-runs-cache.test.ts b/ui/src/lib/live-runs-cache.test.ts new file mode 100644 index 0000000000..fc388e1ee2 --- /dev/null +++ b/ui/src/lib/live-runs-cache.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import type { LiveRunForIssue } from "../api/heartbeats"; +import { patchRunStatusInList, removeRunFromList } from "./live-runs-cache"; + +function run(id: string, status: string): LiveRunForIssue { + return { + id, + status, + invocationSource: "automation", + triggerDetail: null, + startedAt: null, + finishedAt: null, + createdAt: "2026-07-15T00:00:00.000Z", + agentId: "agent-1", + agentName: "Agent One", + adapterType: "codex_local", + }; +} + +describe("removeRunFromList", () => { + it("removes the matching run", () => { + const list = [run("a", "running"), run("b", "running")]; + expect(removeRunFromList(list, "a")).toEqual([run("b", "running")]); + }); + + it("returns the same reference when the run isn't present", () => { + const list = [run("a", "running")]; + expect(removeRunFromList(list, "zzz")).toBe(list); + }); + + it("handles undefined", () => { + expect(removeRunFromList(undefined, "a")).toBeUndefined(); + }); +}); + +describe("patchRunStatusInList", () => { + it("updates status in place and reports present", () => { + const list = [run("a", "queued"), run("b", "running")]; + const { next, present } = patchRunStatusInList(list, "a", "running"); + expect(present).toBe(true); + expect(next?.find((r) => r.id === "a")?.status).toBe("running"); + expect(next?.find((r) => r.id === "b")).toBe(list[1]); // untouched entry kept by ref + }); + + it("returns the same reference and present=false when the run isn't in the list", () => { + const list = [run("a", "running")]; + const { next, present } = patchRunStatusInList(list, "new", "running"); + expect(present).toBe(false); + expect(next).toBe(list); + }); + + it("returns the same reference (no re-render) when status is unchanged", () => { + const list = [run("a", "running")]; + const { next, present } = patchRunStatusInList(list, "a", "running"); + expect(present).toBe(true); + expect(next).toBe(list); // unchanged → original reference preserved + }); + + it("handles undefined", () => { + const { next, present } = patchRunStatusInList(undefined, "a", "running"); + expect(present).toBe(false); + expect(next).toBeUndefined(); + }); +}); diff --git a/ui/src/lib/live-runs-cache.ts b/ui/src/lib/live-runs-cache.ts new file mode 100644 index 0000000000..f0533d20c6 --- /dev/null +++ b/ui/src/lib/live-runs-cache.ts @@ -0,0 +1,49 @@ +import type { LiveRunForIssue } from "../api/heartbeats"; + +/** + * Pure cache operations for the company `liveRuns` list so run-lifecycle + * websocket events can patch it in place instead of invalidating it and + * triggering a full HTTP refetch. The company live-runs list is observed on + * almost every page (the sidebar), so its refetch is the most ambient source of + * live-update churn — event-sourcing it removes that entirely for the common + * cases (a run finishing, or a status change on a run already in the list). + * + * A genuinely new run can't be reconstructed from a status event alone (the + * list item needs fields the event doesn't carry), so the caller falls back to + * a single refetch for that case; and a reconnect reconciles any missed events. + */ + +/** Remove a run from the list. Returns the same reference if it wasn't present. */ +export function removeRunFromList( + runs: LiveRunForIssue[] | undefined, + runId: string, +): LiveRunForIssue[] | undefined { + if (!runs) return runs; + const next = runs.filter((run) => run.id !== runId); + return next.length === runs.length ? runs : next; +} + +/** + * Update a run's `status` in place. `present` reports whether the run was in the + * list; when it wasn't, `next` is the original reference and the caller should + * refetch to pick up the new run. + */ +export function patchRunStatusInList( + runs: LiveRunForIssue[] | undefined, + runId: string, + status: string, +): { next: LiveRunForIssue[] | undefined; present: boolean } { + if (!runs) return { next: runs, present: false }; + let present = false; + let changed = false; + const next = runs.map((run) => { + if (run.id !== runId) return run; + present = true; + if (run.status === status) return run; + changed = true; + return { ...run, status }; + }); + // Preserve the original reference when nothing actually changed (run absent, + // or its status already matched) so redundant events don't trigger re-renders. + return { next: changed ? next : runs, present }; +}