From df37b294b8acb8ecc07c15a1b1908ce0be59107a Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 5 May 2025 10:49:00 -0500 Subject: [PATCH] ToolCall UI Update (#2429) --- ui/desktop/src/components/GooseMessage.tsx | 8 +- .../src/components/ToolCallArguments.tsx | 30 +-- .../src/components/ToolCallWithResponse.tsx | 245 ++++++++++-------- ui/desktop/src/components/ui/Dot.tsx | 24 ++ ui/desktop/src/components/ui/Expand.tsx | 9 + 5 files changed, 185 insertions(+), 131 deletions(-) create mode 100644 ui/desktop/src/components/ui/Dot.tsx create mode 100644 ui/desktop/src/components/ui/Expand.tsx diff --git a/ui/desktop/src/components/GooseMessage.tsx b/ui/desktop/src/components/GooseMessage.tsx index a4fc0cb9..3e25097c 100644 --- a/ui/desktop/src/components/GooseMessage.tsx +++ b/ui/desktop/src/components/GooseMessage.tsx @@ -103,9 +103,7 @@ export default function GooseMessage({
{textContent && (
-
0 ? 'rounded-b-none' : 'rounded-bl-none'}`} - > +
{}
{/* Only show MessageCopyLink if there's text content and no tool requests/responses */} @@ -126,9 +124,7 @@ export default function GooseMessage({ {toolRequests.length > 0 && (
-
+
{toolRequests.map((toolRequest) => ( +
- {key} - {value} + {key} + {value}
); } return ( -
+
- {key} -
+ {key} +
{isExpanded ? ( -
- +
+
) : ( - {value.slice(0, 60)}... + {value.slice(0, 60)}... )}
@@ -73,8 +71,8 @@ export function ToolCallArguments({ args }: ToolCallArgumentsProps) { return (
- {key}: -
{content}
+ {key}: +
{content}
); diff --git a/ui/desktop/src/components/ToolCallWithResponse.tsx b/ui/desktop/src/components/ToolCallWithResponse.tsx index ab9146e1..327cf16d 100644 --- a/ui/desktop/src/components/ToolCallWithResponse.tsx +++ b/ui/desktop/src/components/ToolCallWithResponse.tsx @@ -1,12 +1,11 @@ import React from 'react'; import { Card } from './ui/card'; -import Box from './ui/Box'; import { ToolCallArguments } from './ToolCallArguments'; import MarkdownContent from './MarkdownContent'; -import { LoadingPlaceholder } from './LoadingPlaceholder'; -import { ChevronUp } from 'lucide-react'; import { Content, ToolRequestMessageContent, ToolResponseMessageContent } from '../types/message'; import { snakeToTitleCase } from '../utils'; +import Dot, { LoadingStatus } from './ui/Dot'; +import Expand from './ui/Expand'; interface ToolCallWithResponseProps { isCancelledMessage: boolean; @@ -20,138 +19,166 @@ export default function ToolCallWithResponse({ toolResponse, }: ToolCallWithResponseProps) { const toolCall = toolRequest.toolCall.status === 'success' ? toolRequest.toolCall.value : null; - if (!toolCall) { return null; } return ( -
+
- - {!isCancelledMessage ? ( - toolResponse ? ( - - ) : ( - - ) - ) : undefined} +
); } +interface ToolCallExpandableProps { + label: string | React.ReactNode; + defaultExpanded?: boolean; + forceExpand?: boolean; + children: React.ReactNode; + className?: string; +} + +function ToolCallExpandable({ + label, + defaultExpanded = false, + forceExpand, + children, + className = '', +}: ToolCallExpandableProps) { + const [isExpanded, setIsExpanded] = React.useState(defaultExpanded); + const toggleExpand = () => setIsExpanded((prev) => !prev); + React.useEffect(() => { + if (forceExpand) setIsExpanded(true); + }, [forceExpand]); + + return ( +
+ + {isExpanded &&
{children}
} +
+ ); +} + interface ToolCallViewProps { + isCancelledMessage: boolean; + toolCall: { + name: string; + arguments: Record; + }; + toolResponse?: ToolResponseMessageContent; +} + +function ToolCallView({ isCancelledMessage, toolCall, toolResponse }: ToolCallViewProps) { + const isToolDetails = Object.entries(toolCall?.arguments).length > 0; + const loadingStatus: LoadingStatus = !toolResponse?.toolResult.status + ? 'loading' + : toolResponse?.toolResult.status; + + const toolResults: { result: Content; defaultExpanded: boolean }[] = + loadingStatus === 'success' && Array.isArray(toolResponse?.toolResult.value) + ? toolResponse.toolResult.value + .filter((item) => { + const audience = item.annotations?.audience as string[] | undefined; + return !audience || audience.includes('user'); + }) + .map((item) => ({ + result: item, + defaultExpanded: ((item.annotations?.priority as number | undefined) ?? -1) >= 0.5, + })) + : []; + + const shouldExpand = toolResults.some((v) => v.defaultExpanded); + + return ( + + + + {snakeToTitleCase(toolCall.name.substring(toolCall.name.lastIndexOf('__') + 2))} + + + } + > + {/* Tool Details */} + {isToolDetails && ( +
+ +
+ )} + + {/* Tool Output */} + {!isCancelledMessage && ( + <> + {toolResults.map(({ result, defaultExpanded }, index) => { + const isLast = index === toolResults.length - 1; + return ( +
+ +
+ ); + })} + + )} +
+ ); +} + +interface ToolDetailsViewProps { toolCall: { name: string; arguments: Record; }; } -function ToolCallView({ toolCall }: ToolCallViewProps) { +function ToolDetailsView({ toolCall }: ToolDetailsViewProps) { return ( -
-
- - - {snakeToTitleCase(toolCall.name.substring(toolCall.name.lastIndexOf('__') + 2))} - -
- + {toolCall.arguments && } - -
-
+
); } interface ToolResultViewProps { - result?: Content[]; + result: Content; + defaultExpanded: boolean; } -function ToolResultView({ result }: ToolResultViewProps) { - // State to track expanded items - const [expandedItems, setExpandedItems] = React.useState([]); - - // If no result info, don't show anything - if (!result) return null; - - // Find results where either audience is not set, or it's set to a list that includes user - const filteredResults = result.filter((item) => { - // Check audience (which may not be in the type) - const audience = item.annotations?.audience; - - return !audience || audience.includes('user'); - }); - - if (filteredResults.length === 0) return null; - - const toggleExpand = (index: number) => { - setExpandedItems((prev) => - prev.includes(index) ? prev.filter((i) => i !== index) : [...prev, index] - ); - }; - - const shouldShowExpanded = (item: Content, index: number) => { - return ( - (item.annotations && - item.annotations.priority !== undefined && - item.annotations.priority >= 0.5) || - expandedItems.includes(index) - ); - }; - +function ToolResultView({ result, defaultExpanded }: ToolResultViewProps) { return ( -
- {filteredResults.map((item, index) => { - const isExpanded = shouldShowExpanded(item, index); - const shouldMinimize = - !item.annotations || - item.annotations.priority === undefined || - item.annotations.priority < 0.5; - return ( -
- {shouldMinimize && ( - - )} - {(isExpanded || !shouldMinimize) && ( - <> - {item.type === 'text' && item.text && ( - - )} - {item.type === 'image' && ( - Tool result { - console.error('Failed to load image: Invalid MIME-type encoded image data'); - e.currentTarget.style.display = 'none'; - }} - /> - )} - - )} -
- ); - })} -
+ Output} + defaultExpanded={defaultExpanded} + > +
+ {result.type === 'text' && result.text && ( + + )} + {result.type === 'image' && ( + Tool result { + console.error('Failed to load image'); + e.currentTarget.style.display = 'none'; + }} + /> + )} +
+
); } diff --git a/ui/desktop/src/components/ui/Dot.tsx b/ui/desktop/src/components/ui/Dot.tsx new file mode 100644 index 00000000..c6583b8e --- /dev/null +++ b/ui/desktop/src/components/ui/Dot.tsx @@ -0,0 +1,24 @@ +export type LoadingStatus = 'loading' | 'success' | 'error'; +export default function Dot({ + size, + loadingStatus, +}: { + size: number; + loadingStatus: LoadingStatus; +}) { + const backgroundColor = + { + loading: '#2693FF', + success: 'var(--icon-extra-subtle)', + error: '#CC0023', + }[loadingStatus] ?? 'var(--icon-extra-subtle)'; + + return ( +
+ ); +} diff --git a/ui/desktop/src/components/ui/Expand.tsx b/ui/desktop/src/components/ui/Expand.tsx new file mode 100644 index 00000000..0529248b --- /dev/null +++ b/ui/desktop/src/components/ui/Expand.tsx @@ -0,0 +1,9 @@ +import { ChevronUp } from 'lucide-react'; + +export default function Expand({ size, isExpanded }: { size: number; isExpanded: boolean }) { + return ( + + ); +}