diff --git a/components/Chat/MarkdownMessage.tsx b/components/Chat/MarkdownMessage.tsx index 9a86546..6f1a1c3 100644 --- a/components/Chat/MarkdownMessage.tsx +++ b/components/Chat/MarkdownMessage.tsx @@ -18,7 +18,7 @@ import { Text, Title, } from '@mantine/core'; -import { ToolCallDisplay } from './ToolCallDisplay'; +import { ToolCall, ToolCallGroup } from './ToolCallDisplay'; import classes from './MarkdownMessage.module.css'; // Import KaTeX CSS for LaTeX rendering import 'katex/dist/katex.min.css'; @@ -93,6 +93,47 @@ function parseContentWithToolCalls(content: string): ContentSegment[] { return segments; } +/** + * Grouped segment type - either text or a group of consecutive tool calls + */ +interface GroupedSegment { + type: 'text' | 'toolGroup'; + content?: string; + toolCalls?: ToolCall[]; +} + +/** + * Group consecutive tool call segments together + */ +function groupConsecutiveToolCalls(segments: ContentSegment[]): GroupedSegment[] { + const grouped: GroupedSegment[] = []; + let currentToolGroup: ToolCall[] = []; + + for (const segment of segments) { + if (segment.type === 'tool' && segment.toolCall) { + // Add to current tool group + currentToolGroup.push(segment.toolCall); + } else { + // Flush any pending tool group before adding text + if (currentToolGroup.length > 0) { + grouped.push({ type: 'toolGroup', toolCalls: currentToolGroup }); + currentToolGroup = []; + } + // Add text segment + if (segment.content) { + grouped.push({ type: 'text', content: segment.content }); + } + } + } + + // Flush any remaining tool group + if (currentToolGroup.length > 0) { + grouped.push({ type: 'toolGroup', toolCalls: currentToolGroup }); + } + + return grouped; +} + /** * Hook that tracks content changes and returns content with fade-in markers for new text */ @@ -334,21 +375,15 @@ export function MarkdownMessage({ content, isStreaming = false }: MarkdownMessag ); } - // When not streaming, parse and render tool calls + // When not streaming, parse and render tool calls (grouped) const segments = parseContentWithToolCalls(content); + const groupedSegments = groupConsecutiveToolCalls(segments); return (
- {segments.map((segment, index) => { - if (segment.type === 'tool' && segment.toolCall) { - return ( - - ); + {groupedSegments.map((segment, index) => { + if (segment.type === 'toolGroup' && segment.toolCalls) { + return ; } return ; })} diff --git a/components/Chat/ToolCallDisplay.module.css b/components/Chat/ToolCallDisplay.module.css index 2100a25..6b3ea55 100644 --- a/components/Chat/ToolCallDisplay.module.css +++ b/components/Chat/ToolCallDisplay.module.css @@ -27,3 +27,45 @@ max-height: 200px; overflow-y: auto; } + +/* Group container for multiple tool calls */ +.groupContainer { + background-color: var(--mantine-color-default-hover); + overflow: hidden; +} + +.groupHeader { + transition: background-color 0.15s ease; +} + +.groupHeader:hover { + background-color: var(--mantine-color-default-hover); +} + +.groupContent { + padding: var(--mantine-spacing-xs); + border-top: 1px solid var(--mantine-color-default-border); +} + +/* Nested tool call inside group */ +.nestedContainer { + background-color: var(--mantine-color-body); + overflow: hidden; +} + +.nestedContainer:not(:last-child) { + margin-bottom: var(--mantine-spacing-xs); +} + +.nestedHeader { + transition: background-color 0.15s ease; +} + +.nestedHeader:hover { + background-color: var(--mantine-color-default-hover); +} + +.nestedContent { + padding: 0 var(--mantine-spacing-xs) var(--mantine-spacing-xs); + border-top: 1px solid var(--mantine-color-default-border); +} diff --git a/components/Chat/ToolCallDisplay.tsx b/components/Chat/ToolCallDisplay.tsx index 43eede2..cffded2 100644 --- a/components/Chat/ToolCallDisplay.tsx +++ b/components/Chat/ToolCallDisplay.tsx @@ -6,10 +6,21 @@ import { ActionIcon, Code, Collapse, Group, Paper, Text, useMantineTheme } from import { useThemeContext } from '@/components/DynamicThemeProvider'; import classes from './ToolCallDisplay.module.css'; +export interface ToolCall { + toolName: string; + args: Record; + result: string; +} + interface ToolCallDisplayProps { toolName: string; args: Record; result: string; + nested?: boolean; +} + +interface ToolCallGroupProps { + toolCalls: ToolCall[]; } // Friendly tool names @@ -25,12 +36,19 @@ const toolDisplayNames: Record = { generate_image: 'Image Generation', }; -export function ToolCallDisplay({ toolName, args, result }: ToolCallDisplayProps) { +function getDisplayName(toolName: string): string { + return toolDisplayNames[toolName] || toolName; +} + +/** + * Display a single tool call (can be standalone or nested inside a group) + */ +export function ToolCallDisplay({ toolName, args, result, nested = false }: ToolCallDisplayProps) { const [opened, setOpened] = useState(false); const { primaryColor } = useThemeContext(); const theme = useMantineTheme(); - const displayName = toolDisplayNames[toolName] || toolName; + const displayName = getDisplayName(toolName); const isError = result.startsWith('Error:'); // Format args for display @@ -38,10 +56,14 @@ export function ToolCallDisplay({ toolName, args, result }: ToolCallDisplayProps .map(([key, value]) => `${key}: ${JSON.stringify(value)}`) .join(', '); + const containerClass = nested ? classes.nestedContainer : classes.container; + const headerClass = nested ? classes.nestedHeader : classes.header; + const contentClass = nested ? classes.nestedContent : classes.content; + return ( - + setOpened(!opened)} gap="xs" wrap="nowrap" @@ -63,7 +85,7 @@ export function ToolCallDisplay({ toolName, args, result }: ToolCallDisplayProps -
+
{Object.keys(args).length > 0 && (
@@ -87,3 +109,68 @@ export function ToolCallDisplay({ toolName, args, result }: ToolCallDisplayProps ); } + +/** + * Display a group of consecutive tool calls in a single collapsible container + */ +export function ToolCallGroup({ toolCalls }: ToolCallGroupProps) { + const [opened, setOpened] = useState(false); + const { primaryColor } = useThemeContext(); + const theme = useMantineTheme(); + + if (toolCalls.length === 0) { + return null; + } + + // If only one tool call, render it directly without the group wrapper + if (toolCalls.length === 1) { + const tc = toolCalls[0]; + return ; + } + + // Get summary of tool names + const toolNames = toolCalls.map((tc) => getDisplayName(tc.toolName)); + const uniqueTools = Array.from(new Set(toolNames)); + const summary = + uniqueTools.length <= 2 + ? uniqueTools.join(', ') + : `${uniqueTools.slice(0, 2).join(', ')} +${uniqueTools.length - 2} more`; + + return ( + + setOpened(!opened)} + gap="xs" + wrap="nowrap" + p="xs" + style={{ cursor: 'pointer' }} + > + + {opened ? : } + + + + {toolCalls.length} Tool Calls + + + {summary} + + + + +
+ {toolCalls.map((tc, index) => ( + + ))} +
+
+
+ ); +}