From 93a368a4f46bb17162b04dca5c77a13bc9acd986 Mon Sep 17 00:00:00 2001 From: Dobes Vandermeer Date: Sun, 22 Feb 2026 17:14:49 -0800 Subject: [PATCH] Improve display of agent/tool cards in chat session - Parse JSON payloads and render basic tree view - Render strings as markdown - Copy to clipboard button copies original text - Add view source button in case of render/parse issues - Prevent ScrollArea from overflow horizontally --- ui/src/components/ToolDisplay.tsx | 99 +++----- ui/src/components/chat/AgentCallDisplay.tsx | 93 +++---- ui/src/components/chat/ChatMessage.tsx | 43 +++- ui/src/components/chat/CollapsibleSection.tsx | 65 +++++ ui/src/components/chat/SmartContent.tsx | 226 ++++++++++++++++++ ui/src/components/ui/scroll-area.tsx | 2 +- 6 files changed, 411 insertions(+), 117 deletions(-) create mode 100644 ui/src/components/chat/CollapsibleSection.tsx create mode 100644 ui/src/components/chat/SmartContent.tsx diff --git a/ui/src/components/ToolDisplay.tsx b/ui/src/components/ToolDisplay.tsx index 1cbc756d2..8471ceed3 100644 --- a/ui/src/components/ToolDisplay.tsx +++ b/ui/src/components/ToolDisplay.tsx @@ -1,9 +1,9 @@ import { useState } from "react"; import { FunctionCall } from "@/types"; -import { ScrollArea } from "@radix-ui/react-scroll-area"; -import { FunctionSquare, CheckCircle, Clock, Code, ChevronUp, ChevronDown, Loader2, Text, Check, Copy, AlertCircle } from "lucide-react"; -import { Button } from "@/components/ui/button"; +import { FunctionSquare, CheckCircle, Clock, Code, Loader2, Text, AlertCircle } from "lucide-react"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +import { SmartContent, parseContentString } from "@/components/chat/SmartContent"; +import { CollapsibleSection } from "@/components/chat/CollapsibleSection"; export type ToolCallStatus = "requested" | "executing" | "completed"; @@ -17,24 +17,15 @@ interface ToolDisplayProps { isError?: boolean; } + +// ── Main component ───────────────────────────────────────────────────────── const ToolDisplay = ({ call, result, status = "requested", isError = false }: ToolDisplayProps) => { const [areArgumentsExpanded, setAreArgumentsExpanded] = useState(false); const [areResultsExpanded, setAreResultsExpanded] = useState(false); - const [isCopied, setIsCopied] = useState(false); const hasResult = result !== undefined; + const parsedResult = hasResult ? parseContentString(result.content) : null; - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(result?.content || ""); - setIsCopied(true); - setTimeout(() => setIsCopied(false), 2000); - } catch (err) { - console.error("Failed to copy text:", err); - } - }; - - // Define UI elements based on status const getStatusDisplay = () => { if (isError && status === "executing") { return ( @@ -80,8 +71,13 @@ const ToolDisplay = ({ call, result, status = "requested", isError = false }: To } }; + const argsContent = ; + const resultContent = parsedResult !== null + ? + : null; + return ( - +
@@ -94,53 +90,30 @@ const ToolDisplay = ({ call, result, status = "requested", isError = false }: To {getStatusDisplay()}
- -
- - {areArgumentsExpanded && ( -
- -
-                  {JSON.stringify(call.args, null, 2)}
-                
-
-
- )} -
-
- {status === "executing" && !hasResult && ( -
- - Executing... -
- )} - {hasResult && ( - <> - - {areResultsExpanded && ( -
- -
-                      {result.content}
-                    
-
- - -
- )} - - )} -
+ + setAreArgumentsExpanded(!areArgumentsExpanded)} + previewContent={argsContent} + expandedContent={argsContent} + /> + {status === "executing" && !hasResult && ( +
+ + Executing... +
+ )} + {hasResult && resultContent && ( + setAreResultsExpanded(!areResultsExpanded)} + previewContent={resultContent} + expandedContent={resultContent} + errorStyle={isError} + /> + )}
); diff --git a/ui/src/components/chat/AgentCallDisplay.tsx b/ui/src/components/chat/AgentCallDisplay.tsx index 6ce80d14c..b1d99be37 100644 --- a/ui/src/components/chat/AgentCallDisplay.tsx +++ b/ui/src/components/chat/AgentCallDisplay.tsx @@ -1,9 +1,12 @@ import { useMemo, useState } from "react"; +import Link from "next/link"; import { FunctionCall } from "@/types"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { convertToUserFriendlyName } from "@/lib/utils"; -import { ChevronDown, ChevronUp, MessageSquare, Loader2, AlertCircle, CheckCircle } from "lucide-react"; +import { MessageSquare, Loader2, AlertCircle, CheckCircle } from "lucide-react"; import KagentLogo from "../kagent-logo"; +import { SmartContent, parseContentString } from "./SmartContent"; +import { CollapsibleSection } from "./CollapsibleSection"; export type AgentCallStatus = "requested" | "executing" | "completed"; @@ -17,6 +20,10 @@ interface AgentCallDisplayProps { isError?: boolean; } +const AGENT_TOOL_NAME_RE = /^(.+)__NS__(.+)$/; + + + const AgentCallDisplay = ({ call, result, status = "requested", isError = false }: AgentCallDisplayProps) => { const [areInputsExpanded, setAreInputsExpanded] = useState(false); const [areResultsExpanded, setAreResultsExpanded] = useState(false); @@ -24,6 +31,11 @@ const AgentCallDisplay = ({ call, result, status = "requested", isError = false const agentDisplay = useMemo(() => convertToUserFriendlyName(call.name), [call.name]); const hasResult = result !== undefined; + const agentMatch = call.name.match(AGENT_TOOL_NAME_RE); + const functionCallLink = agentMatch + ? `/agents/${agentMatch[1].replace(/_/g, "-")}/${agentMatch[2].replace(/_/g, "-")}/function-calls/${call.id}` + : null; + const getStatusDisplay = () => { if (isError && status === "executing") { return ( @@ -68,6 +80,12 @@ const AgentCallDisplay = ({ call, result, status = "requested", isError = false } }; + const parsedResult = hasResult && result?.content ? parseContentString(result.content) : null; + const argsContent = ; + const resultContent = parsedResult !== null + ? + : null; + return ( @@ -76,50 +94,44 @@ const AgentCallDisplay = ({ call, result, status = "requested", isError = false {agentDisplay} -
{call.id}
+
+ {functionCallLink ? ( + + {call.id} + + ) : ( + call.id + )} +
{getStatusDisplay()}
- -
- - {areInputsExpanded && ( -
-
{JSON.stringify(call.args, null, 2)}
-
- )} -
- -
- {status === "executing" && !hasResult && ( -
- - {agentDisplay} is responding... -
- )} - {hasResult && result?.content && ( -
- - {areResultsExpanded && ( -
-
-                    {result?.content}
-                  
-
- )} -
- )} -
+ + setAreInputsExpanded(!areInputsExpanded)} + previewContent={argsContent} + expandedContent={argsContent} + /> + {status === "executing" && !hasResult && ( +
+ + {agentDisplay} is responding... +
+ )} + {hasResult && resultContent && ( + setAreResultsExpanded(!areResultsExpanded)} + previewContent={resultContent} + expandedContent={resultContent} + errorStyle={isError} + /> + )}
); @@ -127,4 +139,3 @@ const AgentCallDisplay = ({ call, result, status = "requested", isError = false export default AgentCallDisplay; - diff --git a/ui/src/components/chat/ChatMessage.tsx b/ui/src/components/chat/ChatMessage.tsx index c1d7214c7..fe4a14a8d 100644 --- a/ui/src/components/chat/ChatMessage.tsx +++ b/ui/src/components/chat/ChatMessage.tsx @@ -2,7 +2,7 @@ import { Message, TextPart } from "@a2a-js/sdk"; import { TruncatableText } from "@/components/chat/TruncatableText"; import ToolCallDisplay from "@/components/chat/ToolCallDisplay"; import KagentLogo from "../kagent-logo"; -import { ThumbsUp, ThumbsDown } from "lucide-react"; +import { ThumbsUp, ThumbsDown, Copy, Check } from "lucide-react"; import { useState } from "react"; import { FeedbackDialog } from "./FeedbackDialog"; import { toast } from "sonner"; @@ -21,6 +21,7 @@ interface ChatMessageProps { export default function ChatMessage({ message, allMessages, agentContext }: ChatMessageProps) { const [feedbackDialogOpen, setFeedbackDialogOpen] = useState(false); const [isPositiveFeedback, setIsPositiveFeedback] = useState(true); + const [copied, setCopied] = useState(false); const textParts = message.parts?.filter(part => part.kind === "text") || []; const content = textParts.map(part => (part as TextPart).text).join(""); @@ -108,6 +109,13 @@ export default function ChatMessage({ message, allMessages, agentContext }: Chat } + const handleCopy = () => { + navigator.clipboard.writeText(String(content)).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }; + const handleFeedback = (isPositive: boolean) => { if (!messageId) { console.error("Message ID is undefined, cannot submit feedback."); @@ -127,22 +135,33 @@ export default function ChatMessage({ message, allMessages, agentContext }: Chat
{displayName}
:
{displayName}
} - {source !== "user" && messageId !== undefined && ( + {source !== "user" && (
- + {messageId !== undefined && ( + <> + + + + )}
)} diff --git a/ui/src/components/chat/CollapsibleSection.tsx b/ui/src/components/chat/CollapsibleSection.tsx new file mode 100644 index 000000000..2d23fd5a8 --- /dev/null +++ b/ui/src/components/chat/CollapsibleSection.tsx @@ -0,0 +1,65 @@ +import React from "react"; +import { ChevronUp, ChevronDown } from "lucide-react"; +import { ScrollArea } from "@radix-ui/react-scroll-area"; + +interface CollapsibleSectionProps { + icon: React.ComponentType<{ className?: string }>; + expanded: boolean; + onToggle: () => void; + previewContent: React.ReactNode; + expandedContent: React.ReactNode; + errorStyle?: boolean; +} + +export function CollapsibleSection({ + icon: Icon, + expanded, + onToggle, + previewContent, + expandedContent, + errorStyle, +}: CollapsibleSectionProps) { + if (!expanded) { + return ( + + ); + } + + return ( +
+
+ +
+
+ + {expandedContent} + +
+
+
+ +
+ ); +} diff --git a/ui/src/components/chat/SmartContent.tsx b/ui/src/components/chat/SmartContent.tsx new file mode 100644 index 000000000..8b37c8d86 --- /dev/null +++ b/ui/src/components/chat/SmartContent.tsx @@ -0,0 +1,226 @@ +"use client"; + +import React, { useState } from "react"; +import ReactMarkdown from "react-markdown"; +import gfm from "remark-gfm"; +import rehypeExternalLinks from "rehype-external-links"; +import CodeBlock from "./CodeBlock"; +import { Braces, Brackets, Type, Hash, ToggleLeft, Ban, Check, Copy, Code, Eye } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +// ── Markdown plumbing (shared with TruncatableText) ──────────────────────── + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const markdownComponents: Record> = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + code: (props: any) => { + const { children, className } = props; + if (className) return {[children]}; + return {children}; + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + table: (props: any) => ( + {props.children}
+ ), +}; + +function MarkdownBlock({ content, className }: { content: string; className?: string }) { + return ( +
+ + {content} + +
+ ); +} + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function tryParseJson(s: string): unknown | null { + const trimmed = s.trim(); + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return null; + try { + return JSON.parse(trimmed); + } catch { + return null; + } +} + +const MARKDOWN_RE = /^#{1,6}\s|^\s*[-*+]\s|\*\*|__|\[.*\]\(.*\)|```|^\s*\d+\.\s|^\s*>/m; + +function looksLikeMarkdown(s: string): boolean { + return MARKDOWN_RE.test(s); +} + +function isInlineValue(value: unknown): boolean { + if (value === null || value === undefined) return true; + if (typeof value === "boolean" || typeof value === "number") return true; + if (typeof value === "string") { + if (value.length > 80 || value.includes("\n")) return false; + if (tryParseJson(value) !== null) return false; + return true; + } + return false; +} + +function rawSource(data: unknown): string { + if (typeof data === "string") return data; + return JSON.stringify(data, null, 2); +} + +// ── Type icons ────────────────────────────────────────────────────────────── + +function TypeIcon({ value }: { value: unknown }) { + const cls = "w-3 h-3 shrink-0"; + if (value === null || value === undefined) return ; + if (typeof value === "boolean") return ; + if (typeof value === "number") return ; + if (typeof value === "string") return ; + if (Array.isArray(value)) return ; + if (typeof value === "object") return ; + return null; +} + +// ── Recursive value renderer ──────────────────────────────────────────────── + +function ValueRenderer({ value, className }: { value: unknown; className?: string }) { + if (value === null || value === undefined) { + return null; + } + + if (typeof value === "boolean") { + return {value ? "true" : "false"}; + } + + if (typeof value === "number") { + return {String(value)}; + } + + if (typeof value === "string") { + return ; + } + + if (Array.isArray(value)) { + if (value.length === 0) return {"[]"}; + return ( +
+ {value.map((item, i) => ( +
+ +
+ ))} +
+ ); + } + + if (typeof value === "object") { + return } className={className} />; + } + + return {String(value)}; +} + +function StringRenderer({ content, className }: { content: string; className?: string }) { + const parsed = tryParseJson(content); + if (parsed !== null && typeof parsed === "object") { + return ; + } + + if (content.includes("\n") || looksLikeMarkdown(content)) { + return ; + } + + return {content}; +} + +function ObjectRenderer({ obj, className }: { obj: Record; className?: string }) { + const entries = Object.entries(obj); + if (entries.length === 0) { + return {"{}"}; + } + + return ( +
+ {entries.map(([key, val]) => { + const inline = isInlineValue(val); + if (inline) { + return ( +
+
+ + {key} +
+
+ +
+
+ ); + } + return ( +
+
+ + {key} +
+
+ +
+
+ ); + })} +
+ ); +} + +// ── Public API ────────────────────────────────────────────────────────────── + +export function SmartContent({ data, className }: { data: unknown; className?: string }) { + const [viewSource, setViewSource] = useState(false); + const [copied, setCopied] = useState(false); + + const source = rawSource(data); + + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation(); + try { + await navigator.clipboard.writeText(source); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { /* clipboard unavailable */ } + }; + + const handleToggleSource = (e: React.MouseEvent) => { + e.stopPropagation(); + setViewSource(v => !v); + }; + + return ( +
+
+ + +
+ {viewSource ? ( +
{source}
+ ) : ( + + )} +
+ ); +} + +export function parseContentString(content: string): unknown { + const trimmed = content.trim(); + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { return JSON.parse(trimmed); } catch { /* fall through */ } + } + return trimmed; +} diff --git a/ui/src/components/ui/scroll-area.tsx b/ui/src/components/ui/scroll-area.tsx index 0b4a48d87..97dd44550 100644 --- a/ui/src/components/ui/scroll-area.tsx +++ b/ui/src/components/ui/scroll-area.tsx @@ -14,7 +14,7 @@ const ScrollArea = React.forwardRef< className={cn("relative overflow-hidden", className)} {...props} > - + {children}