mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-19 04:00:38 +09:00
Polish issue chat chain-of-thought rendering
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
2a372fbe8a
commit
627fbc80ac
7 changed files with 404 additions and 37 deletions
|
|
@ -50,6 +50,7 @@ import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft";
|
||||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||||
import { timeAgo } from "../lib/timeAgo";
|
import { timeAgo } from "../lib/timeAgo";
|
||||||
import {
|
import {
|
||||||
|
describeToolInput,
|
||||||
displayToolName,
|
displayToolName,
|
||||||
formatToolPayload,
|
formatToolPayload,
|
||||||
parseToolPayload,
|
parseToolPayload,
|
||||||
|
|
@ -238,15 +239,33 @@ function runStatusClass(status: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function IssueChatChainOfThought() {
|
function IssueChatChainOfThought() {
|
||||||
|
const message = useMessage();
|
||||||
|
const custom = message.metadata.custom as Record<string, unknown>;
|
||||||
|
const customLabel = typeof custom.chainOfThoughtLabel === "string" && custom.chainOfThoughtLabel.trim().length > 0
|
||||||
|
? custom.chainOfThoughtLabel
|
||||||
|
: null;
|
||||||
|
const label = customLabel
|
||||||
|
? customLabel
|
||||||
|
: "Chain of thought";
|
||||||
return (
|
return (
|
||||||
<ChainOfThoughtPrimitive.Root className="rounded-md bg-background/70">
|
<ChainOfThoughtPrimitive.Root className="overflow-hidden rounded-2xl border border-border/70 bg-[linear-gradient(180deg,rgba(255,255,255,0.92),rgba(248,250,252,0.82))] shadow-[0_10px_30px_rgba(15,23,42,0.04)] dark:bg-[linear-gradient(180deg,rgba(15,23,42,0.62),rgba(15,23,42,0.4))]">
|
||||||
<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">
|
<ChainOfThoughtPrimitive.AccordionTrigger className="group flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition-colors hover:bg-accent/10">
|
||||||
<span className="inline-flex items-center gap-2 uppercase tracking-[0.14em]">
|
<span className="inline-flex flex-col items-start gap-0.5">
|
||||||
Chain of thought
|
<span className={cn(customLabel ? "text-sm font-medium normal-case tracking-normal text-foreground/90" : "uppercase tracking-[0.14em]")}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{customLabel ? (
|
||||||
|
<span className="text-[10px] uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
Chain of thought
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-2 text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
Details
|
||||||
|
<ChevronDown className="h-4 w-4 transition-transform group-data-[state=open]:rotate-180" />
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="h-4 w-4 transition-transform group-data-[state=open]:rotate-180" />
|
|
||||||
</ChainOfThoughtPrimitive.AccordionTrigger>
|
</ChainOfThoughtPrimitive.AccordionTrigger>
|
||||||
<div className="mr-2 border-r border-border/70 pr-3">
|
<div className="border-t border-border/60 bg-background/35 px-4 py-3">
|
||||||
<ChainOfThoughtPrimitive.Parts
|
<ChainOfThoughtPrimitive.Parts
|
||||||
components={{
|
components={{
|
||||||
Reasoning: ({ text }) => <IssueChatReasoningPart text={text} />,
|
Reasoning: ({ text }) => <IssueChatReasoningPart text={text} />,
|
||||||
|
|
@ -261,7 +280,7 @@ function IssueChatChainOfThought() {
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
Layout: ({ children }) => <div className="space-y-2 pb-1 pl-1">{children}</div>,
|
Layout: ({ children }) => <div className="space-y-2.5">{children}</div>,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -271,8 +290,12 @@ function IssueChatChainOfThought() {
|
||||||
|
|
||||||
function IssueChatReasoningPart({ text }: { text: string }) {
|
function IssueChatReasoningPart({ text }: { text: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-sm bg-accent/20 px-3 py-2">
|
<div className="rounded-xl border border-border/60 bg-background/70 px-3.5 py-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.45)]">
|
||||||
<MarkdownBody className="text-sm leading-6">{text}</MarkdownBody>
|
<div className="mb-2 inline-flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-cyan-500/70" />
|
||||||
|
Reasoning
|
||||||
|
</div>
|
||||||
|
<MarkdownBody className="text-sm leading-6 text-foreground/88">{text}</MarkdownBody>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -299,6 +322,7 @@ function IssueChatToolPart({
|
||||||
: result === undefined
|
: result === undefined
|
||||||
? ""
|
? ""
|
||||||
: formatToolPayload(result);
|
: formatToolPayload(result);
|
||||||
|
const inputDetails = describeToolInput(toolName, parsedArgs);
|
||||||
const displayName = displayToolName(toolName, parsedArgs);
|
const displayName = displayToolName(toolName, parsedArgs);
|
||||||
const summary =
|
const summary =
|
||||||
result === undefined
|
result === undefined
|
||||||
|
|
@ -308,35 +332,37 @@ function IssueChatToolPart({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-sm border px-3 py-2",
|
"rounded-xl border px-3.5 py-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.35)]",
|
||||||
isError
|
isError
|
||||||
? "border-red-300/70 bg-red-50/70 dark:border-red-500/40 dark:bg-red-500/10"
|
? "border-red-300/70 bg-[linear-gradient(180deg,rgba(254,242,242,0.95),rgba(254,242,242,0.72))] dark:border-red-500/40 dark:bg-red-500/10"
|
||||||
: "border-border/70 bg-background/70",
|
: "border-border/70 bg-[linear-gradient(180deg,rgba(255,255,255,0.94),rgba(248,250,252,0.78))] dark:bg-background/70",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center justify-between gap-3 text-left"
|
className="flex w-full items-start justify-between gap-3 text-left"
|
||||||
onClick={() => setOpen((current) => !current)}
|
onClick={() => setOpen((current) => !current)}
|
||||||
>
|
>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
<span className="inline-flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||||
{displayName}
|
<span className={cn("h-1.5 w-1.5 rounded-full", result === undefined ? "bg-cyan-500/75" : isError ? "bg-red-500/75" : "bg-emerald-500/75")} />
|
||||||
|
Tool call
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-1 block text-sm text-foreground/80">{summary}</span>
|
<span className="mt-1 block text-sm font-medium text-foreground">{displayName}</span>
|
||||||
|
<span className="mt-1.5 block text-sm leading-6 text-foreground/72">{summary}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="shrink-0 flex items-center gap-2">
|
<span className="shrink-0 flex items-center gap-2 pt-0.5">
|
||||||
{result === undefined ? (
|
{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">
|
<span className="inline-flex items-center gap-1 rounded-full border border-cyan-400/35 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" />
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
Running
|
Running
|
||||||
</span>
|
</span>
|
||||||
) : isError ? (
|
) : isError ? (
|
||||||
<span className="inline-flex items-center rounded-full border border-red-400/50 bg-red-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-red-700 dark:text-red-200">
|
<span className="inline-flex items-center rounded-full border border-red-400/45 bg-red-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-red-700 dark:text-red-200">
|
||||||
Error
|
Error
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="inline-flex items-center rounded-full border border-emerald-400/50 bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-emerald-700 dark:text-emerald-200">
|
<span className="inline-flex items-center rounded-full border border-emerald-400/45 bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-emerald-700 dark:text-emerald-200">
|
||||||
Complete
|
Complete
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -345,21 +371,39 @@ function IssueChatToolPart({
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open ? (
|
{open ? (
|
||||||
<div className="mt-3 space-y-3">
|
<div className="mt-3 space-y-3 border-t border-border/60 pt-3">
|
||||||
{rawArgsText ? (
|
{inputDetails.length > 0 ? (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
<div className="mb-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
Input
|
Input
|
||||||
</div>
|
</div>
|
||||||
<pre className="overflow-x-auto rounded-md bg-accent/40 p-2 text-xs text-foreground">{rawArgsText}</pre>
|
<dl className="space-y-2">
|
||||||
|
{inputDetails.map((detail) => (
|
||||||
|
<div key={`${detail.label}:${detail.value}`} className="rounded-xl border border-border/60 bg-background/70 px-3 py-2.5">
|
||||||
|
<dt className="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
{detail.label}
|
||||||
|
</dt>
|
||||||
|
<dd className={cn("mt-1 text-sm leading-6 text-foreground/85", detail.tone === "code" && "font-mono text-[13px] leading-5")}>
|
||||||
|
{detail.value}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
) : rawArgsText ? (
|
||||||
|
<div>
|
||||||
|
<div className="mb-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
Input
|
||||||
|
</div>
|
||||||
|
<pre className="overflow-x-auto rounded-xl border border-border/60 bg-background/70 p-2.5 text-xs leading-5 text-foreground/85">{rawArgsText}</pre>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{result !== undefined ? (
|
{result !== undefined ? (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
<div className="mb-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
Result
|
Result
|
||||||
</div>
|
</div>
|
||||||
<pre className="overflow-x-auto rounded-md bg-accent/40 p-2 text-xs text-foreground">{resultText}</pre>
|
<pre className="overflow-x-auto rounded-xl border border-border/60 bg-background/70 p-2.5 text-xs leading-5 text-foreground/85">{resultText}</pre>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1250,8 +1294,29 @@ export function IssueChatThread({
|
||||||
}
|
}
|
||||||
return [...deduped.values()].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
return [...deduped.values()].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||||
}, [activeRun, liveRuns]);
|
}, [activeRun, liveRuns]);
|
||||||
|
const transcriptRuns = useMemo(() => {
|
||||||
|
const combined = new Map<string, { id: string; status: string; adapterType: string }>();
|
||||||
|
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({
|
const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({
|
||||||
runs: enableLiveTranscriptPolling ? displayLiveRuns : [],
|
runs: enableLiveTranscriptPolling ? transcriptRuns : [],
|
||||||
companyId,
|
companyId,
|
||||||
});
|
});
|
||||||
const resolvedTranscriptByRun = transcriptsByRunId ?? transcriptByRun;
|
const resolvedTranscriptByRun = transcriptsByRunId ?? transcriptByRun;
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,21 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type { LiveEvent } from "@paperclipai/shared";
|
import type { LiveEvent } from "@paperclipai/shared";
|
||||||
import { instanceSettingsApi } from "../../api/instanceSettings";
|
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 { buildTranscript, getUIAdapter, onAdapterChange, type RunLogChunk, type TranscriptEntry } from "../../adapters";
|
||||||
import { queryKeys } from "../../lib/queryKeys";
|
import { queryKeys } from "../../lib/queryKeys";
|
||||||
|
|
||||||
const LOG_POLL_INTERVAL_MS = 2000;
|
const LOG_POLL_INTERVAL_MS = 2000;
|
||||||
const LOG_READ_LIMIT_BYTES = 256_000;
|
const LOG_READ_LIMIT_BYTES = 256_000;
|
||||||
|
|
||||||
|
export interface RunTranscriptSource {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
adapterType: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface UseLiveRunTranscriptsOptions {
|
interface UseLiveRunTranscriptsOptions {
|
||||||
runs: LiveRunForIssue[];
|
runs: RunTranscriptSource[];
|
||||||
companyId?: string | null;
|
companyId?: string | null;
|
||||||
maxChunksPerRun?: number;
|
maxChunksPerRun?: number;
|
||||||
}
|
}
|
||||||
|
|
@ -141,7 +147,7 @@ export function useLiveRunTranscripts({
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const readRunLog = async (run: LiveRunForIssue) => {
|
const readRunLog = async (run: RunTranscriptSource) => {
|
||||||
const offset = logOffsetByRunRef.current.get(run.id) ?? 0;
|
const offset = logOffsetByRunRef.current.get(run.id) ?? 0;
|
||||||
try {
|
try {
|
||||||
const result = await heartbeatsApi.log(run.id, offset, LOG_READ_LIMIT_BYTES);
|
const result = await heartbeatsApi.log(run.id, offset, LOG_READ_LIMIT_BYTES);
|
||||||
|
|
@ -166,13 +172,16 @@ export function useLiveRunTranscripts({
|
||||||
};
|
};
|
||||||
|
|
||||||
void readAll();
|
void readAll();
|
||||||
const interval = window.setInterval(() => {
|
const activeRuns = runs.filter((run) => !isTerminalStatus(run.status));
|
||||||
void readAll();
|
const interval = activeRuns.length > 0
|
||||||
}, LOG_POLL_INTERVAL_MS);
|
? window.setInterval(() => {
|
||||||
|
void Promise.all(activeRuns.map((run) => readRunLog(run)));
|
||||||
|
}, LOG_POLL_INTERVAL_MS)
|
||||||
|
: null;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.clearInterval(interval);
|
if (interval !== null) window.clearInterval(interval);
|
||||||
};
|
};
|
||||||
}, [runIdsKey, runs]);
|
}, [runIdsKey, runs]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -194,6 +194,35 @@ export const issueChatUxLinkedRuns: IssueChatLinkedRun[] = [
|
||||||
];
|
];
|
||||||
|
|
||||||
export const issueChatUxTranscriptsByRunId = new Map<string, readonly IssueChatTranscriptEntry[]>([
|
export const issueChatUxTranscriptsByRunId = new Map<string, readonly IssueChatTranscriptEntry[]>([
|
||||||
|
[
|
||||||
|
"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",
|
"run-live-1",
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -245,7 +245,7 @@ describe("buildIssueChatMessages", () => {
|
||||||
[{ kind: "assistant", ts: "2026-04-06T12:04:01.000Z", text: "Streaming reply" }],
|
[{ kind: "assistant", ts: "2026-04-06T12:04:01.000Z", text: "Streaming reply" }],
|
||||||
],
|
],
|
||||||
]),
|
]),
|
||||||
hasOutputForRun: () => true,
|
hasOutputForRun: (runId) => runId === "run-live-1",
|
||||||
companyId: "company-1",
|
companyId: "company-1",
|
||||||
projectId: "project-1",
|
projectId: "project-1",
|
||||||
agentMap,
|
agentMap,
|
||||||
|
|
@ -269,4 +269,53 @@ describe("buildIssueChatMessages", () => {
|
||||||
text: "Streaming reply",
|
text: "Streaming reply",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps succeeded runs as assistant messages when transcript output exists", () => {
|
||||||
|
const agentMap = new Map<string, Agent>([["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." },
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -281,6 +281,53 @@ function runTimestamp(run: IssueChatLinkedRun) {
|
||||||
return run.finishedAt ?? run.startedAt ?? run.createdAt;
|
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<string, Agent>) {
|
function createHistoricalRunMessage(run: IssueChatLinkedRun, agentMap?: Map<string, Agent>) {
|
||||||
const agentName = agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8);
|
const agentName = agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8);
|
||||||
const message: ThreadSystemMessage = {
|
const message: ThreadSystemMessage = {
|
||||||
|
|
@ -302,6 +349,43 @@ function createHistoricalRunMessage(run: IssueChatLinkedRun, agentMap?: Map<stri
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createHistoricalTranscriptMessage(args: {
|
||||||
|
run: IssueChatLinkedRun;
|
||||||
|
transcript: readonly IssueChatTranscriptEntry[];
|
||||||
|
hasOutput: boolean;
|
||||||
|
agentMap?: Map<string, Agent>;
|
||||||
|
}) {
|
||||||
|
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[]) {
|
export function buildAssistantPartsFromTranscript(entries: readonly IssueChatTranscriptEntry[]) {
|
||||||
const orderedParts: Array<TextMessagePart | ReasoningMessagePart | ToolCallMessagePart<JsonObject, unknown>> = [];
|
const orderedParts: Array<TextMessagePart | ReasoningMessagePart | ToolCallMessagePart<JsonObject, unknown>> = [];
|
||||||
const toolParts = new Map<string, ToolCallMessagePart<JsonObject, unknown>>();
|
const toolParts = new Map<string, ToolCallMessagePart<JsonObject, unknown>>();
|
||||||
|
|
@ -495,6 +579,7 @@ function createLiveRunMessage(args: {
|
||||||
adapterType: run.adapterType,
|
adapterType: run.adapterType,
|
||||||
notices,
|
notices,
|
||||||
waitingText,
|
waitingText,
|
||||||
|
chainOfThoughtLabel: runDurationLabel(run),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
return message;
|
return message;
|
||||||
|
|
@ -548,6 +633,21 @@ export function buildIssueChatMessages(args: {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const run of [...linkedRuns].sort((a, b) => toTimestamp(runTimestamp(a)) - toTimestamp(runTimestamp(b)))) {
|
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;
|
if (run.status === "succeeded") continue;
|
||||||
orderedMessages.push({
|
orderedMessages.push({
|
||||||
createdAtMs: toTimestamp(runTimestamp(run)),
|
createdAtMs: toTimestamp(runTimestamp(run)),
|
||||||
|
|
|
||||||
38
ui/src/lib/transcriptPresentation.test.ts
Normal file
38
ui/src/lib/transcriptPresentation.test.ts
Normal file
|
|
@ -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" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -6,6 +6,12 @@ type TranscriptActivity = {
|
||||||
status: "running" | "completed";
|
status: "running" | "completed";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface ToolInputDetail {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
tone?: "default" | "code";
|
||||||
|
}
|
||||||
|
|
||||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||||
return value as Record<string, unknown>;
|
return value as Record<string, unknown>;
|
||||||
|
|
@ -137,13 +143,19 @@ export function summarizeToolInput(
|
||||||
: typeof record.cmd === "string"
|
: typeof record.cmd === "string"
|
||||||
? record.cmd
|
? record.cmd
|
||||||
: null;
|
: null;
|
||||||
|
const humanDescription =
|
||||||
|
summarizeRecord(record, ["description", "summary", "reason", "goal", "intent", "action", "task"])
|
||||||
|
?? null;
|
||||||
|
if (humanDescription) {
|
||||||
|
return truncate(humanDescription, compactMax);
|
||||||
|
}
|
||||||
if (command && isCommandTool(name, record)) {
|
if (command && isCommandTool(name, record)) {
|
||||||
return truncate(stripWrappedShell(command), compactMax);
|
return truncate(stripWrappedShell(command), compactMax);
|
||||||
}
|
}
|
||||||
|
|
||||||
const direct =
|
const direct =
|
||||||
summarizeRecord(record, ["command", "cmd", "path", "filePath", "file_path", "query", "url", "prompt", "message"])
|
summarizeRecord(record, ["path", "filePath", "file_path", "query", "url", "prompt", "message"])
|
||||||
?? summarizeRecord(record, ["pattern", "name", "title", "target", "tool"])
|
?? summarizeRecord(record, ["pattern", "name", "title", "target", "tool", "command", "cmd"])
|
||||||
?? null;
|
?? null;
|
||||||
if (direct) return truncate(direct, compactMax);
|
if (direct) return truncate(direct, compactMax);
|
||||||
|
|
||||||
|
|
@ -160,6 +172,71 @@ export function summarizeToolInput(
|
||||||
return truncate(`${keys.length} fields: ${keys.slice(0, 3).join(", ")}`, compactMax);
|
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<string>();
|
||||||
|
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(
|
export function summarizeToolResult(
|
||||||
result: string | undefined,
|
result: string | undefined,
|
||||||
isError: boolean | undefined,
|
isError: boolean | undefined,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue