From 4ca03daa4cf51d4ec06f9073e515baaf0c2356dc Mon Sep 17 00:00:00 2001 From: Gagancreates Date: Tue, 28 Apr 2026 20:10:13 +0530 Subject: [PATCH] feat: group consecutive tool calls into collapsible summary Consecutive plain tool calls are now grouped into a single collapsible row instead of rendering as individual items. - Header shows the currently-executing tool name live with a vertical ticker animation, then switches to "Ran N tools" on completion - Expanding the group reveals each tool call individually collapsible - Tool calls with pending permission requests render individually - Special cards (web search, composio connect, app actions) excluded --- apps/x/apps/renderer/src/App.tsx | 19 +++- .../src/components/ai-elements/tool.tsx | 89 +++++++++++++++++++ .../renderer/src/components/chat-sidebar.tsx | 19 +++- .../renderer/src/lib/chat-conversation.ts | 66 ++++++++++++++ 4 files changed, 189 insertions(+), 4 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 67f3f06a..0e925e2e 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -35,7 +35,7 @@ import { import { Shimmer } from '@/components/ai-elements/shimmer'; import { useSmoothedText } from './hooks/useSmoothedText'; -import { Tool, ToolContent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool'; +import { Tool, ToolContent, ToolGroupComponent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool'; import { WebSearchResult } from '@/components/ai-elements/web-search-result'; import { AppActionCard } from '@/components/ai-elements/app-action-card'; import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'; @@ -76,10 +76,12 @@ import { getAppActionCardData, getComposioConnectCardData, getToolDisplayName, + groupConversationItems, inferRunTitleFromMessage, isChatMessage, isErrorMessage, isToolCall, + isToolGroup, normalizeToolInput, normalizeToolOutput, parseAttachedFiles, @@ -4578,7 +4580,20 @@ function App() { ) : ( <> - {tabState.conversation.map(item => { + {groupConversationItems( + tabState.conversation, + (id) => !!tabState.allPermissionRequests.get(id) + ).map(item => { + if (isToolGroup(item)) { + return ( + isToolOpenForTab(tab.id, toolId)} + onToolOpenChange={(toolId, open) => setToolOpenForTab(tab.id, toolId, open)} + /> + ) + } const rendered = renderConversationItem(item, tab.id) if (isToolCall(item)) { const permRequest = tabState.allPermissionRequests.get(item.id) diff --git a/apps/x/apps/renderer/src/components/ai-elements/tool.tsx b/apps/x/apps/renderer/src/components/ai-elements/tool.tsx index 66feb1c6..5f65fa32 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/tool.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/tool.tsx @@ -17,6 +17,9 @@ import { XCircleIcon, } from "lucide-react"; import { type ComponentProps, type ReactNode, isValidElement, useState } from "react"; +import { AnimatePresence, motion } from "motion/react"; +import type { ToolCall, ToolGroup as ToolGroupType } from "@/lib/chat-conversation"; +import { getToolDisplayName, getToolGroupSummary, toToolState } from "@/lib/chat-conversation"; const formatToolValue = (value: unknown) => { if (typeof value === "string") return value; @@ -224,3 +227,89 @@ export const ToolTabbedContent = ({ ); }; + +export type ToolGroupProps = { + group: ToolGroupType + isToolOpen: (toolId: string) => boolean + onToolOpenChange: (toolId: string, open: boolean) => void +} + +const getGroupState = (tools: ToolCall[]): ToolUIPart["state"] => { + if (tools.some(t => t.status === 'error')) return 'output-error' + if (tools.some(t => t.status === 'running')) return 'input-available' + if (tools.some(t => t.status === 'pending')) return 'input-streaming' + return 'output-available' +} + +export const ToolGroupComponent = ({ group, isToolOpen, onToolOpenChange }: ToolGroupProps) => { + const [open, setOpen] = useState(false) + const state = getGroupState(group.items) + const isCompleted = state === 'output-available' || state === 'output-error' + const runningTool = group.items.find(t => t.status === 'running' || t.status === 'pending') + const currentTool = runningTool ?? group.items[group.items.length - 1] + const summary = isCompleted + ? `Ran ${group.items.length} tool${group.items.length !== 1 ? 's' : ''}` + : currentTool ? getToolDisplayName(currentTool) : getToolGroupSummary(group.items) + + return ( + + +
+ +
+ + + {summary} + + +
+
+
+ {getStatusBadge(state)} + +
+
+ +
+ {group.items.map((tool) => { + const toolState = toToolState(tool.status) + const isOpen = isToolOpen(tool.id) + return ( + onToolOpenChange(tool.id, o)} + className="mb-0 border-border/60" + > + + + + + + ) + })} +
+
+
+ ) +} diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 0a407d5d..07f1b637 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -16,7 +16,7 @@ import { MessageResponse, } from '@/components/ai-elements/message' import { Shimmer } from '@/components/ai-elements/shimmer' -import { Tool, ToolContent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool' +import { Tool, ToolContent, ToolGroupComponent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool' import { WebSearchResult } from '@/components/ai-elements/web-search-result' import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card' import { PermissionRequest } from '@/components/ai-elements/permission-request' @@ -40,9 +40,11 @@ import { getWebSearchCardData, getComposioConnectCardData, getToolDisplayName, + groupConversationItems, isChatMessage, isErrorMessage, isToolCall, + isToolGroup, normalizeToolInput, normalizeToolOutput, parseAttachedFiles, @@ -591,7 +593,20 @@ export function ChatSidebar({ ) : ( <> - {tabState.conversation.map((item) => { + {groupConversationItems( + tabState.conversation, + (id) => !!tabState.allPermissionRequests.get(id) + ).map((item) => { + if (isToolGroup(item)) { + return ( + isToolOpenForTab?.(tab.id, toolId) ?? false} + onToolOpenChange={(toolId, open) => onToolOpenChangeForTab?.(tab.id, toolId, open)} + /> + ) + } const rendered = renderConversationItem(item, tab.id) if (isToolCall(item) && onPermissionResponse) { const permRequest = tabState.allPermissionRequests.get(item.id) diff --git a/apps/x/apps/renderer/src/lib/chat-conversation.ts b/apps/x/apps/renderer/src/lib/chat-conversation.ts index 693961c9..150edacb 100644 --- a/apps/x/apps/renderer/src/lib/chat-conversation.ts +++ b/apps/x/apps/renderer/src/lib/chat-conversation.ts @@ -586,6 +586,72 @@ export const getComposioActionCardData = (tool: ToolCall): ComposioActionCardDat return null } +export type ToolGroup = { + type: 'tool-group' + items: ToolCall[] + groupId: string +} + +export type GroupedConversationItem = ConversationItem | ToolGroup + +export const isToolGroup = (item: GroupedConversationItem): item is ToolGroup => + 'type' in item && (item as ToolGroup).type === 'tool-group' + +const isPlainToolCall = (item: ConversationItem): item is ToolCall => { + if (!isToolCall(item)) return false + if (getWebSearchCardData(item)) return false + if (getComposioConnectCardData(item)) return false + if (getAppActionCardData(item)) return false + return true +} + +export const groupConversationItems = ( + items: ConversationItem[], + hasPermissionRequest: (id: string) => boolean +): GroupedConversationItem[] => { + const result: GroupedConversationItem[] = [] + let i = 0 + + while (i < items.length) { + const item = items[i] + if (isPlainToolCall(item) && !hasPermissionRequest(item.id)) { + const group: ToolCall[] = [item] + i++ + while ( + i < items.length && + isPlainToolCall(items[i] as ConversationItem) && + !hasPermissionRequest((items[i] as ToolCall).id) + ) { + group.push(items[i] as ToolCall) + i++ + } + if (group.length === 1) { + result.push(group[0]) + } else { + result.push({ type: 'tool-group', items: group, groupId: group[0].id }) + } + } else { + result.push(item) + i++ + } + } + + return result +} + +export const getToolGroupSummary = (tools: ToolCall[]): string => { + const seen = new Set() + const names: string[] = [] + for (const tool of tools) { + const name = getToolDisplayName(tool) + if (!seen.has(name)) { + seen.add(name) + names.push(name) + } + } + return names.join(' ยท ') +} + export const inferRunTitleFromMessage = (content: string): string | undefined => { const { message } = parseAttachedFiles(content) const normalized = message.replace(/\s+/g, ' ').trim()