From 87b8e217014649306457fe68aad802dc2a124a6b Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 11 Mar 2026 10:35:41 -0500 Subject: [PATCH] Humanize run transcripts across run detail and live surfaces Co-Authored-By: Paperclip --- packages/adapter-utils/src/types.ts | 2 +- .../claude-local/src/ui/parse-stdout.ts | 6 + .../codex-local/src/ui/parse-stdout.ts | 2 + .../cursor-local/src/ui/parse-stdout.ts | 7 + .../opencode-local/src/ui/parse-stdout.ts | 1 + .../src/__tests__/codex-local-adapter.test.ts | 1 + .../__tests__/cursor-local-adapter.test.ts | 3 +- .../__tests__/opencode-local-adapter.test.ts | 1 + ui/src/adapters/index.ts | 1 + ui/src/adapters/transcript.ts | 18 +- ui/src/components/ActiveAgentsPanel.tsx | 485 ++----------- ui/src/components/LiveRunWidget.tsx | 636 +++------------- .../transcript/RunTranscriptView.tsx | 681 ++++++++++++++++++ .../transcript/useLiveRunTranscripts.ts | 282 ++++++++ ui/src/pages/AgentDetail.tsx | 151 +--- 15 files changed, 1179 insertions(+), 1098 deletions(-) create mode 100644 ui/src/components/transcript/RunTranscriptView.tsx create mode 100644 ui/src/components/transcript/useLiveRunTranscripts.ts diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 8eb01190..3ffbaec1 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -189,7 +189,7 @@ export type TranscriptEntry = | { kind: "assistant"; ts: string; text: string; delta?: boolean } | { kind: "thinking"; ts: string; text: string; delta?: boolean } | { kind: "user"; ts: string; text: string } - | { kind: "tool_call"; ts: string; name: string; input: unknown } + | { kind: "tool_call"; ts: string; name: string; input: unknown; toolUseId?: string } | { kind: "tool_result"; ts: string; toolUseId: string; content: string; isError: boolean } | { kind: "init"; ts: string; model: string; sessionId: string } | { kind: "result"; ts: string; text: string; inputTokens: number; outputTokens: number; cachedTokens: number; costUsd: number; subtype: string; isError: boolean; errors: string[] } diff --git a/packages/adapters/claude-local/src/ui/parse-stdout.ts b/packages/adapters/claude-local/src/ui/parse-stdout.ts index 51c2b3c6..f7bd1b2a 100644 --- a/packages/adapters/claude-local/src/ui/parse-stdout.ts +++ b/packages/adapters/claude-local/src/ui/parse-stdout.ts @@ -71,6 +71,12 @@ export function parseClaudeStdoutLine(line: string, ts: string): TranscriptEntry kind: "tool_call", ts, name: typeof block.name === "string" ? block.name : "unknown", + toolUseId: + typeof block.id === "string" + ? block.id + : typeof block.tool_use_id === "string" + ? block.tool_use_id + : undefined, input: block.input ?? {}, }); } diff --git a/packages/adapters/codex-local/src/ui/parse-stdout.ts b/packages/adapters/codex-local/src/ui/parse-stdout.ts index bc28f510..7f4028a0 100644 --- a/packages/adapters/codex-local/src/ui/parse-stdout.ts +++ b/packages/adapters/codex-local/src/ui/parse-stdout.ts @@ -64,6 +64,7 @@ function parseCommandExecutionItem( kind: "tool_call", ts, name: "command_execution", + toolUseId: id || command || "command_execution", input: { id, command, @@ -148,6 +149,7 @@ function parseCodexItem( kind: "tool_call", ts, name: asString(item.name, "unknown"), + toolUseId: asString(item.id), input: item.input ?? {}, }]; } diff --git a/packages/adapters/cursor-local/src/ui/parse-stdout.ts b/packages/adapters/cursor-local/src/ui/parse-stdout.ts index 33fd970b..43e56d55 100644 --- a/packages/adapters/cursor-local/src/ui/parse-stdout.ts +++ b/packages/adapters/cursor-local/src/ui/parse-stdout.ts @@ -142,6 +142,12 @@ function parseAssistantMessage(messageRaw: unknown, ts: string): TranscriptEntry kind: "tool_call", ts, name, + toolUseId: + asString(part.tool_use_id) || + asString(part.toolUseId) || + asString(part.call_id) || + asString(part.id) || + undefined, input, }); continue; @@ -199,6 +205,7 @@ function parseCursorToolCallEvent(event: Record, ts: string): T kind: "tool_call", ts, name: toolName, + toolUseId: callId, input, }]; } diff --git a/packages/adapters/opencode-local/src/ui/parse-stdout.ts b/packages/adapters/opencode-local/src/ui/parse-stdout.ts index 2060125a..f8c98633 100644 --- a/packages/adapters/opencode-local/src/ui/parse-stdout.ts +++ b/packages/adapters/opencode-local/src/ui/parse-stdout.ts @@ -50,6 +50,7 @@ function parseToolUse(parsed: Record, ts: string): TranscriptEn kind: "tool_call", ts, name: toolName, + toolUseId: asString(part.callID) || asString(part.id) || undefined, input, }; diff --git a/server/src/__tests__/codex-local-adapter.test.ts b/server/src/__tests__/codex-local-adapter.test.ts index 6136f76a..07733399 100644 --- a/server/src/__tests__/codex-local-adapter.test.ts +++ b/server/src/__tests__/codex-local-adapter.test.ts @@ -70,6 +70,7 @@ describe("codex_local ui stdout parser", () => { kind: "tool_call", ts, name: "command_execution", + toolUseId: "item_2", input: { id: "item_2", command: "/bin/zsh -lc ls" }, }, ]); diff --git a/server/src/__tests__/cursor-local-adapter.test.ts b/server/src/__tests__/cursor-local-adapter.test.ts index 258109cb..a1949af1 100644 --- a/server/src/__tests__/cursor-local-adapter.test.ts +++ b/server/src/__tests__/cursor-local-adapter.test.ts @@ -165,6 +165,7 @@ describe("cursor ui stdout parser", () => { kind: "tool_call", ts, name: "shellToolCall", + toolUseId: "call_shell_1", input: { command: longCommand }, }, ]); @@ -254,7 +255,7 @@ describe("cursor ui stdout parser", () => { }), ts, ), - ).toEqual([{ kind: "tool_call", ts, name: "readToolCall", input: { path: "README.md" } }]); + ).toEqual([{ kind: "tool_call", ts, name: "readToolCall", toolUseId: "call_1", input: { path: "README.md" } }]); expect( parseCursorStdoutLine( diff --git a/server/src/__tests__/opencode-local-adapter.test.ts b/server/src/__tests__/opencode-local-adapter.test.ts index e37bc1ec..d4f89a49 100644 --- a/server/src/__tests__/opencode-local-adapter.test.ts +++ b/server/src/__tests__/opencode-local-adapter.test.ts @@ -103,6 +103,7 @@ describe("opencode_local ui stdout parser", () => { kind: "tool_call", ts, name: "bash", + toolUseId: "call_1", input: { command: "ls -1" }, }, { diff --git a/ui/src/adapters/index.ts b/ui/src/adapters/index.ts index b92299e0..a4be1438 100644 --- a/ui/src/adapters/index.ts +++ b/ui/src/adapters/index.ts @@ -6,3 +6,4 @@ export type { UIAdapterModule, AdapterConfigFieldsProps, } from "./types"; +export type { RunLogChunk } from "./transcript"; diff --git a/ui/src/adapters/transcript.ts b/ui/src/adapters/transcript.ts index 143f472a..394fd999 100644 --- a/ui/src/adapters/transcript.ts +++ b/ui/src/adapters/transcript.ts @@ -1,8 +1,8 @@ import type { TranscriptEntry, StdoutLineParser } from "./types"; -type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }; +export type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }; -function appendTranscriptEntry(entries: TranscriptEntry[], entry: TranscriptEntry) { +export function appendTranscriptEntry(entries: TranscriptEntry[], entry: TranscriptEntry) { if ((entry.kind === "thinking" || entry.kind === "assistant") && entry.delta) { const last = entries[entries.length - 1]; if (last && last.kind === entry.kind && last.delta) { @@ -14,6 +14,12 @@ function appendTranscriptEntry(entries: TranscriptEntry[], entry: TranscriptEntr entries.push(entry); } +export function appendTranscriptEntries(entries: TranscriptEntry[], incoming: TranscriptEntry[]) { + for (const entry of incoming) { + appendTranscriptEntry(entries, entry); + } +} + export function buildTranscript(chunks: RunLogChunk[], parser: StdoutLineParser): TranscriptEntry[] { const entries: TranscriptEntry[] = []; let stdoutBuffer = ""; @@ -34,18 +40,14 @@ export function buildTranscript(chunks: RunLogChunk[], parser: StdoutLineParser) for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; - for (const entry of parser(trimmed, chunk.ts)) { - appendTranscriptEntry(entries, entry); - } + appendTranscriptEntries(entries, parser(trimmed, chunk.ts)); } } const trailing = stdoutBuffer.trim(); if (trailing) { const ts = chunks.length > 0 ? chunks[chunks.length - 1]!.ts : new Date().toISOString(); - for (const entry of parser(trailing, ts)) { - appendTranscriptEntry(entries, entry); - } + appendTranscriptEntries(entries, parser(trailing, ts)); } return entries; diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx index d3dcd1d3..cbbeb225 100644 --- a/ui/src/components/ActiveAgentsPanel.tsx +++ b/ui/src/components/ActiveAgentsPanel.tsx @@ -1,191 +1,19 @@ -import { useEffect, useMemo, useRef, useState, type MutableRefObject } from "react"; +import { useMemo } from "react"; import { Link } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; -import type { Issue, LiveEvent } from "@paperclipai/shared"; +import type { Issue } from "@paperclipai/shared"; import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats"; import { issuesApi } from "../api/issues"; -import { getUIAdapter } from "../adapters"; import type { TranscriptEntry } from "../adapters"; import { queryKeys } from "../lib/queryKeys"; import { cn, relativeTime } from "../lib/utils"; import { ExternalLink } from "lucide-react"; import { Identity } from "./Identity"; +import { RunTranscriptView } from "./transcript/RunTranscriptView"; +import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts"; -type FeedTone = "info" | "warn" | "error" | "assistant" | "tool"; - -interface FeedItem { - id: string; - ts: string; - runId: string; - agentId: string; - agentName: string; - text: string; - tone: FeedTone; - dedupeKey: string; - streamingKind?: "assistant" | "thinking"; -} - -const MAX_FEED_ITEMS = 40; -const MAX_FEED_TEXT_LENGTH = 220; -const MAX_STREAMING_TEXT_LENGTH = 4000; const MIN_DASHBOARD_RUNS = 4; -function readString(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value : null; -} - -function summarizeEntry(entry: TranscriptEntry): { text: string; tone: FeedTone } | null { - if (entry.kind === "assistant") { - const text = entry.text.trim(); - return text ? { text, tone: "assistant" } : null; - } - if (entry.kind === "thinking") { - const text = entry.text.trim(); - return text ? { text: `[thinking] ${text}`, tone: "info" } : null; - } - if (entry.kind === "tool_call") { - return { text: `tool ${entry.name}`, tone: "tool" }; - } - if (entry.kind === "tool_result") { - const base = entry.content.trim(); - return { - text: entry.isError ? `tool error: ${base}` : `tool result: ${base}`, - tone: entry.isError ? "error" : "tool", - }; - } - if (entry.kind === "stderr") { - const text = entry.text.trim(); - return text ? { text, tone: "error" } : null; - } - if (entry.kind === "system") { - const text = entry.text.trim(); - return text ? { text, tone: "warn" } : null; - } - if (entry.kind === "stdout") { - const text = entry.text.trim(); - return text ? { text, tone: "info" } : null; - } - return null; -} - -function createFeedItem( - run: LiveRunForIssue, - ts: string, - text: string, - tone: FeedTone, - nextId: number, - options?: { - streamingKind?: "assistant" | "thinking"; - preserveWhitespace?: boolean; - }, -): FeedItem | null { - if (!text.trim()) return null; - const base = options?.preserveWhitespace ? text : text.trim(); - const maxLength = options?.streamingKind ? MAX_STREAMING_TEXT_LENGTH : MAX_FEED_TEXT_LENGTH; - const normalized = base.length > maxLength ? base.slice(-maxLength) : base; - return { - id: `${run.id}:${nextId}`, - ts, - runId: run.id, - agentId: run.agentId, - agentName: run.agentName, - text: normalized, - tone, - dedupeKey: `feed:${run.id}:${ts}:${tone}:${normalized}`, - streamingKind: options?.streamingKind, - }; -} - -function parseStdoutChunk( - run: LiveRunForIssue, - chunk: string, - ts: string, - pendingByRun: Map, - nextIdRef: MutableRefObject, -): FeedItem[] { - const pendingKey = `${run.id}:stdout`; - const combined = `${pendingByRun.get(pendingKey) ?? ""}${chunk}`; - const split = combined.split(/\r?\n/); - pendingByRun.set(pendingKey, split.pop() ?? ""); - const adapter = getUIAdapter(run.adapterType); - - const summarized: Array<{ text: string; tone: FeedTone; streamingKind?: "assistant" | "thinking" }> = []; - const appendSummary = (entry: TranscriptEntry) => { - if (entry.kind === "assistant" && entry.delta) { - const text = entry.text; - if (!text.trim()) return; - const last = summarized[summarized.length - 1]; - if (last && last.streamingKind === "assistant") { - last.text += text; - } else { - summarized.push({ text, tone: "assistant", streamingKind: "assistant" }); - } - return; - } - - if (entry.kind === "thinking" && entry.delta) { - const text = entry.text; - if (!text.trim()) return; - const last = summarized[summarized.length - 1]; - if (last && last.streamingKind === "thinking") { - last.text += text; - } else { - summarized.push({ text: `[thinking] ${text}`, tone: "info", streamingKind: "thinking" }); - } - return; - } - - const summary = summarizeEntry(entry); - if (!summary) return; - summarized.push({ text: summary.text, tone: summary.tone }); - }; - - const items: FeedItem[] = []; - for (const line of split.slice(-8)) { - const trimmed = line.trim(); - if (!trimmed) continue; - const parsed = adapter.parseStdoutLine(trimmed, ts); - if (parsed.length === 0) { - const fallback = createFeedItem(run, ts, trimmed, "info", nextIdRef.current++); - if (fallback) items.push(fallback); - continue; - } - for (const entry of parsed) { - appendSummary(entry); - } - } - - for (const summary of summarized) { - const item = createFeedItem(run, ts, summary.text, summary.tone, nextIdRef.current++, { - streamingKind: summary.streamingKind, - preserveWhitespace: !!summary.streamingKind, - }); - if (item) items.push(item); - } - - return items; -} - -function parseStderrChunk( - run: LiveRunForIssue, - chunk: string, - ts: string, - pendingByRun: Map, - nextIdRef: MutableRefObject, -): FeedItem[] { - const pendingKey = `${run.id}:stderr`; - const combined = `${pendingByRun.get(pendingKey) ?? ""}${chunk}`; - const split = combined.split(/\r?\n/); - pendingByRun.set(pendingKey, split.pop() ?? ""); - - const items: FeedItem[] = []; - for (const line of split.slice(-8)) { - const item = createFeedItem(run, ts, line, "error", nextIdRef.current++); - if (item) items.push(item); - } - return items; -} - function isRunActive(run: LiveRunForIssue): boolean { return run.status === "queued" || run.status === "running"; } @@ -195,11 +23,6 @@ interface ActiveAgentsPanelProps { } export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) { - const [feedByRun, setFeedByRun] = useState>(new Map()); - const seenKeysRef = useRef(new Set()); - const pendingByRunRef = useRef(new Map()); - const nextIdRef = useRef(1); - const { data: liveRuns } = useQuery({ queryKey: [...queryKeys.liveRuns(companyId), "dashboard"], queryFn: () => heartbeatsApi.liveRunsForCompany(companyId, MIN_DASHBOARD_RUNS), @@ -220,179 +43,30 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) { return map; }, [issues]); - const runById = useMemo(() => new Map(runs.map((r) => [r.id, r])), [runs]); - const activeRunIds = useMemo(() => new Set(runs.filter(isRunActive).map((r) => r.id)), [runs]); - - // Clean up pending buffers for runs that ended - useEffect(() => { - const stillActive = new Set(); - for (const runId of activeRunIds) { - stillActive.add(`${runId}:stdout`); - stillActive.add(`${runId}:stderr`); - } - for (const key of pendingByRunRef.current.keys()) { - if (!stillActive.has(key)) { - pendingByRunRef.current.delete(key); - } - } - }, [activeRunIds]); - - // WebSocket connection for streaming - useEffect(() => { - if (activeRunIds.size === 0) return; - - let closed = false; - let reconnectTimer: number | null = null; - let socket: WebSocket | null = null; - - const appendItems = (runId: string, items: FeedItem[]) => { - if (items.length === 0) return; - setFeedByRun((prev) => { - const next = new Map(prev); - const existing = [...(next.get(runId) ?? [])]; - for (const item of items) { - if (seenKeysRef.current.has(item.dedupeKey)) continue; - seenKeysRef.current.add(item.dedupeKey); - - const last = existing[existing.length - 1]; - if ( - item.streamingKind && - last && - last.runId === item.runId && - last.streamingKind === item.streamingKind - ) { - const mergedText = `${last.text}${item.text}`; - const nextText = - mergedText.length > MAX_STREAMING_TEXT_LENGTH - ? mergedText.slice(-MAX_STREAMING_TEXT_LENGTH) - : mergedText; - existing[existing.length - 1] = { - ...last, - ts: item.ts, - text: nextText, - dedupeKey: last.dedupeKey, - }; - continue; - } - - existing.push(item); - } - if (seenKeysRef.current.size > 6000) { - seenKeysRef.current.clear(); - } - next.set(runId, existing.slice(-MAX_FEED_ITEMS)); - return next; - }); - }; - - const scheduleReconnect = () => { - if (closed) return; - reconnectTimer = window.setTimeout(connect, 1500); - }; - - const connect = () => { - if (closed) return; - const protocol = window.location.protocol === "https:" ? "wss" : "ws"; - const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(companyId)}/events/ws`; - socket = new WebSocket(url); - - socket.onmessage = (message) => { - const raw = typeof message.data === "string" ? message.data : ""; - if (!raw) return; - - let event: LiveEvent; - try { - event = JSON.parse(raw) as LiveEvent; - } catch { - return; - } - - if (event.companyId !== companyId) return; - const payload = event.payload ?? {}; - const runId = readString(payload["runId"]); - if (!runId || !activeRunIds.has(runId)) return; - - const run = runById.get(runId); - if (!run) return; - - if (event.type === "heartbeat.run.event") { - const seq = typeof payload["seq"] === "number" ? payload["seq"] : null; - const eventType = readString(payload["eventType"]) ?? "event"; - const messageText = readString(payload["message"]) ?? eventType; - const dedupeKey = `${runId}:event:${seq ?? `${eventType}:${messageText}:${event.createdAt}`}`; - if (seenKeysRef.current.has(dedupeKey)) return; - seenKeysRef.current.add(dedupeKey); - if (seenKeysRef.current.size > 6000) seenKeysRef.current.clear(); - const tone = eventType === "error" ? "error" : eventType === "lifecycle" ? "warn" : "info"; - const item = createFeedItem(run, event.createdAt, messageText, tone, nextIdRef.current++); - if (item) appendItems(run.id, [item]); - return; - } - - if (event.type === "heartbeat.run.status") { - const status = readString(payload["status"]) ?? "updated"; - const dedupeKey = `${runId}:status:${status}:${readString(payload["finishedAt"]) ?? ""}`; - if (seenKeysRef.current.has(dedupeKey)) return; - seenKeysRef.current.add(dedupeKey); - if (seenKeysRef.current.size > 6000) seenKeysRef.current.clear(); - const tone = status === "failed" || status === "timed_out" ? "error" : "warn"; - const item = createFeedItem(run, event.createdAt, `run ${status}`, tone, nextIdRef.current++); - if (item) appendItems(run.id, [item]); - return; - } - - if (event.type === "heartbeat.run.log") { - const chunk = readString(payload["chunk"]); - if (!chunk) return; - const stream = readString(payload["stream"]) === "stderr" ? "stderr" : "stdout"; - if (stream === "stderr") { - appendItems(run.id, parseStderrChunk(run, chunk, event.createdAt, pendingByRunRef.current, nextIdRef)); - return; - } - appendItems(run.id, parseStdoutChunk(run, chunk, event.createdAt, pendingByRunRef.current, nextIdRef)); - } - }; - - socket.onerror = () => { - socket?.close(); - }; - - socket.onclose = () => { - scheduleReconnect(); - }; - }; - - connect(); - - return () => { - closed = true; - if (reconnectTimer !== null) window.clearTimeout(reconnectTimer); - if (socket) { - socket.onmessage = null; - socket.onerror = null; - socket.onclose = null; - socket.close(1000, "active_agents_panel_unmount"); - } - }; - }, [activeRunIds, companyId, runById]); + const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({ + runs, + companyId, + maxChunksPerRun: 120, + }); return (
-

+

Agents

{runs.length === 0 ? ( -
+

No recent agent runs.

) : ( -
+
{runs.map((run) => ( ))} @@ -405,104 +79,75 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) { function AgentRunCard({ run, issue, - feed, + transcript, + hasOutput, isActive, }: { run: LiveRunForIssue; issue?: Issue; - feed: FeedItem[]; + transcript: TranscriptEntry[]; + hasOutput: boolean; isActive: boolean; }) { - const bodyRef = useRef(null); - const recent = feed.slice(-20); - - useEffect(() => { - const body = bodyRef.current; - if (!body) return; - body.scrollTo({ top: body.scrollHeight, behavior: "smooth" }); - }, [feed.length]); - return (
- {/* Header */} -
-
- {isActive ? ( - - - - - ) : ( - - - - )} - - {isActive && ( - Live - )} -
- - - -
+
+
+
+
+ {isActive ? ( + + + + + ) : ( + + )} + +
+
+ {isActive ? "Live now" : run.finishedAt ? `Finished ${relativeTime(run.finishedAt)}` : `Started ${relativeTime(run.createdAt)}`} +
+
- {/* Issue context */} - {run.issueId && ( -
- {issue?.identifier ?? run.issueId.slice(0, 8)} - {issue?.title ? ` - ${issue.title}` : ""} +
- )} - {/* Feed body */} -
- {isActive && recent.length === 0 && ( -
Waiting for output...
- )} - {!isActive && recent.length === 0 && ( -
- {run.finishedAt ? `Finished ${relativeTime(run.finishedAt)}` : `Started ${relativeTime(run.createdAt)}`} + {run.issueId && ( +
+ + {issue?.identifier ?? run.issueId.slice(0, 8)} + {issue?.title ? ` - ${issue.title}` : ""} +
)} - {recent.map((item, index) => ( -
- {relativeTime(item.ts)} - - {item.text} - -
- ))} +
+ +
+
); diff --git a/ui/src/components/LiveRunWidget.tsx b/ui/src/components/LiveRunWidget.tsx index 9d176179..f320ce7d 100644 --- a/ui/src/components/LiveRunWidget.tsx +++ b/ui/src/components/LiveRunWidget.tsx @@ -1,262 +1,32 @@ -import { useEffect, useMemo, useRef, useState, type MutableRefObject } from "react"; +import { useMemo, useState } from "react"; import { Link } from "@/lib/router"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import type { LiveEvent } from "@paperclipai/shared"; import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats"; -import { getUIAdapter } from "../adapters"; -import type { TranscriptEntry } from "../adapters"; import { queryKeys } from "../lib/queryKeys"; -import { cn, relativeTime, formatDateTime } from "../lib/utils"; +import { formatDateTime } from "../lib/utils"; import { ExternalLink, Square } from "lucide-react"; import { Identity } from "./Identity"; import { StatusBadge } from "./StatusBadge"; +import { RunTranscriptView } from "./transcript/RunTranscriptView"; +import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts"; interface LiveRunWidgetProps { issueId: string; companyId?: string | null; } -type FeedTone = "info" | "warn" | "error" | "assistant" | "tool"; - -interface FeedItem { - id: string; - ts: string; - runId: string; - agentId: string; - agentName: string; - text: string; - tone: FeedTone; - dedupeKey: string; - streamingKind?: "assistant" | "thinking"; -} - -const MAX_FEED_ITEMS = 80; -const MAX_FEED_TEXT_LENGTH = 220; -const MAX_STREAMING_TEXT_LENGTH = 4000; -const LOG_POLL_INTERVAL_MS = 2000; -const LOG_READ_LIMIT_BYTES = 256_000; - -function readString(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value : null; -} - function toIsoString(value: string | Date | null | undefined): string | null { if (!value) return null; return typeof value === "string" ? value : value.toISOString(); } -function summarizeEntry(entry: TranscriptEntry): { text: string; tone: FeedTone } | null { - if (entry.kind === "assistant") { - const text = entry.text.trim(); - return text ? { text, tone: "assistant" } : null; - } - if (entry.kind === "thinking") { - const text = entry.text.trim(); - return text ? { text: `[thinking] ${text}`, tone: "info" } : null; - } - if (entry.kind === "tool_call") { - return { text: `tool ${entry.name}`, tone: "tool" }; - } - if (entry.kind === "tool_result") { - const base = entry.content.trim(); - return { - text: entry.isError ? `tool error: ${base}` : `tool result: ${base}`, - tone: entry.isError ? "error" : "tool", - }; - } - if (entry.kind === "stderr") { - const text = entry.text.trim(); - return text ? { text, tone: "error" } : null; - } - if (entry.kind === "system") { - const text = entry.text.trim(); - return text ? { text, tone: "warn" } : null; - } - if (entry.kind === "stdout") { - const text = entry.text.trim(); - return text ? { text, tone: "info" } : null; - } - return null; -} - -function createFeedItem( - run: LiveRunForIssue, - ts: string, - text: string, - tone: FeedTone, - nextId: number, - options?: { - streamingKind?: "assistant" | "thinking"; - preserveWhitespace?: boolean; - }, -): FeedItem | null { - if (!text.trim()) return null; - const base = options?.preserveWhitespace ? text : text.trim(); - const maxLength = options?.streamingKind ? MAX_STREAMING_TEXT_LENGTH : MAX_FEED_TEXT_LENGTH; - const normalized = base.length > maxLength ? base.slice(-maxLength) : base; - return { - id: `${run.id}:${nextId}`, - ts, - runId: run.id, - agentId: run.agentId, - agentName: run.agentName, - text: normalized, - tone, - dedupeKey: `feed:${run.id}:${ts}:${tone}:${normalized}`, - streamingKind: options?.streamingKind, - }; -} - -function parseStdoutChunk( - run: LiveRunForIssue, - chunk: string, - ts: string, - pendingByRun: Map, - nextIdRef: MutableRefObject, -): FeedItem[] { - const pendingKey = `${run.id}:stdout`; - const combined = `${pendingByRun.get(pendingKey) ?? ""}${chunk}`; - const split = combined.split(/\r?\n/); - pendingByRun.set(pendingKey, split.pop() ?? ""); - const adapter = getUIAdapter(run.adapterType); - - const summarized: Array<{ text: string; tone: FeedTone; streamingKind?: "assistant" | "thinking" }> = []; - const appendSummary = (entry: TranscriptEntry) => { - if (entry.kind === "assistant" && entry.delta) { - const text = entry.text; - if (!text.trim()) return; - const last = summarized[summarized.length - 1]; - if (last && last.streamingKind === "assistant") { - last.text += text; - } else { - summarized.push({ text, tone: "assistant", streamingKind: "assistant" }); - } - return; - } - - if (entry.kind === "thinking" && entry.delta) { - const text = entry.text; - if (!text.trim()) return; - const last = summarized[summarized.length - 1]; - if (last && last.streamingKind === "thinking") { - last.text += text; - } else { - summarized.push({ text: `[thinking] ${text}`, tone: "info", streamingKind: "thinking" }); - } - return; - } - - const summary = summarizeEntry(entry); - if (!summary) return; - summarized.push({ text: summary.text, tone: summary.tone }); - }; - - const items: FeedItem[] = []; - for (const line of split.slice(-8)) { - const trimmed = line.trim(); - if (!trimmed) continue; - const parsed = adapter.parseStdoutLine(trimmed, ts); - if (parsed.length === 0) { - if (run.adapterType === "openclaw_gateway") { - continue; - } - const fallback = createFeedItem(run, ts, trimmed, "info", nextIdRef.current++); - if (fallback) items.push(fallback); - continue; - } - for (const entry of parsed) { - appendSummary(entry); - } - } - - for (const summary of summarized) { - const item = createFeedItem(run, ts, summary.text, summary.tone, nextIdRef.current++, { - streamingKind: summary.streamingKind, - preserveWhitespace: !!summary.streamingKind, - }); - if (item) items.push(item); - } - - return items; -} - -function parseStderrChunk( - run: LiveRunForIssue, - chunk: string, - ts: string, - pendingByRun: Map, - nextIdRef: MutableRefObject, -): FeedItem[] { - const pendingKey = `${run.id}:stderr`; - const combined = `${pendingByRun.get(pendingKey) ?? ""}${chunk}`; - const split = combined.split(/\r?\n/); - pendingByRun.set(pendingKey, split.pop() ?? ""); - - const items: FeedItem[] = []; - for (const line of split.slice(-8)) { - const item = createFeedItem(run, ts, line, "error", nextIdRef.current++); - if (item) items.push(item); - } - return items; -} - -function parsePersistedLogContent( - runId: string, - content: string, - pendingByRun: Map, -): Array<{ ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }> { - if (!content) return []; - - const pendingKey = `${runId}:records`; - const combined = `${pendingByRun.get(pendingKey) ?? ""}${content}`; - const split = combined.split("\n"); - pendingByRun.set(pendingKey, split.pop() ?? ""); - - const parsed: Array<{ ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }> = []; - for (const line of split) { - const trimmed = line.trim(); - if (!trimmed) continue; - try { - const raw = JSON.parse(trimmed) as { ts?: unknown; stream?: unknown; chunk?: unknown }; - const stream = raw.stream === "stderr" || raw.stream === "system" ? raw.stream : "stdout"; - const chunk = typeof raw.chunk === "string" ? raw.chunk : ""; - const ts = typeof raw.ts === "string" ? raw.ts : new Date().toISOString(); - if (!chunk) continue; - parsed.push({ ts, stream, chunk }); - } catch { - // Ignore malformed log rows. - } - } - - return parsed; +function isRunActive(status: string): boolean { + return status === "queued" || status === "running"; } export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) { const queryClient = useQueryClient(); - const [feed, setFeed] = useState([]); const [cancellingRunIds, setCancellingRunIds] = useState(new Set()); - const seenKeysRef = useRef(new Set()); - const pendingByRunRef = useRef(new Map()); - const pendingLogRowsByRunRef = useRef(new Map()); - const logOffsetByRunRef = useRef(new Map()); - const runMetaByIdRef = useRef(new Map()); - const nextIdRef = useRef(1); - const bodyRef = useRef(null); - - const handleCancelRun = async (runId: string) => { - setCancellingRunIds((prev) => new Set(prev).add(runId)); - try { - await heartbeatsApi.cancel(runId); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId) }); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId) }); - } finally { - setCancellingRunIds((prev) => { - const next = new Set(prev); - next.delete(runId); - return next; - }); - } - }; const { data: liveRuns } = useQuery({ queryKey: queryKeys.issues.liveRuns(issueId), @@ -297,329 +67,93 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) { ); }, [activeRun, issueId, liveRuns]); - const runById = useMemo(() => new Map(runs.map((run) => [run.id, run])), [runs]); - const activeRunIds = useMemo(() => new Set(runs.map((run) => run.id)), [runs]); - const runIdsKey = useMemo( - () => runs.map((run) => run.id).sort((a, b) => a.localeCompare(b)).join(","), - [runs], - ); - const appendItems = (items: FeedItem[]) => { - if (items.length === 0) return; - setFeed((prev) => { - const next = [...prev]; - for (const item of items) { - if (seenKeysRef.current.has(item.dedupeKey)) continue; - seenKeysRef.current.add(item.dedupeKey); + const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({ runs, companyId }); - const last = next[next.length - 1]; - if ( - item.streamingKind && - last && - last.runId === item.runId && - last.streamingKind === item.streamingKind - ) { - const mergedText = `${last.text}${item.text}`; - const nextText = - mergedText.length > MAX_STREAMING_TEXT_LENGTH - ? mergedText.slice(-MAX_STREAMING_TEXT_LENGTH) - : mergedText; - next[next.length - 1] = { - ...last, - ts: item.ts, - text: nextText, - dedupeKey: last.dedupeKey, - }; - continue; - } - - next.push(item); - } - if (seenKeysRef.current.size > 6000) { - seenKeysRef.current.clear(); - } - if (next.length === prev.length) return prev; - return next.slice(-MAX_FEED_ITEMS); - }); + const handleCancelRun = async (runId: string) => { + setCancellingRunIds((prev) => new Set(prev).add(runId)); + try { + await heartbeatsApi.cancel(runId); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId) }); + } finally { + setCancellingRunIds((prev) => { + const next = new Set(prev); + next.delete(runId); + return next; + }); + } }; - useEffect(() => { - const body = bodyRef.current; - if (!body) return; - body.scrollTo({ top: body.scrollHeight, behavior: "smooth" }); - }, [feed.length]); - - useEffect(() => { - for (const run of runs) { - runMetaByIdRef.current.set(run.id, { agentId: run.agentId, agentName: run.agentName }); - } - }, [runs]); - - useEffect(() => { - const stillActive = new Set(); - for (const runId of activeRunIds) { - stillActive.add(`${runId}:stdout`); - stillActive.add(`${runId}:stderr`); - } - for (const key of pendingByRunRef.current.keys()) { - if (!stillActive.has(key)) { - pendingByRunRef.current.delete(key); - } - } - const liveRunIds = new Set(activeRunIds); - for (const key of pendingLogRowsByRunRef.current.keys()) { - const runId = key.replace(/:records$/, ""); - if (!liveRunIds.has(runId)) { - pendingLogRowsByRunRef.current.delete(key); - } - } - for (const runId of logOffsetByRunRef.current.keys()) { - if (!liveRunIds.has(runId)) { - logOffsetByRunRef.current.delete(runId); - } - } - }, [activeRunIds]); - - useEffect(() => { - if (runs.length === 0) return; - - let cancelled = false; - - const readRunLog = async (run: LiveRunForIssue) => { - const offset = logOffsetByRunRef.current.get(run.id) ?? 0; - try { - const result = await heartbeatsApi.log(run.id, offset, LOG_READ_LIMIT_BYTES); - if (cancelled) return; - - const rows = parsePersistedLogContent(run.id, result.content, pendingLogRowsByRunRef.current); - const items: FeedItem[] = []; - for (const row of rows) { - if (row.stream === "stderr") { - items.push( - ...parseStderrChunk(run, row.chunk, row.ts, pendingByRunRef.current, nextIdRef), - ); - continue; - } - if (row.stream === "system") { - const item = createFeedItem(run, row.ts, row.chunk, "warn", nextIdRef.current++); - if (item) items.push(item); - continue; - } - items.push( - ...parseStdoutChunk(run, row.chunk, row.ts, pendingByRunRef.current, nextIdRef), - ); - } - appendItems(items); - - if (result.nextOffset !== undefined) { - logOffsetByRunRef.current.set(run.id, result.nextOffset); - return; - } - if (result.content.length > 0) { - logOffsetByRunRef.current.set(run.id, offset + result.content.length); - } - } catch { - // Ignore log read errors while run output is initializing. - } - }; - - const readAll = async () => { - await Promise.all(runs.map((run) => readRunLog(run))); - }; - - void readAll(); - const interval = window.setInterval(() => { - void readAll(); - }, LOG_POLL_INTERVAL_MS); - - return () => { - cancelled = true; - window.clearInterval(interval); - }; - }, [runIdsKey, runs]); - - useEffect(() => { - if (!companyId || activeRunIds.size === 0) return; - - let closed = false; - let reconnectTimer: number | null = null; - let socket: WebSocket | null = null; - - const scheduleReconnect = () => { - if (closed) return; - reconnectTimer = window.setTimeout(connect, 1500); - }; - - const connect = () => { - if (closed) return; - const protocol = window.location.protocol === "https:" ? "wss" : "ws"; - const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(companyId)}/events/ws`; - socket = new WebSocket(url); - - socket.onmessage = (message) => { - const raw = typeof message.data === "string" ? message.data : ""; - if (!raw) return; - - let event: LiveEvent; - try { - event = JSON.parse(raw) as LiveEvent; - } catch { - return; - } - - if (event.companyId !== companyId) return; - const payload = event.payload ?? {}; - const runId = readString(payload["runId"]); - if (!runId || !activeRunIds.has(runId)) return; - - const run = runById.get(runId); - if (!run) return; - - if (event.type === "heartbeat.run.event") { - const seq = typeof payload["seq"] === "number" ? payload["seq"] : null; - const eventType = readString(payload["eventType"]) ?? "event"; - const messageText = readString(payload["message"]) ?? eventType; - const dedupeKey = `${runId}:event:${seq ?? `${eventType}:${messageText}:${event.createdAt}`}`; - if (seenKeysRef.current.has(dedupeKey)) return; - seenKeysRef.current.add(dedupeKey); - if (seenKeysRef.current.size > 2000) { - seenKeysRef.current.clear(); - } - const tone = eventType === "error" ? "error" : eventType === "lifecycle" ? "warn" : "info"; - const item = createFeedItem(run, event.createdAt, messageText, tone, nextIdRef.current++); - if (item) appendItems([item]); - return; - } - - if (event.type === "heartbeat.run.status") { - const status = readString(payload["status"]) ?? "updated"; - const dedupeKey = `${runId}:status:${status}:${readString(payload["finishedAt"]) ?? ""}`; - if (seenKeysRef.current.has(dedupeKey)) return; - seenKeysRef.current.add(dedupeKey); - if (seenKeysRef.current.size > 2000) { - seenKeysRef.current.clear(); - } - const tone = status === "failed" || status === "timed_out" ? "error" : "warn"; - const item = createFeedItem(run, event.createdAt, `run ${status}`, tone, nextIdRef.current++); - if (item) appendItems([item]); - return; - } - - if (event.type === "heartbeat.run.log") { - const chunk = readString(payload["chunk"]); - if (!chunk) return; - const stream = readString(payload["stream"]) === "stderr" ? "stderr" : "stdout"; - if (stream === "stderr") { - appendItems(parseStderrChunk(run, chunk, event.createdAt, pendingByRunRef.current, nextIdRef)); - return; - } - appendItems(parseStdoutChunk(run, chunk, event.createdAt, pendingByRunRef.current, nextIdRef)); - } - }; - - socket.onerror = () => { - socket?.close(); - }; - - socket.onclose = () => { - scheduleReconnect(); - }; - }; - - connect(); - - return () => { - closed = true; - if (reconnectTimer !== null) window.clearTimeout(reconnectTimer); - if (socket) { - socket.onmessage = null; - socket.onerror = null; - socket.onclose = null; - socket.close(1000, "issue_live_widget_unmount"); - } - }; - }, [activeRunIds, companyId, runById]); - - if (runs.length === 0 && feed.length === 0) return null; - - const recent = feed.slice(-25); + if (runs.length === 0) return null; return ( -
- {runs.length > 0 ? ( - runs.map((run) => ( -
-
- - - - - {formatDateTime(run.startedAt ?? run.createdAt)} - -
-
- Run - - {run.id.slice(0, 8)} - - -
- - - Open run - - -
-
-
- )) - ) : ( -
- Recent run updates +
+
+
+ Live Runs +
+
+ Streamed with the same transcript UI used on the full run detail page.
- )} - -
- {recent.length === 0 && ( -
Waiting for run output...
- )} - {recent.map((item, index) => ( -
- {relativeTime(item.ts)} -
- - [{item.runId.slice(0, 8)}] - {item.text} -
-
- ))}
+
+ {runs.map((run) => { + const isActive = isRunActive(run.status); + const transcript = transcriptByRun.get(run.id) ?? []; + return ( +
+
+
+ + + +
+ + {run.id.slice(0, 8)} + + + {formatDateTime(run.startedAt ?? run.createdAt)} +
+
+ +
+ {isActive && ( + + )} + + Open run + + +
+
+ +
+ +
+
+ ); + })} +
); } diff --git a/ui/src/components/transcript/RunTranscriptView.tsx b/ui/src/components/transcript/RunTranscriptView.tsx new file mode 100644 index 00000000..110fa7ac --- /dev/null +++ b/ui/src/components/transcript/RunTranscriptView.tsx @@ -0,0 +1,681 @@ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import type { TranscriptEntry } from "../../adapters"; +import { MarkdownBody } from "../MarkdownBody"; +import { cn, formatTokens, relativeTime } from "../../lib/utils"; +import { + Bot, + BrainCircuit, + ChevronDown, + ChevronRight, + CircleAlert, + Info, + TerminalSquare, + User, + Wrench, +} from "lucide-react"; + +export type TranscriptMode = "nice" | "raw"; +export type TranscriptDensity = "comfortable" | "compact"; + +interface RunTranscriptViewProps { + entries: TranscriptEntry[]; + mode?: TranscriptMode; + density?: TranscriptDensity; + limit?: number; + streaming?: boolean; + emptyMessage?: string; + className?: string; +} + +type TranscriptBlock = + | { + type: "message"; + role: "assistant" | "user"; + ts: string; + text: string; + streaming: boolean; + } + | { + type: "thinking"; + ts: string; + text: string; + streaming: boolean; + } + | { + type: "tool"; + ts: string; + endTs?: string; + name: string; + toolUseId?: string; + input: unknown; + result?: string; + isError?: boolean; + status: "running" | "completed" | "error"; + } + | { + type: "event"; + ts: string; + label: string; + tone: "info" | "warn" | "error" | "neutral"; + text: string; + detail?: string; + }; + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function compactWhitespace(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function truncate(value: string, max: number): string { + return value.length > max ? `${value.slice(0, Math.max(0, max - 1))}…` : value; +} + +function stripMarkdown(value: string): string { + return compactWhitespace( + value + .replace(/```[\s\S]*?```/g, " code ") + .replace(/`([^`]+)`/g, "$1") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/[*_#>-]/g, " "), + ); +} + +function formatTimestamp(ts: string): string { + const date = new Date(ts); + if (Number.isNaN(date.getTime())) return ts; + return date.toLocaleTimeString("en-US", { hour12: false }); +} + +function formatUnknown(value: unknown): string { + if (typeof value === "string") return value; + if (value === null || value === undefined) return ""; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function formatToolPayload(value: unknown): string { + if (typeof value === "string") { + try { + return JSON.stringify(JSON.parse(value), null, 2); + } catch { + return value; + } + } + return formatUnknown(value); +} + +function extractToolUseId(input: unknown): string | undefined { + const record = asRecord(input); + if (!record) return undefined; + const candidates = [ + record.toolUseId, + record.tool_use_id, + record.callId, + record.call_id, + record.id, + ]; + for (const candidate of candidates) { + if (typeof candidate === "string" && candidate.trim()) { + return candidate; + } + } + return undefined; +} + +function summarizeRecord(record: Record, keys: string[]): string | null { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" && value.trim()) { + return truncate(compactWhitespace(value), 120); + } + } + return null; +} + +function summarizeToolInput(name: string, input: unknown, density: TranscriptDensity): string { + const compactMax = density === "compact" ? 72 : 120; + if (typeof input === "string") return truncate(compactWhitespace(input), compactMax); + const record = asRecord(input); + if (!record) { + const serialized = compactWhitespace(formatUnknown(input)); + return serialized ? truncate(serialized, compactMax) : `Inspect ${name} input`; + } + + const direct = + summarizeRecord(record, ["command", "cmd", "path", "filePath", "file_path", "query", "url", "prompt", "message"]) + ?? summarizeRecord(record, ["pattern", "name", "title", "target", "tool"]) + ?? null; + if (direct) return truncate(direct, compactMax); + + if (Array.isArray(record.paths) && record.paths.length > 0) { + const first = record.paths.find((value): value is string => typeof value === "string" && value.trim().length > 0); + if (first) { + return truncate(`${record.paths.length} paths, starting with ${first}`, compactMax); + } + } + + const keys = Object.keys(record); + if (keys.length === 0) return `No ${name} input`; + if (keys.length === 1) return truncate(`${keys[0]} payload`, compactMax); + return truncate(`${keys.length} fields: ${keys.slice(0, 3).join(", ")}`, compactMax); +} + +function summarizeToolResult(result: string | undefined, isError: boolean | undefined, density: TranscriptDensity): string { + if (!result) return isError ? "Tool failed" : "Waiting for result"; + const lines = result + .split(/\r?\n/) + .map((line) => compactWhitespace(line)) + .filter(Boolean); + const firstLine = lines[0] ?? result; + return truncate(firstLine, density === "compact" ? 84 : 140); +} + +function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): TranscriptBlock[] { + const blocks: TranscriptBlock[] = []; + const pendingToolBlocks = new Map>(); + + for (const entry of entries) { + const previous = blocks[blocks.length - 1]; + + if (entry.kind === "assistant" || entry.kind === "user") { + const isStreaming = streaming && entry.kind === "assistant" && entry.delta === true; + if (previous?.type === "message" && previous.role === entry.kind) { + previous.text += previous.text.endsWith("\n") || entry.text.startsWith("\n") ? entry.text : `\n${entry.text}`; + previous.ts = entry.ts; + previous.streaming = previous.streaming || isStreaming; + } else { + blocks.push({ + type: "message", + role: entry.kind, + ts: entry.ts, + text: entry.text, + streaming: isStreaming, + }); + } + continue; + } + + if (entry.kind === "thinking") { + const isStreaming = streaming && entry.delta === true; + if (previous?.type === "thinking") { + previous.text += previous.text.endsWith("\n") || entry.text.startsWith("\n") ? entry.text : `\n${entry.text}`; + previous.ts = entry.ts; + previous.streaming = previous.streaming || isStreaming; + } else { + blocks.push({ + type: "thinking", + ts: entry.ts, + text: entry.text, + streaming: isStreaming, + }); + } + continue; + } + + if (entry.kind === "tool_call") { + const toolBlock: Extract = { + type: "tool", + ts: entry.ts, + name: entry.name, + toolUseId: entry.toolUseId ?? extractToolUseId(entry.input), + input: entry.input, + status: "running", + }; + blocks.push(toolBlock); + if (toolBlock.toolUseId) { + pendingToolBlocks.set(toolBlock.toolUseId, toolBlock); + } + continue; + } + + if (entry.kind === "tool_result") { + const matched = + pendingToolBlocks.get(entry.toolUseId) + ?? [...blocks].reverse().find((block): block is Extract => block.type === "tool" && block.status === "running"); + + if (matched) { + matched.result = entry.content; + matched.isError = entry.isError; + matched.status = entry.isError ? "error" : "completed"; + matched.endTs = entry.ts; + pendingToolBlocks.delete(entry.toolUseId); + } else { + blocks.push({ + type: "tool", + ts: entry.ts, + endTs: entry.ts, + name: "tool", + toolUseId: entry.toolUseId, + input: null, + result: entry.content, + isError: entry.isError, + status: entry.isError ? "error" : "completed", + }); + } + continue; + } + + if (entry.kind === "init") { + blocks.push({ + type: "event", + ts: entry.ts, + label: "init", + tone: "info", + text: `Model ${entry.model}${entry.sessionId ? ` • session ${entry.sessionId}` : ""}`, + }); + continue; + } + + if (entry.kind === "result") { + const summary = `tokens in ${formatTokens(entry.inputTokens)} • out ${formatTokens(entry.outputTokens)} • cached ${formatTokens(entry.cachedTokens)} • $${entry.costUsd.toFixed(6)}`; + const detailParts = [ + entry.text.trim(), + entry.subtype ? `subtype=${entry.subtype}` : "", + entry.errors.length > 0 ? `errors=${entry.errors.join(" | ")}` : "", + ].filter(Boolean); + blocks.push({ + type: "event", + ts: entry.ts, + label: "result", + tone: entry.isError ? "error" : "info", + text: summary, + detail: detailParts.join("\n\n") || undefined, + }); + continue; + } + + if (entry.kind === "stderr") { + blocks.push({ + type: "event", + ts: entry.ts, + label: "stderr", + tone: "error", + text: entry.text, + }); + continue; + } + + if (entry.kind === "system") { + blocks.push({ + type: "event", + ts: entry.ts, + label: "system", + tone: "warn", + text: entry.text, + }); + continue; + } + + blocks.push({ + type: "event", + ts: entry.ts, + label: "stdout", + tone: "neutral", + text: entry.text, + }); + } + + return blocks; +} + +function TranscriptDisclosure({ + icon, + label, + tone, + summary, + timestamp, + defaultOpen, + compact, + children, +}: { + icon: typeof BrainCircuit; + label: string; + tone: "thinking" | "tool"; + summary: string; + timestamp: string; + defaultOpen: boolean; + compact: boolean; + children: ReactNode; +}) { + const [open, setOpen] = useState(defaultOpen); + const [touched, setTouched] = useState(false); + + useEffect(() => { + if (!touched) { + setOpen(defaultOpen); + } + }, [defaultOpen, touched]); + + const Icon = icon; + const borderTone = + tone === "thinking" + ? "border-amber-500/25 bg-amber-500/[0.07]" + : "border-cyan-500/25 bg-cyan-500/[0.07]"; + const iconTone = + tone === "thinking" + ? "text-amber-700 dark:text-amber-300" + : "text-cyan-700 dark:text-cyan-300"; + + return ( +
+ + {open &&
{children}
} +
+ ); +} + +function TranscriptMessageBlock({ + block, + density, +}: { + block: Extract; + density: TranscriptDensity; +}) { + const isAssistant = block.role === "assistant"; + const Icon = isAssistant ? Bot : User; + const panelTone = isAssistant + ? "border-emerald-500/25 bg-emerald-500/[0.08]" + : "border-violet-500/20 bg-violet-500/[0.07]"; + const iconTone = isAssistant + ? "text-emerald-700 dark:text-emerald-300" + : "text-violet-700 dark:text-violet-300"; + const compact = density === "compact"; + + return ( +
+
+ + + + + {isAssistant ? "Assistant" : "User"} + + {formatTimestamp(block.ts)} + {block.streaming && ( + + + + + + Streaming + + )} +
+ {compact ? ( +
+ {truncate(stripMarkdown(block.text), 360)} +
+ ) : ( + + {block.text} + + )} +
+ ); +} + +function TranscriptThinkingBlock({ + block, + density, +}: { + block: Extract; + density: TranscriptDensity; +}) { + const compact = density === "compact"; + return ( + +
+ {block.text} +
+
+ ); +} + +function TranscriptToolCard({ + block, + density, +}: { + block: Extract; + density: TranscriptDensity; +}) { + const compact = density === "compact"; + const statusLabel = + block.status === "running" + ? "Running" + : block.status === "error" + ? "Errored" + : "Completed"; + const statusTone = + block.status === "running" + ? "border-cyan-500/25 bg-cyan-500/10 text-cyan-700 dark:text-cyan-300" + : block.status === "error" + ? "border-red-500/25 bg-red-500/10 text-red-700 dark:text-red-300" + : "border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"; + + return ( + +
+
+ + {statusLabel} + + {block.toolUseId && ( + + {truncate(block.toolUseId, compact ? 24 : 40)} + + )} +
+
+
+
+ Input +
+
+              {formatToolPayload(block.input) || ""}
+            
+
+
+
+ Result +
+
+              {block.result ? formatToolPayload(block.result) : "Waiting for result..."}
+            
+
+
+
+
+ ); +} + +function TranscriptEventRow({ + block, + density, +}: { + block: Extract; + density: TranscriptDensity; +}) { + const compact = density === "compact"; + const toneClasses = + block.tone === "error" + ? "border-red-500/20 bg-red-500/[0.06] text-red-700 dark:text-red-300" + : block.tone === "warn" + ? "border-amber-500/20 bg-amber-500/[0.06] text-amber-700 dark:text-amber-300" + : block.tone === "info" + ? "border-sky-500/20 bg-sky-500/[0.06] text-sky-700 dark:text-sky-300" + : "border-border/70 bg-background/70 text-foreground/75"; + + return ( +
+
+ {block.tone === "error" ? ( + + ) : block.tone === "warn" ? ( + + ) : ( + + )} +
+
+ + {block.label} + + + {compact ? relativeTime(block.ts) : formatTimestamp(block.ts)} + +
+
+ {block.text} +
+ {block.detail && ( +
+              {block.detail}
+            
+ )} +
+
+
+ ); +} + +function RawTranscriptView({ + entries, + density, +}: { + entries: TranscriptEntry[]; + density: TranscriptDensity; +}) { + const compact = density === "compact"; + return ( +
+ {entries.map((entry, idx) => ( +
+ {formatTimestamp(entry.ts)} + + {entry.kind} + +
+            {entry.kind === "tool_call"
+              ? `${entry.name}\n${formatToolPayload(entry.input)}`
+              : entry.kind === "tool_result"
+                ? formatToolPayload(entry.content)
+                : entry.kind === "result"
+                  ? `${entry.text}\n${formatTokens(entry.inputTokens)} / ${formatTokens(entry.outputTokens)} / $${entry.costUsd.toFixed(6)}`
+                  : entry.kind === "init"
+                    ? `model=${entry.model}${entry.sessionId ? ` session=${entry.sessionId}` : ""}`
+                    : entry.text}
+          
+
+ ))} +
+ ); +} + +export function RunTranscriptView({ + entries, + mode = "nice", + density = "comfortable", + limit, + streaming = false, + emptyMessage = "No transcript yet.", + className, +}: RunTranscriptViewProps) { + const blocks = useMemo(() => normalizeTranscript(entries, streaming), [entries, streaming]); + const visibleBlocks = limit ? blocks.slice(-limit) : blocks; + const visibleEntries = limit ? entries.slice(-limit) : entries; + + if (entries.length === 0) { + return ( +
+ {emptyMessage} +
+ ); + } + + if (mode === "raw") { + return ( +
+ +
+ ); + } + + return ( +
+ {visibleBlocks.map((block, index) => ( +
+ {block.type === "message" && } + {block.type === "thinking" && } + {block.type === "tool" && } + {block.type === "event" && } +
+ ))} +
+ ); +} diff --git a/ui/src/components/transcript/useLiveRunTranscripts.ts b/ui/src/components/transcript/useLiveRunTranscripts.ts new file mode 100644 index 00000000..acc5b082 --- /dev/null +++ b/ui/src/components/transcript/useLiveRunTranscripts.ts @@ -0,0 +1,282 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import type { LiveEvent } from "@paperclipai/shared"; +import { heartbeatsApi, type LiveRunForIssue } from "../../api/heartbeats"; +import { buildTranscript, getUIAdapter, type RunLogChunk, type TranscriptEntry } from "../../adapters"; + +const LOG_POLL_INTERVAL_MS = 2000; +const LOG_READ_LIMIT_BYTES = 256_000; + +interface UseLiveRunTranscriptsOptions { + runs: LiveRunForIssue[]; + companyId?: string | null; + maxChunksPerRun?: number; +} + +function readString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +function isTerminalStatus(status: string): boolean { + return status === "failed" || status === "timed_out" || status === "cancelled" || status === "succeeded"; +} + +function parsePersistedLogContent( + runId: string, + content: string, + pendingByRun: Map, +): Array { + if (!content) return []; + + const pendingKey = `${runId}:records`; + const combined = `${pendingByRun.get(pendingKey) ?? ""}${content}`; + const split = combined.split("\n"); + pendingByRun.set(pendingKey, split.pop() ?? ""); + + const parsed: Array = []; + for (const line of split) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const raw = JSON.parse(trimmed) as { ts?: unknown; stream?: unknown; chunk?: unknown }; + const stream = raw.stream === "stderr" || raw.stream === "system" ? raw.stream : "stdout"; + const chunk = typeof raw.chunk === "string" ? raw.chunk : ""; + const ts = typeof raw.ts === "string" ? raw.ts : new Date().toISOString(); + if (!chunk) continue; + parsed.push({ + ts, + stream, + chunk, + dedupeKey: `persisted:${runId}:${ts}:${stream}:${chunk}`, + }); + } catch { + // Ignore malformed log rows. + } + } + + return parsed; +} + +export function useLiveRunTranscripts({ + runs, + companyId, + maxChunksPerRun = 200, +}: UseLiveRunTranscriptsOptions) { + const [chunksByRun, setChunksByRun] = useState>(new Map()); + const seenChunkKeysRef = useRef(new Set()); + const pendingLogRowsByRunRef = useRef(new Map()); + const logOffsetByRunRef = useRef(new Map()); + + const runById = useMemo(() => new Map(runs.map((run) => [run.id, run])), [runs]); + const activeRunIds = useMemo( + () => new Set(runs.filter((run) => !isTerminalStatus(run.status)).map((run) => run.id)), + [runs], + ); + const runIdsKey = useMemo( + () => runs.map((run) => run.id).sort((a, b) => a.localeCompare(b)).join(","), + [runs], + ); + + const appendChunks = (runId: string, chunks: Array) => { + if (chunks.length === 0) return; + setChunksByRun((prev) => { + const next = new Map(prev); + const existing = [...(next.get(runId) ?? [])]; + let changed = false; + + for (const chunk of chunks) { + if (seenChunkKeysRef.current.has(chunk.dedupeKey)) continue; + seenChunkKeysRef.current.add(chunk.dedupeKey); + existing.push({ ts: chunk.ts, stream: chunk.stream, chunk: chunk.chunk }); + changed = true; + } + + if (!changed) return prev; + if (seenChunkKeysRef.current.size > 12000) { + seenChunkKeysRef.current.clear(); + } + next.set(runId, existing.slice(-maxChunksPerRun)); + return next; + }); + }; + + useEffect(() => { + const knownRunIds = new Set(runs.map((run) => run.id)); + setChunksByRun((prev) => { + const next = new Map(); + for (const [runId, chunks] of prev) { + if (knownRunIds.has(runId)) { + next.set(runId, chunks); + } + } + return next.size === prev.size ? prev : next; + }); + + for (const key of pendingLogRowsByRunRef.current.keys()) { + const runId = key.replace(/:records$/, ""); + if (!knownRunIds.has(runId)) { + pendingLogRowsByRunRef.current.delete(key); + } + } + for (const runId of logOffsetByRunRef.current.keys()) { + if (!knownRunIds.has(runId)) { + logOffsetByRunRef.current.delete(runId); + } + } + }, [runs]); + + useEffect(() => { + if (runs.length === 0) return; + + let cancelled = false; + + const readRunLog = async (run: LiveRunForIssue) => { + const offset = logOffsetByRunRef.current.get(run.id) ?? 0; + try { + const result = await heartbeatsApi.log(run.id, offset, LOG_READ_LIMIT_BYTES); + if (cancelled) return; + + appendChunks(run.id, parsePersistedLogContent(run.id, result.content, pendingLogRowsByRunRef.current)); + + if (result.nextOffset !== undefined) { + logOffsetByRunRef.current.set(run.id, result.nextOffset); + return; + } + if (result.content.length > 0) { + logOffsetByRunRef.current.set(run.id, offset + result.content.length); + } + } catch { + // Ignore log read errors while output is initializing. + } + }; + + const readAll = async () => { + await Promise.all(runs.map((run) => readRunLog(run))); + }; + + void readAll(); + const interval = window.setInterval(() => { + void readAll(); + }, LOG_POLL_INTERVAL_MS); + + return () => { + cancelled = true; + window.clearInterval(interval); + }; + }, [runIdsKey, runs]); + + useEffect(() => { + if (!companyId || activeRunIds.size === 0) return; + + let closed = false; + let reconnectTimer: number | null = null; + let socket: WebSocket | null = null; + + const scheduleReconnect = () => { + if (closed) return; + reconnectTimer = window.setTimeout(connect, 1500); + }; + + const connect = () => { + if (closed) return; + const protocol = window.location.protocol === "https:" ? "wss" : "ws"; + const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(companyId)}/events/ws`; + socket = new WebSocket(url); + + socket.onmessage = (message) => { + const raw = typeof message.data === "string" ? message.data : ""; + if (!raw) return; + + let event: LiveEvent; + try { + event = JSON.parse(raw) as LiveEvent; + } catch { + return; + } + + if (event.companyId !== companyId) return; + const payload = event.payload ?? {}; + const runId = readString(payload["runId"]); + if (!runId || !activeRunIds.has(runId)) return; + if (!runById.has(runId)) return; + + if (event.type === "heartbeat.run.log") { + const chunk = readString(payload["chunk"]); + if (!chunk) return; + const stream = + readString(payload["stream"]) === "stderr" + ? "stderr" + : readString(payload["stream"]) === "system" + ? "system" + : "stdout"; + appendChunks(runId, [{ + ts: event.createdAt, + stream, + chunk, + dedupeKey: `socket:log:${runId}:${event.createdAt}:${stream}:${chunk}`, + }]); + return; + } + + if (event.type === "heartbeat.run.event") { + const seq = typeof payload["seq"] === "number" ? payload["seq"] : null; + const eventType = readString(payload["eventType"]) ?? "event"; + const messageText = readString(payload["message"]) ?? eventType; + appendChunks(runId, [{ + ts: event.createdAt, + stream: eventType === "error" ? "stderr" : "system", + chunk: messageText, + dedupeKey: `socket:event:${runId}:${seq ?? `${eventType}:${messageText}:${event.createdAt}`}`, + }]); + return; + } + + if (event.type === "heartbeat.run.status") { + const status = readString(payload["status"]) ?? "updated"; + appendChunks(runId, [{ + ts: event.createdAt, + stream: isTerminalStatus(status) && status !== "succeeded" ? "stderr" : "system", + chunk: `run ${status}`, + dedupeKey: `socket:status:${runId}:${status}:${readString(payload["finishedAt"]) ?? ""}`, + }]); + } + }; + + socket.onerror = () => { + socket?.close(); + }; + + socket.onclose = () => { + scheduleReconnect(); + }; + }; + + connect(); + + return () => { + closed = true; + if (reconnectTimer !== null) window.clearTimeout(reconnectTimer); + if (socket) { + socket.onmessage = null; + socket.onerror = null; + socket.onclose = null; + socket.close(1000, "live_run_transcripts_unmount"); + } + }; + }, [activeRunIds, companyId, runById]); + + const transcriptByRun = useMemo(() => { + const next = new Map(); + for (const run of runs) { + const adapter = getUIAdapter(run.adapterType); + next.set(run.id, buildTranscript(chunksByRun.get(run.id) ?? [], adapter.parseStdoutLine)); + } + return next; + }, [chunksByRun, runs]); + + return { + transcriptByRun, + hasOutputForRun(runId: string) { + return (chunksByRun.get(runId)?.length ?? 0) > 0; + }, + }; +} diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 77923019..b0eeb89f 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -17,7 +17,6 @@ import { AgentConfigForm } from "../components/AgentConfigForm"; import { PageTabBar } from "../components/PageTabBar"; import { adapterLabels, roleLabels } from "../components/agent-config-primitives"; import { getUIAdapter, buildTranscript } from "../adapters"; -import type { TranscriptEntry } from "../adapters"; import { StatusBadge } from "../components/StatusBadge"; import { agentStatusDot, agentStatusDotDefault } from "../lib/status-colors"; import { MarkdownBody } from "../components/MarkdownBody"; @@ -58,6 +57,7 @@ import { } from "lucide-react"; import { Input } from "@/components/ui/input"; import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker"; +import { RunTranscriptView, type TranscriptMode } from "../components/transcript/RunTranscriptView"; import { isUuidLike, type Agent, type HeartbeatRun, type HeartbeatRunEvent, type AgentRuntimeState, type LiveEvent } from "@paperclipai/shared"; import { agentRouteRef } from "../lib/utils"; @@ -1675,6 +1675,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin const [logOffset, setLogOffset] = useState(0); const [isFollowing, setIsFollowing] = useState(false); const [isStreamingConnected, setIsStreamingConnected] = useState(false); + const [transcriptMode, setTranscriptMode] = useState("nice"); const logEndRef = useRef(null); const pendingLogLineRef = useRef(""); const scrollContainerRef = useRef(null); @@ -2028,6 +2029,10 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin const adapter = useMemo(() => getUIAdapter(adapterType), [adapterType]); const transcript = useMemo(() => buildTranscript(logLines, adapter.parseStdoutLine), [logLines, adapter]); + useEffect(() => { + setTranscriptMode("nice"); + }, [run.id]); + if (loading && logLoading) { return

Loading run logs...

; } @@ -2120,6 +2125,23 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin Transcript ({transcript.length})
+
+ {(["nice", "raw"] as const).map((mode) => ( + + ))} +
{isLive && !isFollowing && (
-
- {transcript.length === 0 && !run.logRef && ( -
No persisted transcript for this run.
+
+ + {logError && ( +
+ {logError} +
)} - {transcript.map((entry, idx) => { - const time = new Date(entry.ts).toLocaleTimeString("en-US", { hour12: false }); - const grid = "grid grid-cols-[auto_auto_1fr] gap-x-2 sm:gap-x-3 items-baseline"; - const tsCell = "text-neutral-400 dark:text-neutral-600 select-none w-12 sm:w-16 text-[10px] sm:text-xs"; - const lblCell = "w-14 sm:w-20 text-[10px] sm:text-xs"; - const contentCell = "min-w-0 whitespace-pre-wrap break-words overflow-hidden"; - const expandCell = "col-span-full md:col-start-3 md:col-span-1"; - - if (entry.kind === "assistant") { - return ( -
- {time} - assistant - {entry.text} -
- ); - } - - if (entry.kind === "thinking") { - return ( -
- {time} - thinking - {entry.text} -
- ); - } - - if (entry.kind === "user") { - return ( -
- {time} - user - {entry.text} -
- ); - } - - if (entry.kind === "tool_call") { - return ( -
- {time} - tool_call - {entry.name} -
-                  {JSON.stringify(entry.input, null, 2)}
-                
-
- ); - } - - if (entry.kind === "tool_result") { - return ( -
- {time} - tool_result - {entry.isError ? error : } -
-                  {(() => { try { return JSON.stringify(JSON.parse(entry.content), null, 2); } catch { return entry.content; } })()}
-                
-
- ); - } - - if (entry.kind === "init") { - return ( -
- {time} - init - model: {entry.model}{entry.sessionId ? `, session: ${entry.sessionId}` : ""} -
- ); - } - - if (entry.kind === "result") { - return ( -
- {time} - result - - tokens in={formatTokens(entry.inputTokens)} out={formatTokens(entry.outputTokens)} cached={formatTokens(entry.cachedTokens)} cost=${entry.costUsd.toFixed(6)} - - {(entry.subtype || entry.isError || entry.errors.length > 0) && ( -
- subtype={entry.subtype || "unknown"} is_error={entry.isError ? "true" : "false"} - {entry.errors.length > 0 ? ` errors=${entry.errors.join(" | ")}` : ""} -
- )} - {entry.text && ( -
{entry.text}
- )} -
- ); - } - - const rawText = entry.text; - const label = - entry.kind === "stderr" ? "stderr" : - entry.kind === "system" ? "system" : - "stdout"; - const color = - entry.kind === "stderr" ? "text-red-600 dark:text-red-300" : - entry.kind === "system" ? "text-blue-600 dark:text-blue-300" : - "text-neutral-500"; - return ( -
- {time} - {label} - {rawText} -
- ) - })} - {logError &&
{logError}
}