diff --git a/ui/src/pages/AgentDetail.liveRun.test.ts b/ui/src/pages/AgentDetail.liveRun.test.ts new file mode 100644 index 0000000000..0f606690b7 --- /dev/null +++ b/ui/src/pages/AgentDetail.liveRun.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import type { HeartbeatRun } from "@paperclipai/shared"; +import { getRunSnapshotIssueId, resolveLatestRunNavigation, type LatestRunIssue } from "./AgentDetail"; + +const AGENT_ID = "agent-1"; + +function makeRun(overrides: Partial = {}): HeartbeatRun { + return { + id: "d3bd37f9-1111-2222-3333-444455556666", + contextSnapshot: null, + ...overrides, + } as HeartbeatRun; +} + +function issuesMap(...issues: LatestRunIssue[]): Map { + return new Map(issues.map((i) => [i.id, i])); +} + +describe("resolveLatestRunNavigation", () => { + const runHref = `/agents/${AGENT_ID}/runs/d3bd37f9-1111-2222-3333-444455556666`; + + it("resolves the task from contextSnapshot.issueId and points the row at the task detail page", () => { + const issue: LatestRunIssue = { id: "issue-7", title: "Fix the thing", status: "in_progress", identifier: "PAP-7" }; + const run = makeRun({ contextSnapshot: { issueId: "issue-7" } as HeartbeatRun["contextSnapshot"] }); + + const nav = resolveLatestRunNavigation(run, AGENT_ID, issuesMap(issue)); + + expect(nav.task).toBe(issue); + expect(nav.runHref).toBe(runHref); + expect(nav.rowHref).toBe("/issues/PAP-7"); + }); + + it("falls back to contextSnapshot.taskId when issueId is absent (older snapshots)", () => { + const issue: LatestRunIssue = { id: "issue-9", title: "Legacy", status: "todo", identifier: "PAP-9" }; + const run = makeRun({ contextSnapshot: { taskId: "issue-9" } as HeartbeatRun["contextSnapshot"] }); + + const nav = resolveLatestRunNavigation(run, AGENT_ID, issuesMap(issue)); + + expect(nav.task).toBe(issue); + expect(nav.rowHref).toBe("/issues/PAP-9"); + }); + + it("prefers issueId over taskId when both are present", () => { + const wanted: LatestRunIssue = { id: "issue-a", title: "A", status: "in_progress", identifier: "PAP-A" }; + const other: LatestRunIssue = { id: "issue-b", title: "B", status: "in_progress", identifier: "PAP-B" }; + const run = makeRun({ contextSnapshot: { issueId: "issue-a", taskId: "issue-b" } as HeartbeatRun["contextSnapshot"] }); + + const nav = resolveLatestRunNavigation(run, AGENT_ID, issuesMap(wanted, other)); + + expect(nav.task).toBe(wanted); + expect(nav.rowHref).toBe("/issues/PAP-A"); + }); + + it("uses the issue id when the resolved task has no identifier slug", () => { + const issue: LatestRunIssue = { id: "issue-noident", title: "No slug", status: "in_progress", identifier: null }; + const run = makeRun({ contextSnapshot: { issueId: "issue-noident" } as HeartbeatRun["contextSnapshot"] }); + + const nav = resolveLatestRunNavigation(run, AGENT_ID, issuesMap(issue)); + + expect(nav.rowHref).toBe("/issues/issue-noident"); + }); + + it("falls back to the run detail page for a pure timer heartbeat with no snapshot", () => { + const run = makeRun({ contextSnapshot: null }); + + const nav = resolveLatestRunNavigation(run, AGENT_ID, issuesMap()); + + expect(nav.task).toBeUndefined(); + expect(nav.runHref).toBe(runHref); + expect(nav.rowHref).toBe(runHref); + }); + + it("falls back to the run detail page when the snapshot issue is absent from the lookup (LatestRunCard fills this gap via a targeted fetch)", () => { + const run = makeRun({ contextSnapshot: { issueId: "issue-missing" } as HeartbeatRun["contextSnapshot"] }); + + const nav = resolveLatestRunNavigation(run, AGENT_ID, issuesMap()); + + expect(nav.task).toBeUndefined(); + expect(nav.rowHref).toBe(runHref); + }); + + it("resolves an issue fetched directly (outside the bounded assigned-issues page) once folded into the lookup", () => { + // Mirrors LatestRunCard folding a directly-fetched fallback issue into the + // map keyed by the snapshot id, so a live run whose task sits beyond the + // server page limit still links to the task detail page. + const fetched: LatestRunIssue = { id: "issue-far", title: "Far task", status: "in_progress", identifier: "PAP-999" }; + const run = makeRun({ contextSnapshot: { issueId: "issue-far" } as HeartbeatRun["contextSnapshot"] }); + + const nav = resolveLatestRunNavigation(run, AGENT_ID, issuesMap(fetched)); + + expect(nav.task).toBe(fetched); + expect(nav.rowHref).toBe("/issues/PAP-999"); + }); +}); + +describe("getRunSnapshotIssueId", () => { + it("reads issueId from the snapshot", () => { + expect(getRunSnapshotIssueId(makeRun({ contextSnapshot: { issueId: "issue-7" } as HeartbeatRun["contextSnapshot"] }))).toBe("issue-7"); + }); + + it("falls back to taskId for older snapshots", () => { + expect(getRunSnapshotIssueId(makeRun({ contextSnapshot: { taskId: "issue-9" } as HeartbeatRun["contextSnapshot"] }))).toBe("issue-9"); + }); + + it("prefers issueId over taskId", () => { + expect( + getRunSnapshotIssueId(makeRun({ contextSnapshot: { issueId: "issue-a", taskId: "issue-b" } as HeartbeatRun["contextSnapshot"] })), + ).toBe("issue-a"); + }); + + it("returns undefined for a pure timer heartbeat with no snapshot", () => { + expect(getRunSnapshotIssueId(makeRun({ contextSnapshot: null }))).toBeUndefined(); + }); + + it("coerces non-string ids to strings", () => { + expect(getRunSnapshotIssueId(makeRun({ contextSnapshot: { issueId: 42 } as unknown as HeartbeatRun["contextSnapshot"] }))).toBe("42"); + }); +}); diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 4f7a21986c..4e21a7d280 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -41,6 +41,7 @@ import { StatusBadge } from "../components/StatusBadge"; import { MarkdownBody } from "../components/MarkdownBody"; import { CopyText } from "../components/CopyText"; import { EntityRow } from "../components/EntityRow"; +import { StatusGlyph } from "../components/StatusGlyph"; import { MembershipAction } from "../components/MembershipAction"; import { StarToggle } from "../components/StarToggle"; import { Identity } from "../components/Identity"; @@ -1429,21 +1430,81 @@ function SummaryRow({ label, children }: { label: string; children: React.ReactN ); } -function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: string }) { - if (runs.length === 0) return null; +export type LatestRunIssue = { id: string; title: string; status: string; identifier?: string | null }; - const sorted = [...runs].sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() +/** + * The id of the issue a run works on, read from its context snapshot. Newer + * snapshots use `issueId`; older ones use `taskId`. Returns undefined for pure + * timer heartbeats that carry no task reference. + */ +export function getRunSnapshotIssueId( + run: Pick, +): string | undefined { + const ctx = run.contextSnapshot as Record | null; + const issueId = ctx?.issueId ?? ctx?.taskId; + return issueId ? String(issueId) : undefined; +} + +/** + * Resolve the Live Run section's two navigation destinations and the task (if + * any) the run works on. The run→task link lives in the run's context snapshot + * (`issueId`, falling back to `taskId` for older snapshots); the `HeartbeatRun` + * itself doesn't carry the issue id. The heading always links to the run detail + * page; the running row links to the task detail page when the snapshot resolves + * to a known issue, otherwise falls back to the run detail page (pure timer + * heartbeats or an issue that can't be resolved). + */ +export function resolveLatestRunNavigation( + run: Pick, + agentId: string, + issuesById: Map, +): { task: LatestRunIssue | undefined; runHref: string; rowHref: string } { + const issueId = getRunSnapshotIssueId(run); + const task = issueId ? issuesById.get(issueId) : undefined; + const runHref = `/agents/${agentId}/runs/${run.id}`; + const rowHref = task ? `/issues/${task.identifier ?? task.id}` : runHref; + return { task, runHref, rowHref }; +} + +function LatestRunCard({ + runs, + agentId, + issuesById, +}: { + runs: HeartbeatRun[]; + agentId: string; + issuesById: Map; +}) { + const sorted = useMemo( + () => + [...runs].sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ), + [runs] ); const liveRun = sorted.find((r) => r.status === "running" || r.status === "queued"); const run = liveRun ?? sorted[0]; - const isLive = run.status === "running" || run.status === "queued"; - const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" }; - const StatusIcon = statusInfo.icon; - const summaryRaw = run.resultJson - ? String((run.resultJson as Record).summary ?? (run.resultJson as Record).result ?? "") - : run.error ?? ""; + + // The assigned-issues list this card resolves against is bounded (server page + // limit), so a live run can reference a valid issue that isn't on the loaded + // page. When the snapshot points at an issue we don't already have, fetch it + // directly so the running row always links to the task rather than falling + // back to run metadata. `enabled` keeps this a no-op for the common case. + const snapshotIssueId = run ? getRunSnapshotIssueId(run) : undefined; + const needsFallbackFetch = !!snapshotIssueId && !issuesById.has(snapshotIssueId); + const { data: fallbackIssue } = useQuery({ + queryKey: queryKeys.issues.detail(snapshotIssueId ?? "__none__"), + queryFn: () => issuesApi.get(snapshotIssueId as string), + enabled: needsFallbackFetch, + staleTime: 30_000, + }); + + const summaryRaw = run + ? run.resultJson + ? String((run.resultJson as Record).summary ?? (run.resultJson as Record).result ?? "") + : run.error ?? "" + : ""; // Extract a clean 2-3 line excerpt: first non-empty, non-header, non-list-mark lines const summary = useMemo(() => { @@ -1463,28 +1524,48 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin return excerpt.join(" "); }, [summaryRaw]); + if (!run) return null; + + const isLive = run.status === "running" || run.status === "queued"; + // Fold any directly-fetched fallback issue into the lookup, keyed by the same + // snapshot id used to resolve the row so it hits regardless of id-vs-slug. + const effectiveIssuesById = + fallbackIssue && snapshotIssueId + ? new Map(issuesById).set(snapshotIssueId, { + id: fallbackIssue.id, + title: fallbackIssue.title, + status: fallbackIssue.status, + identifier: fallbackIssue.identifier, + }) + : issuesById; + const { task, runHref, rowHref } = resolveLatestRunNavigation(run, agentId, effectiveIssuesById); + const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" }; + const StatusIcon = statusInfo.icon; + return (
-

- {isLive && ( - - - - - )} - {isLive ? "Live Run" : "Latest Run"} -

- View details → +

+ {isLive && ( + + + + + )} + {isLive ? "Live Run" : "Latest Run"} + + · {run.id.slice(0, 8)} + +

- {run.id.slice(0, 8)} - - {sourceLabels[run.invocationSource] ?? run.invocationSource} - + {task ? ( + <> + + + {task.identifier ?? task.id.slice(0, 8)} + + {task.title} + + ) : ( + <> + {run.id.slice(0, 8)} + + {sourceLabels[run.invocationSource] ?? run.invocationSource} + + + )} {relativeTime(run.createdAt)}
@@ -1533,10 +1626,16 @@ function AgentOverview({ agentId: string; agentRouteId: string; }) { + const issuesById = useMemo(() => { + const map = new Map(); + for (const issue of assignedIssues) map.set(issue.id, issue); + return map; + }, [assignedIssues]); + return (
{/* Latest Run */} - + {/* Charts */}