From 5e9c223077141171dcbd86717dfd2108cf807f3e Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 11 Mar 2026 13:29:40 -0500 Subject: [PATCH] Tighten live run transcript streaming and stdout Co-Authored-By: Paperclip --- server/src/services/heartbeat.ts | 4 +- ui/src/components/ActiveAgentsPanel.tsx | 1 + ui/src/components/LiveRunWidget.tsx | 3 +- .../transcript/RunTranscriptView.tsx | 65 +++++++++++++++++-- .../transcript/useLiveRunTranscripts.ts | 7 +- 5 files changed, 68 insertions(+), 12 deletions(-) diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index af0952ac..f4c2715b 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -1368,12 +1368,13 @@ export function heartbeatService(db: Db) { const onLog = async (stream: "stdout" | "stderr", chunk: string) => { if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, chunk); if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, chunk); + const ts = new Date().toISOString(); if (handle) { await runLogStore.append(handle, { stream, chunk, - ts: new Date().toISOString(), + ts, }); } @@ -1388,6 +1389,7 @@ export function heartbeatService(db: Db) { payload: { runId: run.id, agentId: run.agentId, + ts, stream, chunk: payloadChunk, truncated: payloadChunk.length !== chunk.length, diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx index 8712bfda..5991bb9a 100644 --- a/ui/src/components/ActiveAgentsPanel.tsx +++ b/ui/src/components/ActiveAgentsPanel.tsx @@ -146,6 +146,7 @@ function AgentRunCard({ density="compact" limit={5} streaming={isActive} + collapseStdout emptyMessage={hasOutput ? "Waiting for transcript parsing..." : isActive ? "Waiting for output..." : "No transcript captured."} /> diff --git a/ui/src/components/LiveRunWidget.tsx b/ui/src/components/LiveRunWidget.tsx index f320ce7d..2c0f702e 100644 --- a/ui/src/components/LiveRunWidget.tsx +++ b/ui/src/components/LiveRunWidget.tsx @@ -87,7 +87,7 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) { if (runs.length === 0) return null; return ( -
+
Live Runs @@ -147,6 +147,7 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) { density="compact" limit={8} streaming={isActive} + collapseStdout emptyMessage={hasOutputForRun(run.id) ? "Waiting for transcript parsing..." : "Waiting for run output..."} />
diff --git a/ui/src/components/transcript/RunTranscriptView.tsx b/ui/src/components/transcript/RunTranscriptView.tsx index 298bf6c7..8aec4911 100644 --- a/ui/src/components/transcript/RunTranscriptView.tsx +++ b/ui/src/components/transcript/RunTranscriptView.tsx @@ -21,6 +21,7 @@ interface RunTranscriptViewProps { density?: TranscriptDensity; limit?: number; streaming?: boolean; + collapseStdout?: boolean; emptyMessage?: string; className?: string; } @@ -70,6 +71,11 @@ type TranscriptBlock = status: "running" | "completed" | "error"; }>; } + | { + type: "stdout"; + ts: string; + text: string; + } | { type: "event"; ts: string; @@ -480,13 +486,16 @@ function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): Tr continue; } - blocks.push({ - type: "event", - ts: entry.ts, - label: "stdout", - tone: "neutral", - text: entry.text, - }); + if (previous?.type === "stdout") { + previous.text += previous.text.endsWith("\n") || entry.text.startsWith("\n") ? entry.text : `\n${entry.text}`; + previous.ts = entry.ts; + } else { + blocks.push({ + type: "stdout", + ts: entry.ts, + text: entry.text, + }); + } } return groupCommandBlocks(blocks); @@ -859,6 +868,44 @@ function TranscriptEventRow({ ); } +function TranscriptStdoutRow({ + block, + density, + collapseByDefault, +}: { + block: Extract; + density: TranscriptDensity; + collapseByDefault: boolean; +}) { + const [open, setOpen] = useState(!collapseByDefault); + + return ( +
+
+ + stdout + + +
+ {open && ( +
+          {block.text}
+        
+ )} +
+ ); +} + function RawTranscriptView({ entries, density, @@ -903,6 +950,7 @@ export function RunTranscriptView({ density = "comfortable", limit, streaming = false, + collapseStdout = false, emptyMessage = "No transcript yet.", className, }: RunTranscriptViewProps) { @@ -937,6 +985,9 @@ export function RunTranscriptView({ {block.type === "thinking" && } {block.type === "tool" && } {block.type === "command_group" && } + {block.type === "stdout" && ( + + )} {block.type === "activity" && } {block.type === "event" && }
diff --git a/ui/src/components/transcript/useLiveRunTranscripts.ts b/ui/src/components/transcript/useLiveRunTranscripts.ts index acc5b082..993663d8 100644 --- a/ui/src/components/transcript/useLiveRunTranscripts.ts +++ b/ui/src/components/transcript/useLiveRunTranscripts.ts @@ -46,7 +46,7 @@ function parsePersistedLogContent( ts, stream, chunk, - dedupeKey: `persisted:${runId}:${ts}:${stream}:${chunk}`, + dedupeKey: `log:${runId}:${ts}:${stream}:${chunk}`, }); } catch { // Ignore malformed log rows. @@ -202,6 +202,7 @@ export function useLiveRunTranscripts({ if (event.type === "heartbeat.run.log") { const chunk = readString(payload["chunk"]); if (!chunk) return; + const ts = readString(payload["ts"]) ?? event.createdAt; const stream = readString(payload["stream"]) === "stderr" ? "stderr" @@ -209,10 +210,10 @@ export function useLiveRunTranscripts({ ? "system" : "stdout"; appendChunks(runId, [{ - ts: event.createdAt, + ts, stream, chunk, - dedupeKey: `socket:log:${runId}:${event.createdAt}:${stream}:${chunk}`, + dedupeKey: `log:${runId}:${ts}:${stream}:${chunk}`, }]); return; }