Refine issue chat chain-of-thought mapping

This commit is contained in:
dotta 2026-04-06 17:45:54 -05:00
parent d8a7342686
commit 2a372fbe8a
5 changed files with 356 additions and 20 deletions

View file

@ -49,6 +49,13 @@ import { AgentIcon } from "./AgentIconPicker";
import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft";
import { formatAssigneeUserLabel } from "../lib/assignees";
import { timeAgo } from "../lib/timeAgo";
import {
displayToolName,
formatToolPayload,
parseToolPayload,
summarizeToolInput,
summarizeToolResult,
} from "../lib/transcriptPresentation";
import { cn, formatDateTime, formatShortDate } from "../lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@ -235,7 +242,7 @@ function IssueChatChainOfThought() {
<ChainOfThoughtPrimitive.Root className="rounded-md bg-background/70">
<ChainOfThoughtPrimitive.AccordionTrigger className="group flex w-full items-center justify-between gap-3 rounded-sm px-3 py-2 text-left text-xs font-medium text-muted-foreground transition-colors hover:bg-accent/20 hover:text-foreground">
<span className="inline-flex items-center gap-2 uppercase tracking-[0.14em]">
Thinking
Chain of thought
</span>
<ChevronDown className="h-4 w-4 transition-transform group-data-[state=open]:rotate-180" />
</ChainOfThoughtPrimitive.AccordionTrigger>
@ -244,9 +251,10 @@ function IssueChatChainOfThought() {
components={{
Reasoning: ({ text }) => <IssueChatReasoningPart text={text} />,
tools: {
Fallback: ({ toolName, argsText, result, isError }) => (
Fallback: ({ toolName, args, argsText, result, isError }) => (
<IssueChatToolPart
toolName={toolName}
args={args}
argsText={argsText}
result={result}
isError={isError}
@ -271,22 +279,31 @@ function IssueChatReasoningPart({ text }: { text: string }) {
function IssueChatToolPart({
toolName,
args,
argsText,
result,
isError,
}: {
toolName: string;
argsText: string;
args?: unknown;
argsText?: string;
result?: unknown;
isError?: boolean;
}) {
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(Boolean(isError));
const rawArgsText = argsText ?? "";
const parsedArgs = args ?? parseToolPayload(rawArgsText);
const resultText =
typeof result === "string"
? result
: result === undefined
? ""
: JSON.stringify(result, null, 2);
: formatToolPayload(result);
const displayName = displayToolName(toolName, parsedArgs);
const summary =
result === undefined
? summarizeToolInput(toolName, parsedArgs)
: summarizeToolResult(resultText, isError);
return (
<div
@ -302,11 +319,13 @@ function IssueChatToolPart({
className="flex w-full items-center justify-between gap-3 text-left"
onClick={() => setOpen((current) => !current)}
>
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Tool
<span className="min-w-0 flex-1">
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
{displayName}
</span>
<span className="mt-1 block text-sm text-foreground/80">{summary}</span>
</span>
<span className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">{toolName}</span>
<span className="shrink-0 flex items-center gap-2">
{result === undefined ? (
<span className="inline-flex items-center gap-1 rounded-full border border-cyan-400/40 bg-cyan-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-cyan-700 dark:text-cyan-200">
<Loader2 className="h-3 w-3 animate-spin" />
@ -321,17 +340,18 @@ function IssueChatToolPart({
Complete
</span>
)}
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform", open && "rotate-180")} />
</span>
</button>
{open ? (
<div className="mt-3 space-y-3">
{argsText ? (
{rawArgsText ? (
<div>
<div className="mb-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Input
</div>
<pre className="overflow-x-auto rounded-md bg-accent/40 p-2 text-xs text-foreground">{argsText}</pre>
<pre className="overflow-x-auto rounded-md bg-accent/40 p-2 text-xs text-foreground">{rawArgsText}</pre>
</div>
) : null}
{result !== undefined ? (

View file

@ -73,7 +73,7 @@ describe("buildAssistantPartsFromTranscript", () => {
{ kind: "stderr", ts: "2026-04-06T12:00:05.000Z", text: "warn: noisy setup output" },
]);
expect(result.parts).toHaveLength(3);
expect(result.parts).toHaveLength(4);
expect(result.parts[0]).toMatchObject({ type: "text", text: "Working on it. Done." });
expect(result.parts[1]).toMatchObject({ type: "reasoning", text: "Need to inspect files." });
expect(result.parts[2]).toMatchObject({
@ -83,7 +83,11 @@ describe("buildAssistantPartsFromTranscript", () => {
result: "file contents",
isError: false,
});
expect(result.notices).toEqual(["warn: noisy setup output"]);
expect(result.parts[3]).toMatchObject({
type: "reasoning",
text: "Background: warn: noisy setup output",
});
expect(result.notices).toEqual([]);
});
it("preserves transcript ordering when text and tool activity are interleaved", () => {
@ -129,6 +133,52 @@ describe("buildAssistantPartsFromTranscript", () => {
{ type: "tool-call", toolCallId: "tool-2", toolName: "write_file", result: "saved" },
]);
});
it("projects init, system activity, and errors into reasoning parts", () => {
const result = buildAssistantPartsFromTranscript([
{
kind: "init",
ts: "2026-04-06T12:00:00.000Z",
model: "gpt-5.4",
sessionId: "session-123",
},
{
kind: "system",
ts: "2026-04-06T12:00:01.000Z",
text: "item started: planning_step (id=step-1)",
},
{
kind: "system",
ts: "2026-04-06T12:00:02.000Z",
text: "item completed: planning_step (id=step-1)",
},
{
kind: "result",
ts: "2026-04-06T12:00:03.000Z",
text: "Tool crashed during execution",
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
costUsd: 0,
subtype: "error",
isError: true,
errors: ["ENOENT: missing file"],
},
]);
expect(result.parts).toMatchObject([
{
type: "reasoning",
text: [
"Started gpt-5.4 session session-123.",
"Working on planning step.",
"Completed planning step.",
"Run error: ENOENT: missing file",
].join("\n"),
},
]);
expect(result.notices).toEqual([]);
});
});
describe("buildIssueChatMessages", () => {
@ -205,7 +255,6 @@ describe("buildIssueChatMessages", () => {
expect(messages.map((message) => `${message.role}:${message.id}`)).toEqual([
"system:activity:event-1",
"user:comment-1",
"system:run:run-history-1",
"assistant:comment-2",
"assistant:live-run:run-live-1",
]);

View file

@ -11,6 +11,11 @@ import type { Agent, IssueComment } from "@paperclipai/shared";
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
import { formatAssigneeUserLabel } from "./assignees";
import type { IssueTimelineEvent } from "./issue-timeline-events";
import {
parseSystemActivity,
shouldHideNiceModeStderr,
summarizeNotice,
} from "./transcriptPresentation";
type JsonValue = null | string | number | boolean | JsonValue[] | { [key: string]: JsonValue };
type JsonObject = { [key: string]: JsonValue };
@ -49,6 +54,7 @@ export interface IssueChatTranscriptEntry {
| "diff";
ts: string;
text?: string;
delta?: boolean;
name?: string;
input?: unknown;
toolUseId?: string;
@ -57,6 +63,13 @@ export interface IssueChatTranscriptEntry {
isError?: boolean;
subtype?: string;
errors?: string[];
model?: string;
sessionId?: string;
inputTokens?: number;
outputTokens?: number;
cachedTokens?: number;
costUsd?: number;
changeType?: "add" | "remove" | "context" | "hunk" | "file_header" | "truncation";
}
type MessageWithOrder = {
@ -121,6 +134,25 @@ function stringifyUnknown(value: unknown) {
}
}
function mergePartText(
previous: TextMessagePart | ReasoningMessagePart,
next: TextMessagePart | ReasoningMessagePart,
) {
if (!previous.text) return next.text;
if (!next.text) return previous.text;
if (
previous.text.endsWith("\n")
|| next.text.startsWith("\n")
|| previous.text.endsWith(" ")
|| next.text.startsWith(" ")
) {
return `${previous.text}${next.text}`;
}
return previous.type === "text"
? `${previous.text} ${next.text}`
: `${previous.text}\n${next.text}`;
}
function createAssistantMetadata(custom: Record<string, unknown>) {
return {
unstable_state: null,
@ -330,20 +362,51 @@ export function buildAssistantPartsFromTranscript(entries: readonly IssueChatTra
toolParts.set(toolCallId, nextPart);
continue;
}
if (entry.kind === "init" && entry.model) {
const sessionSuffix = entry.sessionId ? ` session ${entry.sessionId}` : "";
orderedParts.push({ type: "reasoning", text: `Started ${entry.model}${sessionSuffix}.` });
continue;
}
if (entry.kind === "stderr" && entry.text) {
notices.push(entry.text);
if (!shouldHideNiceModeStderr(entry.text)) {
orderedParts.push({ type: "reasoning", text: `Background: ${summarizeNotice(entry.text)}` });
}
continue;
}
if (entry.kind === "system" && entry.text) {
notices.push(entry.text);
const normalized = entry.text.trim().toLowerCase();
if (normalized === "turn started") continue;
const activity = parseSystemActivity(entry.text);
if (activity) {
orderedParts.push({
type: "reasoning",
text: activity.status === "running"
? `Working on ${activity.name.toLowerCase()}.`
: `Completed ${activity.name.toLowerCase()}.`,
});
} else {
orderedParts.push({ type: "reasoning", text: `System: ${summarizeNotice(entry.text)}` });
}
continue;
}
if (entry.kind === "result") {
if (entry.isError && entry.errors?.length) {
notices.push(...entry.errors);
for (const error of entry.errors) {
orderedParts.push({ type: "reasoning", text: `Run error: ${summarizeNotice(error)}` });
}
} else if (entry.text) {
notices.push(entry.text);
orderedParts.push({
type: "reasoning",
text: entry.isError
? `Run error: ${summarizeNotice(entry.text)}`
: `Run update: ${summarizeNotice(entry.text)}`,
});
}
continue;
}
if (entry.kind === "stdout" && entry.text) {
orderedParts.push({ type: "reasoning", text: `Log: ${summarizeNotice(entry.text)}` });
continue;
}
}
@ -357,7 +420,7 @@ export function buildAssistantPartsFromTranscript(entries: readonly IssueChatTra
if (previous && previous.type === part.type && previous.parentId === part.parentId) {
mergedParts[mergedParts.length - 1] = {
...previous,
text: `${previous.text}${part.text}`,
text: mergePartText(previous, part),
};
continue;
}

View file

@ -0,0 +1,204 @@
type TranscriptDensity = "comfortable" | "compact";
type TranscriptActivity = {
activityId?: string;
name: string;
status: "running" | "completed";
};
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
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 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 "";
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function summarizeRecord(record: Record<string, unknown>, 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 parseStructuredToolResult(result: string | undefined) {
if (!result) return null;
const lines = result.split(/\r?\n/);
const metadata = new Map<string, string>();
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,
};
}
export function formatToolPayload(value: unknown): string {
if (typeof value === "string") {
try {
return JSON.stringify(JSON.parse(value), null, 2);
} catch {
return value;
}
}
return formatUnknown(value);
}
export function parseToolPayload(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return value;
}
}
export 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"));
}
export function displayToolName(name: string, input: unknown): string {
if (isCommandTool(name, input)) return "Executing command";
return humanizeLabel(name);
}
export function summarizeToolInput(
name: string,
input: unknown,
density: TranscriptDensity = "comfortable",
): string {
const compactMax = density === "compact" ? 72 : 120;
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"])
?? 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);
}
export function summarizeToolResult(
result: string | undefined,
isError: boolean | undefined,
density: TranscriptDensity = "comfortable",
): 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))
.filter(Boolean);
const firstLine = lines[0] ?? result;
return truncate(firstLine, density === "compact" ? 84 : 140);
}
export function parseSystemActivity(text: string): TranscriptActivity | 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,
};
}
export function shouldHideNiceModeStderr(text: string): boolean {
const normalized = compactWhitespace(text).toLowerCase();
return normalized.startsWith("[paperclip] skipping saved session resume");
}
export function summarizeNotice(text: string, max = 160): string {
return truncate(compactWhitespace(text), max);
}

View file

@ -22,7 +22,7 @@ import { Bot, FlaskConical, MessagesSquare, Route, Sparkles, WandSparkles } from
const noop = async () => {};
const highlights = [
"Running assistant replies with streamed text, reasoning, tool cards, and noisy notices",
"Running assistant replies with streamed text, reasoning, tool cards, and background status notes",
"Historical issue events and linked runs rendered inline with the chat timeline",
"Queued user messages, settled assistant comments, and feedback controls",
"Empty and disabled-composer states without relying on live backend data",