diff --git a/ui/src/lib/issueActiveRun.test.ts b/ui/src/lib/issueActiveRun.test.ts new file mode 100644 index 00000000..78b96118 --- /dev/null +++ b/ui/src/lib/issueActiveRun.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import type { Issue } from "@paperclipai/shared"; +import type { ActiveRunForIssue } from "../api/heartbeats"; +import { resolveIssueActiveRun, shouldTrackIssueActiveRun } from "./issueActiveRun"; + +describe("issueActiveRun", () => { + const makeIssue = ( + overrides: Partial>, + ): Pick => ({ + status: "todo", + executionRunId: null, + ...overrides, + }); + + it("tracks active runs while an issue is still in progress", () => { + expect(shouldTrackIssueActiveRun(makeIssue({ status: "in_progress" }))).toBe(true); + }); + + it("tracks active runs while an execution run id is still attached", () => { + expect(shouldTrackIssueActiveRun(makeIssue({ status: "done", executionRunId: "run-123" }))).toBe(true); + }); + + it("drops stale cached active runs once the issue is closed and unlocked", () => { + const staleActiveRun: ActiveRunForIssue = { + id: "run-123", + status: "running", + invocationSource: "assignment", + triggerDetail: "system", + startedAt: "2026-04-13T01:29:00.000Z", + finishedAt: null, + createdAt: "2026-04-13T01:29:00.000Z", + agentId: "agent-1", + agentName: "Builder", + adapterType: "codex_local", + issueId: "issue-1", + }; + + expect( + resolveIssueActiveRun( + makeIssue({ status: "done" }), + staleActiveRun, + ), + ).toBeNull(); + }); +}); diff --git a/ui/src/lib/issueActiveRun.ts b/ui/src/lib/issueActiveRun.ts new file mode 100644 index 00000000..48b8d5c3 --- /dev/null +++ b/ui/src/lib/issueActiveRun.ts @@ -0,0 +1,15 @@ +import type { Issue } from "@paperclipai/shared"; +import type { ActiveRunForIssue } from "../api/heartbeats"; + +export function shouldTrackIssueActiveRun( + issue: Pick | null | undefined, +): boolean { + return Boolean(issue && (issue.status === "in_progress" || issue.executionRunId)); +} + +export function resolveIssueActiveRun( + issue: Pick | null | undefined, + activeRun: ActiveRunForIssue | null | undefined, +): ActiveRunForIssue | null { + return shouldTrackIssueActiveRun(issue) ? (activeRun ?? null) : null; +} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index fb315979..3fbc72f9 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -26,6 +26,7 @@ import { readIssueDetailHeaderSeed, rememberIssueDetailLocationState, } from "../lib/issueDetailBreadcrumb"; +import { resolveIssueActiveRun, shouldTrackIssueActiveRun } from "../lib/issueActiveRun"; import { hasBlockingShortcutDialog, resolveIssueDetailGoKeyAction, @@ -471,13 +472,15 @@ export function IssueDetail() { placeholderData: keepPreviousData, }); - const { data: activeRun, isLoading: activeRunLoading } = useQuery({ + const shouldPollActiveRun = shouldTrackIssueActiveRun(issue); + const { data: rawActiveRun, isLoading: activeRunLoading } = useQuery({ queryKey: queryKeys.issues.activeRun(issueId!), queryFn: () => heartbeatsApi.activeRunForIssue(issueId!), - enabled: !!issueId && (!!issue?.executionRunId || issue?.status === "in_progress"), + enabled: !!issueId && shouldPollActiveRun, refetchInterval: (liveRuns?.length ?? 0) > 0 ? false : 3000, placeholderData: keepPreviousData, }); + const activeRun = resolveIssueActiveRun(issue, rawActiveRun); const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun; const runningIssueRun = useMemo(