diff --git a/ui/src/components/ToolDisplay.tsx b/ui/src/components/ToolDisplay.tsx
index 163bed29b..c2809b01c 100644
--- a/ui/src/components/ToolDisplay.tsx
+++ b/ui/src/components/ToolDisplay.tsx
@@ -1,10 +1,11 @@
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, ShieldAlert } from "lucide-react";
+import { FunctionSquare, CheckCircle, Clock, Code, Loader2, Text, AlertCircle, ShieldAlert } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
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" | "pending_approval" | "approved" | "rejected";
@@ -22,25 +23,17 @@ interface ToolDisplayProps {
onReject?: (reason?: string) => void;
}
+
+// ── Main component ─────────────────────────────────────────────────────────
const ToolDisplay = ({ call, result, status = "requested", isError = false, isDecided = false, onApprove, onReject }: ToolDisplayProps) => {
const [areArgumentsExpanded, setAreArgumentsExpanded] = useState(status === "pending_approval");
const [areResultsExpanded, setAreResultsExpanded] = useState(false);
- const [isCopied, setIsCopied] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [showRejectForm, setShowRejectForm] = useState(false);
const [rejectionReason, setRejectionReason] = useState("");
const hasResult = result !== undefined;
-
- 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);
- }
- };
+ const parsedResult = hasResult ? parseContentString(result.content) : null;
const handleApprove = async () => {
if (!onApprove) {
@@ -138,15 +131,20 @@ const ToolDisplay = ({ call, result, status = "requested", isError = false, isDe
}
};
+ const argsContent = ;
+ const resultContent = parsedResult !== null
+ ?
+ : null;
+
const borderClass = status === "pending_approval"
- ? 'border-amber-300 dark:border-amber-700'
- : status === "rejected"
- ? 'border-red-300 dark:border-red-700'
- : status === "approved"
- ? 'border-green-300 dark:border-green-700'
- : isError
- ? 'border-red-300'
- : '';
+ ? 'border-amber-300 dark:border-amber-700'
+ : status === "rejected"
+ ? 'border-red-300 dark:border-red-700'
+ : status === "approved"
+ ? 'border-green-300 dark:border-green-700'
+ : isError
+ ? 'border-red-300'
+ : '';
return (
@@ -163,22 +161,13 @@ const ToolDisplay = ({ call, result, status = "requested", isError = false, isDe
-
-
- {areArgumentsExpanded && (
-
-
-
- {JSON.stringify(call.args, null, 2)}
-
-
-
- )}
-
+ setAreArgumentsExpanded(!areArgumentsExpanded)}
+ previewContent={argsContent}
+ expandedContent={argsContent}
+ />
{/* Approval buttons — hidden when decided (batch) or submitting */}
{status === "pending_approval" && !isSubmitting && !isDecided && !showRejectForm && (
@@ -242,32 +231,20 @@ const ToolDisplay = ({ call, result, status = "requested", isError = false, isDe
{status === "executing" && !hasResult && (
-
+
Executing...
)}
- {hasResult && (
- <>
-
- {areResultsExpanded && (
-
-
-
- {result.content}
-
-
-
-
-
- )}
- >
+ {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 c73883bf2..45dac4e15 100644
--- a/ui/src/components/chat/ChatMessage.tsx
+++ b/ui/src/components/chat/ChatMessage.tsx
@@ -3,7 +3,7 @@ import { TruncatableText } from "@/components/chat/TruncatableText";
import ToolCallDisplay from "@/components/chat/ToolCallDisplay";
import AskUserDisplay, { AskUserQuestion } from "@/components/chat/AskUserDisplay";
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";
@@ -26,6 +26,7 @@ interface ChatMessageProps {
export default function ChatMessage({ message, allMessages, agentContext, onApprove, onReject, onAskUserSubmit, pendingDecisions }: 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("");
@@ -146,6 +147,13 @@ export default function ChatMessage({ message, allMessages, agentContext, onAppr
}
+ 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.");
@@ -165,22 +173,33 @@ export default function ChatMessage({ message, allMessages, agentContext, onAppr
{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) => (
+
+ ),
+};
+
+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 (
+
+ );
+ }
+ return (
+
+ );
+ })}
+
+ );
+}
+
+// ── 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}