diff --git a/ui/src/lib/optimistic-issue-runs.test.ts b/ui/src/lib/optimistic-issue-runs.test.ts new file mode 100644 index 00000000..8efb9660 --- /dev/null +++ b/ui/src/lib/optimistic-issue-runs.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import type { RunForIssue } from "../api/activity"; +import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats"; +import { removeLiveRunById, upsertInterruptedRun } from "./optimistic-issue-runs"; + +function createLiveRun(overrides: Partial = {}): LiveRunForIssue { + return { + id: "run-1", + status: "running", + invocationSource: "manual", + triggerDetail: null, + startedAt: "2026-04-08T21:00:00.000Z", + finishedAt: null, + createdAt: "2026-04-08T21:00:00.000Z", + agentId: "agent-1", + agentName: "CodexCoder", + adapterType: "codex_local", + ...overrides, + }; +} + +function createActiveRun(overrides: Partial = {}): ActiveRunForIssue { + return { + id: "run-1", + companyId: "company-1", + agentId: "agent-1", + agentName: "CodexCoder", + adapterType: "codex_local", + invocationSource: "on_demand", + triggerDetail: null, + status: "running", + startedAt: new Date("2026-04-08T21:00:00.000Z"), + finishedAt: null, + error: null, + wakeupRequestId: null, + exitCode: null, + signal: null, + usageJson: { inputTokens: 1 }, + resultJson: { summary: "partial" }, + sessionIdBefore: null, + sessionIdAfter: null, + logStore: null, + logRef: null, + logBytes: null, + logSha256: null, + logCompressed: false, + stdoutExcerpt: null, + stderrExcerpt: null, + errorCode: null, + externalRunId: null, + processPid: null, + processStartedAt: null, + retryOfRunId: null, + processLossRetryCount: 0, + contextSnapshot: null, + createdAt: new Date("2026-04-08T21:00:00.000Z"), + updatedAt: new Date("2026-04-08T21:00:00.000Z"), + ...overrides, + }; +} + +describe("upsertInterruptedRun", () => { + it("adds a synthetic cancelled historical run when the live run has not reached linkedRuns yet", () => { + const runs = upsertInterruptedRun(undefined, createLiveRun(), "2026-04-08T21:00:10.000Z"); + expect(runs).toEqual([{ + runId: "run-1", + status: "cancelled", + agentId: "agent-1", + startedAt: "2026-04-08T21:00:00.000Z", + finishedAt: "2026-04-08T21:00:10.000Z", + createdAt: "2026-04-08T21:00:00.000Z", + invocationSource: "manual", + usageJson: null, + resultJson: null, + }]); + }); + + it("updates an existing linked run in place when the interrupted run is already present", () => { + const existing: RunForIssue[] = [{ + runId: "run-1", + status: "running", + agentId: "agent-1", + startedAt: "2026-04-08T21:00:00.000Z", + finishedAt: null, + createdAt: "2026-04-08T21:00:00.000Z", + invocationSource: "manual", + usageJson: { inputTokens: 2 }, + resultJson: { summary: "partial" }, + }]; + + const runs = upsertInterruptedRun(existing, createActiveRun(), "2026-04-08T21:00:11.000Z"); + expect(runs).toEqual([{ + runId: "run-1", + status: "cancelled", + agentId: "agent-1", + startedAt: "2026-04-08T21:00:00.000Z", + finishedAt: "2026-04-08T21:00:11.000Z", + createdAt: "2026-04-08T21:00:00.000Z", + invocationSource: "on_demand", + usageJson: { inputTokens: 2 }, + resultJson: { summary: "partial" }, + }]); + }); +}); + +describe("removeLiveRunById", () => { + it("removes an interrupted live run from the live list", () => { + const runs = removeLiveRunById([ + createLiveRun(), + createLiveRun({ id: "run-2" }), + ], "run-1"); + expect(runs?.map((run) => run.id)).toEqual(["run-2"]); + }); +}); diff --git a/ui/src/lib/optimistic-issue-runs.ts b/ui/src/lib/optimistic-issue-runs.ts new file mode 100644 index 00000000..f344868b --- /dev/null +++ b/ui/src/lib/optimistic-issue-runs.ts @@ -0,0 +1,68 @@ +import type { RunForIssue } from "../api/activity"; +import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats"; + +export interface InterruptRunSource { + id: string; + agentId: string; + startedAt: Date | string | null; + createdAt: Date | string; + invocationSource: string; + usageJson?: Record | null; + resultJson?: Record | null; +} + +function toTimestamp(value: Date | string | null | undefined) { + if (!value) return 0; + return new Date(value).getTime(); +} + +function toIsoString(value: Date | string | null | undefined) { + if (!value) return null; + return value instanceof Date ? value.toISOString() : value; +} + +export function upsertInterruptedRun( + runs: RunForIssue[] | undefined, + run: InterruptRunSource, + finishedAt: string, +): RunForIssue[] { + const nextRun: RunForIssue = { + runId: run.id, + status: "cancelled", + agentId: run.agentId, + startedAt: toIsoString(run.startedAt), + finishedAt, + createdAt: toIsoString(run.createdAt) ?? finishedAt, + invocationSource: run.invocationSource, + usageJson: run.usageJson ?? null, + resultJson: run.resultJson ?? null, + }; + + const current = runs ?? []; + const existingIndex = current.findIndex((entry) => entry.runId === run.id); + if (existingIndex === -1) { + return [...current, nextRun].sort((a, b) => { + const diff = toTimestamp(a.startedAt ?? a.createdAt) - toTimestamp(b.startedAt ?? b.createdAt); + if (diff !== 0) return diff; + return a.runId.localeCompare(b.runId); + }); + } + + const updated = [...current]; + updated[existingIndex] = { + ...updated[existingIndex], + ...nextRun, + usageJson: updated[existingIndex]?.usageJson ?? nextRun.usageJson, + resultJson: updated[existingIndex]?.resultJson ?? nextRun.resultJson, + }; + return updated; +} + +export function removeLiveRunById( + runs: LiveRunForIssue[] | undefined, + runId: string, +) { + if (!runs) return runs; + const nextRuns = runs.filter((run) => run.id !== runId); + return nextRuns.length === runs.length ? runs : nextRuns; +} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 41d0b25f..dc31159d 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -4,7 +4,7 @@ import { Link, useLocation, useNavigate, useParams } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { issuesApi } from "../api/issues"; import { approvalsApi } from "../api/approvals"; -import { activityApi } from "../api/activity"; +import { activityApi, type RunForIssue } from "../api/activity"; import { heartbeatsApi } from "../api/heartbeats"; import { instanceSettingsApi } from "../api/instanceSettings"; import { agentsApi } from "../api/agents"; @@ -42,6 +42,7 @@ import { type IssueCommentReassignment, type OptimisticIssueComment, } from "../lib/optimistic-issue-comments"; +import { removeLiveRunById, upsertInterruptedRun } from "../lib/optimistic-issue-runs"; import { useProjectOrder } from "../hooks/useProjectOrder"; import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils"; import { ApprovalCard } from "../components/ApprovalCard"; @@ -988,6 +989,44 @@ export function IssueDetail() { const interruptQueuedComment = useMutation({ mutationFn: (runId: string) => heartbeatsApi.cancel(runId), + onMutate: async (runId) => { + await queryClient.cancelQueries({ queryKey: queryKeys.issues.runs(issueId!) }); + await queryClient.cancelQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) }); + await queryClient.cancelQueries({ queryKey: queryKeys.issues.activeRun(issueId!) }); + + const previousRuns = queryClient.getQueryData(queryKeys.issues.runs(issueId!)); + const previousLiveRuns = queryClient.getQueryData(queryKeys.issues.liveRuns(issueId!)); + const previousActiveRun = queryClient.getQueryData(queryKeys.issues.activeRun(issueId!)); + const liveRunList = previousLiveRuns ?? liveRuns ?? []; + const cachedActiveRun = previousActiveRun ?? activeRun; + const targetRun = + cachedActiveRun?.id === runId + ? cachedActiveRun + : liveRunList?.find((run) => run.id === runId) ?? runningIssueRun ?? null; + + if (targetRun) { + const interruptedAt = new Date().toISOString(); + queryClient.setQueryData( + queryKeys.issues.runs(issueId!), + (current) => upsertInterruptedRun(current, targetRun, interruptedAt), + ); + } + + queryClient.setQueryData( + queryKeys.issues.liveRuns(issueId!), + (current: typeof liveRuns) => removeLiveRunById(current, runId), + ); + queryClient.setQueryData( + queryKeys.issues.activeRun(issueId!), + (current: typeof activeRun) => (current?.id === runId ? null : current), + ); + + return { + previousRuns, + previousLiveRuns, + previousActiveRun, + }; + }, onSuccess: () => { invalidateIssue(); pushToast({ @@ -996,7 +1035,10 @@ export function IssueDetail() { tone: "success", }); }, - onError: (err) => { + onError: (err, _runId, context) => { + queryClient.setQueryData(queryKeys.issues.runs(issueId!), context?.previousRuns); + queryClient.setQueryData(queryKeys.issues.liveRuns(issueId!), context?.previousLiveRuns); + queryClient.setQueryData(queryKeys.issues.activeRun(issueId!), context?.previousActiveRun); pushToast({ title: "Interrupt failed", body: err instanceof Error ? err.message : "Unable to interrupt the active run",