diff --git a/apps/web/src/components/editor/panels/chat/index.tsx b/apps/web/src/components/editor/panels/chat/index.tsx index 59ba9c91..c5f96a81 100644 --- a/apps/web/src/components/editor/panels/chat/index.tsx +++ b/apps/web/src/components/editor/panels/chat/index.tsx @@ -67,7 +67,7 @@ export function ChatPanel() {
{messages.map((msg) => ( - + ))} {loading && (
diff --git a/apps/web/src/components/editor/panels/chat/message-bubble.tsx b/apps/web/src/components/editor/panels/chat/message-bubble.tsx index 5e5d965c..aed99f73 100644 --- a/apps/web/src/components/editor/panels/chat/message-bubble.tsx +++ b/apps/web/src/components/editor/panels/chat/message-bubble.tsx @@ -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 = { - 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 ( -
- - {ROLE_LABELS.tool_result} - +
); } } catch { - // Not JSON — fall through to plain text + // not JSON } + + if (toolName) { + return ( +
+ +
+ ); + } + + return ( +
+ +
+ ); } + const hasToolCalls = + message.role === "assistant" && + message.toolCalls && + message.toolCalls.length > 0; + return (
- - {ROLE_LABELS[message.role]} - -

{message.content}

+ {message.content && ( +

{message.content}

+ )} + {hasToolCalls && ( +
+ {message.toolCalls?.map((tc: ToolCall) => ( + + ))} +
+ )}
); } diff --git a/apps/web/src/components/editor/panels/chat/tool-call-card.tsx b/apps/web/src/components/editor/panels/chat/tool-call-card.tsx new file mode 100644 index 00000000..6f634ca8 --- /dev/null +++ b/apps/web/src/components/editor/panels/chat/tool-call-card.tsx @@ -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 ( +
+ + {open && ( +
+
+						{JSON.stringify(toolCall.args, null, 2)}
+					
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/editor/panels/chat/tool-formatters.ts b/apps/web/src/components/editor/panels/chat/tool-formatters.ts new file mode 100644 index 00000000..6811cbfb --- /dev/null +++ b/apps/web/src/components/editor/panels/chat/tool-formatters.ts @@ -0,0 +1,184 @@ +import { formatTimestamp } from "./transcript-utils"; + +export interface ToolSummary { + label: string; + description: string; +} + +export function formatToolCall( + name: string, + args: Record, +): 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 { + 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) => 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`, + }; + }, +}; diff --git a/apps/web/src/components/editor/panels/chat/tool-result-card.tsx b/apps/web/src/components/editor/panels/chat/tool-result-card.tsx new file mode 100644 index 00000000..3f2e9063 --- /dev/null +++ b/apps/web/src/components/editor/panels/chat/tool-result-card.tsx @@ -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 ( +
+ + {open && ( +
+
+						{content}
+					
+
+ )} +
+ ); +}