diff --git a/ui/src/pages/AgentDetail.progress.test.ts b/ui/src/pages/AgentDetail.progress.test.ts new file mode 100644 index 0000000000..03c62e3722 --- /dev/null +++ b/ui/src/pages/AgentDetail.progress.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +import { + buildHeartbeatProgressLogLine, + heartbeatProgressLogLineKey, +} from "./AgentDetail"; + +describe("buildHeartbeatProgressLogLine", () => { + it("renders progress messages with phase prefixes as system log lines", () => { + expect( + buildHeartbeatProgressLogLine( + { + message: "Syncing issue history", + phase: "workspace", + updatedAt: "2026-07-04T05:00:00.000Z", + }, + "2026-07-04T04:59:00.000Z", + ), + ).toEqual({ + ts: "2026-07-04T05:00:00.000Z", + stream: "system", + chunk: "[workspace] Syncing issue history", + }); + }); + + it("renders progress messages without phases using the live event timestamp", () => { + expect( + buildHeartbeatProgressLogLine( + { message: "Preparing workspace" }, + "2026-07-04T05:01:00.000Z", + ), + ).toEqual({ + ts: "2026-07-04T05:01:00.000Z", + stream: "system", + chunk: "Preparing workspace", + }); + }); + + it("ignores empty progress messages", () => { + expect( + buildHeartbeatProgressLogLine( + { message: " ", phase: "workspace" }, + "2026-07-04T05:02:00.000Z", + ), + ).toBeNull(); + }); +}); + +describe("heartbeatProgressLogLineKey", () => { + it("uses the rendered log line fields as the replay key", () => { + const line = { + ts: "2026-07-04T05:03:00.000Z", + stream: "system" as const, + chunk: "[workspace] Syncing issue history", + }; + + expect(heartbeatProgressLogLineKey(line)).toBe( + "2026-07-04T05:03:00.000Z\u0000system\u0000[workspace] Syncing issue history", + ); + }); +}); diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index d591392f3e..7ca04287f8 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -301,7 +301,11 @@ function runMetrics(run: HeartbeatRun) { }; } -type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }; +export type RunLogChunk = { + ts: string; + stream: "stdout" | "stderr" | "system"; + chunk: string; +}; function asRecord(value: unknown): Record | null { if (typeof value !== "object" || value === null || Array.isArray(value)) return null; @@ -314,6 +318,22 @@ function asNonEmptyString(value: unknown): string | null { return trimmed.length > 0 ? trimmed : null; } +export function buildHeartbeatProgressLogLine( + payload: Record, + fallbackTimestamp: string, +): RunLogChunk | null { + const message = asNonEmptyString(payload.message); + if (!message) return null; + const phase = asNonEmptyString(payload.phase); + const ts = asNonEmptyString(payload.updatedAt) ?? fallbackTimestamp; + const chunk = phase ? `[${phase}] ${message}` : message; + return { ts, stream: "system", chunk }; +} + +export function heartbeatProgressLogLineKey(line: RunLogChunk): string { + return `${line.ts}\u0000${line.stream}\u0000${line.chunk}`; +} + export function RunInvocationCard({ payload, censorUsernameInLogs, @@ -3591,6 +3611,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin const [transcriptMode, setTranscriptMode] = useState("nice"); const logEndRef = useRef(null); const pendingLogLineRef = useRef(""); + const seenProgressLogLineKeysRef = useRef>(new Set()); const scrollContainerRef = useRef(null); const isFollowingRef = useRef(false); const lastMetricsRef = useRef<{ scrollHeight: number; distanceFromBottom: number }>({ @@ -3738,6 +3759,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin useEffect(() => { let cancelled = false; pendingLogLineRef.current = ""; + seenProgressLogLineKeysRef.current = new Set(); setLogLines([]); setLogOffset(0); setHasMoreLog(false); @@ -3885,6 +3907,16 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin return; } + if (event.type === "heartbeat.run.progress") { + const line = buildHeartbeatProgressLogLine(payload, event.createdAt); + if (!line) return; + const key = heartbeatProgressLogLineKey(line); + if (seenProgressLogLineKeysRef.current.has(key)) return; + seenProgressLogLineKeysRef.current.add(key); + setLogLines((prev) => [...prev, line]); + return; + } + if (event.type !== "heartbeat.run.event") return; const seq = typeof payload.seq === "number" ? payload.seq : null;