ToolCall UI Update (#2429)

This commit is contained in:
Oliver
2025-05-05 10:49:00 -05:00
committed by GitHub
parent a812d6ff79
commit df37b294b8
5 changed files with 185 additions and 131 deletions
+2 -6
View File
@@ -103,9 +103,7 @@ export default function GooseMessage({
<div className="flex flex-col w-full"> <div className="flex flex-col w-full">
{textContent && ( {textContent && (
<div className="flex flex-col group"> <div className="flex flex-col group">
<div <div className={`goose-message-content py-2`}>
className={`goose-message-content bg-bgSubtle rounded-2xl px-4 py-2 ${toolRequests.length > 0 ? 'rounded-b-none' : 'rounded-bl-none'}`}
>
<div ref={contentRef}>{<MarkdownContent content={textContent} />}</div> <div ref={contentRef}>{<MarkdownContent content={textContent} />}</div>
</div> </div>
{/* Only show MessageCopyLink if there's text content and no tool requests/responses */} {/* 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.length > 0 && (
<div className="relative flex flex-col w-full"> <div className="relative flex flex-col w-full">
<div <div className={`goose-message-tool bg-bgSubtle rounded px-2 py-2 mt-2`}>
className={`goose-message-tool bg-bgApp border border-borderSubtle dark:border-gray-700 ${textContent ? '' : 'rounded-t-2xl'} rounded-b-2xl rounded-bl-none px-4 pt-4 pb-2`}
>
{toolRequests.map((toolRequest) => ( {toolRequests.map((toolRequest) => (
<ToolCallWithResponse <ToolCallWithResponse
// If the message is resumed and not matched tool response, it means the tool is broken or cancelled. // If the message is resumed and not matched tool response, it means the tool is broken or cancelled.
+14 -16
View File
@@ -1,6 +1,6 @@
import { ChevronUp } from 'lucide-react';
import React, { useState } from 'react'; import React, { useState } from 'react';
import MarkdownContent from './MarkdownContent'; import MarkdownContent from './MarkdownContent';
import Expand from './ui/Expand';
type ToolCallArgumentValue = type ToolCallArgumentValue =
| string | string
@@ -28,34 +28,32 @@ export function ToolCallArguments({ args }: ToolCallArgumentsProps) {
if (!needsExpansion) { if (!needsExpansion) {
return ( return (
<div className="mb-2"> <div className="text-sm mb-2">
<div className="flex flex-row"> <div className="flex flex-row">
<span className="text-sm font-medium text-textSubtle min-w-[140px]">{key}</span> <span className="text-textSubtle min-w-[140px]">{key}</span>
<span className="text-sm text-textStandard">{value}</span> <span className="text-textPlaceholder">{value}</span>
</div> </div>
</div> </div>
); );
} }
return ( return (
<div className="mb-2"> <div className="text-sm mb-2">
<div className="flex flex-row"> <div className="flex flex-row">
<span className="text-sm font-medium text-textSubtle min-w-[140px]">{key}</span> <span className="text-textSubtle min-w-[140px]">{key}</span>
<div className="flex items-center"> <div className="w-full flex justify-between items-start">
{isExpanded ? ( {isExpanded ? (
<div className="mt-2"> <div className="">
<MarkdownContent content={value} /> <MarkdownContent content={value} className="text-sm text-textPlaceholder" />
</div> </div>
) : ( ) : (
<span className="text-sm text-textStandard mr-2">{value.slice(0, 60)}...</span> <span className="text-textPlaceholder mr-2">{value.slice(0, 60)}...</span>
)} )}
<button <button
onClick={() => toggleKey(key)} onClick={() => toggleKey(key)}
className="text-sm hover:opacity-75 text-textStandard" className="hover:opacity-75 text-textPlaceholder pr-2"
> >
<ChevronUp <Expand size={5} isExpanded={isExpanded} />
className={`h-5 w-5 transition-all origin-center ${!isExpanded ? 'rotate-180' : ''}`}
/>
</button> </button>
</div> </div>
</div> </div>
@@ -73,8 +71,8 @@ export function ToolCallArguments({ args }: ToolCallArgumentsProps) {
return ( return (
<div className="mb-2"> <div className="mb-2">
<div className="flex flex-row"> <div className="flex flex-row">
<span className="font-medium mr- text-textStandard min-w-[140px]2">{key}:</span> <span className="mr- text-textPlaceholder min-w-[140px]2">{key}:</span>
<pre className="whitespace-pre-wrap text-textStandard">{content}</pre> <pre className="whitespace-pre-wrap text-textPlaceholder">{content}</pre>
</div> </div>
</div> </div>
); );
+136 -109
View File
@@ -1,12 +1,11 @@
import React from 'react'; import React from 'react';
import { Card } from './ui/card'; import { Card } from './ui/card';
import Box from './ui/Box';
import { ToolCallArguments } from './ToolCallArguments'; import { ToolCallArguments } from './ToolCallArguments';
import MarkdownContent from './MarkdownContent'; import MarkdownContent from './MarkdownContent';
import { LoadingPlaceholder } from './LoadingPlaceholder';
import { ChevronUp } from 'lucide-react';
import { Content, ToolRequestMessageContent, ToolResponseMessageContent } from '../types/message'; import { Content, ToolRequestMessageContent, ToolResponseMessageContent } from '../types/message';
import { snakeToTitleCase } from '../utils'; import { snakeToTitleCase } from '../utils';
import Dot, { LoadingStatus } from './ui/Dot';
import Expand from './ui/Expand';
interface ToolCallWithResponseProps { interface ToolCallWithResponseProps {
isCancelledMessage: boolean; isCancelledMessage: boolean;
@@ -20,138 +19,166 @@ export default function ToolCallWithResponse({
toolResponse, toolResponse,
}: ToolCallWithResponseProps) { }: ToolCallWithResponseProps) {
const toolCall = toolRequest.toolCall.status === 'success' ? toolRequest.toolCall.value : null; const toolCall = toolRequest.toolCall.status === 'success' ? toolRequest.toolCall.value : null;
if (!toolCall) { if (!toolCall) {
return null; return null;
} }
return ( return (
<div className="w-full"> <div className={'w-full text-textSubtle text-sm'}>
<Card className=""> <Card className="">
<ToolCallView toolCall={toolCall} /> <ToolCallView {...{ isCancelledMessage, toolCall, toolResponse }} />
{!isCancelledMessage ? (
toolResponse ? (
<ToolResultView
result={
toolResponse.toolResult.status === 'success'
? toolResponse.toolResult.value
: undefined
}
/>
) : (
<LoadingPlaceholder />
)
) : undefined}
</Card> </Card>
</div> </div>
); );
} }
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 (
<div className={className}>
<button onClick={toggleExpand} className="w-full flex justify-between items-center pr-2">
<span className="flex items-center">{label}</span>
<Expand size={5} isExpanded={isExpanded} />
</button>
{isExpanded && <div>{children}</div>}
</div>
);
}
interface ToolCallViewProps { interface ToolCallViewProps {
isCancelledMessage: boolean;
toolCall: {
name: string;
arguments: Record<string, unknown>;
};
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 (
<ToolCallExpandable
defaultExpanded={shouldExpand}
forceExpand={shouldExpand}
label={
<>
<Dot size={2} loadingStatus={loadingStatus} />
<span className="ml-[10px]">
{snakeToTitleCase(toolCall.name.substring(toolCall.name.lastIndexOf('__') + 2))}
</span>
</>
}
>
{/* Tool Details */}
{isToolDetails && (
<div className="bg-bgStandard rounded-t mt-1">
<ToolDetailsView toolCall={toolCall} />
</div>
)}
{/* Tool Output */}
{!isCancelledMessage && (
<>
{toolResults.map(({ result, defaultExpanded }, index) => {
const isLast = index === toolResults.length - 1;
return (
<div
key={index}
className={`bg-bgStandard mt-1 ${isToolDetails ? 'rounded-t-none' : ''} ${isLast ? 'rounded-b' : ''}`}
>
<ToolResultView result={result} defaultExpanded={defaultExpanded} />
</div>
);
})}
</>
)}
</ToolCallExpandable>
);
}
interface ToolDetailsViewProps {
toolCall: { toolCall: {
name: string; name: string;
arguments: Record<string, unknown>; arguments: Record<string, unknown>;
}; };
} }
function ToolCallView({ toolCall }: ToolCallViewProps) { function ToolDetailsView({ toolCall }: ToolDetailsViewProps) {
return ( return (
<div> <ToolCallExpandable label="Tool Details" className="pl-[19px] py-1">
<div className="flex items-center mb-4">
<Box size={16} />
<span className="ml-[8px] text-textStandard">
{snakeToTitleCase(toolCall.name.substring(toolCall.name.lastIndexOf('__') + 2))}
</span>
</div>
{toolCall.arguments && <ToolCallArguments args={toolCall.arguments} />} {toolCall.arguments && <ToolCallArguments args={toolCall.arguments} />}
</ToolCallExpandable>
<div className="self-stretch h-px my-[10px] -mx-4 bg-borderSubtle dark:bg-gray-700" />
</div>
); );
} }
interface ToolResultViewProps { interface ToolResultViewProps {
result?: Content[]; result: Content;
defaultExpanded: boolean;
} }
function ToolResultView({ result }: ToolResultViewProps) { function ToolResultView({ result, defaultExpanded }: ToolResultViewProps) {
// State to track expanded items
const [expandedItems, setExpandedItems] = React.useState<number[]>([]);
// 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)
);
};
return ( return (
<div className=""> <ToolCallExpandable
{filteredResults.map((item, index) => { label={<span className="pl-[19px] py-1">Output</span>}
const isExpanded = shouldShowExpanded(item, index); defaultExpanded={defaultExpanded}
const shouldMinimize = >
!item.annotations || <div className="bg-bgApp rounded-b pl-[19px] pr-2 py-4">
item.annotations.priority === undefined || {result.type === 'text' && result.text && (
item.annotations.priority < 0.5; <MarkdownContent
return ( content={result.text}
<div key={index} className="relative"> className="whitespace-pre-wrap p-2 max-w-full overflow-x-auto"
{shouldMinimize && ( />
<button )}
onClick={() => toggleExpand(index)} {result.type === 'image' && (
className="mb-1 flex items-center text-textStandard" <img
> src={`data:${result.mimeType};base64,${result.data}`}
<span className="mr-2 text-sm">Output</span> alt="Tool result"
<ChevronUp className="max-w-full h-auto rounded-md my-2"
className={`h-5 w-5 transition-all origin-center ${!isExpanded ? 'rotate-180' : ''}`} onError={(e) => {
/> console.error('Failed to load image');
</button> e.currentTarget.style.display = 'none';
)} }}
{(isExpanded || !shouldMinimize) && ( />
<> )}
{item.type === 'text' && item.text && ( </div>
<MarkdownContent </ToolCallExpandable>
content={item.text}
className="whitespace-pre-wrap p-2 max-w-full overflow-x-auto"
/>
)}
{item.type === 'image' && (
<img
src={`data:${item.mimeType};base64,${item.data}`}
alt="Tool result"
className="max-w-full h-auto rounded-md my-2"
onError={(e) => {
console.error('Failed to load image: Invalid MIME-type encoded image data');
e.currentTarget.style.display = 'none';
}}
/>
)}
</>
)}
</div>
);
})}
</div>
); );
} }
+24
View File
@@ -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 (
<div
className={`w-${size} h-${size} rounded-full`}
style={{
backgroundColor: backgroundColor,
}}
/>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { ChevronUp } from 'lucide-react';
export default function Expand({ size, isExpanded }: { size: number; isExpanded: boolean }) {
return (
<ChevronUp
className={`w-${size} h-${size} text-textPlaceholder transition-all origin-center ${isExpanded ? 'rotate-180' : 'rotate-90'}`}
/>
);
}