feat: integrate tool call icons with status indicators and daisy chaining (#4279)

Co-authored-by: dianed-square <73617011+dianed-square@users.noreply.github.com>
Co-authored-by: Zane <75694352+zanesq@users.noreply.github.com>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Spence
2025-08-29 08:39:38 -04:00
committed by GitHub
parent 7599a3e56b
commit 69c29a0870
24 changed files with 920 additions and 73 deletions
+119 -47
View File
@@ -7,6 +7,12 @@ import { extractImagePaths, removeImagePathsFromText } from '../utils/imageUtils
import { formatMessageTimestamp } from '../utils/timeUtils';
import MarkdownContent from './MarkdownContent';
import ToolCallWithResponse from './ToolCallWithResponse';
import ToolCallChain from './ToolCallChain';
import {
identifyConsecutiveToolCalls,
shouldHideMessage,
getChainForMessage,
} from '../utils/toolCallChaining';
import {
Message,
getTextContent,
@@ -18,6 +24,7 @@ import {
import ToolCallConfirmation from './ToolCallConfirmation';
import MessageCopyLink from './MessageCopyLink';
import { NotificationEvent } from '../hooks/useMessageStream';
import { cn } from '../utils';
interface GooseMessageProps {
// messages up to this index are presumed to be "history" from a resumed session, this is used to track older tool confirmation requests
@@ -60,18 +67,23 @@ export default function GooseMessage({
}
const cotRaw = match[1].trim();
const visible = text.replace(match[0], '').trim();
return { visibleText: visible, cotText: cotRaw.length > 0 ? cotRaw : null };
const visibleText = text.replace(regex, '').trim();
return {
visibleText,
cotText: cotRaw || null,
};
};
const { visibleText: textWithoutCot, cotText } = splitChainOfThought(textContent);
// Split out Chain-of-Thought
const { visibleText, cotText } = splitChainOfThought(textContent);
// Extract image paths from the visible part of the message (exclude CoT)
const imagePaths = extractImagePaths(textWithoutCot);
// Extract image paths from the message content
const imagePaths = extractImagePaths(visibleText);
// Remove image paths from text for display
const displayText =
imagePaths.length > 0 ? removeImagePathsFromText(textWithoutCot, imagePaths) : textWithoutCot;
imagePaths.length > 0 ? removeImagePathsFromText(visibleText, imagePaths) : visibleText;
// Memoize the timestamp
const timestamp = useMemo(() => formatMessageTimestamp(message.created), [message.created]);
@@ -79,11 +91,53 @@ export default function GooseMessage({
// Get tool requests from the message
const toolRequests = getToolRequests(message);
// Get current message index
const messageIndex = messages.findIndex((msg) => msg.id === message.id);
// Enhanced chain detection that works during streaming
const toolCallChains = useMemo(() => {
// Always run chain detection, but handle streaming messages specially
const chains = identifyConsecutiveToolCalls(messages);
// If this message is streaming and has tool calls but no text,
// check if it should extend an existing chain
if (isStreaming && toolRequests.length > 0 && !displayText.trim()) {
// Look for an existing chain that this message could extend
const previousMessage = messageIndex > 0 ? messages[messageIndex - 1] : null;
if (previousMessage) {
const prevToolRequests = getToolRequests(previousMessage);
// If previous message has tool calls (with or without text), extend its chain
if (prevToolRequests.length > 0) {
// Find if previous message is part of a chain
const prevChain = chains.find((chain) => chain.includes(messageIndex - 1));
if (prevChain) {
// Extend the existing chain to include this streaming message
const extendedChains = chains.map((chain) =>
chain === prevChain ? [...chain, messageIndex] : chain
);
return extendedChains;
} else {
// Create a new chain with previous and current message
return [...chains, [messageIndex - 1, messageIndex]];
}
}
}
}
return chains;
}, [messages, isStreaming, messageIndex, toolRequests, displayText]);
// Check if this message should be hidden (part of chain but not first)
const shouldHide = shouldHideMessage(messageIndex, toolCallChains);
// Get the chain this message belongs to
const messageChain = getChainForMessage(messageIndex, toolCallChains);
// Extract URLs under a few conditions
// 1. The message is purely text
// 2. The link wasn't also present in the previous message
// 3. The message contains the explicit http:// or https:// protocol at the beginning
const messageIndex = messages?.findIndex((msg) => msg.id === message.id);
const previousMessage = messageIndex > 0 ? messages[messageIndex - 1] : null;
const previousUrls = previousMessage ? extractUrls(getTextContent(previousMessage)) : [];
const urls = toolRequests.length === 0 ? extractUrls(displayText, previousUrls) : [];
@@ -145,10 +199,17 @@ export default function GooseMessage({
appendMessage,
]);
// If this message should be hidden (part of chain but not first), don't render it
if (shouldHide) {
return null;
}
// Determine rendering logic based on chain membership and content
const isFirstInChain = messageChain && messageChain[0] === messageIndex;
return (
<div className="goose-message flex w-[90%] justify-start min-w-0">
<div className="flex flex-col w-full min-w-0">
{/* Chain-of-Thought (hidden by default) */}
{cotText && (
<details className="bg-bgSubtle border border-borderSubtle rounded p-2 mb-2">
<summary className="cursor-pointer text-sm text-textSubtle select-none">
@@ -160,61 +221,73 @@ export default function GooseMessage({
</details>
)}
{/* Visible assistant response */}
{displayText && (
<div className="flex flex-col group">
<div className={`goose-message-content py-2`}>
<div ref={contentRef}>{<MarkdownContent content={displayText} />}</div>
<div ref={contentRef} className="w-full">
<MarkdownContent content={displayText} />
</div>
{/* Render images if any */}
{/* Image previews */}
{imagePaths.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2 mb-2">
<div className="mt-4">
{imagePaths.map((imagePath, index) => (
<ImagePreview key={index} src={imagePath} alt={`Image ${index + 1}`} />
<ImagePreview key={index} src={imagePath} />
))}
</div>
)}
{/* Only show timestamp and copy link when not streaming */}
<div className="relative flex justify-start">
{toolRequests.length === 0 && !isStreaming && (
<div className="text-xs font-mono text-text-muted pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0">
{timestamp}
</div>
)}
{displayText &&
message.content.every((content) => content.type === 'text') &&
!isStreaming && (
{toolRequests.length === 0 && (
<div className="relative flex justify-start">
{!isStreaming && (
<div className="text-xs font-mono text-text-muted pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0">
{timestamp}
</div>
)}
{message.content.every((content) => content.type === 'text') && !isStreaming && (
<div className="absolute left-0 pt-1">
<MessageCopyLink text={displayText} contentRef={contentRef} />
</div>
)}
</div>
</div>
)}
</div>
)}
{toolRequests.length > 0 && (
<div className="relative flex flex-col w-full">
{toolRequests.map((toolRequest) => (
<div className={`goose-message-tool pb-2`} key={toolRequest.id}>
<ToolCallWithResponse
// If the message is resumed and not matched tool response, it means the tool is broken or cancelled.
isCancelledMessage={
messageIndex < messageHistoryIndex &&
toolResponsesMap.get(toolRequest.id) == undefined
}
toolRequest={toolRequest}
toolResponse={toolResponsesMap.get(toolRequest.id)}
notifications={toolCallNotifications.get(toolRequest.id)}
isStreamingMessage={isStreaming}
append={append}
/>
<div className={cn(displayText && 'mt-2')}>
{isFirstInChain ? (
<ToolCallChain
messages={messages}
chainIndices={messageChain}
toolCallNotifications={toolCallNotifications}
toolResponsesMap={toolResponsesMap}
messageHistoryIndex={messageHistoryIndex}
isStreaming={isStreaming}
/>
) : !messageChain ? (
<div className="relative flex flex-col w-full">
<div className="flex flex-col gap-3">
{toolRequests.map((toolRequest) => (
<div className="goose-message-tool" key={toolRequest.id}>
<ToolCallWithResponse
isCancelledMessage={
messageIndex < messageHistoryIndex &&
toolResponsesMap.get(toolRequest.id) == undefined
}
toolRequest={toolRequest}
toolResponse={toolResponsesMap.get(toolRequest.id)}
notifications={toolCallNotifications.get(toolRequest.id)}
isStreamingMessage={isStreaming}
append={append}
/>
</div>
))}
</div>
<div className="text-xs text-text-muted pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0">
{!isStreaming && timestamp}
</div>
</div>
))}
<div className="text-xs text-text-muted pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0">
{!isStreaming && timestamp}
</div>
) : null}
</div>
)}
@@ -229,9 +302,8 @@ export default function GooseMessage({
</div>
{/* TODO(alexhancock): Re-enable link previews once styled well again */}
{/* eslint-disable-next-line no-constant-binary-expression */}
{false && urls.length > 0 && (
<div className="flex flex-wrap mt-[16px]">
{urls.length > 0 && (
<div className="mt-4">
{urls.map((url, index) => (
<LinkPreview key={index} url={url} />
))}
@@ -0,0 +1,56 @@
import { formatMessageTimestamp } from '../utils/timeUtils';
import { Message, getToolRequests } from '../types/message';
import { NotificationEvent } from '../hooks/useMessageStream';
import ToolCallWithResponse from './ToolCallWithResponse';
interface ToolCallChainProps {
messages: Message[];
chainIndices: number[];
toolCallNotifications: Map<string, NotificationEvent[]>;
toolResponsesMap: Map<string, import('../types/message').ToolResponseMessageContent>;
messageHistoryIndex: number;
isStreaming?: boolean;
}
export default function ToolCallChain({
messages,
chainIndices,
toolCallNotifications,
toolResponsesMap,
messageHistoryIndex,
isStreaming = false,
}: ToolCallChainProps) {
const lastMessageIndex = chainIndices[chainIndices.length - 1];
const lastMessage = messages[lastMessageIndex];
const timestamp = lastMessage ? formatMessageTimestamp(lastMessage.created) : '';
return (
<div className="relative flex flex-col w-full">
<div className="flex flex-col gap-3">
{chainIndices.map((messageIndex) => {
const message = messages[messageIndex];
const toolRequests = getToolRequests(message);
return toolRequests.map((toolRequest) => (
<div key={toolRequest.id} className="goose-message-tool">
<ToolCallWithResponse
isCancelledMessage={
messageIndex < messageHistoryIndex &&
toolResponsesMap.get(toolRequest.id) == undefined
}
toolRequest={toolRequest}
toolResponse={toolResponsesMap.get(toolRequest.id)}
notifications={toolCallNotifications.get(toolRequest.id)}
isStreamingMessage={isStreaming}
/>
</div>
));
})}
</div>
<div className="text-xs text-text-muted pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0">
{!isStreaming && timestamp}
</div>
</div>
);
}
@@ -0,0 +1,61 @@
import React from 'react';
import { cn } from '../utils';
export type ToolCallStatus = 'pending' | 'loading' | 'success' | 'error';
interface ToolCallStatusIndicatorProps {
status: ToolCallStatus;
className?: string;
}
export const ToolCallStatusIndicator: React.FC<ToolCallStatusIndicatorProps> = ({
status,
className,
}) => {
const getStatusStyles = () => {
switch (status) {
case 'success':
return 'bg-green-500';
case 'error':
return 'bg-red-500';
case 'loading':
return 'bg-yellow-500 animate-pulse';
case 'pending':
default:
return 'bg-gray-400';
}
};
return (
<div
className={cn(
'absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full border border-background-default',
getStatusStyles(),
className
)}
aria-label={`Tool status: ${status}`}
/>
);
};
/**
* Wrapper component that adds a status indicator to a tool icon
*/
interface ToolIconWithStatusProps {
ToolIcon: React.ComponentType<{ className?: string }>;
status: ToolCallStatus;
className?: string;
}
export const ToolIconWithStatus: React.FC<ToolIconWithStatusProps> = ({
ToolIcon,
status,
className,
}) => {
return (
<div className={cn('relative inline-block', className)}>
<ToolIcon className="w-3 h-3 flex-shrink-0" />
<ToolCallStatusIndicator status={status} />
</div>
);
};
@@ -1,12 +1,14 @@
import { ToolIconWithStatus, ToolCallStatus } from './ToolCallStatusIndicator';
import { getToolCallIcon } from '../utils/toolIconMapping';
import React, { useEffect, useRef, useState } from 'react';
import { Button } from './ui/button';
import { ToolCallArguments, ToolCallArgumentValue } from './ToolCallArguments';
import MarkdownContent from './MarkdownContent';
import { Content, ToolRequestMessageContent, ToolResponseMessageContent } from '../types/message';
import { cn, snakeToTitleCase } from '../utils';
import Dot, { LoadingStatus } from './ui/Dot';
import { LoadingStatus } from './ui/Dot';
import { NotificationEvent } from '../hooks/useMessageStream';
import { ChevronRight, FlaskConical, LoaderCircle } from 'lucide-react';
import { ChevronRight, FlaskConical } from 'lucide-react';
import { TooltipWrapper } from './settings/providers/subcomponents/buttons/TooltipWrapper';
import MCPUIResourceRenderer from './MCPUIResourceRenderer';
import { isUIResource } from '@mcp-ui/client';
@@ -173,16 +175,13 @@ function ToolCallView({
}: ToolCallViewProps) {
const [responseStyle, setResponseStyle] = useState(() => localStorage.getItem('response_style'));
// Listen for localStorage changes to update the response style
useEffect(() => {
const handleStorageChange = () => {
setResponseStyle(localStorage.getItem('response_style'));
};
// Listen for storage events (changes from other tabs/windows)
window.addEventListener('storage', handleStorageChange);
// Listen for custom events (changes from same tab)
window.addEventListener('responseStyleChanged', handleStorageChange);
return () => {
@@ -283,12 +282,10 @@ function ToolCallView({
const args = toolCall.arguments as Record<string, ToolCallArgumentValue>;
const toolName = toolCall.name.substring(toolCall.name.lastIndexOf('__') + 2);
// Helper function to get string value safely
const getStringValue = (value: ToolCallArgumentValue): string => {
return typeof value === 'string' ? value : JSON.stringify(value);
};
// Helper function to truncate long values
const truncate = (str: string, maxLength: number = 50): string => {
return str.length > maxLength ? str.substring(0, maxLength) + '...' : str;
};
@@ -442,34 +439,45 @@ function ToolCallView({
// Fallback tool name formatting
return snakeToTitleCase(toolCall.name.substring(toolCall.name.lastIndexOf('__') + 2));
};
// Map LoadingStatus to ToolCallStatus
const getToolCallStatus = (loadingStatus: LoadingStatus): ToolCallStatus => {
switch (loadingStatus) {
case 'success':
return 'success';
case 'error':
return 'error';
case 'loading':
return 'loading';
default:
return 'pending';
}
};
const toolCallStatus = getToolCallStatus(loadingStatus);
const toolLabel = (
<span className={cn('ml-2', extensionTooltip && 'cursor-pointer hover:opacity-80')}>
{getToolLabelContent()}
<span
className={cn(
'flex items-center gap-2',
extensionTooltip && 'cursor-pointer hover:opacity-80'
)}
>
<ToolIconWithStatus ToolIcon={getToolCallIcon(toolCall.name)} status={toolCallStatus} />
<span>{getToolLabelContent()}</span>
</span>
);
return (
<ToolCallExpandable
isStartExpanded={isRenderingProgress}
isForceExpand={isShouldExpand}
label={
<>
<div className="w-2 flex items-center justify-center">
{loadingStatus === 'loading' ? (
<LoaderCircle className="animate-spin text-text-muted" size={3} />
) : (
<Dot size={2} loadingStatus={loadingStatus} />
)}
</div>
{extensionTooltip ? (
<TooltipWrapper tooltipContent={extensionTooltip} side="top" align="start">
{toolLabel}
</TooltipWrapper>
) : (
toolLabel
)}
</>
extensionTooltip ? (
<TooltipWrapper tooltipContent={extensionTooltip} side="top" align="start">
{toolLabel}
</TooltipWrapper>
) : (
toolLabel
)
}
>
{/* Tool Details */}
@@ -0,0 +1,23 @@
export const Archive = ({ className }: { className?: string }) => (
<svg
width="11"
height="11"
viewBox="0 0 11 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<rect width="11" height="11" rx="2" fill="#1E1E20" />
<path
d="M1.375 6.1875H9.625V8.25C9.625 8.6297 9.3172 8.9375 8.9375 8.9375H2.0625C1.6828 8.9375 1.375 8.6297 1.375 8.25V6.1875Z"
fill="#DBD7CE"
/>
<path d="M1.375 6.1875H9.625V7.5625H1.375V6.1875Z" fill="#434343" />
<path d="M1.375 4.8125H9.625V6.1875H1.375V4.8125Z" fill="white" />
<path d="M1.375 3.4375H9.625V4.8125H1.375V3.4375Z" fill="#616161" />
<path
d="M1.375 2.75C1.375 2.3703 1.6828 2.0625 2.0625 2.0625H8.9375C9.3172 2.0625 9.625 2.3703 9.625 2.75V3.4375H1.375V2.75Z"
fill="#9F9F9F"
/>
</svg>
);
@@ -0,0 +1,27 @@
export const Brain = ({ className }: { className?: string }) => (
<svg
width="11"
height="11"
viewBox="0 0 11 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<rect width="11" height="11" rx="2" fill="#3E3E3E" />
<rect width="11" height="11" rx="2" fill="url(#paint0_linear_6313_782)" />
<path d="M5.5 2.0625L1.375 5.5L5.5 8.9375L9.625 5.5L5.5 2.0625Z" fill="#E74786" />
<defs>
<linearGradient
id="paint0_linear_6313_782"
x1="5.5"
y1="0"
x2="5.5"
y2="11"
gradientUnits="userSpaceOnUse"
>
<stop />
<stop offset="1" stopColor="#323232" />
</linearGradient>
</defs>
</svg>
);
@@ -0,0 +1,33 @@
export const Camera = ({ className }: { className?: string }) => (
<svg
width="11"
height="11"
viewBox="0 0 11 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<rect width="11" height="11" rx="2" fill="url(#paint0_linear_6313_741)" />
<path
d="M1.375 3.4375C1.375 3.0578 1.6828 2.75 2.0625 2.75H8.9375C9.3172 2.75 9.625 3.0578 9.625 3.4375V7.5625C9.625 7.9422 9.3172 8.25 8.9375 8.25H2.0625C1.6828 8.25 1.375 7.9422 1.375 7.5625V3.4375Z"
fill="#2F2F31"
/>
<path
d="M6.53125 5.5C6.53125 6.06954 6.06954 6.53125 5.5 6.53125C4.93046 6.53125 4.46875 6.06954 4.46875 5.5C4.46875 4.93046 4.93046 4.46875 5.5 4.46875C6.06954 4.46875 6.53125 4.93046 6.53125 5.5Z"
fill="white"
/>
<defs>
<linearGradient
id="paint0_linear_6313_741"
x1="5.5"
y1="0"
x2="5.5"
y2="11"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#E2E3F7" />
<stop offset="1" stopColor="#978E8F" />
</linearGradient>
</defs>
</svg>
);
@@ -0,0 +1,13 @@
export const Code2 = ({ className }: { className?: string }) => (
<svg
width="11"
height="11"
viewBox="0 0 11 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<rect width="11" height="11" rx="2" fill="#1C1C1E" />
<rect x="2" y="5" width="7" height="1" rx="0.5" fill="#19FF4F" />
</svg>
);
@@ -0,0 +1,33 @@
export const Eye = ({ className }: { className?: string }) => (
<svg
width="11"
height="11"
viewBox="0 0 11 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<rect width="11" height="11" rx="2" fill="url(#paint0_linear_6313_805)" />
<path
d="M1.375 5.5C1.375 4.36091 2.29841 3.4375 3.4375 3.4375H7.5625C8.70159 3.4375 9.625 4.36091 9.625 5.5V5.5C9.625 6.63909 8.70159 7.5625 7.5625 7.5625H3.4375C2.29841 7.5625 1.375 6.63909 1.375 5.5V5.5Z"
fill="white"
/>
<path
d="M6.53125 5.5C6.53125 6.06954 6.06954 6.53125 5.5 6.53125C4.93046 6.53125 4.46875 6.06954 4.46875 5.5C4.46875 4.93046 4.93046 4.46875 5.5 4.46875C6.06954 4.46875 6.53125 4.93046 6.53125 5.5Z"
fill="black"
/>
<defs>
<linearGradient
id="paint0_linear_6313_805"
x1="5.5"
y1="0"
x2="5.5"
y2="11"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#00A2FF" />
<stop offset="1" stopColor="#5A6DFF" />
</linearGradient>
</defs>
</svg>
);
@@ -0,0 +1,33 @@
export const FileEdit = ({ className }: { className?: string }) => (
<svg
width="11"
height="11"
viewBox="0 0 11 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<rect width="11" height="11" rx="2" fill="url(#paint0_linear_6313_755)" />
<path
d="M8.96875 2.03125L8.96875 4.78125L2.09375 4.78125L2.09375 3.40625C2.09375 2.64686 2.70936 2.03125 3.46875 2.03125L8.96875 2.03125Z"
fill="white"
/>
<path
d="M5.03125 6.03125L5.03125 9.03125L2.03125 9.03125L2.03125 7.53125C2.03125 6.70282 2.29988 6.03125 2.63125 6.03125L5.03125 6.03125Z"
fill="white"
/>
<defs>
<linearGradient
id="paint0_linear_6313_755"
x1="5.5"
y1="0"
x2="5.5"
y2="11"
gradientUnits="userSpaceOnUse"
>
<stop />
<stop offset="1" stopColor="#383838" />
</linearGradient>
</defs>
</svg>
);
@@ -0,0 +1,27 @@
export const FilePlus = ({ className }: { className?: string }) => (
<svg
width="11"
height="11"
viewBox="0 0 11 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<rect width="11" height="11" rx="2" fill="url(#paint0_linear_6313_774)" />
<rect x="2" y="5" width="7" height="1" rx="0.5" fill="white" />
<rect x="6" y="2" width="7" height="1" rx="0.5" transform="rotate(90 6 2)" fill="white" />
<defs>
<linearGradient
id="paint0_linear_6313_774"
x1="5.5"
y1="0"
x2="5.5"
y2="11"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#FF9A00" />
<stop offset="1" stopColor="#FFC800" />
</linearGradient>
</defs>
</svg>
);
@@ -0,0 +1,31 @@
export const FileText = ({ className }: { className?: string }) => (
<svg
width="11"
height="11"
viewBox="0 0 11 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<rect width="11" height="11" rx="2" fill="url(#paint0_linear_6313_764)" />
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8.73614 2.26386C9.00462 2.53235 9.00462 2.96765 8.73614 3.23614L3.23614 8.73614C2.96765 9.00462 2.53235 9.00462 2.26386 8.73614C1.99538 8.46765 1.99538 8.03235 2.26386 7.76386L7.76386 2.26386C8.03235 1.99538 8.46765 1.99538 8.73614 2.26386Z"
fill="white"
/>
<defs>
<linearGradient
id="paint0_linear_6313_764"
x1="5.5"
y1="0"
x2="5.5"
y2="11"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#FFB645" />
<stop offset="1" stopColor="#FF8735" />
</linearGradient>
</defs>
</svg>
);
@@ -0,0 +1,34 @@
export const Globe = ({ className }: { className?: string }) => (
<svg
width="11"
height="11"
viewBox="0 0 11 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<g clipPath="url(#clip0_6313_720)">
<rect width="11" height="11" rx="2" fill="white" />
<path
d="M10.3125 5.5C10.3125 8.15787 8.15787 10.3125 5.5 10.3125C2.84213 10.3125 0.6875 8.15787 0.6875 5.5C0.6875 2.84213 2.84213 0.6875 5.5 0.6875C8.15787 0.6875 10.3125 2.84213 10.3125 5.5Z"
fill="url(#paint0_linear_6313_720)"
/>
</g>
<defs>
<linearGradient
id="paint0_linear_6313_720"
x1="5.5"
y1="0.6875"
x2="5.5"
y2="10.3125"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#00CAF7" />
<stop offset="1" stopColor="#0B54DE" />
</linearGradient>
<clipPath id="clip0_6313_720">
<rect width="11" height="11" rx="2" fill="white" />
</clipPath>
</defs>
</svg>
);
@@ -0,0 +1,16 @@
export const Harddrive = ({ className }: { className?: string }) => (
<svg
width="11"
height="11"
viewBox="0 0 11 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<rect width="11" height="11" rx="2" fill="#1E1E20" />
<path
d="M5 3.5C5 4.32843 4.32843 5 3.5 5C2.67157 5 2 4.32843 2 3.5C2 2.67157 2.67157 2 3.5 2C4.32843 2 5 2.67157 5 3.5Z"
fill="white"
/>
</svg>
);
@@ -0,0 +1,16 @@
export const Monitor = ({ className }: { className?: string }) => (
<svg
width="11"
height="11"
viewBox="0 0 11 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<rect width="11" height="11" rx="2" fill="white" />
<path
d="M9.625 5.5C9.625 7.77817 7.77817 9.625 5.5 9.625C3.22183 9.625 1.375 7.77817 1.375 5.5C1.375 3.22183 3.22183 1.375 5.5 1.375C7.77817 1.375 9.625 3.22183 9.625 5.5Z"
fill="#E60023"
/>
</svg>
);
@@ -0,0 +1,37 @@
export const Numbers = ({ className }: { className?: string }) => (
<svg
width="11"
height="11"
viewBox="0 0 11 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<rect width="11" height="11" rx="2" fill="url(#paint0_linear_6313_717)" />
<path
d="M4.8125 3.4375C4.8125 3.0578 5.1203 2.75 5.5 2.75C5.8797 2.75 6.1875 3.0578 6.1875 3.4375V7.5625C6.1875 7.9422 5.8797 8.25 5.5 8.25C5.1203 8.25 4.8125 7.9422 4.8125 7.5625V3.4375Z"
fill="white"
/>
<path
d="M2.0625 6.1875C2.0625 5.8078 2.3703 5.5 2.75 5.5C3.1297 5.5 3.4375 5.8078 3.4375 6.1875V7.5625C3.4375 7.9422 3.1297 8.25 2.75 8.25C2.3703 8.25 2.0625 7.9422 2.0625 7.5625V6.1875Z"
fill="white"
/>
<path
d="M8.25 4.125C7.8703 4.125 7.5625 4.4328 7.5625 4.8125V7.5625C7.5625 7.9422 7.8703 8.25 8.25 8.25C8.6297 8.25 8.9375 7.9422 8.9375 7.5625V4.8125C8.9375 4.4328 8.6297 4.125 8.25 4.125Z"
fill="white"
/>
<defs>
<linearGradient
id="paint0_linear_6313_717"
x1="5.5"
y1="0"
x2="5.5"
y2="11"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#73FA80" />
<stop offset="1" stopColor="#00D648" />
</linearGradient>
</defs>
</svg>
);
@@ -0,0 +1,13 @@
export const Save = ({ className }: { className?: string }) => (
<svg
width="11"
height="11"
viewBox="0 0 11 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<rect width="11" height="11" rx="2" fill="#C32361" />
<path d="M5.5 2.0625L1.375 5.5L5.5 8.9375L9.625 5.5L5.5 2.0625Z" fill="#E74786" />
</svg>
);
@@ -0,0 +1,29 @@
export const Search = ({ className }: { className?: string }) => (
<svg
width="11"
height="11"
viewBox="0 0 11 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<rect width="11" height="11" rx="2" fill="url(#paint0_linear_6313_760)" />
<path
d="M7.5625 5.5C7.5625 6.63909 6.63909 7.5625 5.5 7.5625C4.36091 7.5625 3.4375 6.63909 3.4375 5.5C3.4375 4.36091 4.36091 3.4375 5.5 3.4375C6.63909 3.4375 7.5625 4.36091 7.5625 5.5Z"
fill="#2D2D2E"
/>
<defs>
<linearGradient
id="paint0_linear_6313_760"
x1="5.5"
y1="0"
x2="5.5"
y2="11"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#D2D5DA" />
<stop offset="1" stopColor="#8B8E95" />
</linearGradient>
</defs>
</svg>
);
@@ -0,0 +1,40 @@
export const Settings = ({ className }: { className?: string }) => (
<svg
width="11"
height="11"
viewBox="0 0 11 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<g clipPath="url(#clip0_6313_731)">
<rect width="11" height="11" rx="2" fill="url(#paint0_linear_6313_731)" />
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.5 8.9375C7.39848 8.9375 8.9375 7.39848 8.9375 5.5C8.9375 3.60152 7.39848 2.0625 5.5 2.0625C3.60152 2.0625 2.0625 3.60152 2.0625 5.5C2.0625 7.39848 3.60152 8.9375 5.5 8.9375ZM5.5 10.3125C8.15787 10.3125 10.3125 8.15787 10.3125 5.5C10.3125 2.84213 8.15787 0.6875 5.5 0.6875C2.84213 0.6875 0.6875 2.84213 0.6875 5.5C0.6875 8.15787 2.84213 10.3125 5.5 10.3125Z"
fill="#2D2D2E"
/>
<path
d="M7.5625 5.5C7.5625 6.63909 6.63909 7.5625 5.5 7.5625C4.36091 7.5625 3.4375 6.63909 3.4375 5.5C3.4375 4.36091 4.36091 3.4375 5.5 3.4375C6.63909 3.4375 7.5625 4.36091 7.5625 5.5Z"
fill="#2D2D2E"
/>
</g>
<defs>
<linearGradient
id="paint0_linear_6313_731"
x1="5.5"
y1="0"
x2="5.5"
y2="11"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#D2D5DA" />
<stop offset="1" stopColor="#8B8E95" />
</linearGradient>
<clipPath id="clip0_6313_731">
<rect width="11" height="11" rx="2" fill="white" />
</clipPath>
</defs>
</svg>
);
@@ -0,0 +1,13 @@
export const Terminal = ({ className }: { className?: string }) => (
<svg
width="11"
height="11"
viewBox="0 0 11 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<rect width="11" height="11" rx="2" fill="#1C1C1E" />
<path d="M2 3L2 2L6 2L6 3L2 3Z" fill="#19FF4F" />
</svg>
);
@@ -0,0 +1,13 @@
export const Tool = ({ className }: { className?: string }) => (
<svg
width="11"
height="11"
viewBox="0 0 11 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<rect width="11" height="11" rx="2" fill="#1E1E20" />
<rect x="2" y="5" width="7" height="1" rx="0.5" fill="white" />
</svg>
);
@@ -0,0 +1,17 @@
export { Archive } from './Archive';
export { Brain } from './Brain';
export { Camera } from './Camera';
export { Code2 } from './Code2';
export { Eye } from './Eye';
export { FileEdit } from './FileEdit';
export { FilePlus } from './FilePlus';
export { FileText } from './FileText';
export { Globe } from './Globe';
export { Harddrive } from './Harddrive';
export { Monitor } from './Monitor';
export { Numbers } from './Numbers';
export { Save } from './Save';
export { Search } from './Search';
export { Settings } from './Settings';
export { Terminal } from './Terminal';
export { Tool } from './Tool';
+60
View File
@@ -0,0 +1,60 @@
import { Message, getToolRequests, getTextContent, getToolResponses } from '../types/message';
export function identifyConsecutiveToolCalls(messages: Message[]): number[][] {
const chains: number[][] = [];
let currentChain: number[] = [];
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
const toolRequests = getToolRequests(message);
const toolResponses = getToolResponses(message);
const textContent = getTextContent(message);
const hasText = textContent.trim().length > 0;
if (toolResponses.length > 0 && toolRequests.length === 0) {
continue;
}
if (toolRequests.length > 0) {
if (hasText) {
if (currentChain.length > 0) {
if (currentChain.length > 1) {
chains.push([...currentChain]);
}
}
currentChain = [i];
} else {
currentChain.push(i);
}
} else if (hasText) {
if (currentChain.length > 1) {
chains.push([...currentChain]);
}
currentChain = [];
} else {
if (currentChain.length > 1) {
chains.push([...currentChain]);
}
currentChain = [];
}
}
if (currentChain.length > 1) {
chains.push(currentChain);
}
return chains;
}
export function shouldHideMessage(messageIndex: number, chains: number[][]): boolean {
for (const chain of chains) {
if (chain.includes(messageIndex)) {
return chain[0] !== messageIndex;
}
}
return false;
}
export function getChainForMessage(messageIndex: number, chains: number[][]): number[] | null {
return chains.find(chain => chain.includes(messageIndex)) || null;
}
+142
View File
@@ -0,0 +1,142 @@
import React from 'react';
import {
Archive,
Brain,
Camera,
Code2,
Eye,
FileEdit,
FilePlus,
FileText,
Globe,
Monitor,
Numbers,
Save,
Search,
Settings,
Terminal,
Tool,
} from '../components/icons/toolcalls';
export type ToolIconProps = {
className?: string;
};
/**
* Maps tool names to their corresponding icon components
* @param toolName - The name of the tool (extracted from toolCall.name)
* @returns React component for the tool icon
*/
export const getToolIcon = (toolName: string): React.ComponentType<ToolIconProps> => {
switch (toolName) {
// Developer Extension Tools
case 'text_editor':
return FileEdit;
case 'shell':
return Terminal;
// Memory Extension Tools
case 'remember_memory':
return Save;
case 'retrieve_memories':
return Brain;
// Computer Controller Extension Tools
case 'automation_script':
return Settings;
case 'computer_control':
return Monitor;
case 'web_scrape':
return Globe;
case 'screen_capture':
return Camera;
case 'pdf_tool':
return FileText;
case 'docx_tool':
return FileText;
case 'xlsx_tool':
return Numbers;
case 'cache':
return Archive;
// File Operations
case 'search':
return Search;
case 'read':
return Eye;
case 'create_file':
return FilePlus;
case 'update_file':
return FileEdit;
// Google Workspace Tools (if still supported)
case 'sheets_tool':
return Numbers;
case 'docs_tool':
return FileText;
// Special Tools
case 'final_output':
return Tool; // Could be a checkmark icon if we had one
// Default fallback for unknown tools
default:
return Tool;
}
};
/**
* Maps extension names to their corresponding icon components
* @param extensionName - The name of the extension
* @returns React component for the extension icon
*/
export const getExtensionIcon = (extensionName: string): React.ComponentType<ToolIconProps> => {
switch (extensionName) {
case 'developer':
return Code2;
case 'memory':
return Brain;
case 'computercontroller':
return Monitor;
default:
return Tool;
}
};
/**
* Helper function to extract tool name from full tool call name
* @param toolCallName - Full tool call name (e.g., "developer__text_editor")
* @returns Extracted tool name (e.g., "text_editor")
*/
export const extractToolName = (toolCallName: string): string => {
return toolCallName.substring(toolCallName.lastIndexOf('__') + 2);
};
/**
* Helper function to extract extension name from full tool call name
* @param toolCallName - Full tool call name (e.g., "developer__text_editor")
* @returns Extracted extension name (e.g., "developer")
*/
export const extractExtensionName = (toolCallName: string): string => {
const lastIndex = toolCallName.lastIndexOf('__');
return lastIndex === -1 ? '' : toolCallName.substring(0, lastIndex);
};
/**
* Main function to get the appropriate icon for a tool call
* @param toolCallName - Full tool call name (e.g., "developer__text_editor")
* @param useExtensionIcon - Whether to use extension icon instead of tool icon
* @returns React component for the icon
*/
export const getToolCallIcon = (
toolCallName: string,
useExtensionIcon: boolean = false
): React.ComponentType<ToolIconProps> => {
if (useExtensionIcon) {
const extensionName = extractExtensionName(toolCallName);
return getExtensionIcon(extensionName);
}
const toolName = extractToolName(toolCallName);
return getToolIcon(toolName);
};