diff --git a/documentation/docs/guides/environment-variables.md b/documentation/docs/guides/environment-variables.md index da50b4cb6e93..f5cc5b1658e2 100644 --- a/documentation/docs/guides/environment-variables.md +++ b/documentation/docs/guides/environment-variables.md @@ -134,7 +134,7 @@ export GOOSE_MAX_TURNS=25 export GOOSE_MAX_TURNS=100 # Use multiple context files -export CONTEXT_FILE_NAMES='["CLAUDE.md", ".goosehints", "project_rules.txt"]' +export CONTEXT_FILE_NAMES='["CLAUDE.md", ".goosehints", ".cursorrules", "project_rules.txt"]' # Set the ANSI theme for the session export GOOSE_CLI_THEME=ansi diff --git a/documentation/docs/guides/recipes/session-recipes.md b/documentation/docs/guides/recipes/session-recipes.md index b8ca683fb69a..6324bb28e15a 100644 --- a/documentation/docs/guides/recipes/session-recipes.md +++ b/documentation/docs/guides/recipes/session-recipes.md @@ -339,7 +339,7 @@ You can turn your current Goose session into a reusable recipe that includes the **Basic Usage** - Run once and exit (see [run options](/docs/guides/goose-cli-commands#run-options) and [recipe commands](/docs/guides/goose-cli-commands#recipe) for more): ```sh - # Using recipe file in current directory or GOOSE_RECIPE_PATH directories + # Using recipe file in current directory or `GOOSE_RECIPE_PATH` directories goose run --recipe recipe.yaml # Using full path diff --git a/documentation/docs/guides/using-goosehints.md b/documentation/docs/guides/using-goosehints.md index 436068abfecb..977118f8a633 100644 --- a/documentation/docs/guides/using-goosehints.md +++ b/documentation/docs/guides/using-goosehints.md @@ -37,8 +37,8 @@ Goose supports two types of hint files: You can use both global and local hints at the same time. When both exist, Goose will consider both your global preferences and project-specific requirements. If the instructions in your local hints file conflict with your global preferences, Goose will prioritize the local hints. -:::tip Custom Context File -You can [customize context file names](#custom-context-files) using the `CONTEXT_FILE_NAMES` environment variable. +:::tip Custom Context Files +You can use other agent rule files with Goose by using the [`CONTEXT_FILE_NAMES` environment variable](#custom-context-files). ::: @@ -143,12 +143,15 @@ Here's how it works: ### Configuration -Set the `CONTEXT_FILE_NAMES` environment variable to a JSON array of filenames. If not set, it defaults to `[".goosehints"]`. +Set the `CONTEXT_FILE_NAMES` environment variable to a JSON array of filenames. The default is `[".goosehints"]`. ```bash # Single custom file export CONTEXT_FILE_NAMES='["AGENTS.md"]' -# Multiple files (loaded in order) +# Project toolchain files +export CONTEXT_FILE_NAMES='[".cursorrules", "AGENTS.md"]' + +# Multiple files export CONTEXT_FILE_NAMES='["CLAUDE.md", ".goosehints", "project_rules.txt"]' ``` diff --git a/ui/desktop/src/components/GooseMessage.tsx b/ui/desktop/src/components/GooseMessage.tsx index 31bc9ae1b5e1..edb5cfe21f3e 100644 --- a/ui/desktop/src/components/GooseMessage.tsx +++ b/ui/desktop/src/components/GooseMessage.tsx @@ -1,12 +1,13 @@ import { useEffect, useMemo, useRef } from 'react'; import LinkPreview from './LinkPreview'; import ImagePreview from './ImagePreview'; -import GooseResponseForm from './GooseResponseForm'; import { extractUrls } from '../utils/urlUtils'; 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, @@ -35,39 +36,21 @@ interface GooseMessageProps { export default function GooseMessage({ messageHistoryIndex, message, - metadata, messages, + metadata: _metadata, toolCallNotifications, - append, + append: _append, appendMessage, isStreaming = false, }: GooseMessageProps) { - const contentRef = useRef(null); - // Track which tool confirmations we've already handled to prevent infinite loops + const contentRef = useRef(null); const handledToolConfirmations = useRef>(new Set()); - // Extract text content from the message - let textContent = getTextContent(message); - - // Utility to split Chain-of-Thought (CoT) from the visible assistant response. - // If the text contains a ... block, everything inside is treated as the - // CoT and removed from the user-visible text. - const splitChainOfThought = (text: string): { visibleText: string; cotText: string | null } => { - const regex = /([\s\S]*?)<\/think>/i; - const match = text.match(regex); - if (!match) { - return { visibleText: text, cotText: null }; - } - - const cotRaw = match[1].trim(); - const visible = text.replace(match[0], '').trim(); - return { visibleText: visible, cotText: cotRaw.length > 0 ? cotRaw : null }; - }; + // Extract image paths from the message content + const imagePaths = extractImagePaths(getTextContent(message)); - const { visibleText: textWithoutCot, cotText } = splitChainOfThought(textContent); - - // Extract image paths from the visible part of the message (exclude CoT) - const imagePaths = extractImagePaths(textWithoutCot); + // Get text content without Chain of Thought + const textWithoutCot = getTextContent(message); // Remove image paths from text for display const displayText = @@ -79,11 +62,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) : []; @@ -132,7 +157,10 @@ export default function GooseMessage({ handledToolConfirmations.current.add(toolConfirmationContent.id); appendMessage( - createToolErrorResponseMessage(toolConfirmationContent.id, 'The tool call is cancelled.') + createToolErrorResponseMessage( + toolConfirmationContent.id, + 'Tool execution was cancelled or interrupted.' + ) ); } } @@ -145,76 +173,96 @@ export default function GooseMessage({ appendMessage, ]); - return ( -
-
- {/* Chain-of-Thought (hidden by default) */} - {cotText && ( -
- - Show thinking - -
- -
-
- )} + // 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; - {/* Visible assistant response */} + return ( +
+
+ {/* Regular text content - ALWAYS show if present */} {displayText && ( -
-
-
{}
+
+
+
- {/* Render images if any */} + {/* Image previews */} {imagePaths.length > 0 && ( -
+
{imagePaths.map((imagePath, index) => ( - + ))}
)} - {/* Only show timestamp and copy link when not streaming */} -
- {toolRequests.length === 0 && !isStreaming && ( -
- {timestamp} -
- )} - {displayText && - message.content.every((content) => content.type === 'text') && - !isStreaming && ( + {/* URLs */} + {urls.length > 0 && ( +
+ {urls.map((url, index) => ( + + ))} +
+ )} + + {/* Show timestamp for text-only messages */} + {toolRequests.length === 0 && ( +
+ {!isStreaming && ( +
+ {timestamp} +
+ )} + {message.content.every((content) => content.type === 'text') && !isStreaming && (
)} -
+
+ )}
)} + {/* Tool calls - either as chain or individual */} {toolRequests.length > 0 && ( -
- {toolRequests.map((toolRequest) => ( -
- + <> + {isFirstInChain ? ( + // This is the first message in a chain - render the entire chain + + ) : !messageChain ? ( + // This message is not part of any chain - render individual tool calls +
+ {toolRequests.map((toolRequest) => ( +
+ +
+ ))} +
+ {!isStreaming && timestamp} +
- ))} -
- {!isStreaming && timestamp} -
-
+ ) : null} + )} {hasToolConfirmation && ( @@ -226,25 +274,6 @@ export default function GooseMessage({ /> )}
- - {/* TODO(alexhancock): Re-enable link previews once styled well again */} - {/* eslint-disable-next-line no-constant-binary-expression */} - {false && urls.length > 0 && ( -
- {urls.map((url, index) => ( - - ))} -
- )} - - {/* enable or disable prompts here */} - {/* NOTE from alexhancock on 1/14/2025 - disabling again temporarily due to non-determinism in when the forms show up */} - {/* eslint-disable-next-line no-constant-binary-expression */} - {false && metadata && ( -
- -
- )}
); } diff --git a/ui/desktop/src/components/GooseMessage.tsx.backup b/ui/desktop/src/components/GooseMessage.tsx.backup new file mode 100644 index 000000000000..31bc9ae1b5e1 --- /dev/null +++ b/ui/desktop/src/components/GooseMessage.tsx.backup @@ -0,0 +1,250 @@ +import { useEffect, useMemo, useRef } from 'react'; +import LinkPreview from './LinkPreview'; +import ImagePreview from './ImagePreview'; +import GooseResponseForm from './GooseResponseForm'; +import { extractUrls } from '../utils/urlUtils'; +import { extractImagePaths, removeImagePathsFromText } from '../utils/imageUtils'; +import { formatMessageTimestamp } from '../utils/timeUtils'; +import MarkdownContent from './MarkdownContent'; +import ToolCallWithResponse from './ToolCallWithResponse'; +import { + Message, + getTextContent, + getToolRequests, + getToolResponses, + getToolConfirmationContent, + createToolErrorResponseMessage, +} from '../types/message'; +import ToolCallConfirmation from './ToolCallConfirmation'; +import MessageCopyLink from './MessageCopyLink'; +import { NotificationEvent } from '../hooks/useMessageStream'; + +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 + // anything before this index should not render any buttons, but anything after should + messageHistoryIndex: number; + message: Message; + messages: Message[]; + metadata?: string[]; + toolCallNotifications: Map; + append: (value: string) => void; + appendMessage: (message: Message) => void; + isStreaming?: boolean; // Whether this message is currently being streamed +} + +export default function GooseMessage({ + messageHistoryIndex, + message, + metadata, + messages, + toolCallNotifications, + append, + appendMessage, + isStreaming = false, +}: GooseMessageProps) { + const contentRef = useRef(null); + // Track which tool confirmations we've already handled to prevent infinite loops + const handledToolConfirmations = useRef>(new Set()); + + // Extract text content from the message + let textContent = getTextContent(message); + + // Utility to split Chain-of-Thought (CoT) from the visible assistant response. + // If the text contains a ... block, everything inside is treated as the + // CoT and removed from the user-visible text. + const splitChainOfThought = (text: string): { visibleText: string; cotText: string | null } => { + const regex = /([\s\S]*?)<\/think>/i; + const match = text.match(regex); + if (!match) { + return { visibleText: text, cotText: null }; + } + + const cotRaw = match[1].trim(); + const visible = text.replace(match[0], '').trim(); + return { visibleText: visible, cotText: cotRaw.length > 0 ? cotRaw : null }; + }; + + const { visibleText: textWithoutCot, cotText } = splitChainOfThought(textContent); + + // Extract image paths from the visible part of the message (exclude CoT) + const imagePaths = extractImagePaths(textWithoutCot); + + // Remove image paths from text for display + const displayText = + imagePaths.length > 0 ? removeImagePathsFromText(textWithoutCot, imagePaths) : textWithoutCot; + + // Memoize the timestamp + const timestamp = useMemo(() => formatMessageTimestamp(message.created), [message.created]); + + // Get tool requests from the message + const toolRequests = getToolRequests(message); + + // 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) : []; + + const toolConfirmationContent = getToolConfirmationContent(message); + const hasToolConfirmation = toolConfirmationContent !== undefined; + + // Find tool responses that correspond to the tool requests in this message + const toolResponsesMap = useMemo(() => { + const responseMap = new Map(); + + // Look for tool responses in subsequent messages + if (messageIndex !== undefined && messageIndex >= 0) { + for (let i = messageIndex + 1; i < messages.length; i++) { + const responses = getToolResponses(messages[i]); + + for (const response of responses) { + // Check if this response matches any of our tool requests + const matchingRequest = toolRequests.find((req) => req.id === response.id); + if (matchingRequest) { + responseMap.set(response.id, response); + } + } + } + } + + return responseMap; + }, [messages, messageIndex, toolRequests]); + + useEffect(() => { + // If the message is the last message in the resumed session and has tool confirmation, it means the tool confirmation + // is broken or cancelled, to contonue use the session, we need to append a tool response to avoid mismatch tool result error. + if ( + messageIndex === messageHistoryIndex - 1 && + hasToolConfirmation && + toolConfirmationContent && + !handledToolConfirmations.current.has(toolConfirmationContent.id) + ) { + // Only append the error message if there isn't already a response for this tool confirmation + const hasExistingResponse = messages.some((msg) => + getToolResponses(msg).some((response) => response.id === toolConfirmationContent.id) + ); + + if (!hasExistingResponse) { + // Mark this tool confirmation as handled to prevent infinite loop + handledToolConfirmations.current.add(toolConfirmationContent.id); + + appendMessage( + createToolErrorResponseMessage(toolConfirmationContent.id, 'The tool call is cancelled.') + ); + } + } + }, [ + messageIndex, + messageHistoryIndex, + hasToolConfirmation, + toolConfirmationContent, + messages, + appendMessage, + ]); + + return ( +
+
+ {/* Chain-of-Thought (hidden by default) */} + {cotText && ( +
+ + Show thinking + +
+ +
+
+ )} + + {/* Visible assistant response */} + {displayText && ( +
+
+
{}
+
+ + {/* Render images if any */} + {imagePaths.length > 0 && ( +
+ {imagePaths.map((imagePath, index) => ( + + ))} +
+ )} + + {/* Only show timestamp and copy link when not streaming */} +
+ {toolRequests.length === 0 && !isStreaming && ( +
+ {timestamp} +
+ )} + {displayText && + message.content.every((content) => content.type === 'text') && + !isStreaming && ( +
+ +
+ )} +
+
+ )} + + {toolRequests.length > 0 && ( +
+ {toolRequests.map((toolRequest) => ( +
+ +
+ ))} +
+ {!isStreaming && timestamp} +
+
+ )} + + {hasToolConfirmation && ( + + )} +
+ + {/* TODO(alexhancock): Re-enable link previews once styled well again */} + {/* eslint-disable-next-line no-constant-binary-expression */} + {false && urls.length > 0 && ( +
+ {urls.map((url, index) => ( + + ))} +
+ )} + + {/* enable or disable prompts here */} + {/* NOTE from alexhancock on 1/14/2025 - disabling again temporarily due to non-determinism in when the forms show up */} + {/* eslint-disable-next-line no-constant-binary-expression */} + {false && metadata && ( +
+ +
+ )} +
+ ); +} diff --git a/ui/desktop/src/components/ToolCallChain.tsx b/ui/desktop/src/components/ToolCallChain.tsx new file mode 100644 index 000000000000..ec5785d627aa --- /dev/null +++ b/ui/desktop/src/components/ToolCallChain.tsx @@ -0,0 +1,63 @@ +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; + toolResponsesMap: Map; + messageHistoryIndex: number; + isStreaming?: boolean; +} + +/** + * Component that renders a chain of consecutive tool call messages with a single timestamp + */ +export default function ToolCallChain({ + messages, + chainIndices, + toolCallNotifications, + toolResponsesMap, + messageHistoryIndex, + isStreaming = false +}: ToolCallChainProps) { + // Get the timestamp from the last message in the chain + const lastMessageIndex = chainIndices[chainIndices.length - 1]; + const lastMessage = messages[lastMessageIndex]; + const timestamp = lastMessage ? formatMessageTimestamp(lastMessage.created) : ''; + + return ( +
+ {/* Render each message's tool calls in the chain */} + {chainIndices.map((messageIndex) => { + const message = messages[messageIndex]; + const toolRequests = getToolRequests(message); + + return toolRequests.map((toolRequest) => ( +
+ +
+ )); + })} + + {/* Single timestamp for the entire chain */} +
+ {!isStreaming && timestamp} +
+
+ ); +} diff --git a/ui/desktop/src/components/ToolCallStatusIndicator.tsx b/ui/desktop/src/components/ToolCallStatusIndicator.tsx new file mode 100644 index 000000000000..7477e5fb0ec7 --- /dev/null +++ b/ui/desktop/src/components/ToolCallStatusIndicator.tsx @@ -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 = ({ + 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 ( +
+ ); +}; + +/** + * 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 = ({ + ToolIcon, + status, + className, +}) => { + return ( +
+ + +
+ ); +}; diff --git a/ui/desktop/src/components/ToolCallWithResponse.tsx b/ui/desktop/src/components/ToolCallWithResponse.tsx index 0565d56b8c97..f91c4d39412c 100644 --- a/ui/desktop/src/components/ToolCallWithResponse.tsx +++ b/ui/desktop/src/components/ToolCallWithResponse.tsx @@ -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'; @@ -439,36 +441,44 @@ 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 = ( - - {getToolLabelContent()} + + + {getToolLabelContent()} ); - return ( -
- {loadingStatus === 'loading' ? ( - - ) : ( - - )} -
- {extensionTooltip ? ( - - {toolLabel} - - ) : ( - toolLabel - )} - - } - > + extensionTooltip ? ( + + {toolLabel} + + ) : ( + toolLabel + ) + } > {/* Tool Details */} {isToolDetails && (
diff --git a/ui/desktop/src/components/icons/toolcalls/Archive.tsx b/ui/desktop/src/components/icons/toolcalls/Archive.tsx new file mode 100644 index 000000000000..5fe09b6bdcd6 --- /dev/null +++ b/ui/desktop/src/components/icons/toolcalls/Archive.tsx @@ -0,0 +1,17 @@ +export const Archive = ({ className }: { className?: string }) => ( + + + + + + + + +); diff --git a/ui/desktop/src/components/icons/toolcalls/Brain.tsx b/ui/desktop/src/components/icons/toolcalls/Brain.tsx new file mode 100644 index 000000000000..1fb37959d446 --- /dev/null +++ b/ui/desktop/src/components/icons/toolcalls/Brain.tsx @@ -0,0 +1,20 @@ +export const Brain = ({ className }: { className?: string }) => ( + + + + + + + + + + + +); diff --git a/ui/desktop/src/components/icons/toolcalls/Camera.tsx b/ui/desktop/src/components/icons/toolcalls/Camera.tsx new file mode 100644 index 000000000000..885634d69656 --- /dev/null +++ b/ui/desktop/src/components/icons/toolcalls/Camera.tsx @@ -0,0 +1,20 @@ +export const Camera = ({ className }: { className?: string }) => ( + + + + + + + + + + + +); diff --git a/ui/desktop/src/components/icons/toolcalls/Code2.tsx b/ui/desktop/src/components/icons/toolcalls/Code2.tsx new file mode 100644 index 000000000000..5b9b86eaf0ba --- /dev/null +++ b/ui/desktop/src/components/icons/toolcalls/Code2.tsx @@ -0,0 +1,13 @@ +export const Code2 = ({ className }: { className?: string }) => ( + + + + +); diff --git a/ui/desktop/src/components/icons/toolcalls/Eye.tsx b/ui/desktop/src/components/icons/toolcalls/Eye.tsx new file mode 100644 index 000000000000..bf4a99597666 --- /dev/null +++ b/ui/desktop/src/components/icons/toolcalls/Eye.tsx @@ -0,0 +1,20 @@ +export const Eye = ({ className }: { className?: string }) => ( + + + + + + + + + + + +); diff --git a/ui/desktop/src/components/icons/toolcalls/FileEdit.tsx b/ui/desktop/src/components/icons/toolcalls/FileEdit.tsx new file mode 100644 index 000000000000..62bb00926b82 --- /dev/null +++ b/ui/desktop/src/components/icons/toolcalls/FileEdit.tsx @@ -0,0 +1,20 @@ +export const FileEdit = ({ className }: { className?: string }) => ( + + + + + + + + + + + +); diff --git a/ui/desktop/src/components/icons/toolcalls/FilePlus.tsx b/ui/desktop/src/components/icons/toolcalls/FilePlus.tsx new file mode 100644 index 000000000000..7b2882e13b00 --- /dev/null +++ b/ui/desktop/src/components/icons/toolcalls/FilePlus.tsx @@ -0,0 +1,20 @@ +export const FilePlus = ({ className }: { className?: string }) => ( + + + + + + + + + + + +); diff --git a/ui/desktop/src/components/icons/toolcalls/FileText.tsx b/ui/desktop/src/components/icons/toolcalls/FileText.tsx new file mode 100644 index 000000000000..b44516152eeb --- /dev/null +++ b/ui/desktop/src/components/icons/toolcalls/FileText.tsx @@ -0,0 +1,19 @@ +export const FileText = ({ className }: { className?: string }) => ( + + + + + + + + + + +); diff --git a/ui/desktop/src/components/icons/toolcalls/Globe.tsx b/ui/desktop/src/components/icons/toolcalls/Globe.tsx new file mode 100644 index 000000000000..c54a82c889ff --- /dev/null +++ b/ui/desktop/src/components/icons/toolcalls/Globe.tsx @@ -0,0 +1,24 @@ +export const Globe = ({ className }: { className?: string }) => ( + + + + + + + + + + + + + + + +); diff --git a/ui/desktop/src/components/icons/toolcalls/Harddrive.tsx b/ui/desktop/src/components/icons/toolcalls/Harddrive.tsx new file mode 100644 index 000000000000..a980bbb7095f --- /dev/null +++ b/ui/desktop/src/components/icons/toolcalls/Harddrive.tsx @@ -0,0 +1,13 @@ +export const Harddrive = ({ className }: { className?: string }) => ( + + + + +); diff --git a/ui/desktop/src/components/icons/toolcalls/Monitor.tsx b/ui/desktop/src/components/icons/toolcalls/Monitor.tsx new file mode 100644 index 000000000000..cffe658f3177 --- /dev/null +++ b/ui/desktop/src/components/icons/toolcalls/Monitor.tsx @@ -0,0 +1,13 @@ +export const Monitor = ({ className }: { className?: string }) => ( + + + + +); diff --git a/ui/desktop/src/components/icons/toolcalls/Numbers.tsx b/ui/desktop/src/components/icons/toolcalls/Numbers.tsx new file mode 100644 index 000000000000..50194bd5e854 --- /dev/null +++ b/ui/desktop/src/components/icons/toolcalls/Numbers.tsx @@ -0,0 +1,21 @@ +export const Numbers = ({ className }: { className?: string }) => ( + + + + + + + + + + + + +); diff --git a/ui/desktop/src/components/icons/toolcalls/Save.tsx b/ui/desktop/src/components/icons/toolcalls/Save.tsx new file mode 100644 index 000000000000..5bbf26956c34 --- /dev/null +++ b/ui/desktop/src/components/icons/toolcalls/Save.tsx @@ -0,0 +1,13 @@ +export const Save = ({ className }: { className?: string }) => ( + + + + +); diff --git a/ui/desktop/src/components/icons/toolcalls/Search.tsx b/ui/desktop/src/components/icons/toolcalls/Search.tsx new file mode 100644 index 000000000000..0de1790198a8 --- /dev/null +++ b/ui/desktop/src/components/icons/toolcalls/Search.tsx @@ -0,0 +1,19 @@ +export const Search = ({ className }: { className?: string }) => ( + + + + + + + + + + +); diff --git a/ui/desktop/src/components/icons/toolcalls/Settings.tsx b/ui/desktop/src/components/icons/toolcalls/Settings.tsx new file mode 100644 index 000000000000..e2910ddb124d --- /dev/null +++ b/ui/desktop/src/components/icons/toolcalls/Settings.tsx @@ -0,0 +1,25 @@ +export const Settings = ({ className }: { className?: string }) => ( + + + + + + + + + + + + + + + + +); diff --git a/ui/desktop/src/components/icons/toolcalls/Terminal.tsx b/ui/desktop/src/components/icons/toolcalls/Terminal.tsx new file mode 100644 index 000000000000..9b9996a36e4e --- /dev/null +++ b/ui/desktop/src/components/icons/toolcalls/Terminal.tsx @@ -0,0 +1,13 @@ +export const Terminal = ({ className }: { className?: string }) => ( + + + + +); diff --git a/ui/desktop/src/components/icons/toolcalls/Tool.tsx b/ui/desktop/src/components/icons/toolcalls/Tool.tsx new file mode 100644 index 000000000000..31c46ff67868 --- /dev/null +++ b/ui/desktop/src/components/icons/toolcalls/Tool.tsx @@ -0,0 +1,13 @@ +export const Tool = ({ className }: { className?: string }) => ( + + + + +); diff --git a/ui/desktop/src/components/icons/toolcalls/index.tsx b/ui/desktop/src/components/icons/toolcalls/index.tsx new file mode 100644 index 000000000000..c77ccff58941 --- /dev/null +++ b/ui/desktop/src/components/icons/toolcalls/index.tsx @@ -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'; diff --git a/ui/desktop/src/utils/toolCallChaining.ts b/ui/desktop/src/utils/toolCallChaining.ts new file mode 100644 index 000000000000..2959f7f88e67 --- /dev/null +++ b/ui/desktop/src/utils/toolCallChaining.ts @@ -0,0 +1,87 @@ +import { Message, getToolRequests, getTextContent, getToolResponses } from '../types/message'; + +/** + * Enhanced function to detect tool call chains including mixed content messages + * @param messages - Array of all messages + * @returns Array of message indices that should be chained together + */ +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; + + // Skip tool response messages - they don't break chains + if (toolResponses.length > 0 && toolRequests.length === 0) { + continue; + } + + // This message has tool calls + if (toolRequests.length > 0) { + if (hasText) { + // Message with text + tools - start a new chain or add to existing + if (currentChain.length > 0) { + // End current chain and start new one + if (currentChain.length > 1) { + chains.push([...currentChain]); + } + } + // Start new chain with this mixed content message + currentChain = [i]; + } else { + // Pure tool call message - add to chain + currentChain.push(i); + } + } else if (hasText) { + // Pure text message - end current chain + if (currentChain.length > 1) { + chains.push([...currentChain]); + } + currentChain = []; + } else { + // Empty or other content - end current chain + if (currentChain.length > 1) { + chains.push([...currentChain]); + } + currentChain = []; + } + } + + // Don't forget the last chain + if (currentChain.length > 1) { + chains.push(currentChain); + } + + return chains; +} + +/** + * Check if a message at given index should be hidden (part of chain but not first) + * @param messageIndex - Index of the message to check + * @param chains - Array of chains (arrays of message indices) + * @returns True if message should be hidden + */ +export function shouldHideMessage(messageIndex: number, chains: number[][]): boolean { + for (const chain of chains) { + if (chain.includes(messageIndex)) { + // Hide if it's in a chain but not the first message + return chain[0] !== messageIndex; + } + } + return false; +} + +/** + * Get the chain that contains the given message index + * @param messageIndex - Index of the message + * @param chains - Array of chains + * @returns The chain containing this message, or null + */ +export function getChainForMessage(messageIndex: number, chains: number[][]): number[] | null { + return chains.find(chain => chain.includes(messageIndex)) || null; +} diff --git a/ui/desktop/src/utils/toolIconMapping.tsx b/ui/desktop/src/utils/toolIconMapping.tsx new file mode 100644 index 000000000000..751913981a07 --- /dev/null +++ b/ui/desktop/src/utils/toolIconMapping.tsx @@ -0,0 +1,143 @@ +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 => { + 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 => { + 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 => { + if (useExtensionIcon) { + const extensionName = extractExtensionName(toolCallName); + return getExtensionIcon(extensionName); + } + + const toolName = extractToolName(toolCallName); + return getToolIcon(toolName); +}; diff --git a/ui/desktop/tests/e2e/app.spec.ts b/ui/desktop/tests/e2e/app.spec.ts index 3b5ce2b91cb6..d545c0f80feb 100644 --- a/ui/desktop/tests/e2e/app.spec.ts +++ b/ui/desktop/tests/e2e/app.spec.ts @@ -100,7 +100,7 @@ async function selectProvider(mainWindow: any, provider: Provider) { const root = document.getElementById('root'); return root && root.children.length > 0; }); - await mainWindow.waitForTimeout(2000); + await mainWindow.waitForTimeout(10000); // Take a screenshot before proceeding await mainWindow.screenshot({ path: `test-results/before-provider-${provider.name.toLowerCase()}-check.png` }); @@ -155,7 +155,7 @@ async function selectProvider(mainWindow: any, provider: Provider) { // Wait for chat interface to appear const chatTextareaAfterClick = await mainWindow.waitForSelector('[data-testid="chat-input"]', - { timeout: 2000 }); + { timeout: 10000 }); expect(await chatTextareaAfterClick.isVisible()).toBe(true); // Take screenshot of chat interface