feat: unify tool call/result into single card with status indicator
This commit is contained in:
parent
1daba0e9ea
commit
25e970f1ae
|
|
@ -4,26 +4,38 @@ import { cn } from "@/utils/ui";
|
|||
import type { ChatMessage, ToolCall } from "@/agent/types";
|
||||
import { isTranscriptData, formatTimestamp } from "./transcript-utils";
|
||||
import type { TranscriptData } from "./transcript-utils";
|
||||
import { ToolCallCard } from "./tool-call-card";
|
||||
import { ToolResultCard } from "./tool-result-card";
|
||||
import {
|
||||
ToolExecutionCard,
|
||||
type ToolExecutionPair,
|
||||
} from "./tool-execution-card";
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: ChatMessage;
|
||||
messages: ChatMessage[];
|
||||
}
|
||||
|
||||
function findToolCallName(
|
||||
messages: ChatMessage[],
|
||||
toolCallId?: string,
|
||||
): string | null {
|
||||
if (!toolCallId) return null;
|
||||
function buildToolCallPairs(messages: ChatMessage[]): ToolExecutionPair[] {
|
||||
const pairs: ToolExecutionPair[] = [];
|
||||
const resultIndex = new Map<string, string>();
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "assistant" && msg.toolCalls) {
|
||||
const match = msg.toolCalls.find((tc: ToolCall) => tc.id === toolCallId);
|
||||
if (match) return match.name;
|
||||
if (msg.role === "tool_result" && msg.toolCallId) {
|
||||
resultIndex.set(msg.toolCallId, msg.content);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "assistant" && msg.toolCalls) {
|
||||
for (const tc of msg.toolCalls) {
|
||||
pairs.push({
|
||||
toolCall: tc,
|
||||
resultContent: resultIndex.get(tc.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pairs;
|
||||
}
|
||||
|
||||
function TranscriptCard({ data }: { data: TranscriptData }) {
|
||||
|
|
@ -53,11 +65,25 @@ function TranscriptCard({ data }: { data: TranscriptData }) {
|
|||
);
|
||||
}
|
||||
|
||||
function isToolResultAbsorbed(
|
||||
message: ChatMessage,
|
||||
allMessages: ChatMessage[],
|
||||
): boolean {
|
||||
if (message.role !== "tool_result" || !message.toolCallId) return false;
|
||||
return allMessages.some(
|
||||
(m) =>
|
||||
m.role === "assistant" &&
|
||||
m.toolCalls?.some((tc: ToolCall) => tc.id === message.toolCallId),
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageBubble({ message, messages }: MessageBubbleProps) {
|
||||
const isUser = message.role === "user";
|
||||
|
||||
if (message.role === "tool_result") {
|
||||
const toolName = findToolCallName(messages, message.toolCallId);
|
||||
if (isToolResultAbsorbed(message, messages)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(message.content);
|
||||
|
|
@ -68,23 +94,9 @@ export function MessageBubble({ message, messages }: MessageBubbleProps) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// not JSON
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (toolName) {
|
||||
return (
|
||||
<div className="px-3 py-0.5">
|
||||
<ToolResultCard name={toolName} content={message.content} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-3 py-0.5">
|
||||
<ToolResultCard name="Tool" content={message.content} />
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasToolCalls =
|
||||
|
|
@ -92,6 +104,18 @@ export function MessageBubble({ message, messages }: MessageBubbleProps) {
|
|||
message.toolCalls &&
|
||||
message.toolCalls.length > 0;
|
||||
|
||||
const toolPairs = hasToolCalls
|
||||
? message.toolCalls!.map((tc: ToolCall) => {
|
||||
const result = messages.find(
|
||||
(m) => m.role === "tool_result" && m.toolCallId === tc.id,
|
||||
);
|
||||
return {
|
||||
toolCall: tc,
|
||||
resultContent: result?.content,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -102,10 +126,10 @@ export function MessageBubble({ message, messages }: MessageBubbleProps) {
|
|||
{message.content && (
|
||||
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
|
||||
)}
|
||||
{hasToolCalls && (
|
||||
{toolPairs.length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{message.toolCalls?.map((tc: ToolCall) => (
|
||||
<ToolCallCard key={tc.id} toolCall={tc} />
|
||||
{toolPairs.map((pair) => (
|
||||
<ToolExecutionCard key={pair.toolCall.id} pair={pair} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,52 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { formatToolCall } from "./tool-formatters";
|
||||
import type { ToolCall } from "@/agent/types";
|
||||
|
||||
interface ToolCallCardProps {
|
||||
toolCall: ToolCall;
|
||||
}
|
||||
|
||||
export function ToolCallCard({ toolCall }: ToolCallCardProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const summary = formatToolCall(toolCall.name, toolCall.args);
|
||||
|
||||
return (
|
||||
<div className="group/tool flex flex-col rounded-md border border-border/50 bg-muted/30 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 text-left transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
aria-label="Expand"
|
||||
className={cn(
|
||||
"size-3 shrink-0 text-muted-foreground transition-transform duration-200",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M6.22 4.22a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06L8.94 8 6.22 5.28a.75.75 0 0 1 0-1.06Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="font-medium text-foreground/80">{summary.label}</span>
|
||||
<span className="text-muted-foreground">{summary.description}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="border-border/50 border-t px-2.5 py-1.5">
|
||||
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] text-muted-foreground">
|
||||
{JSON.stringify(toolCall.args, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { formatToolCall, formatToolResult } from "./tool-formatters";
|
||||
import type { ToolCall } from "@/agent/types";
|
||||
|
||||
export interface ToolExecutionPair {
|
||||
toolCall: ToolCall;
|
||||
resultContent?: string;
|
||||
}
|
||||
|
||||
interface ToolExecutionCardProps {
|
||||
pair: ToolExecutionPair;
|
||||
}
|
||||
|
||||
type Status = "pending" | "success" | "error";
|
||||
|
||||
function getStatus(resultContent?: string): Status {
|
||||
if (resultContent === undefined) return "pending";
|
||||
try {
|
||||
const parsed = JSON.parse(resultContent);
|
||||
if (parsed && typeof parsed === "object" && "error" in parsed) return "error";
|
||||
} catch {}
|
||||
return "success";
|
||||
}
|
||||
|
||||
function StatusIcon({ status }: { status: Status }) {
|
||||
if (status === "pending") {
|
||||
return (
|
||||
<svg
|
||||
className="size-3 shrink-0 animate-spin text-muted-foreground"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
className="opacity-25"
|
||||
/>
|
||||
<path
|
||||
d="M4 12a8 8 0 018-8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return (
|
||||
<svg
|
||||
className="size-3 shrink-0 text-destructive"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8 15A7 7 0 1 0 8 1a7 7 0 0 0 0 14Zm2.78-4.22a.75.75 0 0 1-1.06 0L8 9.06l-1.72 1.72a.75.75 0 1 1-1.06-1.06L6.94 8 5.22 6.28a.75.75 0 0 1 1.06-1.06L8 6.94l1.72-1.72a.75.75 0 1 1 1.06 1.06L9.06 8l1.72 1.72a.75.75 0 0 1 0 1.06Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="size-3 shrink-0 text-emerald-500"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8 15A7 7 0 1 0 8 1a7 7 0 0 0 0 14Zm3.844-8.791a.75.75 0 0 0-1.188-.918l-3.7 4.79-1.649-1.833a.75.75 0 1 0-1.114 1.004l2.25 2.5a.75.75 0 0 0 1.15-.043l4.25-5.5Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolExecutionCard({ pair }: ToolExecutionCardProps) {
|
||||
const { toolCall, resultContent } = pair;
|
||||
const [open, setOpen] = useState(false);
|
||||
const status = getStatus(resultContent);
|
||||
|
||||
const callSummary = formatToolCall(toolCall.name, toolCall.args);
|
||||
const resultSummary = resultContent
|
||||
? formatToolResult(toolCall.name, resultContent)
|
||||
: null;
|
||||
|
||||
const hasResult =
|
||||
status === "success" && resultContent !== undefined && resultContent !== "{}";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group/tool flex flex-col rounded-md border text-xs transition-colors",
|
||||
status === "error"
|
||||
? "border-destructive/30 bg-destructive/5"
|
||||
: "border-border/50 bg-muted/30",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 text-left transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<StatusIcon status={status} />
|
||||
<span className="font-medium text-foreground/80">
|
||||
{callSummary.label}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate">
|
||||
{status === "error"
|
||||
? "Failed"
|
||||
: resultSummary
|
||||
? resultSummary.description
|
||||
: callSummary.description}
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="border-border/50 border-t px-2.5 py-1.5 flex flex-col gap-2">
|
||||
<div>
|
||||
<span className="text-muted-foreground text-[10px] uppercase tracking-wider font-medium">
|
||||
Params
|
||||
</span>
|
||||
<pre className="mt-0.5 max-h-48 overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] text-muted-foreground">
|
||||
{JSON.stringify(toolCall.args, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
{status === "error" && resultContent && (
|
||||
<div>
|
||||
<span className="text-destructive text-[10px] uppercase tracking-wider font-medium">
|
||||
Error
|
||||
</span>
|
||||
<pre className="mt-0.5 max-h-48 overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] text-destructive/80">
|
||||
{resultContent}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{hasResult && status === "success" && (
|
||||
<div>
|
||||
<span className="text-muted-foreground text-[10px] uppercase tracking-wider font-medium">
|
||||
Output
|
||||
</span>
|
||||
<pre className="mt-0.5 max-h-48 overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] text-muted-foreground">
|
||||
{(() => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(resultContent!), null, 2);
|
||||
} catch {
|
||||
return resultContent;
|
||||
}
|
||||
})()}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { formatToolResult } from "./tool-formatters";
|
||||
|
||||
interface ToolResultCardProps {
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function ToolResultCard({ name, content }: ToolResultCardProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const summary = formatToolResult(name, content);
|
||||
|
||||
if (!summary) return null;
|
||||
|
||||
return (
|
||||
<div className="group/result flex flex-col rounded-md border border-border/50 bg-muted/20 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 text-left transition-colors hover:bg-muted/40"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
aria-label="Expand"
|
||||
className={cn(
|
||||
"size-3 shrink-0 text-muted-foreground transition-transform duration-200",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M6.22 4.22a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06L8.94 8 6.22 5.28a.75.75 0 0 1 0-1.06Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="font-medium text-foreground/80">{summary.label}</span>
|
||||
<span className="text-muted-foreground">{summary.description}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="border-border/50 border-t px-2.5 py-1.5">
|
||||
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] text-muted-foreground">
|
||||
{content}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue