From 627fbc80ac248346d8eea66d4c483071341a8094 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 6 Apr 2026 19:43:38 -0500 Subject: [PATCH] Polish issue chat chain-of-thought rendering Co-Authored-By: Paperclip --- ui/src/components/IssueChatThread.tsx | 119 ++++++++++++++---- .../transcript/useLiveRunTranscripts.ts | 23 ++-- ui/src/fixtures/issueChatUxFixtures.ts | 29 +++++ ui/src/lib/issue-chat-messages.test.ts | 51 +++++++- ui/src/lib/issue-chat-messages.ts | 100 +++++++++++++++ ui/src/lib/transcriptPresentation.test.ts | 38 ++++++ ui/src/lib/transcriptPresentation.ts | 81 +++++++++++- 7 files changed, 404 insertions(+), 37 deletions(-) create mode 100644 ui/src/lib/transcriptPresentation.test.ts diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 63974248..56c0b7c5 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -50,6 +50,7 @@ import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft"; import { formatAssigneeUserLabel } from "../lib/assignees"; import { timeAgo } from "../lib/timeAgo"; import { + describeToolInput, displayToolName, formatToolPayload, parseToolPayload, @@ -238,15 +239,33 @@ function runStatusClass(status: string) { } function IssueChatChainOfThought() { + const message = useMessage(); + const custom = message.metadata.custom as Record; + const customLabel = typeof custom.chainOfThoughtLabel === "string" && custom.chainOfThoughtLabel.trim().length > 0 + ? custom.chainOfThoughtLabel + : null; + const label = customLabel + ? customLabel + : "Chain of thought"; return ( - - - - Chain of thought + + + + + {label} + + {customLabel ? ( + + Chain of thought + + ) : null} + + + Details + - -
+
, @@ -261,7 +280,7 @@ function IssueChatChainOfThought() { /> ), }, - Layout: ({ children }) =>
{children}
, + Layout: ({ children }) =>
{children}
, }} />
@@ -271,8 +290,12 @@ function IssueChatChainOfThought() { function IssueChatReasoningPart({ text }: { text: string }) { return ( -
- {text} +
+
+ + Reasoning +
+ {text}
); } @@ -299,6 +322,7 @@ function IssueChatToolPart({ : result === undefined ? "" : formatToolPayload(result); + const inputDetails = describeToolInput(toolName, parsedArgs); const displayName = displayToolName(toolName, parsedArgs); const summary = result === undefined @@ -308,35 +332,37 @@ function IssueChatToolPart({ return (
{open ? ( -
- {rawArgsText ? ( +
+ {inputDetails.length > 0 ? (
-
+
Input
-
{rawArgsText}
+
+ {inputDetails.map((detail) => ( +
+
+ {detail.label} +
+
+ {detail.value} +
+
+ ))} +
+
+ ) : rawArgsText ? ( +
+
+ Input +
+
{rawArgsText}
) : null} {result !== undefined ? (
-
+
Result
-
{resultText}
+
{resultText}
) : null}
@@ -1250,8 +1294,29 @@ export function IssueChatThread({ } return [...deduped.values()].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); }, [activeRun, liveRuns]); + const transcriptRuns = useMemo(() => { + const combined = new Map(); + for (const run of displayLiveRuns) { + combined.set(run.id, { + id: run.id, + status: run.status, + adapterType: run.adapterType, + }); + } + for (const run of linkedRuns) { + if (combined.has(run.runId)) continue; + const adapterType = agentMap?.get(run.agentId)?.adapterType; + if (!adapterType) continue; + combined.set(run.runId, { + id: run.runId, + status: run.status, + adapterType, + }); + } + return [...combined.values()]; + }, [agentMap, displayLiveRuns, linkedRuns]); const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({ - runs: enableLiveTranscriptPolling ? displayLiveRuns : [], + runs: enableLiveTranscriptPolling ? transcriptRuns : [], companyId, }); const resolvedTranscriptByRun = transcriptsByRunId ?? transcriptByRun; diff --git a/ui/src/components/transcript/useLiveRunTranscripts.ts b/ui/src/components/transcript/useLiveRunTranscripts.ts index a34fedae..868645d6 100644 --- a/ui/src/components/transcript/useLiveRunTranscripts.ts +++ b/ui/src/components/transcript/useLiveRunTranscripts.ts @@ -2,15 +2,21 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import type { LiveEvent } from "@paperclipai/shared"; import { instanceSettingsApi } from "../../api/instanceSettings"; -import { heartbeatsApi, type LiveRunForIssue } from "../../api/heartbeats"; +import { heartbeatsApi } from "../../api/heartbeats"; import { buildTranscript, getUIAdapter, onAdapterChange, type RunLogChunk, type TranscriptEntry } from "../../adapters"; import { queryKeys } from "../../lib/queryKeys"; const LOG_POLL_INTERVAL_MS = 2000; const LOG_READ_LIMIT_BYTES = 256_000; +export interface RunTranscriptSource { + id: string; + status: string; + adapterType: string; +} + interface UseLiveRunTranscriptsOptions { - runs: LiveRunForIssue[]; + runs: RunTranscriptSource[]; companyId?: string | null; maxChunksPerRun?: number; } @@ -141,7 +147,7 @@ export function useLiveRunTranscripts({ let cancelled = false; - const readRunLog = async (run: LiveRunForIssue) => { + const readRunLog = async (run: RunTranscriptSource) => { const offset = logOffsetByRunRef.current.get(run.id) ?? 0; try { const result = await heartbeatsApi.log(run.id, offset, LOG_READ_LIMIT_BYTES); @@ -166,13 +172,16 @@ export function useLiveRunTranscripts({ }; void readAll(); - const interval = window.setInterval(() => { - void readAll(); - }, LOG_POLL_INTERVAL_MS); + const activeRuns = runs.filter((run) => !isTerminalStatus(run.status)); + const interval = activeRuns.length > 0 + ? window.setInterval(() => { + void Promise.all(activeRuns.map((run) => readRunLog(run))); + }, LOG_POLL_INTERVAL_MS) + : null; return () => { cancelled = true; - window.clearInterval(interval); + if (interval !== null) window.clearInterval(interval); }; }, [runIdsKey, runs]); diff --git a/ui/src/fixtures/issueChatUxFixtures.ts b/ui/src/fixtures/issueChatUxFixtures.ts index 17925c00..978e7df3 100644 --- a/ui/src/fixtures/issueChatUxFixtures.ts +++ b/ui/src/fixtures/issueChatUxFixtures.ts @@ -194,6 +194,35 @@ export const issueChatUxLinkedRuns: IssueChatLinkedRun[] = [ ]; export const issueChatUxTranscriptsByRunId = new Map([ + [ + "run-history-1", + [ + { + kind: "thinking", + ts: "2026-04-06T11:58:03.000Z", + text: "Reviewing the issue thread to see where transcript noise still leaks into the conversation.", + }, + { + kind: "tool_call", + ts: "2026-04-06T11:58:07.000Z", + name: "read_file", + toolUseId: "tool-history-1", + input: { path: "ui/src/lib/issue-chat-messages.ts" }, + }, + { + kind: "tool_result", + ts: "2026-04-06T11:58:11.000Z", + toolUseId: "tool-history-1", + content: "Found the run projection path that decides whether transcript output survives after completion.", + isError: false, + }, + { + kind: "assistant", + ts: "2026-04-06T11:59:24.000Z", + text: "Kept the completed run context attached to the chat timeline so the reasoning can stay folded instead of disappearing.", + }, + ], + ], [ "run-live-1", [ diff --git a/ui/src/lib/issue-chat-messages.test.ts b/ui/src/lib/issue-chat-messages.test.ts index c0ec0cbb..ebe0e508 100644 --- a/ui/src/lib/issue-chat-messages.test.ts +++ b/ui/src/lib/issue-chat-messages.test.ts @@ -245,7 +245,7 @@ describe("buildIssueChatMessages", () => { [{ kind: "assistant", ts: "2026-04-06T12:04:01.000Z", text: "Streaming reply" }], ], ]), - hasOutputForRun: () => true, + hasOutputForRun: (runId) => runId === "run-live-1", companyId: "company-1", projectId: "project-1", agentMap, @@ -269,4 +269,53 @@ describe("buildIssueChatMessages", () => { text: "Streaming reply", }); }); + + it("keeps succeeded runs as assistant messages when transcript output exists", () => { + const agentMap = new Map([["agent-1", createAgent("agent-1", "CodexCoder")]]); + const messages = buildIssueChatMessages({ + comments: [], + timelineEvents: [], + linkedRuns: [ + { + runId: "run-history-1", + status: "succeeded", + agentId: "agent-1", + createdAt: new Date("2026-04-06T12:01:00.000Z"), + startedAt: new Date("2026-04-06T12:01:00.000Z"), + finishedAt: new Date("2026-04-06T12:03:00.000Z"), + }, + ], + liveRuns: [], + transcriptsByRunId: new Map([ + [ + "run-history-1", + [ + { kind: "thinking", ts: "2026-04-06T12:01:10.000Z", text: "Checking the current issue thread." }, + { kind: "assistant", ts: "2026-04-06T12:02:30.000Z", text: "Updated the thread renderer." }, + ], + ], + ]), + hasOutputForRun: (runId) => runId === "run-history-1", + agentMap, + currentUserId: "user-1", + }); + + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + id: "historical-run:run-history-1", + role: "assistant", + status: { type: "complete", reason: "stop" }, + metadata: { + custom: { + kind: "historical-run", + runId: "run-history-1", + chainOfThoughtLabel: "Worked for 2 minutes", + }, + }, + }); + expect(messages[0]?.content).toMatchObject([ + { type: "reasoning", text: "Checking the current issue thread." }, + { type: "text", text: "Updated the thread renderer." }, + ]); + }); }); diff --git a/ui/src/lib/issue-chat-messages.ts b/ui/src/lib/issue-chat-messages.ts index 404d1a51..e5d82f71 100644 --- a/ui/src/lib/issue-chat-messages.ts +++ b/ui/src/lib/issue-chat-messages.ts @@ -281,6 +281,53 @@ function runTimestamp(run: IssueChatLinkedRun) { return run.finishedAt ?? run.startedAt ?? run.createdAt; } +function formatDurationWords(ms: number | null) { + if (ms === null || !Number.isFinite(ms) || ms <= 0) return null; + const totalSeconds = Math.max(1, Math.round(ms / 1000)); + if (totalSeconds < 60) { + return `${totalSeconds} second${totalSeconds === 1 ? "" : "s"}`; + } + const totalMinutes = Math.round(totalSeconds / 60); + if (totalMinutes < 60) { + return `${totalMinutes} minute${totalMinutes === 1 ? "" : "s"}`; + } + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + if (minutes === 0) { + return `${hours} hour${hours === 1 ? "" : "s"}`; + } + return `${hours} hour${hours === 1 ? "" : "s"} ${minutes} minute${minutes === 1 ? "" : "s"}`; +} + +function runDurationLabel(run: { + status: string; + createdAt: Date | string; + startedAt: Date | string | null; + finishedAt?: Date | string | null; +}) { + const start = run.startedAt ?? run.createdAt; + const end = run.finishedAt ?? null; + const durationMs = end ? Math.max(0, toTimestamp(end) - toTimestamp(start)) : null; + const durationText = formatDurationWords(durationMs); + switch (run.status) { + case "succeeded": + return durationText ? `Worked for ${durationText}` : "Finished work"; + case "failed": + case "error": + return durationText ? `Failed after ${durationText}` : "Run failed"; + case "timed_out": + return durationText ? `Timed out after ${durationText}` : "Run timed out"; + case "cancelled": + return durationText ? `Cancelled after ${durationText}` : "Run cancelled"; + case "queued": + return "Queued"; + case "running": + return "Working..."; + default: + return formatStatusLabel(run.status); + } +} + function createHistoricalRunMessage(run: IssueChatLinkedRun, agentMap?: Map) { const agentName = agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8); const message: ThreadSystemMessage = { @@ -302,6 +349,43 @@ function createHistoricalRunMessage(run: IssueChatLinkedRun, agentMap?: Map; +}) { + const { run, transcript, hasOutput, agentMap } = args; + const agentName = agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8); + const { parts, notices } = buildAssistantPartsFromTranscript(transcript); + const waitingText = hasOutput ? "" : "Run finished"; + const content = parts.length > 0 + ? parts + : waitingText + ? [{ type: "text", text: waitingText } satisfies TextMessagePart] + : []; + + const message: ThreadAssistantMessage = { + id: `historical-run:${run.runId}`, + role: "assistant", + createdAt: toDate(run.startedAt ?? run.createdAt), + content, + status: { type: "complete", reason: "stop" }, + metadata: createAssistantMetadata({ + kind: "historical-run", + anchorId: `run-${run.runId}`, + runId: run.runId, + runAgentId: run.agentId, + runAgentName: agentName, + runStatus: run.status, + notices, + waitingText, + chainOfThoughtLabel: runDurationLabel(run), + }), + }; + return message; +} + export function buildAssistantPartsFromTranscript(entries: readonly IssueChatTranscriptEntry[]) { const orderedParts: Array> = []; const toolParts = new Map>(); @@ -495,6 +579,7 @@ function createLiveRunMessage(args: { adapterType: run.adapterType, notices, waitingText, + chainOfThoughtLabel: runDurationLabel(run), }), }; return message; @@ -548,6 +633,21 @@ export function buildIssueChatMessages(args: { } for (const run of [...linkedRuns].sort((a, b) => toTimestamp(runTimestamp(a)) - toTimestamp(runTimestamp(b)))) { + const transcript = transcriptsByRunId?.get(run.runId) ?? []; + const hasRunOutput = transcript.length > 0 || (hasOutputForRun?.(run.runId) ?? false); + if (hasRunOutput) { + orderedMessages.push({ + createdAtMs: toTimestamp(run.startedAt ?? run.createdAt), + order: 2, + message: createHistoricalTranscriptMessage({ + run, + transcript, + hasOutput: hasRunOutput, + agentMap, + }), + }); + continue; + } if (run.status === "succeeded") continue; orderedMessages.push({ createdAtMs: toTimestamp(runTimestamp(run)), diff --git a/ui/src/lib/transcriptPresentation.test.ts b/ui/src/lib/transcriptPresentation.test.ts new file mode 100644 index 00000000..fba04729 --- /dev/null +++ b/ui/src/lib/transcriptPresentation.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { describeToolInput, summarizeToolInput } from "./transcriptPresentation"; + +describe("summarizeToolInput", () => { + it("prefers human descriptions over raw commands when both exist", () => { + expect( + summarizeToolInput("command_execution", { + description: "Inspect the issue chat thread layout classes", + command: "zsh -lc 'sed -n \"1,220p\" ui/src/components/IssueChatThread.tsx'", + }), + ).toBe("Inspect the issue chat thread layout classes"); + }); +}); + +describe("describeToolInput", () => { + it("keeps command tools description-first in the detail view", () => { + expect( + describeToolInput("command_execution", { + description: "Inspect the issue chat thread layout classes", + command: "zsh -lc 'sed -n \"1,220p\" ui/src/components/IssueChatThread.tsx'", + cwd: "/workspace/paperclip", + }), + ).toEqual([ + { label: "Intent", value: "Inspect the issue chat thread layout classes", tone: "default" }, + { label: "Directory", value: "/workspace/paperclip", tone: "default" }, + ]); + }); + + it("surfaces concise structured details for file tools", () => { + expect( + describeToolInput("read_file", { + path: "ui/src/lib/issue-chat-messages.ts", + }), + ).toEqual([ + { label: "Path", value: "ui/src/lib/issue-chat-messages.ts", tone: "default" }, + ]); + }); +}); diff --git a/ui/src/lib/transcriptPresentation.ts b/ui/src/lib/transcriptPresentation.ts index 9e920c06..32442a90 100644 --- a/ui/src/lib/transcriptPresentation.ts +++ b/ui/src/lib/transcriptPresentation.ts @@ -6,6 +6,12 @@ type TranscriptActivity = { status: "running" | "completed"; }; +export interface ToolInputDetail { + label: string; + value: string; + tone?: "default" | "code"; +} + function asRecord(value: unknown): Record | null { if (typeof value !== "object" || value === null || Array.isArray(value)) return null; return value as Record; @@ -137,13 +143,19 @@ export function summarizeToolInput( : typeof record.cmd === "string" ? record.cmd : null; + const humanDescription = + summarizeRecord(record, ["description", "summary", "reason", "goal", "intent", "action", "task"]) + ?? null; + if (humanDescription) { + return truncate(humanDescription, compactMax); + } 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"]) + summarizeRecord(record, ["path", "filePath", "file_path", "query", "url", "prompt", "message"]) + ?? summarizeRecord(record, ["pattern", "name", "title", "target", "tool", "command", "cmd"]) ?? null; if (direct) return truncate(direct, compactMax); @@ -160,6 +172,71 @@ export function summarizeToolInput( return truncate(`${keys.length} fields: ${keys.slice(0, 3).join(", ")}`, compactMax); } +function readToolDetailValue(value: unknown, max = 200): string | null { + if (typeof value === "string") { + const normalized = compactWhitespace(value); + return normalized ? truncate(normalized, max) : null; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + return null; +} + +export function describeToolInput(name: string, input: unknown): ToolInputDetail[] { + if (typeof input === "string") { + const summary = compactWhitespace(isCommandTool(name, input) ? stripWrappedShell(input) : input); + return summary ? [{ label: isCommandTool(name, input) ? "Command" : "Input", value: truncate(summary, 200), tone: "code" }] : []; + } + + const record = asRecord(input); + if (!record) return []; + + const details: ToolInputDetail[] = []; + const seen = new Set(); + const pushDetail = (label: string, value: string | null, tone: ToolInputDetail["tone"] = "default") => { + if (!value) return; + const key = `${label}:${value}`; + if (seen.has(key)) return; + seen.add(key); + details.push({ label, value, tone }); + }; + + pushDetail( + "Intent", + summarizeRecord(record, ["description", "summary", "reason", "goal", "intent", "action", "task"]) ?? null, + ); + pushDetail("Path", readToolDetailValue(record.path) ?? readToolDetailValue(record.filePath) ?? readToolDetailValue(record.file_path)); + pushDetail("Directory", readToolDetailValue(record.cwd)); + pushDetail("Query", readToolDetailValue(record.query)); + pushDetail("Target", readToolDetailValue(record.url) ?? readToolDetailValue(record.target)); + pushDetail("Prompt", readToolDetailValue(record.prompt) ?? readToolDetailValue(record.message)); + pushDetail("Pattern", readToolDetailValue(record.pattern)); + pushDetail("Name", readToolDetailValue(record.name) ?? readToolDetailValue(record.title)); + + if (Array.isArray(record.paths) && record.paths.length > 0) { + const paths = record.paths + .filter((value): value is string => typeof value === "string" && value.trim().length > 0) + .slice(0, 3) + .join(", "); + if (paths) { + const suffix = record.paths.length > 3 ? `, +${record.paths.length - 3} more` : ""; + pushDetail("Paths", `${paths}${suffix}`); + } + } + + const command = typeof record.command === "string" + ? record.command + : typeof record.cmd === "string" + ? record.cmd + : null; + if (command && isCommandTool(name, record) && !details.some((detail) => detail.label === "Intent")) { + pushDetail("Command", truncate(stripWrappedShell(command), 200), "code"); + } + + return details; +} + export function summarizeToolResult( result: string | undefined, isError: boolean | undefined,