diff --git a/ui/src/components/transcript/useLiveRunTranscripts.test.tsx b/ui/src/components/transcript/useLiveRunTranscripts.test.tsx
index 29d3364770..eac7a5a01b 100644
--- a/ui/src/components/transcript/useLiveRunTranscripts.test.tsx
+++ b/ui/src/components/transcript/useLiveRunTranscripts.test.tsx
@@ -234,6 +234,42 @@ describe("useLiveRunTranscripts", () => {
container.remove();
});
+ it("does not request persisted logs until a queued run starts", async () => {
+ function Harness({ status }: { status: "queued" | "running" }) {
+ useLiveRunTranscripts({
+ companyId: "company-1",
+ runs: [{ id: "run-queued", status, adapterType: "codex_local" }],
+ });
+ return null;
+ }
+
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ const root = createRoot(container);
+
+ await act(async () => {
+ root.render();
+ await Promise.resolve();
+ });
+
+ expect(logMock).not.toHaveBeenCalled();
+ expect(FakeWebSocket.instances).toHaveLength(0);
+
+ await act(async () => {
+ root.render();
+ await Promise.resolve();
+ });
+
+ expect(logMock).toHaveBeenCalledTimes(1);
+ expect(logMock).toHaveBeenCalledWith("run-queued", 0, 256_000);
+ expect(FakeWebSocket.instances).toHaveLength(1);
+
+ act(() => {
+ root.unmount();
+ });
+ container.remove();
+ });
+
it("can hydrate active runs without opening the live event socket", async () => {
function Harness() {
useLiveRunTranscripts({
diff --git a/ui/src/components/transcript/useLiveRunTranscripts.ts b/ui/src/components/transcript/useLiveRunTranscripts.ts
index 8cd2d1e46d..4a5e9afa79 100644
--- a/ui/src/components/transcript/useLiveRunTranscripts.ts
+++ b/ui/src/components/transcript/useLiveRunTranscripts.ts
@@ -72,6 +72,10 @@ function isTerminalStatus(status: string): boolean {
return status === "failed" || status === "timed_out" || status === "cancelled" || status === "interrupted" || status === "succeeded";
}
+function canReadPersistedLog(run: RunTranscriptSource): boolean {
+ return run.status === "running" || isTerminalStatus(run.status);
+}
+
function runKnownLogBytes(run: RunTranscriptSource): number | null {
const bytes = run.status === "queued"
? run.logBytes
@@ -157,7 +161,7 @@ export function useLiveRunTranscripts({
const runById = useMemo(() => new Map(normalizedRuns.map((run) => [run.id, run])), [normalizedRuns]);
const activeRunIds = useMemo(
- () => new Set(normalizedRuns.filter((run) => !isTerminalStatus(run.status)).map((run) => run.id)),
+ () => new Set(normalizedRuns.filter((run) => run.status === "running").map((run) => run.id)),
[normalizedRuns],
);
const runIdsKey = useMemo(
@@ -276,7 +280,8 @@ export function useLiveRunTranscripts({
}, [normalizedRuns, pruneTick]);
useEffect(() => {
- if (normalizedRuns.length === 0) return;
+ const readableRuns = normalizedRuns.filter(canReadPersistedLog);
+ if (readableRuns.length === 0) return;
let cancelled = false;
@@ -315,11 +320,11 @@ export function useLiveRunTranscripts({
};
const readAll = async () => {
- await Promise.all(normalizedRuns.map((run) => readRunLog(run)));
+ await Promise.all(readableRuns.map((run) => readRunLog(run)));
};
void readAll();
- const activeRuns = normalizedRuns.filter((run) => !isTerminalStatus(run.status));
+ const activeRuns = readableRuns.filter((run) => run.status === "running");
// The realtime websocket is the primary live source when enabled, so the
// recurring poll only needs to run as a slow fallback rather than doubling
// the live update work every couple of seconds.
@@ -513,7 +518,7 @@ export function useLiveRunTranscripts({
return {
transcriptByRun,
- isInitialHydrating: normalizedRuns.some((run) => !hydratedRunIds.has(run.id)),
+ isInitialHydrating: normalizedRuns.some((run) => canReadPersistedLog(run) && !hydratedRunIds.has(run.id)),
hasOutputForRun(runId: string) {
return (chunksByRun.get(runId)?.length ?? 0) > 0 || runById.get(runId)?.hasStoredOutput === true;
},
diff --git a/ui/src/context/LiveUpdatesProvider.test.ts b/ui/src/context/LiveUpdatesProvider.test.ts
index f0ab8cd67b..7f25ccc7e7 100644
--- a/ui/src/context/LiveUpdatesProvider.test.ts
+++ b/ui/src/context/LiveUpdatesProvider.test.ts
@@ -143,6 +143,25 @@ describe("LiveUpdatesProvider issue invalidation", () => {
});
});
+ it("refreshes the selected run detail after lifecycle events", () => {
+ const invalidations: unknown[] = [];
+ const queryClient = {
+ invalidateQueries: (input: unknown) => {
+ invalidations.push(input);
+ },
+ };
+
+ __liveUpdatesTestUtils.invalidateHeartbeatQueries(
+ queryClient as never,
+ "company-1",
+ { runId: "run-1", agentId: "agent-1" },
+ );
+
+ expect(invalidations).toContainEqual({
+ queryKey: queryKeys.runDetail("run-1"),
+ });
+ });
+
it("applies heartbeat progress payloads directly to cached visible issue runs", () => {
const cache = new Map([
[JSON.stringify(queryKeys.liveRuns("company-1")), [{ id: "run-1", currentStatusMessage: null }]],
@@ -1088,8 +1107,10 @@ describe("LiveUpdatesProvider run lifecycle toasts", () => {
describe("applyRunLifecycleToCompanyLiveRuns", () => {
function makeClient(initial: Array<{ id: string; status: string }>) {
+ const initialDetail = initial.find((run) => run.id === "run-1");
const cache = new Map([
[JSON.stringify(queryKeys.liveRuns("company-1")), initial],
+ [JSON.stringify(queryKeys.runDetail("run-1")), initialDetail],
]);
const client = {
getQueryData: (key: unknown) => cache.get(JSON.stringify(key)),
@@ -1100,18 +1121,31 @@ describe("applyRunLifecycleToCompanyLiveRuns", () => {
},
};
const read = () => cache.get(JSON.stringify(queryKeys.liveRuns("company-1")));
- return { client, read };
+ const readDetail = () => cache.get(JSON.stringify(queryKeys.runDetail("run-1")));
+ return { client, read, readDetail };
}
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 { client, read, readDetail } = 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" },
+ {
+ runId: "run-1",
+ status: "succeeded",
+ finishedAt: "2026-07-24T10:00:00.000Z",
+ error: null,
+ errorCode: null,
+ },
);
expect(patched).toBe(true);
expect(read()).toEqual([{ id: "run-2", status: "running" }]);
+ expect(readDetail()).toEqual(expect.objectContaining({
+ status: "succeeded",
+ finishedAt: "2026-07-24T10:00:00.000Z",
+ error: null,
+ errorCode: null,
+ }));
});
it("patches status in place for a run already in the list", () => {
@@ -1125,6 +1159,30 @@ describe("applyRunLifecycleToCompanyLiveRuns", () => {
expect(read()).toEqual([{ id: "run-1", status: "running" }]);
});
+ it("patches the selected run detail when a queued run starts", () => {
+ const { client, readDetail } = makeClient([{ id: "run-1", status: "queued" }]);
+
+ __liveUpdatesTestUtils.applyRunLifecycleToCompanyLiveRuns(
+ client as never,
+ "company-1",
+ {
+ runId: "run-1",
+ status: "running",
+ startedAt: "2026-07-24T09:59:00.000Z",
+ error: null,
+ errorCode: null,
+ },
+ );
+
+ expect(readDetail()).toEqual(expect.objectContaining({
+ id: "run-1",
+ status: "running",
+ startedAt: "2026-07-24T09:59:00.000Z",
+ error: null,
+ errorCode: null,
+ }));
+ });
+
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(
diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx
index b7ffeec361..586c1272c7 100644
--- a/ui/src/context/LiveUpdatesProvider.tsx
+++ b/ui/src/context/LiveUpdatesProvider.tsx
@@ -10,7 +10,7 @@ import {
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 { Agent, HeartbeatRun, Issue, IssueComment, LiveEvent } from "@paperclipai/shared";
import type { RunForIssue } from "../api/activity";
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
import type { CompanyUserDirectoryResponse } from "../api/access";
@@ -868,6 +868,26 @@ function applyRunLifecycleToCompanyLiveRuns(
const status = readString(payload.status);
if (!runId || !status) return false;
+ queryClient.setQueryData(
+ queryKeys.runDetail(runId),
+ (current: HeartbeatRun | undefined) => {
+ if (!current) return current;
+ const has = (key: string) => Object.prototype.hasOwnProperty.call(payload, key);
+ return {
+ ...current,
+ status: status as HeartbeatRun["status"],
+ ...(has("invocationSource")
+ ? { invocationSource: readString(payload.invocationSource) ?? current.invocationSource }
+ : {}),
+ ...(has("triggerDetail") ? { triggerDetail: readString(payload.triggerDetail) } : {}),
+ ...(has("error") ? { error: readString(payload.error) } : {}),
+ ...(has("errorCode") ? { errorCode: readString(payload.errorCode) } : {}),
+ ...(has("startedAt") ? { startedAt: readString(payload.startedAt) } : {}),
+ ...(has("finishedAt") ? { finishedAt: readString(payload.finishedAt) } : {}),
+ };
+ },
+ );
+
if (TERMINAL_RUN_STATUSES.has(status)) {
queryClient.setQueryData(
queryKeys.liveRuns(companyId),
@@ -909,6 +929,10 @@ function invalidateHeartbeatQueries(
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId) });
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(companyId, agentId) });
}
+ const runId = readString(payload.runId);
+ if (runId) {
+ queryClient.invalidateQueries({ queryKey: queryKeys.runDetail(runId) });
+ }
}
function invalidateHeartbeatProgressQueries(
@@ -1287,6 +1311,7 @@ export const __liveUpdatesTestUtils = {
applyRunLiveStatusPatchToCaches,
hydrateVisibleIssueComment,
invalidateActivityQueries,
+ invalidateHeartbeatQueries,
invalidateHeartbeatProgressQueries,
invalidateVisibleIssueRunQueries,
readRunLiveStatusPatchFromPayload,
diff --git a/ui/src/pages/AgentDetail.progress.test.ts b/ui/src/pages/AgentDetail.progress.test.ts
index 83c2623556..e7d017d26d 100644
--- a/ui/src/pages/AgentDetail.progress.test.ts
+++ b/ui/src/pages/AgentDetail.progress.test.ts
@@ -5,6 +5,8 @@ import { queryKeys } from "../lib/queryKeys";
import {
buildHeartbeatProgressLogLine,
heartbeatProgressLogLineKey,
+ runDetailRefetchIntervalMs,
+ shouldPollRunShellLog,
syncAgentRouteAfterRename,
} from "./AgentDetail";
@@ -63,6 +65,22 @@ describe("heartbeatProgressLogLineKey", () => {
});
});
+describe("shouldPollRunShellLog", () => {
+ it("polls only after a queued run has started running", () => {
+ expect(shouldPollRunShellLog("queued")).toBe(false);
+ expect(shouldPollRunShellLog("running")).toBe(true);
+ expect(shouldPollRunShellLog("succeeded")).toBe(false);
+ });
+});
+
+describe("runDetailRefetchIntervalMs", () => {
+ it("polls run state only while the selected run remains queued", () => {
+ expect(runDetailRefetchIntervalMs("queued")).toBe(5_000);
+ expect(runDetailRefetchIntervalMs("running")).toBe(15_000);
+ expect(runDetailRefetchIntervalMs("succeeded")).toBe(false);
+ });
+});
+
describe("syncAgentRouteAfterRename", () => {
it("replaces stale agent routes after a rename changes the URL key", () => {
const queryClient = new QueryClient();
diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx
index b76eb60975..5b87a80505 100644
--- a/ui/src/pages/AgentDetail.tsx
+++ b/ui/src/pages/AgentDetail.tsx
@@ -364,6 +364,16 @@ export function heartbeatProgressLogLineKey(line: RunLogChunk): string {
return `${line.ts}\u0000${line.stream}\u0000${line.chunk}`;
}
+export function shouldPollRunShellLog(status: HeartbeatRun["status"]): boolean {
+ return status === "running";
+}
+
+export function runDetailRefetchIntervalMs(status: HeartbeatRun["status"]): 5000 | 15000 | false {
+ if (status === "queued") return 5000;
+ if (status === "running") return 15000;
+ return false;
+}
+
export function RunInvocationCard({
payload,
censorUsernameInLogs,
@@ -3079,6 +3089,9 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }
queryKey: queryKeys.runDetail(initialRun.id),
queryFn: () => heartbeatsApi.get(initialRun.id),
enabled: Boolean(initialRun.id),
+ refetchInterval: (query) => runDetailRefetchIntervalMs(
+ (query.state.data ?? initialRun).status,
+ ),
});
const run = hydratedRun ?? initialRun;
const metrics = runMetrics(run);
@@ -3594,6 +3607,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
distanceFromBottom: Number.POSITIVE_INFINITY,
});
const isLive = run.status === "running" || run.status === "queued";
+ const shouldPollShellLog = shouldPollRunShellLog(run.status);
const { data: workspaceOperations = [] } = useQuery({
queryKey: queryKeys.runWorkspaceOperations(run.id),
queryFn: () => heartbeatsApi.workspaceOperations(run.id),
@@ -3745,7 +3759,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
setLoadingMoreLog(false);
setLogError(null);
- if (!run.logRef && !isLive) {
+ if (!run.logRef && !shouldPollShellLog) {
setLogLoading(false);
return () => {
cancelled = true;
@@ -3760,10 +3774,10 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
appendLogContent(result.content, result.nextOffset === undefined);
const next = result.nextOffset ?? result.content.length;
setLogOffset(next);
- setHasMoreLog(!isLive && result.nextOffset !== undefined);
+ setHasMoreLog(!shouldPollShellLog && result.nextOffset !== undefined);
} catch (err) {
if (!cancelled) {
- if (isLive && isRunLogUnavailable(err)) {
+ if (shouldPollShellLog && isRunLogUnavailable(err)) {
setLogLoading(false);
return;
}
@@ -3778,7 +3792,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
return () => {
cancelled = true;
};
- }, [run.id, run.logRef, run.logBytes, isLive]);
+ }, [run.id, run.logRef, run.logBytes, shouldPollShellLog]);
async function loadMorePersistedLog() {
if (loadingMoreLog || !hasMoreLog) return;
@@ -3816,7 +3830,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
// Poll shell log for running runs
useEffect(() => {
- if (!isLive || isStreamingConnected) return;
+ if (!shouldPollShellLog || isStreamingConnected) return;
const interval = setInterval(async () => {
try {
const result = await heartbeatsApi.log(run.id, logOffset, 256_000);
@@ -3834,7 +3848,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
}
}, 2000);
return () => clearInterval(interval);
- }, [run.id, isLive, isStreamingConnected, logOffset]);
+ }, [run.id, shouldPollShellLog, isStreamingConnected, logOffset]);
// Stream live updates from websocket (primary path for running runs).
useEffect(() => {