feat: unify tool call/result into single card with status indicator

This commit is contained in:
Luis Esteban Acevedo Ladino 2026-04-27 12:19:24 -05:00
parent 1daba0e9ea
commit 25e970f1ae
4 changed files with 219 additions and 137 deletions

View File

@ -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>
)}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}