From 87b8e217014649306457fe68aad802dc2a124a6b Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 11 Mar 2026 10:35:41 -0500 Subject: [PATCH 01/11] 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}
}
From 6e4694716b578d03b7b6f668e298bdc5e1547c16 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 11 Mar 2026 10:47:22 -0500 Subject: [PATCH 02/11] Add a run transcript UX fixture lab Co-Authored-By: Paperclip --- ui/src/App.tsx | 3 + ui/src/fixtures/runTranscriptFixtures.ts | 226 +++++++++++++++ ui/src/pages/RunTranscriptUxLab.tsx | 346 +++++++++++++++++++++++ 3 files changed, 575 insertions(+) create mode 100644 ui/src/fixtures/runTranscriptFixtures.ts create mode 100644 ui/src/pages/RunTranscriptUxLab.tsx diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 114034d1..a3e35de1 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -23,6 +23,7 @@ import { Activity } from "./pages/Activity"; import { Inbox } from "./pages/Inbox"; import { CompanySettings } from "./pages/CompanySettings"; import { DesignGuide } from "./pages/DesignGuide"; +import { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab"; import { OrgChart } from "./pages/OrgChart"; import { NewAgent } from "./pages/NewAgent"; import { AuthPage } from "./pages/Auth"; @@ -145,6 +146,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> ); @@ -246,6 +248,7 @@ export function App() { } /> } /> } /> + } /> }> {boardRoutes()} diff --git a/ui/src/fixtures/runTranscriptFixtures.ts b/ui/src/fixtures/runTranscriptFixtures.ts new file mode 100644 index 00000000..df120344 --- /dev/null +++ b/ui/src/fixtures/runTranscriptFixtures.ts @@ -0,0 +1,226 @@ +import type { TranscriptEntry } from "../adapters"; + +export interface RunTranscriptFixtureMeta { + sourceRunId: string; + fixtureLabel: string; + agentName: string; + agentId: string; + issueIdentifier: string; + issueTitle: string; + startedAt: string; + finishedAt: string | null; +} + +export const runTranscriptFixtureMeta: RunTranscriptFixtureMeta = { + sourceRunId: "65a79d5d-5f85-4392-a5cc-8fb48beb9e71", + fixtureLabel: "Sanitized development fixture", + agentName: "CodexCoder", + agentId: "codexcoder-fixture", + issueIdentifier: "PAP-473", + issueTitle: "Humanize run transcripts across run detail and live surfaces", + startedAt: "2026-03-11T15:21:05.948Z", + finishedAt: null, +}; + +// Sanitized from a real development run. Paths, secrets, env vars, and user-local identifiers +// are replaced with safe placeholders while preserving the interaction shape. +export const runTranscriptFixtureEntries: TranscriptEntry[] = [ + { + kind: "stderr", + ts: "2026-03-11T15:21:05.594Z", + text: "[paperclip] Skipping saved session resume for task \"PAP-473\" because wake reason is issue_assigned.", + }, + { + kind: "init", + ts: "2026-03-11T15:21:05.948Z", + model: "codex", + sessionId: "sess_fixture_01", + }, + { + kind: "system", + ts: "2026-03-11T15:21:07.249Z", + text: "turn started", + }, + { + kind: "assistant", + ts: "2026-03-11T15:21:18.851Z", + text: "I’m starting with the Paperclip heartbeat procedure and the repo-required docs so I can see what task is actually assigned and then work only on that scope.", + }, + { + kind: "tool_call", + ts: "2026-03-11T15:21:19.026Z", + name: "command_execution", + toolUseId: "item_1", + input: { + command: "sed -n '1,220p' doc/GOAL.md", + cwd: "/workspace/paperclip", + }, + }, + { + kind: "tool_call", + ts: "2026-03-11T15:21:19.026Z", + name: "command_execution", + toolUseId: "item_2", + input: { + command: "sed -n '1,320p' doc/SPEC-implementation.md", + cwd: "/workspace/paperclip", + }, + }, + { + kind: "tool_result", + ts: "2026-03-11T15:21:19.034Z", + toolUseId: "item_1", + content: "command: sed -n '1,220p' doc/GOAL.md\nstatus: completed\nexit_code: 0\n\n# Paperclip\n\nPaperclip is the backbone of the autonomous economy.", + isError: false, + }, + { + kind: "tool_result", + ts: "2026-03-11T15:21:19.035Z", + toolUseId: "item_2", + content: "command: sed -n '1,320p' doc/SPEC-implementation.md\nstatus: completed\nexit_code: 0\n\n# Paperclip V1 Implementation Spec\n\nPaperclip V1 must provide a full control-plane loop for autonomous agents.", + isError: false, + }, + { + kind: "assistant", + ts: "2026-03-11T15:22:08.806Z", + text: "I’ve got the rendering entry points. I’m reading the existing transcript parser and the three UI surfaces together so I can collapse them onto one renderer instead of layering another one-off view.", + }, + { + kind: "thinking", + ts: "2026-03-11T15:22:12.044Z", + text: "The current transcript UX duplicates parsing logic in multiple surfaces. A shared presentation layer will make the detail page and live surfaces behave the same way while keeping raw fallback available.", + }, + { + kind: "tool_call", + ts: "2026-03-11T15:22:15.401Z", + name: "command_execution", + toolUseId: "item_3", + input: { + command: "rg -n \"LiveRunWidget|ActiveAgentsPanel|buildTranscript|TranscriptEntry|transcript\" ui/src server/src packages", + cwd: "/workspace/paperclip", + }, + }, + { + kind: "tool_result", + ts: "2026-03-11T15:22:15.982Z", + toolUseId: "item_3", + content: "command: rg -n ...\nstatus: completed\nexit_code: 0\n\nui/src/components/ActiveAgentsPanel.tsx\nui/src/components/LiveRunWidget.tsx\nui/src/pages/AgentDetail.tsx", + isError: false, + }, + { + kind: "system", + ts: "2026-03-11T15:22:21.617Z", + text: "item started: web_search (id=ws_fixture_reasoning_ui)", + }, + { + kind: "system", + ts: "2026-03-11T15:22:24.806Z", + text: "item completed: web_search (id=ws_fixture_reasoning_ui)", + }, + { + kind: "assistant", + ts: "2026-03-11T15:22:29.214Z", + text: "I’ve confirmed the duplication: the run page has the only full transcript renderer, while live surfaces still collapse output into feed strings. I’m pulling markdown utilities and adapter shapes now so the new renderer can stay shared.", + }, + { + kind: "tool_call", + ts: "2026-03-11T15:23:04.251Z", + name: "command_execution", + toolUseId: "item_4", + input: { + command: "pnpm -r typecheck", + cwd: "/workspace/paperclip", + }, + }, + { + kind: "tool_result", + ts: "2026-03-11T15:23:26.903Z", + toolUseId: "item_4", + content: "command: pnpm -r typecheck\nstatus: completed\nexit_code: 0\n\npackages/adapter-utils typecheck: Done\nserver typecheck: Done\nui typecheck: Done\ncli typecheck: Done", + isError: false, + }, + { + kind: "assistant", + ts: "2026-03-11T15:23:34.102Z", + text: "Typecheck is clean. I’m running the remaining repo checks now so the transcript work is verified under the full workspace build, not just the UI package.", + }, + { + kind: "tool_call", + ts: "2026-03-11T15:23:40.511Z", + name: "command_execution", + toolUseId: "item_5", + input: { + command: "pnpm test:run", + cwd: "/workspace/paperclip", + }, + }, + { + kind: "tool_result", + ts: "2026-03-11T15:23:56.944Z", + toolUseId: "item_5", + content: "command: pnpm test:run\nstatus: failed\nexit_code: 1\n\nFAIL packages/db/src/runtime-config.test.ts\nFAIL src/__tests__/worktree.test.ts\n\nAll transcript-adapter tests passed after snapshot updates.", + isError: true, + }, + { + kind: "thinking", + ts: "2026-03-11T15:24:02.188Z", + text: "The remaining failures are unrelated to transcript rendering, so I should isolate them in the handoff and keep the UI change set focused.", + }, + { + kind: "assistant", + ts: "2026-03-11T15:24:08.490Z", + text: "The new parser metadata changed a few adapter test snapshots exactly where expected, and the remaining red tests are outside this change set. I’m keeping the transcript patch focused and shipping with that caveat called out.", + }, + { + kind: "tool_call", + ts: "2026-03-11T15:24:21.327Z", + name: "command_execution", + toolUseId: "item_6", + input: { + command: "pnpm build", + cwd: "/workspace/paperclip", + }, + }, + { + kind: "tool_result", + ts: "2026-03-11T15:24:44.030Z", + toolUseId: "item_6", + content: "command: pnpm build\nstatus: completed\nexit_code: 0\n\nui build: built successfully\ncli build: Done", + isError: false, + }, + { + kind: "tool_call", + ts: "2026-03-11T15:24:52.504Z", + name: "command_execution", + toolUseId: "item_7", + input: { + command: "git commit -m \"Humanize run transcripts across run detail and live surfaces\"", + cwd: "/workspace/paperclip", + }, + }, + { + kind: "tool_result", + ts: "2026-03-11T15:24:53.104Z", + toolUseId: "item_7", + content: "command: git commit ...\nstatus: completed\nexit_code: 0\n\n87b8e21 Humanize run transcripts across run detail and live surfaces", + isError: false, + }, + { + kind: "assistant", + ts: "2026-03-11T15:25:00.908Z", + text: "Implemented the transcript UI refactor and switched the run detail page, issue live widget, and dashboard agent card onto the same rendering language.", + }, + { + kind: "result", + ts: "2026-03-11T15:25:05.440Z", + text: "Transcript rollout complete with shared nice/raw rendering and compact live variants.", + inputTokens: 11240, + outputTokens: 3460, + cachedTokens: 520, + costUsd: 0.048121, + subtype: "success", + isError: false, + errors: [], + }, +]; + diff --git a/ui/src/pages/RunTranscriptUxLab.tsx b/ui/src/pages/RunTranscriptUxLab.tsx new file mode 100644 index 00000000..14556482 --- /dev/null +++ b/ui/src/pages/RunTranscriptUxLab.tsx @@ -0,0 +1,346 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { cn, formatDateTime } from "../lib/utils"; +import { Identity } from "../components/Identity"; +import { StatusBadge } from "../components/StatusBadge"; +import { RunTranscriptView, type TranscriptDensity, type TranscriptMode } from "../components/transcript/RunTranscriptView"; +import { runTranscriptFixtureEntries, runTranscriptFixtureMeta } from "../fixtures/runTranscriptFixtures"; +import { ExternalLink, FlaskConical, LayoutPanelLeft, MonitorCog, PanelsTopLeft, RadioTower, ShieldCheck } from "lucide-react"; + +type SurfaceId = "detail" | "live" | "dashboard"; + +const surfaceOptions: Array<{ + id: SurfaceId; + label: string; + eyebrow: string; + description: string; + icon: typeof LayoutPanelLeft; +}> = [ + { + id: "detail", + label: "Run Detail", + eyebrow: "Full transcript", + description: "The long-form run page with the `Nice | Raw` toggle and the most inspectable transcript view.", + icon: MonitorCog, + }, + { + id: "live", + label: "Issue Widget", + eyebrow: "Live stream", + description: "The issue-detail live run widget, optimized for following an active run without leaving the task page.", + icon: RadioTower, + }, + { + id: "dashboard", + label: "Dashboard Card", + eyebrow: "Dense card", + description: "The active-agents dashboard card, tuned for compact scanning while keeping the same transcript language.", + icon: PanelsTopLeft, + }, +]; + +function previewEntries(surface: SurfaceId) { + if (surface === "dashboard") { + return runTranscriptFixtureEntries.slice(-9); + } + if (surface === "live") { + return runTranscriptFixtureEntries.slice(-14); + } + return runTranscriptFixtureEntries; +} + +function RunDetailPreview({ + mode, + streaming, + density, +}: { + mode: TranscriptMode; + streaming: boolean; + density: TranscriptDensity; +}) { + return ( +
+
+
+ + Run Detail + + + + {formatDateTime(runTranscriptFixtureMeta.startedAt)} + +
+
+ Transcript ({runTranscriptFixtureEntries.length}) +
+
+
+ +
+
+ ); +} + +function LiveWidgetPreview({ + streaming, + mode, + density, +}: { + streaming: boolean; + mode: TranscriptMode; + density: TranscriptDensity; +}) { + return ( +
+
+
+ Live Runs +
+
+ Compact live transcript stream for the issue detail page. +
+
+
+
+
+ +
+ + {runTranscriptFixtureMeta.sourceRunId.slice(0, 8)} + + + {formatDateTime(runTranscriptFixtureMeta.startedAt)} +
+
+ + Open run + + +
+
+ +
+
+
+ ); +} + +function DashboardPreview({ + streaming, + mode, + density, +}: { + streaming: boolean; + mode: TranscriptMode; + density: TranscriptDensity; +}) { + return ( +
+
+
+
+
+
+ + +
+
+ {streaming ? "Live now" : "Finished 2m ago"} +
+
+ + + +
+
+ {runTranscriptFixtureMeta.issueIdentifier} - {runTranscriptFixtureMeta.issueTitle} +
+
+
+ +
+
+
+ ); +} + +export function RunTranscriptUxLab() { + const [selectedSurface, setSelectedSurface] = useState("detail"); + const [detailMode, setDetailMode] = useState("nice"); + const [streaming, setStreaming] = useState(true); + const [density, setDensity] = useState("comfortable"); + + const selected = surfaceOptions.find((option) => option.id === selectedSurface) ?? surfaceOptions[0]; + + return ( +
+
+
+ + +
+
+
+
+ {selected.eyebrow} +
+

{selected.label}

+

+ {selected.description} +

+
+ +
+ + Source run {runTranscriptFixtureMeta.sourceRunId.slice(0, 8)} + + + {runTranscriptFixtureMeta.issueIdentifier} + +
+
+ +
+ + Controls + +
+ {(["nice", "raw"] as const).map((mode) => ( + + ))} +
+
+ {(["comfortable", "compact"] as const).map((nextDensity) => ( + + ))} +
+ +
+ + {selectedSurface === "detail" ? ( +
+ +
+ ) : selectedSurface === "live" ? ( +
+ +
+ ) : ( + + )} +
+
+
+
+ ); +} From ab2f9e90eb78021d748636717eda884141d38ffd Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 11 Mar 2026 11:45:05 -0500 Subject: [PATCH 03/11] Refine transcript chrome and labels Co-Authored-By: Paperclip --- .../transcript/RunTranscriptView.tsx | 245 +++++++----------- 1 file changed, 93 insertions(+), 152 deletions(-) diff --git a/ui/src/components/transcript/RunTranscriptView.tsx b/ui/src/components/transcript/RunTranscriptView.tsx index 110fa7ac..05defa14 100644 --- a/ui/src/components/transcript/RunTranscriptView.tsx +++ b/ui/src/components/transcript/RunTranscriptView.tsx @@ -1,14 +1,11 @@ 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 { cn, formatTokens } from "../../lib/utils"; import { - Bot, - BrainCircuit, ChevronDown, ChevronRight, CircleAlert, - Info, TerminalSquare, User, Wrench, @@ -84,12 +81,6 @@ function stripMarkdown(value: string): string { ); } -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 ""; @@ -326,22 +317,10 @@ function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): Tr } 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); @@ -353,43 +332,20 @@ function TranscriptDisclosure({ } }, [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}
} + {open &&
{children}
}
); } @@ -402,35 +358,16 @@ function TranscriptMessageBlock({ 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 - - )} -
+
+ {!isAssistant && ( +
+ + User +
+ )} {compact ? (
{truncate(stripMarkdown(block.text), 360)} @@ -440,6 +377,15 @@ function TranscriptMessageBlock({ {block.text} )} + {block.streaming && ( +
+ + + + + Streaming +
+ )}
); } @@ -451,21 +397,15 @@ function TranscriptThinkingBlock({ block: Extract; density: TranscriptDensity; }) { - const compact = density === "compact"; return ( - -
- {block.text} -
-
+ {block.text} +
); } @@ -485,57 +425,68 @@ function TranscriptToolCard({ : "Completed"; const statusTone = block.status === "running" - ? "border-cyan-500/25 bg-cyan-500/10 text-cyan-700 dark:text-cyan-300" + ? "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"; + ? "text-red-700 dark:text-red-300" + : "text-emerald-700 dark:text-emerald-300"; + const detailsClass = cn( + "space-y-3", + block.status === "error" && "rounded-xl border border-red-500/20 bg-red-500/[0.06] p-3", + ); return ( - -
-
- - {statusLabel} - - {block.toolUseId && ( - - {truncate(block.toolUseId, compact ? 24 : 40)} +
+
+ +
+
+ + {block.name} - )} -
-
-
-
- Input -
-
-              {formatToolPayload(block.input) || ""}
-            
+ + {statusLabel} + + {block.toolUseId && ( + + {truncate(block.toolUseId, compact ? 24 : 40)} + + )}
-
-
- Result -
-
-              {block.result ? formatToolPayload(block.result) : "Waiting for result..."}
-            
+
+ {block.status === "running" + ? summarizeToolInput(block.name, block.input, density) + : summarizeToolResult(block.result, block.isError, density)}
- + +
+
+
+
+ Input +
+
+                {formatToolPayload(block.input) || ""}
+              
+
+
+
+ Result +
+
+                {block.result ? formatToolPayload(block.result) : "Waiting for result..."}
+              
+
+
+
+
+
); } @@ -549,37 +500,34 @@ function TranscriptEventRow({ 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" + ? "rounded-xl border border-red-500/20 bg-red-500/[0.06] p-3 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" + ? "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"; + ? "text-sky-700 dark:text-sky-300" + : "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}
             
)} @@ -598,23 +546,16 @@ function RawTranscriptView({ }) { const compact = density === "compact"; return ( -
+
{entries.map((entry, idx) => (
- {formatTimestamp(entry.ts)} - + {entry.kind}

From b3e71ca562e687ca1d6daf7cc907cacef6526fc2 Mon Sep 17 00:00:00 2001
From: Dotta 
Date: Wed, 11 Mar 2026 12:14:12 -0500
Subject: [PATCH 04/11] Polish transcript event widgets

Co-Authored-By: Paperclip 
---
 .../transcript/RunTranscriptView.tsx          | 340 +++++++++++++-----
 ui/src/pages/RunTranscriptUxLab.tsx           |  14 +-
 2 files changed, 253 insertions(+), 101 deletions(-)

diff --git a/ui/src/components/transcript/RunTranscriptView.tsx b/ui/src/components/transcript/RunTranscriptView.tsx
index 05defa14..77dbbea0 100644
--- a/ui/src/components/transcript/RunTranscriptView.tsx
+++ b/ui/src/components/transcript/RunTranscriptView.tsx
@@ -1,8 +1,9 @@
-import { useEffect, useMemo, useState, type ReactNode } from "react";
+import { useMemo, useState } from "react";
 import type { TranscriptEntry } from "../../adapters";
 import { MarkdownBody } from "../MarkdownBody";
 import { cn, formatTokens } from "../../lib/utils";
 import {
+  Check,
   ChevronDown,
   ChevronRight,
   CircleAlert,
@@ -49,6 +50,13 @@ type TranscriptBlock =
       isError?: boolean;
       status: "running" | "completed" | "error";
     }
+  | {
+      type: "activity";
+      ts: string;
+      activityId?: string;
+      name: string;
+      status: "running" | "completed";
+    }
   | {
       type: "event";
       ts: string;
@@ -81,6 +89,21 @@ function stripMarkdown(value: string): string {
   );
 }
 
+function humanizeLabel(value: string): string {
+  return value
+    .replace(/[_-]+/g, " ")
+    .trim()
+    .replace(/\b\w/g, (char) => char.toUpperCase());
+}
+
+function stripWrappedShell(command: string): string {
+  const trimmed = compactWhitespace(command);
+  const shellWrapped = trimmed.match(/^(?:(?:\/bin\/)?(?:zsh|bash|sh)|cmd(?:\.exe)?(?:\s+\/d)?(?:\s+\/s)?(?:\s+\/c)?)\s+(?:-lc|\/c)\s+(.+)$/i);
+  const inner = shellWrapped?.[1] ?? trimmed;
+  const quoted = inner.match(/^(['"])([\s\S]*)\1$/);
+  return compactWhitespace(quoted?.[2] ?? inner);
+}
+
 function formatUnknown(value: unknown): string {
   if (typeof value === "string") return value;
   if (value === null || value === undefined) return "";
@@ -132,13 +155,25 @@ function summarizeRecord(record: Record, keys: string[]): strin
 
 function summarizeToolInput(name: string, input: unknown, density: TranscriptDensity): string {
   const compactMax = density === "compact" ? 72 : 120;
-  if (typeof input === "string") return truncate(compactWhitespace(input), compactMax);
+  if (typeof input === "string") {
+    const normalized = isCommandTool(name, input) ? stripWrappedShell(input) : compactWhitespace(input);
+    return truncate(normalized, compactMax);
+  }
   const record = asRecord(input);
   if (!record) {
     const serialized = compactWhitespace(formatUnknown(input));
     return serialized ? truncate(serialized, compactMax) : `Inspect ${name} input`;
   }
 
+  const command = typeof record.command === "string"
+    ? record.command
+    : typeof record.cmd === "string"
+      ? record.cmd
+      : null;
+  if (command && isCommandTool(name, record)) {
+    return truncate(stripWrappedShell(command), compactMax);
+  }
+
   const direct =
     summarizeRecord(record, ["command", "cmd", "path", "filePath", "file_path", "query", "url", "prompt", "message"])
     ?? summarizeRecord(record, ["pattern", "name", "title", "target", "tool"])
@@ -158,8 +193,61 @@ function summarizeToolInput(name: string, input: unknown, density: TranscriptDen
   return truncate(`${keys.length} fields: ${keys.slice(0, 3).join(", ")}`, compactMax);
 }
 
+function parseStructuredToolResult(result: string | undefined) {
+  if (!result) return null;
+  const lines = result.split(/\r?\n/);
+  const metadata = new Map();
+  let bodyStartIndex = lines.findIndex((line) => line.trim() === "");
+  if (bodyStartIndex === -1) bodyStartIndex = lines.length;
+
+  for (let index = 0; index < bodyStartIndex; index += 1) {
+    const match = lines[index]?.match(/^([a-z_]+):\s*(.+)$/i);
+    if (match) {
+      metadata.set(match[1].toLowerCase(), compactWhitespace(match[2]));
+    }
+  }
+
+  const body = lines.slice(Math.min(bodyStartIndex + 1, lines.length))
+    .map((line) => compactWhitespace(line))
+    .filter(Boolean)
+    .join("\n");
+
+  return {
+    command: metadata.get("command") ?? null,
+    status: metadata.get("status") ?? null,
+    exitCode: metadata.get("exit_code") ?? null,
+    body,
+  };
+}
+
+function isCommandTool(name: string, input: unknown): boolean {
+  if (name === "command_execution" || name === "shell" || name === "shellToolCall" || name === "bash") {
+    return true;
+  }
+  if (typeof input === "string") {
+    return /\b(?:bash|zsh|sh|cmd|powershell)\b/i.test(input);
+  }
+  const record = asRecord(input);
+  return Boolean(record && (typeof record.command === "string" || typeof record.cmd === "string"));
+}
+
+function displayToolName(name: string, input: unknown): string {
+  if (isCommandTool(name, input)) return "Executing command";
+  return humanizeLabel(name);
+}
+
 function summarizeToolResult(result: string | undefined, isError: boolean | undefined, density: TranscriptDensity): string {
   if (!result) return isError ? "Tool failed" : "Waiting for result";
+  const structured = parseStructuredToolResult(result);
+  if (structured) {
+    if (structured.body) {
+      return truncate(structured.body.split("\n")[0] ?? structured.body, density === "compact" ? 84 : 140);
+    }
+    if (structured.status === "completed") return "Completed";
+    if (structured.status === "failed" || structured.status === "error") {
+      return structured.exitCode ? `Failed with exit code ${structured.exitCode}` : "Failed";
+    }
+  }
   const lines = result
     .split(/\r?\n/)
     .map((line) => compactWhitespace(line))
@@ -168,9 +256,20 @@ function summarizeToolResult(result: string | undefined, isError: boolean | unde
   return truncate(firstLine, density === "compact" ? 84 : 140);
 }
 
+function parseSystemActivity(text: string): { activityId?: string; name: string; status: "running" | "completed" } | null {
+  const match = text.match(/^item (started|completed):\s*([a-z0-9_-]+)(?:\s+\(id=([^)]+)\))?$/i);
+  if (!match) return null;
+  return {
+    status: match[1].toLowerCase() === "started" ? "running" : "completed",
+    name: humanizeLabel(match[2] ?? "Activity"),
+    activityId: match[3] || undefined,
+  };
+}
+
 function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): TranscriptBlock[] {
   const blocks: TranscriptBlock[] = [];
   const pendingToolBlocks = new Map>();
+  const pendingActivityBlocks = new Map>();
 
   for (const entry of entries) {
     const previous = blocks[blocks.length - 1];
@@ -214,7 +313,7 @@ function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): Tr
       const toolBlock: Extract = {
         type: "tool",
         ts: entry.ts,
-        name: entry.name,
+        name: displayToolName(entry.name, entry.input),
         toolUseId: entry.toolUseId ?? extractToolUseId(entry.input),
         input: entry.input,
         status: "running",
@@ -259,25 +358,18 @@ function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): Tr
         ts: entry.ts,
         label: "init",
         tone: "info",
-        text: `Model ${entry.model}${entry.sessionId ? ` • session ${entry.sessionId}` : ""}`,
+        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,
+        text: entry.text.trim() || entry.errors[0] || (entry.isError ? "Run failed" : "Completed"),
       });
       continue;
     }
@@ -294,6 +386,33 @@ function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): Tr
     }
 
     if (entry.kind === "system") {
+      if (compactWhitespace(entry.text).toLowerCase() === "turn started") {
+        continue;
+      }
+      const activity = parseSystemActivity(entry.text);
+      if (activity) {
+        const existing = activity.activityId ? pendingActivityBlocks.get(activity.activityId) : undefined;
+        if (existing) {
+          existing.status = activity.status;
+          existing.ts = entry.ts;
+          if (activity.status === "completed" && activity.activityId) {
+            pendingActivityBlocks.delete(activity.activityId);
+          }
+        } else {
+          const block: Extract = {
+            type: "activity",
+            ts: entry.ts,
+            activityId: activity.activityId,
+            name: activity.name,
+            status: activity.status,
+          };
+          blocks.push(block);
+          if (activity.status === "running" && activity.activityId) {
+            pendingActivityBlocks.set(activity.activityId, block);
+          }
+        }
+        continue;
+      }
       blocks.push({
         type: "event",
         ts: entry.ts,
@@ -316,40 +435,6 @@ function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): Tr
   return blocks;
 }
 
-function TranscriptDisclosure({
-  defaultOpen,
-  children,
-}: {
-  defaultOpen: boolean;
-  children: ReactNode;
-}) {
-  const [open, setOpen] = useState(defaultOpen);
-  const [touched, setTouched] = useState(false);
-
-  useEffect(() => {
-    if (!touched) {
-      setOpen(defaultOpen);
-    }
-  }, [defaultOpen, touched]);
-
-  return (
-    
- - {open &&
{children}
} -
- ); -} - function TranscriptMessageBlock({ block, density, @@ -416,7 +501,11 @@ function TranscriptToolCard({ block: Extract; density: TranscriptDensity; }) { + const [open, setOpen] = useState(block.status === "error"); const compact = density === "compact"; + const commandTool = isCommandTool(block.name, block.input); + const parsedResult = parseStructuredToolResult(block.result); + const commandPreview = summarizeToolInput(block.name, block.input, density); const statusLabel = block.status === "running" ? "Running" @@ -433,59 +522,129 @@ function TranscriptToolCard({ "space-y-3", block.status === "error" && "rounded-xl border border-red-500/20 bg-red-500/[0.06] p-3", ); + const iconClass = cn( + "mt-0.5 h-3.5 w-3.5 shrink-0", + block.status === "error" + ? "text-red-600 dark:text-red-300" + : block.status === "completed" + ? "text-emerald-600 dark:text-emerald-300" + : "text-cyan-600 dark:text-cyan-300", + ); + const summary = block.status === "running" + ? commandTool + ? commandPreview + : summarizeToolInput(block.name, block.input, density) + : block.status === "completed" && parsedResult?.body + ? truncate(parsedResult.body.split("\n")[0] ?? parsedResult.body, compact ? 84 : 140) + : summarizeToolResult(block.result, block.isError, density); return (
-
- +
+ {block.status === "error" ? ( + + ) : block.status === "completed" && !commandTool ? ( + + ) : commandTool ? ( + + ) : ( + + )}
{block.name} - - {statusLabel} - - {block.toolUseId && ( - - {truncate(block.toolUseId, compact ? 24 : 40)} + {!commandTool && ( + + {statusLabel} )}
-
- {block.status === "running" - ? summarizeToolInput(block.name, block.input, density) - : summarizeToolResult(block.result, block.isError, density)} +
+ {commandTool ? ( + + {summary} + + ) : ( + + {summary} + + )}
+ {commandTool && block.status !== "running" && ( +
+ {block.status === "error" + ? "Command failed" + : parsedResult?.status === "completed" + ? "Command completed" + : statusLabel} +
+ )}
+
- -
-
-
-
- Input + {open && ( +
+
+
+
+
+ Input +
+
+                  {formatToolPayload(block.input) || ""}
+                
-
-                {formatToolPayload(block.input) || ""}
-              
-
-
-
- Result +
+
+ Result +
+
+                  {block.result ? formatToolPayload(block.result) : "Waiting for result..."}
+                
-
-                {block.result ? formatToolPayload(block.result) : "Waiting for result..."}
-              
- + )} +
+ ); +} + +function TranscriptActivityRow({ + block, + density, +}: { + block: Extract; + density: TranscriptDensity; +}) { + return ( +
+ {block.status === "completed" ? ( + + ) : ( + + + + + )} +
+ {block.name} +
); } @@ -518,14 +677,18 @@ function TranscriptEventRow({ )}
-
- - {block.label} - -
-
- {block.text} -
+ {block.label === "result" && block.tone !== "error" ? ( +
+ {block.text} +
+ ) : ( +
+ + {block.label} + + {block.text ? {block.text} : null} +
+ )} {block.detail && (
               {block.detail}
@@ -614,6 +777,7 @@ export function RunTranscriptView({
           {block.type === "message" && }
           {block.type === "thinking" && }
           {block.type === "tool" && }
+          {block.type === "activity" && }
           {block.type === "event" && }
         
))} diff --git a/ui/src/pages/RunTranscriptUxLab.tsx b/ui/src/pages/RunTranscriptUxLab.tsx index 14556482..3885431a 100644 --- a/ui/src/pages/RunTranscriptUxLab.tsx +++ b/ui/src/pages/RunTranscriptUxLab.tsx @@ -6,7 +6,7 @@ import { Identity } from "../components/Identity"; import { StatusBadge } from "../components/StatusBadge"; import { RunTranscriptView, type TranscriptDensity, type TranscriptMode } from "../components/transcript/RunTranscriptView"; import { runTranscriptFixtureEntries, runTranscriptFixtureMeta } from "../fixtures/runTranscriptFixtures"; -import { ExternalLink, FlaskConical, LayoutPanelLeft, MonitorCog, PanelsTopLeft, RadioTower, ShieldCheck } from "lucide-react"; +import { ExternalLink, FlaskConical, LayoutPanelLeft, MonitorCog, PanelsTopLeft, RadioTower } from "lucide-react"; type SurfaceId = "detail" | "live" | "dashboard"; @@ -247,18 +247,6 @@ export function RunTranscriptUxLab() { ); })}
- -
-
- - Sanitization rules -
-
    -
  • No real home-directory segments or workstation names.
  • -
  • No API keys, env var payloads, or secret-bearing commands.
  • -
  • Only safe, representative command/result excerpts remain.
  • -
-
From 487c86f58eeb55199c128e55c44f0a1791e7087e Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 11 Mar 2026 13:14:08 -0500 Subject: [PATCH 05/11] Tighten command transcript rows and dashboard card Co-Authored-By: Paperclip --- ui/src/components/ActiveAgentsPanel.tsx | 6 +- .../transcript/RunTranscriptView.tsx | 225 +++++++++++++++--- ui/src/pages/RunTranscriptUxLab.tsx | 16 +- 3 files changed, 203 insertions(+), 44 deletions(-) diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx index cbbeb225..8712bfda 100644 --- a/ui/src/components/ActiveAgentsPanel.tsx +++ b/ui/src/components/ActiveAgentsPanel.tsx @@ -91,7 +91,7 @@ function AgentRunCard({ }) { return (
{run.issueId && ( -
+
-
+
; + } | { type: "event"; ts: string; @@ -266,6 +279,50 @@ function parseSystemActivity(text: string): { activityId?: string; name: string; }; } +function groupCommandBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] { + const grouped: TranscriptBlock[] = []; + let pending: Array["items"][number]> = []; + let groupTs: string | null = null; + let groupEndTs: string | undefined; + + const flush = () => { + if (pending.length === 0 || !groupTs) return; + grouped.push({ + type: "command_group", + ts: groupTs, + endTs: groupEndTs, + items: pending, + }); + pending = []; + groupTs = null; + groupEndTs = undefined; + }; + + for (const block of blocks) { + if (block.type === "tool" && isCommandTool(block.name, block.input)) { + if (!groupTs) { + groupTs = block.ts; + } + groupEndTs = block.endTs ?? block.ts; + pending.push({ + ts: block.ts, + endTs: block.endTs, + input: block.input, + result: block.result, + isError: block.isError, + status: block.status, + }); + continue; + } + + flush(); + grouped.push(block); + } + + flush(); + return grouped; +} + function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): TranscriptBlock[] { const blocks: TranscriptBlock[] = []; const pendingToolBlocks = new Map>(); @@ -432,7 +489,7 @@ function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): Tr }); } - return blocks; + return groupCommandBlocks(blocks); } function TranscriptMessageBlock({ @@ -503,9 +560,7 @@ function TranscriptToolCard({ }) { const [open, setOpen] = useState(block.status === "error"); const compact = density === "compact"; - const commandTool = isCommandTool(block.name, block.input); const parsedResult = parseStructuredToolResult(block.result); - const commandPreview = summarizeToolInput(block.name, block.input, density); const statusLabel = block.status === "running" ? "Running" @@ -531,9 +586,7 @@ function TranscriptToolCard({ : "text-cyan-600 dark:text-cyan-300", ); const summary = block.status === "running" - ? commandTool - ? commandPreview - : summarizeToolInput(block.name, block.input, density) + ? summarizeToolInput(block.name, block.input, density) : block.status === "completed" && parsedResult?.body ? truncate(parsedResult.body.split("\n")[0] ?? parsedResult.body, compact ? 84 : 140) : summarizeToolResult(block.result, block.isError, density); @@ -543,10 +596,8 @@ function TranscriptToolCard({
{block.status === "error" ? ( - ) : block.status === "completed" && !commandTool ? ( + ) : block.status === "completed" ? ( - ) : commandTool ? ( - ) : ( )} @@ -555,32 +606,13 @@ function TranscriptToolCard({ {block.name} - {!commandTool && ( - - {statusLabel} - - )} + + {statusLabel} +
-
- {commandTool ? ( - - {summary} - - ) : ( - - {summary} - - )} +
+ {summary}
- {commandTool && block.status !== "running" && ( -
- {block.status === "error" - ? "Command failed" - : parsedResult?.status === "completed" - ? "Command completed" - : statusLabel} -
- )}
+
+ {open && ( +
+ {block.items.map((item, index) => ( +
+
+ + + {summarizeToolInput("command_execution", item.input, density)} + +
+ {item.result && ( +
+                  {formatToolPayload(item.result)}
+                
+ )} +
+ ))} +
+ )} +
+ ); +} + function TranscriptActivityRow({ block, density, @@ -777,6 +935,7 @@ export function RunTranscriptView({ {block.type === "message" && } {block.type === "thinking" && } {block.type === "tool" && } + {block.type === "command_group" && } {block.type === "activity" && } {block.type === "event" && }
diff --git a/ui/src/pages/RunTranscriptUxLab.tsx b/ui/src/pages/RunTranscriptUxLab.tsx index 3885431a..80759cf9 100644 --- a/ui/src/pages/RunTranscriptUxLab.tsx +++ b/ui/src/pages/RunTranscriptUxLab.tsx @@ -60,7 +60,7 @@ function RunDetailPreview({ density: TranscriptDensity; }) { return ( -
+
@@ -97,7 +97,7 @@ function LiveWidgetPreview({ density: TranscriptDensity; }) { return ( -
+
Live Runs @@ -149,7 +149,7 @@ function DashboardPreview({ return (
-
+
{runTranscriptFixtureMeta.issueIdentifier} - {runTranscriptFixtureMeta.issueTitle}
-
+
-
+