feat: replace raw JSON tool display with collapsible summary cards in chat UI

This commit is contained in:
Luis Esteban Acevedo Ladino 2026-04-24 20:48:58 -05:00
parent 4c5e151822
commit 7c19c72058
5 changed files with 344 additions and 24 deletions

View File

@ -67,7 +67,7 @@ export function ChatPanel() {
<ScrollArea className="flex-1 p-3" ref={scrollRef}>
<div className="flex flex-col gap-2">
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
<MessageBubble key={msg.id} message={msg} messages={messages} />
))}
{loading && (
<div className="flex items-center gap-2 px-3 py-2">

View File

@ -1,23 +1,30 @@
"use client";
import { cn } from "@/utils/ui";
import type { ChatMessage } from "@/agent/types";
import {
isTranscriptData,
formatTimestamp,
} from "./transcript-utils";
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";
interface MessageBubbleProps {
message: ChatMessage;
messages: ChatMessage[];
}
const ROLE_LABELS: Record<ChatMessage["role"], string> = {
user: "You",
assistant: "Assistant",
tool_result: "Tool",
system: "System",
};
function findToolCallName(
messages: ChatMessage[],
toolCallId?: string,
): string | null {
if (!toolCallId) return null;
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;
}
}
return null;
}
function TranscriptCard({ data }: { data: TranscriptData }) {
return (
@ -46,39 +53,62 @@ function TranscriptCard({ data }: { data: TranscriptData }) {
);
}
export function MessageBubble({ message }: MessageBubbleProps) {
export function MessageBubble({ message, messages }: MessageBubbleProps) {
const isUser = message.role === "user";
// For tool_result messages, try to parse as transcript
if (message.role === "tool_result") {
const toolName = findToolCallName(messages, message.toolCallId);
try {
const parsed = JSON.parse(message.content);
if (isTranscriptData(parsed)) {
return (
<div className="flex flex-col gap-1 rounded-md px-3 py-2">
<span className="text-muted-foreground text-xs font-medium">
{ROLE_LABELS.tool_result}
</span>
<div className="px-3 py-0.5">
<TranscriptCard data={parsed} />
</div>
);
}
} catch {
// Not JSON — fall through to plain text
// not JSON
}
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>
);
}
const hasToolCalls =
message.role === "assistant" &&
message.toolCalls &&
message.toolCalls.length > 0;
return (
<div
className={cn(
"flex flex-col gap-1 rounded-md px-3 py-2",
"flex flex-col gap-1.5 rounded-md px-3 py-2",
isUser ? "bg-secondary" : "bg-transparent",
)}
>
<span className="text-muted-foreground text-xs font-medium">
{ROLE_LABELS[message.role]}
</span>
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
{message.content && (
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
)}
{hasToolCalls && (
<div className="flex flex-col gap-1">
{message.toolCalls?.map((tc: ToolCall) => (
<ToolCallCard key={tc.id} toolCall={tc} />
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,52 @@
"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,184 @@
import { formatTimestamp } from "./transcript-utils";
export interface ToolSummary {
label: string;
description: string;
}
export function formatToolCall(
name: string,
args: Record<string, unknown>,
): ToolSummary {
const formatter = TOOL_CALL_FORMATTERS[name];
if (formatter) return formatter(args);
return {
label: formatToolName(name),
description: summarizeArgs(args),
};
}
export function formatToolResult(
name: string,
raw: string,
): ToolSummary | null {
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object" && "error" in parsed) {
return {
label: formatToolName(name),
description: String(parsed.error),
};
}
const formatter = TOOL_RESULT_FORMATTERS[name];
if (formatter) return formatter(parsed);
return null;
} catch {
return null;
}
}
function formatToolName(name: string): string {
return name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
function formatSeconds(s: unknown): string {
if (typeof s !== "number" || !Number.isFinite(s)) return "?";
return formatTimestamp(s);
}
function summarizeArgs(args: Record<string, unknown>): string {
const entries = Object.entries(args);
if (entries.length === 0) return "No arguments";
return entries
.map(([key, val]) => {
if (Array.isArray(val)) {
return `${key}: [${val.length} items]`;
}
if (typeof val === "object" && val !== null) {
return `${key}: {...}`;
}
return `${key}: ${String(val)}`;
})
.join(", ");
}
const TOOL_CALL_FORMATTERS: Record<
string,
(args: Record<string, unknown>) => ToolSummary
> = {
split: (args) => {
const times = args.times as number[] | undefined;
if (!times || times.length === 0)
return { label: "Split", description: "No times specified" };
const formatted = times.map((t) => formatSeconds(t)).join(", ");
return {
label: "Split",
description: `at ${formatted}`,
};
},
load_context: (args) => {
const targetType = String(args.targetType ?? "unknown");
const id = args.id ?? args.assetId ?? args.elementId;
if (targetType === "asset") {
return {
label: "Load Asset",
description: id ? String(id) : "Loading asset...",
};
}
return {
label: "Load Context",
description: id ? `${targetType} ${String(id)}` : targetType,
};
},
list_timeline: () => ({
label: "List Timeline",
description: "Fetching timeline tracks",
}),
list_project_assets: (args) => {
const type = args.type ?? "all";
const filter = args.filter ?? "all";
const parts: string[] = [];
if (type !== "all") parts.push(String(type));
if (filter !== "all") parts.push(String(filter));
return {
label: "List Assets",
description:
parts.length > 0 ? parts.join(", ") : "Fetching project assets",
};
},
transcribe_video: (args) => {
const lang = args.language ? String(args.language) : null;
return {
label: "Transcribe",
description: lang ? `language: ${lang}` : "Transcribing audio",
};
},
};
const TOOL_RESULT_FORMATTERS: Record<
string,
(parsed: unknown) => ToolSummary | null
> = {
split: (parsed) => {
const data = parsed as {
success?: boolean;
affectedElements?: string[];
} | null;
if (!data) return null;
const count = data.affectedElements?.length ?? 0;
return {
label: "Split",
description: data.success
? `${count} element${count !== 1 ? "s" : ""} affected`
: "Failed",
};
},
load_context: (parsed) => {
const data = parsed as {
status?: string;
cached?: boolean;
context?: { kind?: string; assetName?: string };
} | null;
if (!data) return null;
const assetName = data.context?.assetName;
const cached = data.cached ? " (cached)" : "";
return {
label: "Load Context",
description: assetName
? `${assetName}${cached}`
: `${data.status ?? "loaded"}${cached}`,
};
},
list_timeline: (parsed) => {
const data = parsed as { tracks?: unknown[] } | null;
if (!data) return null;
const count = data.tracks?.length ?? 0;
return {
label: "Timeline",
description: `${count} track${count !== 1 ? "s" : ""}`,
};
},
list_project_assets: (parsed) => {
const data = parsed as { assets?: unknown[] } | null;
if (!data) return null;
const count = data.assets?.length ?? 0;
return {
label: "Assets",
description: `${count} asset${count !== 1 ? "s" : ""}`,
};
},
transcribe_video: (parsed) => {
const data = parsed as {
assetName?: string;
language?: string;
segmentCount?: number;
duration?: number;
} | null;
if (!data) return null;
return {
label: "Transcript",
description: `${data.assetName ?? "audio"} · ${data.segmentCount ?? 0} segments`,
};
},
};

View File

@ -0,0 +1,54 @@
"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>
);
}