Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions ui/src/components/ToolDisplay.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -134,6 +150,13 @@ export const InChatLayoutLongUrl: Story = {
},
};

export const InChatLayoutLongToolId: Story = {
decorators: [ChatLayoutDecorator],
args: {
...VeryLongToolId.args,
},
};

export const InChatLayoutUnbreakable: Story = {
decorators: [ChatLayoutDecorator],
args: {
Expand Down
105 changes: 41 additions & 64 deletions ui/src/components/ToolDisplay.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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) {
Expand Down Expand Up @@ -143,53 +136,49 @@ const ToolDisplay = ({ call, result, status = "requested", isError = false, isDe
}
};

const argsContent = <SmartContent data={call.args} />;
const resultContent = parsedResult !== null
? <SmartContent data={parsedResult} className={isError ? "text-red-600 dark:text-red-400" : ""} />
: 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 (
<Card className={`w-full mx-auto my-1 min-w-full ${borderClass}`}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-xs flex space-x-5">
<div className="flex items-center font-medium">
<CardTitle className="text-xs flex space-x-5 min-w-0">
<div className="flex items-center font-medium shrink-0">
<FunctionSquare className="w-4 h-4 mr-2" />
{call.name}
</div>
{subagentName && (
<div className="flex items-center text-muted-foreground font-normal">
<div className="flex items-center text-muted-foreground font-normal shrink-0">
via {convertToUserFriendlyName(subagentName)} subagent
</div>
)}
<div className="font-light">{call.id}</div>
<div className="font-light truncate min-w-0">{call.id}</div>
</CardTitle>
<div className="flex items-center gap-2 text-xs">
<div className="flex items-center gap-2 text-xs shrink-0 pl-2">
{tokenStats && <TokenStatsTooltip stats={tokenStats} />}
{getStatusDisplay()}
</div>
</CardHeader>
<CardContent>
<div className="space-y-2 mt-4">
<Button variant="ghost" size="sm" className="p-0 h-auto justify-start" onClick={() => setAreArgumentsExpanded(!areArgumentsExpanded)}>
<Code className="w-4 h-4 mr-2" />
<span className="mr-2">Arguments</span>
{areArgumentsExpanded ? <ChevronUp className="w-4 h-4 ml-auto" /> : <ChevronDown className="w-4 h-4 ml-auto" />}
</Button>
{areArgumentsExpanded && (
<div className="relative">
<ScrollArea className="max-h-96 overflow-y-auto p-4 w-full mt-2 bg-muted/50">
<pre className="text-sm whitespace-pre-wrap break-words">
{JSON.stringify(call.args, null, 2)}
</pre>
</ScrollArea>
</div>
)}
</div>
<CollapsibleSection
icon={Code}
expanded={areArgumentsExpanded}
onToggle={() => setAreArgumentsExpanded(!areArgumentsExpanded)}
previewContent={argsContent}
expandedContent={argsContent}
/>

{/* Approval buttons — hidden when decided (batch) or submitting */}
{status === "pending_approval" && !isSubmitting && !isDecided && !showRejectForm && (
Expand Down Expand Up @@ -253,32 +242,20 @@ const ToolDisplay = ({ call, result, status = "requested", isError = false, isDe

<div className="mt-4 w-full">
{status === "executing" && !hasResult && (
<div className="flex items-center gap-2 py-2">
<div className="flex items-center gap-2 py-1">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Executing...</span>
</div>
)}
{hasResult && (
<>
<Button variant="ghost" size="sm" className="p-0 h-auto justify-start" onClick={() => setAreResultsExpanded(!areResultsExpanded)}>
<Text className="w-4 h-4 mr-2" />
<span className="mr-2">{isError ? "Error" : "Results"}</span>
{areResultsExpanded ? <ChevronUp className="w-4 h-4 ml-auto" /> : <ChevronDown className="w-4 h-4 ml-auto" />}
</Button>
{areResultsExpanded && (
<div className="relative">
<ScrollArea className={`max-h-96 overflow-y-auto p-4 w-full mt-2 ${isError ? 'bg-red-50 dark:bg-red-950/10' : ''}`}>
<pre className={`text-sm whitespace-pre-wrap break-words ${isError ? 'text-red-600 dark:text-red-400' : ''}`}>
{result.content}
</pre>
</ScrollArea>

<Button variant="ghost" size="sm" className="absolute top-2 right-2 p-2" onClick={handleCopy}>
{isCopied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</Button>
</div>
)}
</>
{hasResult && resultContent && (
<CollapsibleSection
icon={Text}
expanded={areResultsExpanded}
onToggle={() => setAreResultsExpanded(!areResultsExpanded)}
previewContent={resultContent}
expandedContent={resultContent}
errorStyle={isError}
/>
)}
</div>
</CardContent>
Expand Down
80 changes: 39 additions & 41 deletions ui/src/components/chat/AgentCallDisplay.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
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";
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
Expand Down Expand Up @@ -132,7 +134,9 @@
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);

Check warning on line 139 in ui/src/components/chat/AgentCallDisplay.tsx

View workflow job for this annotation

GitHub Actions / ui-tests

'isAgent' is assigned a value but never used. Allowed unused vars must match /^_/u

const getStatusDisplay = () => {
if (isError && status === "executing") {
Expand Down Expand Up @@ -178,6 +182,12 @@
}
};

const parsedResult = hasResult && result?.content ? parseJsonOrString(result.content) : null;
const argsContent = <SmartContent data={call.args} />;
const resultContent = parsedResult !== null
? <SmartContent data={parsedResult} className={isError ? "text-red-600 dark:text-red-400" : ""} />
: null;

return (
<Card className={`w-full mx-auto my-1 min-w-full ${isError ? 'border-red-300' : ''}`}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
Expand All @@ -186,51 +196,39 @@
<KagentLogo className="w-4 h-4 mr-2" />
{agentDisplay}
</div>
<div className="font-light">{call.id}</div>
<div className="font-light">
{call.id}
</div>
</CardTitle>
<div className="flex items-center gap-2 text-xs">
{tokenStats && <TokenStatsTooltip stats={tokenStats} />}
{getStatusDisplay()}
</div>
</CardHeader>
<CardContent>
<div className="space-y-2 mt-2">
<button className="text-xs flex items-center gap-2" onClick={() => setAreInputsExpanded(!areInputsExpanded)}>
<MessageSquare className="w-4 h-4" />
<span>Input</span>
{areInputsExpanded ? <ChevronUp className="w-4 h-4 ml-1" /> : <ChevronDown className="w-4 h-4 ml-1" />}
</button>
{areInputsExpanded && (
<div className="mt-2 bg-muted/50 p-3 rounded">
<pre className="text-sm whitespace-pre-wrap break-words">{JSON.stringify(call.args, null, 2)}</pre>
</div>
)}
</div>

<div className="mt-4 w-full">
{status === "executing" && !hasResult && (
<div className="flex items-center gap-2 py-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">{agentDisplay} is responding...</span>
</div>
)}
{hasResult && result?.content && (
<div className="space-y-2">
<button className="text-xs flex items-center gap-2" onClick={() => setAreResultsExpanded(!areResultsExpanded)}>
<MessageSquare className="w-4 h-4" />
<span>Output</span>
{areResultsExpanded ? <ChevronUp className="w-4 h-4 ml-1" /> : <ChevronDown className="w-4 h-4 ml-1" />}
</button>
{areResultsExpanded && (
<div className={`mt-2 ${isError ? 'bg-red-50 dark:bg-red-950/10' : 'bg-muted/50'} p-3 rounded`}>
<pre className={`text-sm whitespace-pre-wrap break-words ${isError ? 'text-red-600 dark:text-red-400' : ''}`}>
{result?.content}
</pre>
</div>
)}
</div>
)}
</div>
<CardContent className="space-y-1 pt-0">
<CollapsibleSection
icon={MessageSquare}
expanded={areInputsExpanded}
onToggle={() => setAreInputsExpanded(!areInputsExpanded)}
previewContent={argsContent}
expandedContent={argsContent}
/>
{status === "executing" && !hasResult && (
<div className="flex items-center gap-2 py-1">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">{agentDisplay} is responding...</span>
</div>
)}
{hasResult && resultContent && (
<CollapsibleSection
icon={MessageSquare}
expanded={areResultsExpanded}
onToggle={() => setAreResultsExpanded(!areResultsExpanded)}
previewContent={resultContent}
expandedContent={resultContent}
errorStyle={isError}
/>
)}

{showActivitySection && (
<div className="mt-4 border-t pt-3">
Expand Down
20 changes: 19 additions & 1 deletion ui/src/components/chat/ChatMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;

Expand Down Expand Up @@ -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.");
Expand All @@ -172,6 +183,13 @@ export default function ChatMessage({ message, allMessages, agentContext, onAppr
{source !== "user" && (
<div className="flex mt-2 justify-end items-center gap-2">
{tokenStats && <TokenStatsTooltip stats={tokenStats} />}
<button
onClick={handleCopy}
className="p-1 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
aria-label="Copy to clipboard"
>
{copied ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
</button>
{messageId !== undefined && (
<>
<button
Expand Down
Loading
Loading