diff --git a/ui/src/components/ToolDisplay.stories.tsx b/ui/src/components/ToolDisplay.stories.tsx index 63d389a0f..998192d63 100644 --- a/ui/src/components/ToolDisplay.stories.tsx +++ b/ui/src/components/ToolDisplay.stories.tsx @@ -73,6 +73,22 @@ export const VeryLongUrl: Story = { }, }; +export const VeryLongToolId: Story = { + args: { + call: { + id: "call_super_duper_long_tool_identifier_that_goes_on_and_on_and_on_and_will_surely_overflow_if_not_truncated_properly_abc123_def456_ghi789", + name: "some_function", + args: { + path: "/src/components/App.tsx", + }, + }, + result: { + content: "Result text here", + }, + status: "completed", + }, +}; + export const LongUnbreakableString: Story = { args: { call: { @@ -134,6 +150,13 @@ export const InChatLayoutLongUrl: Story = { }, }; +export const InChatLayoutLongToolId: Story = { + decorators: [ChatLayoutDecorator], + args: { + ...VeryLongToolId.args, + }, +}; + export const InChatLayoutUnbreakable: Story = { decorators: [ChatLayoutDecorator], args: { diff --git a/ui/src/components/ToolDisplay.tsx b/ui/src/components/ToolDisplay.tsx index 1fd6ef81a..b8cd18fae 100644 --- a/ui/src/components/ToolDisplay.tsx +++ b/ui/src/components/ToolDisplay.tsx @@ -1,12 +1,13 @@ import { useState } from "react"; import { FunctionCall, TokenStats } 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 TokenStatsTooltip from "@/components/chat/TokenStatsTooltip"; import { convertToUserFriendlyName } from "@/lib/utils"; +import { SmartContent, parseJsonOrString } from "@/components/chat/SmartContent"; +import { CollapsibleSection } from "@/components/chat/CollapsibleSection"; export type ToolCallStatus = "requested" | "executing" | "completed" | "pending_approval" | "approved" | "rejected"; @@ -27,25 +28,17 @@ interface ToolDisplayProps { tokenStats?: TokenStats; } + +// ── Main component ───────────────────────────────────────────────────────── const ToolDisplay = ({ call, result, status = "requested", isError = false, isDecided = false, subagentName, onApprove, onReject, tokenStats }: 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 ? parseJsonOrString(result.content) : null; const handleApprove = async () => { if (!onApprove) { @@ -143,53 +136,49 @@ 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 ( - -
+ +
{call.name}
{subagentName && ( -
+
via {convertToUserFriendlyName(subagentName)} subagent
)} -
{call.id}
+
{call.id}
-
+
{tokenStats && } {getStatusDisplay()}
-
- - {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 && ( @@ -253,32 +242,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 0c8dcd1be..52dda24ba 100644 --- a/ui/src/components/chat/AgentCallDisplay.tsx +++ b/ui/src/components/chat/AgentCallDisplay.tsx @@ -1,7 +1,7 @@ import { createContext, useContext, useMemo, useState, useEffect } from "react"; import { FunctionCall, TokenStats } from "@/types"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; -import { convertToUserFriendlyName } from "@/lib/utils"; +import { convertToUserFriendlyName, isAgentToolName } from "@/lib/utils"; import { ChevronDown, ChevronUp, MessageSquare, Loader2, AlertCircle, CheckCircle, Activity } from "lucide-react"; import KagentLogo from "../kagent-logo"; import TokenStatsTooltip from "@/components/chat/TokenStatsTooltip"; @@ -9,6 +9,8 @@ import { getSubagentSessionWithEvents } from "@/app/actions/sessions"; import { Message, Task } from "@a2a-js/sdk"; import { extractMessagesFromTasks } from "@/lib/messageHandlers"; import ChatMessage from "@/components/chat/ChatMessage"; +import { SmartContent, parseJsonOrString } from "./SmartContent"; +import { CollapsibleSection } from "./CollapsibleSection"; // Track and avoid too deep nested agent viewing to avoid UI issues // In theory this works for infinite depth @@ -132,7 +134,9 @@ const AgentCallDisplay = ({ call, result, status = "requested", isError = false, const activityDepth = useContext(ActivityDepthContext); const agentDisplay = useMemo(() => convertToUserFriendlyName(call.name), [call.name]); const hasResult = result !== undefined; - const showActivitySection = !!subagentSessionId && !isError && activityDepth < MAX_ACTIVITY_DEPTH; +const showActivitySection = !!subagentSessionId && !isError && activityDepth < MAX_ACTIVITY_DEPTH; + + const isAgent = isAgentToolName(call.name); const getStatusDisplay = () => { if (isError && status === "executing") { @@ -178,6 +182,12 @@ const AgentCallDisplay = ({ call, result, status = "requested", isError = false, } }; + const parsedResult = hasResult && result?.content ? parseJsonOrString(result.content) : null; + const argsContent = ; + const resultContent = parsedResult !== null + ? + : null; + return ( @@ -186,51 +196,39 @@ const AgentCallDisplay = ({ call, result, status = "requested", isError = false, {agentDisplay}
-
{call.id}
+
+ {call.id} +
{tokenStats && } {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} + /> + )} {showActivitySection && (
diff --git a/ui/src/components/chat/ChatMessage.tsx b/ui/src/components/chat/ChatMessage.tsx index cf71a65b6..8b6304c85 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 TokenStatsTooltip from "@/components/chat/TokenStatsTooltip"; import type { TokenStats } from "@/types"; import { useState } from "react"; @@ -29,6 +29,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); if (!message) return null; @@ -150,6 +151,16 @@ export default function ChatMessage({ message, allMessages, agentContext, onAppr } + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(String(content)); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + /* clipboard unavailable */ + } + }; + const handleFeedback = (isPositive: boolean) => { if (!messageId) { console.error("Message ID is undefined, cannot submit feedback."); @@ -172,6 +183,13 @@ export default function ChatMessage({ message, allMessages, agentContext, onAppr {source !== "user" && (
{tokenStats && } + {messageId !== undefined && ( <> + ); + } + + return ( +
+
+ +
+
+ + {expandedContent} + +
+
+
+ +
+ ); +} diff --git a/ui/src/components/chat/SmartContent.stories.tsx b/ui/src/components/chat/SmartContent.stories.tsx new file mode 100644 index 000000000..3d740cd81 --- /dev/null +++ b/ui/src/components/chat/SmartContent.stories.tsx @@ -0,0 +1,153 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { SmartContent } from "./SmartContent"; + +const meta = { + title: "Chat/SmartContent", + component: SmartContent, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const PlainString: Story = { + args: { + data: "Hello, this is a plain text string.", + }, +}; + +export const MarkdownString: Story = { + args: { + data: `# Heading 1 +## Heading 2 +This is a paragraph with **bold** and *italic* text. + +- Item 1 +- Item 2 +- Item 3 + +\`\`\`javascript +const x = 42; +console.log(x); +\`\`\``, + }, +}; + +export const SimpleJsonObject: Story = { + args: { + data: { + name: "John Doe", + age: 30, + email: "john@example.com", + active: true, + }, + }, +}; + +export const NestedJsonObject: Story = { + args: { + data: { + user: { + id: 123, + profile: { + firstName: "Jane", + lastName: "Smith", + contact: { + email: "jane@example.com", + phone: "+1-555-0123", + }, + }, + }, + settings: { + theme: "dark", + notifications: true, + }, + }, + }, +}; + +export const JsonWithJsonInString: Story = { + args: { + data: { + message: "User data", + payload: '{"nested": "json", "value": 42}', + timestamp: 1234567890, + }, + }, +}; + +export const ArrayOfItems: Story = { + args: { + data: [ + { id: 1, name: "Item 1", status: "active" }, + { id: 2, name: "Item 2", status: "inactive" }, + { id: 3, name: "Item 3", status: "pending" }, + ], + }, +}; + +export const NullValue: Story = { + args: { + data: null, + }, +}; + +export const UndefinedValue: Story = { + args: { + data: undefined, + }, +}; + +export const DeeplyNestedObject: Story = { + args: { + data: { + level1: { + level2: { + level3: { + level4: { + level5: { + value: "deeply nested", + count: 5, + }, + }, + }, + }, + }, + }, + }, +}; + +export const EmptyObject: Story = { + args: { + data: {}, + }, +}; + +export const EmptyArray: Story = { + args: { + data: [], + }, +}; + +export const WithErrorClassName: Story = { + args: { + data: "Error message displayed in red", + className: "text-red-500", + }, +}; + +export const MixedTypes: Story = { + args: { + data: { + string: "text value", + number: 42, + boolean: true, + null: null, + array: [1, 2, 3], + object: { nested: "value" }, + }, + }, +}; diff --git a/ui/src/components/chat/SmartContent.tsx b/ui/src/components/chat/SmartContent.tsx new file mode 100644 index 000000000..9e39b74df --- /dev/null +++ b/ui/src/components/chat/SmartContent.tsx @@ -0,0 +1,241 @@ +"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 ──────────────────────────────────────────────── + +const MAX_RENDER_DEPTH = 10; + +interface RendererProps { + className?: string; + depth?: number; +} + +function ValueRenderer({ value, className, depth = 0 }: RendererProps & { value: unknown }) { + if (depth >= MAX_RENDER_DEPTH) { + return
{JSON.stringify(value, null, 2)}
; + } + + 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) => { + const isPrimitive = typeof item === "string" || typeof item === "number" || typeof item === "boolean"; + const itemKey = isPrimitive ? `${typeof item}:${String(item)}` : i; + return ( +
+ +
+ ); + })} +
+ ); + } + + if (typeof value === "object") { + return } className={className} depth={depth} />; + } + + return {String(value)}; +} + +function StringRenderer({ content, className, depth = 0 }: RendererProps & { content: 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, depth = 0 }: RendererProps & { obj: Record }) { + 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 parseJsonOrString(content: string): unknown { + const trimmed = content.trim(); + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { return JSON.parse(trimmed); } catch { /* fall through */ } + } + return trimmed; +}