diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index f3bfed02..bb6f465e 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -38,9 +38,9 @@ import { Tool, ToolContent, ToolHeader, ToolTabbedContent } from '@/components/a 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'; -import { PermissionRequest } from '@/components/ai-elements/permission-request'; import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'; import { Suggestions } from '@/components/ai-elements/suggestions'; +import { ToolCallStack } from '@/components/ai-elements/tool-call-stack'; import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js'; import { SidebarInset, @@ -67,6 +67,7 @@ import { type ConversationItem, type ToolCall, createEmptyChatTabViewState, + groupConversationItems, getWebSearchCardData, getAppActionCardData, getComposioConnectCardData, @@ -842,6 +843,7 @@ function App() { const chatDraftsRef = useRef(new Map()) const chatScrollTopByTabRef = useRef(new Map()) const [toolOpenByTab, setToolOpenByTab] = useState>>({}) + const [toolGroupOpenByTab, setToolGroupOpenByTab] = useState>>({}) const [chatViewportAnchorByTab, setChatViewportAnchorByTab] = useState>({}) const activeChatTabIdRef = useRef(activeChatTabId) activeChatTabIdRef.current = activeChatTabId @@ -868,6 +870,22 @@ function App() { } }) }, []) + const getToolGroupOpenForTab = useCallback((tabId: string, groupId: string): boolean | undefined => { + return toolGroupOpenByTab[tabId]?.[groupId] + }, [toolGroupOpenByTab]) + const setToolGroupOpenForTab = useCallback((tabId: string, groupId: string, open: boolean) => { + setToolGroupOpenByTab((prev) => { + const prevForTab = prev[tabId] ?? {} + if (prevForTab[groupId] === open) return prev + return { + ...prev, + [tabId]: { + ...prevForTab, + [groupId]: open, + }, + } + }) + }, []) const setChatViewportAnchor = useCallback((tabId: string, messageId: string | null) => { setChatViewportAnchorByTab((prev) => { const prevForTab = prev[tabId] @@ -997,6 +1015,22 @@ function App() { }) }, [chatTabs]) + useEffect(() => { + const tabIds = new Set(chatTabs.map((tab) => tab.id)) + setToolGroupOpenByTab((prev) => { + let changed = false + const next: Record> = {} + for (const [tabId, state] of Object.entries(prev)) { + if (tabIds.has(tabId)) { + next[tabId] = state + } else { + changed = true + } + } + return changed ? next : prev + }) + }, [chatTabs]) + // Workspace root for full paths const [workspaceRoot, setWorkspaceRoot] = useState('') @@ -2477,6 +2511,12 @@ function App() { delete next[tabId] return next }) + setToolGroupOpenByTab((prev) => { + if (!(tabId in prev)) return prev + const next = { ...prev } + delete next[tabId] + return next + }) if (tabId === activeChatTabId && nextTabs.length > 0) { const newIdx = Math.min(idx, nextTabs.length - 1) @@ -3808,6 +3848,63 @@ function App() { } }, [isGraphOpen, knowledgeFilePaths]) + const renderToolCallItem = (item: ToolCall, tabId: string, className?: string) => { + const appActionData = getAppActionCardData(item) + if (appActionData) { + return + } + const webSearchData = getWebSearchCardData(item) + if (webSearchData) { + return ( + + ) + } + const composioConnectData = getComposioConnectCardData(item) + if (composioConnectData) { + // Skip rendering if this is a duplicate "already connected" card + if (composioConnectData.hidden) return null + return ( + + ) + } + const toolTitle = getToolDisplayName(item) + const errorText = item.status === 'error' ? 'Tool error' : '' + const output = normalizeToolOutput(item.result, item.status) + const input = normalizeToolInput(item.input) + return ( + setToolOpenForTab(tabId, item.id, open)} + > + + + + + + ) + } + const renderConversationItem = (item: ConversationItem, tabId: string) => { if (isChatMessage(item)) { if (item.role === 'user') { @@ -3854,57 +3951,7 @@ function App() { } if (isToolCall(item)) { - const appActionData = getAppActionCardData(item) - if (appActionData) { - return - } - const webSearchData = getWebSearchCardData(item) - if (webSearchData) { - return ( - - ) - } - const composioConnectData = getComposioConnectCardData(item) - if (composioConnectData) { - // Skip rendering if this is a duplicate "already connected" card - if (composioConnectData.hidden) return null - return ( - - ) - } - const toolTitle = getToolDisplayName(item) - const errorText = item.status === 'error' ? 'Tool error' : '' - const output = normalizeToolOutput(item.result, item.status) - const input = normalizeToolInput(item.input) - return ( - setToolOpenForTab(tabId, item.id, open)} - > - - - - - - ) + return renderToolCallItem(item, tabId) } if (isErrorMessage(item)) { @@ -4369,29 +4416,23 @@ function App() { ) : ( <> - {tabState.conversation.map(item => { - const rendered = renderConversationItem(item, tab.id) - if (isToolCall(item)) { - const permRequest = tabState.allPermissionRequests.get(item.id) - if (permRequest) { - const response = tabState.permissionResponses.get(item.id) || null - return ( - - {rendered} - handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} - onApproveSession={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')} - onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')} - onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} - isProcessing={isActive && isProcessing} - response={response} - /> - - ) - } + {groupConversationItems(tabState.conversation).map((block) => { + if (block.kind === 'tool-group') { + return ( + setToolGroupOpenForTab(tab.id, block.key, open)} + allPermissionRequests={tabState.allPermissionRequests} + permissionResponses={tabState.permissionResponses} + isProcessing={isActive && isProcessing} + onPermissionResponse={handlePermissionResponse} + renderToolCall={(tool) => renderToolCallItem(tool, tab.id)} + /> + ) } - return rendered + return renderConversationItem(block.item, tab.id) })} {Array.from(tabState.pendingAskHumanRequests.values()).map((request) => ( @@ -4518,6 +4559,8 @@ function App() { onAskHumanResponse={handleAskHumanResponse} isToolOpenForTab={isToolOpenForTab} onToolOpenChangeForTab={setToolOpenForTab} + getToolGroupOpenForTab={getToolGroupOpenForTab} + onToolGroupOpenChangeForTab={setToolGroupOpenForTab} onOpenKnowledgeFile={(path) => { navigateToFile(path) }} onActivate={() => setActiveShortcutPane('right')} isRecording={isRecording} diff --git a/apps/x/apps/renderer/src/components/ai-elements/app-action-card.tsx b/apps/x/apps/renderer/src/components/ai-elements/app-action-card.tsx index 35f605c5..1bec3c73 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/app-action-card.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/app-action-card.tsx @@ -1,5 +1,6 @@ "use client"; +import { cn } from "@/lib/utils"; import { CheckCircleIcon, FileTextIcon, @@ -10,8 +11,9 @@ import { PlusCircleIcon, } from "lucide-react"; import type { AppActionCardData } from "@/lib/chat-conversation"; +import type { ComponentProps } from "react"; -interface AppActionCardProps { +interface AppActionCardProps extends ComponentProps<"div"> { data: AppActionCardData; status: "pending" | "running" | "completed" | "error"; } @@ -23,12 +25,15 @@ const actionIcons: Record = { "create-base": , }; -export function AppActionCard({ data, status }: AppActionCardProps) { +export function AppActionCard({ className, data, status, ...props }: AppActionCardProps) { const isRunning = status === "pending" || status === "running"; const isError = status === "error"; return ( -
+
{actionIcons[data.action] || } diff --git a/apps/x/apps/renderer/src/components/ai-elements/composio-connect-card.tsx b/apps/x/apps/renderer/src/components/ai-elements/composio-connect-card.tsx index 731eeb09..c4ecb8ab 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/composio-connect-card.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/composio-connect-card.tsx @@ -8,8 +8,10 @@ import { XCircleIcon, } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { ComponentProps } from "react"; -interface ComposioConnectCardProps { +interface ComposioConnectCardProps extends ComponentProps<"div"> { toolkitSlug: string; toolkitDisplayName: string; status: "pending" | "running" | "completed" | "error"; @@ -18,11 +20,13 @@ interface ComposioConnectCardProps { } export function ComposioConnectCard({ + className, toolkitSlug, toolkitDisplayName, status, alreadyConnected, onConnected, + ...props }: ComposioConnectCardProps) { const [connectionState, setConnectionState] = useState< "idle" | "connecting" | "connected" | "error" @@ -73,7 +77,10 @@ export function ComposioConnectCard({ const displayName = toolkitDisplayName || toolkitSlug; return ( -
+
{/* Toolkit initial */}
diff --git a/apps/x/apps/renderer/src/components/ai-elements/tool-call-stack.tsx b/apps/x/apps/renderer/src/components/ai-elements/tool-call-stack.tsx new file mode 100644 index 00000000..57cc3806 --- /dev/null +++ b/apps/x/apps/renderer/src/components/ai-elements/tool-call-stack.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; +import { ToolPermissionRequestEvent } from "@x/shared/src/runs.js"; +import { + AlertTriangleIcon, + CheckCircleIcon, + ChevronDownIcon, + ClockIcon, + WrenchIcon, + XCircleIcon, +} from "lucide-react"; +import type { ComponentProps, ReactNode } from "react"; +import z from "zod"; + +import { + type PermissionResponse, + type ToolCall, + getToolDisplayName, +} from "@/lib/chat-conversation"; +import { PermissionRequest } from "./permission-request"; + +type PermissionScope = "once" | "session" | "always"; + +export type ToolCallStackProps = ComponentProps & { + tools: ToolCall[]; + allPermissionRequests?: Map>; + permissionResponses?: Map; + isProcessing?: boolean; + onPermissionResponse?: ( + toolCallId: string, + subflow: string[], + response: PermissionResponse, + scope?: PermissionScope, + ) => void; + renderToolCall: (tool: ToolCall) => ReactNode; +}; + +const GROUP_PREVIEW_LIMIT = 3; + +const getGroupPreview = (tools: ToolCall[]) => { + const labels = Array.from(new Set(tools.map((tool) => getToolDisplayName(tool)))); + if (labels.length === 0) return "No tool activity"; + if (labels.length <= GROUP_PREVIEW_LIMIT) return labels.join(" • "); + return `${labels.slice(0, GROUP_PREVIEW_LIMIT).join(" • ")} • +${labels.length - GROUP_PREVIEW_LIMIT} more`; +}; + +const getGroupStatus = ( + tools: ToolCall[], + hasPendingPermission: boolean, +): { + icon: ReactNode; + label: string; + className: string; +} => { + if (hasPendingPermission) { + return { + icon: , + label: "Awaiting approval", + className: "bg-amber-100 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300", + }; + } + + if (tools.some((tool) => tool.status === "error")) { + return { + icon: , + label: "Error", + className: "bg-red-100 text-red-700 dark:bg-red-950/40 dark:text-red-300", + }; + } + + if (tools.some((tool) => tool.status === "pending" || tool.status === "running")) { + return { + icon: , + label: "Running", + className: "bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-300", + }; + } + + return { + icon: , + label: "Completed", + className: "bg-green-100 text-green-700 dark:bg-green-950/40 dark:text-green-300", + }; +}; + +export const ToolCallStack = ({ + tools, + allPermissionRequests = new Map(), + permissionResponses = new Map(), + isProcessing = false, + onPermissionResponse, + renderToolCall, + className, + open, + onOpenChange, + ...props +}: ToolCallStackProps) => { + const hasPendingPermission = tools.some((tool) => { + if (!allPermissionRequests.has(tool.id)) return false; + return !permissionResponses.has(tool.id); + }); + const defaultOpen = + hasPendingPermission || + tools.some((tool) => tool.status === "pending" || tool.status === "running"); + const effectiveOpen = hasPendingPermission || (open ?? defaultOpen); + const groupStatus = getGroupStatus(tools, hasPendingPermission); + + const handleOpenChange = (nextOpen: boolean) => { + if (hasPendingPermission && !nextOpen) return; + onOpenChange?.(nextOpen); + }; + + return ( + + +
+
+ + Tool activity + + {groupStatus.icon} + {groupStatus.label} + +
+

+ {getGroupPreview(tools)} +

+
+
+ + {tools.length} + + +
+
+ + +
+
+ {tools.map((tool) => { + const permissionRequest = allPermissionRequests.get(tool.id); + const response = permissionResponses.get(tool.id) ?? null; + const renderedTool = renderToolCall(tool); + + if (!renderedTool && !permissionRequest) return null; + + return ( +
+ {renderedTool} + {permissionRequest && onPermissionResponse && ( + onPermissionResponse(permissionRequest.toolCall.toolCallId, permissionRequest.subflow, "approve")} + onApproveSession={() => onPermissionResponse(permissionRequest.toolCall.toolCallId, permissionRequest.subflow, "approve", "session")} + onApproveAlways={() => onPermissionResponse(permissionRequest.toolCall.toolCallId, permissionRequest.subflow, "approve", "always")} + onDeny={() => onPermissionResponse(permissionRequest.toolCall.toolCallId, permissionRequest.subflow, "deny")} + isProcessing={isProcessing} + response={response} + /> + )} +
+ ); + })} +
+
+
+
+ ); +}; diff --git a/apps/x/apps/renderer/src/components/ai-elements/web-search-result.tsx b/apps/x/apps/renderer/src/components/ai-elements/web-search-result.tsx index 30e5c002..e0ef91d1 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/web-search-result.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/web-search-result.tsx @@ -1,5 +1,6 @@ "use client"; +import { cn } from "@/lib/utils"; import { Collapsible, CollapsibleContent, @@ -11,8 +12,9 @@ import { GlobeIcon, LoaderIcon, } from "lucide-react"; +import type { ComponentProps } from "react"; -interface WebSearchResultProps { +interface WebSearchResultProps extends ComponentProps { query: string; results: Array<{ title: string; url: string; description: string }>; status: "pending" | "running" | "completed" | "error"; @@ -27,11 +29,22 @@ function getDomain(url: string): string { } } -export function WebSearchResult({ query, results, status, title = "Searched the web" }: WebSearchResultProps) { +export function WebSearchResult({ + className, + query, + results, + status, + title = "Searched the web", + ...props +}: WebSearchResultProps) { const isRunning = status === "pending" || status === "running"; return ( - +
diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 64b1e843..a46d3f18 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -16,10 +16,11 @@ import { MessageResponse, } from '@/components/ai-elements/message' import { Shimmer } from '@/components/ai-elements/shimmer' +import { AppActionCard } from '@/components/ai-elements/app-action-card' import { Tool, ToolContent, ToolHeader, ToolTabbedContent } from '@/components/ai-elements/tool' +import { ToolCallStack } from '@/components/ai-elements/tool-call-stack' 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' import { AskHumanRequest } from '@/components/ai-elements/ask-human-request' import { Suggestions } from '@/components/ai-elements/suggestions' import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input' @@ -34,10 +35,13 @@ import { type ChatTabViewState, type ConversationItem, type PermissionResponse, + type ToolCall, createEmptyChatTabViewState, + getAppActionCardData, getWebSearchCardData, getComposioConnectCardData, getToolDisplayName, + groupConversationItems, isChatMessage, isErrorMessage, isToolCall, @@ -111,6 +115,8 @@ interface ChatSidebarProps { onAskHumanResponse?: (toolCallId: string, subflow: string[], response: string) => void isToolOpenForTab?: (tabId: string, toolId: string) => boolean onToolOpenChangeForTab?: (tabId: string, toolId: string, open: boolean) => void + getToolGroupOpenForTab?: (tabId: string, groupId: string) => boolean | undefined + onToolGroupOpenChangeForTab?: (tabId: string, groupId: string, open: boolean) => void onOpenKnowledgeFile?: (path: string) => void onActivate?: () => void // Voice / TTS props @@ -164,6 +170,8 @@ export function ChatSidebar({ onAskHumanResponse, isToolOpenForTab, onToolOpenChangeForTab, + getToolGroupOpenForTab, + onToolGroupOpenChangeForTab, onOpenKnowledgeFile, onActivate, isRecording, @@ -287,6 +295,58 @@ export function ChatSidebar({ }, [activeChatTabId, activeTabState, chatTabStates, emptyTabState]) const hasConversation = activeTabState.conversation.length > 0 || Boolean(activeTabState.currentAssistantMessage) + const renderToolCallItem = (item: ToolCall, tabId: string, className?: string) => { + const appActionData = getAppActionCardData(item) + if (appActionData) { + return + } + const webSearchData = getWebSearchCardData(item) + if (webSearchData) { + return ( + + ) + } + const composioConnectData = getComposioConnectCardData(item) + if (composioConnectData) { + if (composioConnectData.hidden) return null + return ( + + ) + } + const toolTitle = getToolDisplayName(item) + const errorText = item.status === 'error' ? 'Tool error' : '' + const output = normalizeToolOutput(item.result, item.status) + const input = normalizeToolInput(item.input) + return ( + onToolOpenChangeForTab?.(tabId, item.id, open)} + > + + + + + + ) + } + const renderConversationItem = (item: ConversationItem, tabId: string) => { if (isChatMessage(item)) { if (item.role === 'user') { @@ -333,48 +393,7 @@ export function ChatSidebar({ } if (isToolCall(item)) { - const webSearchData = getWebSearchCardData(item) - if (webSearchData) { - return ( - - ) - } - const composioConnectData = getComposioConnectCardData(item) - if (composioConnectData) { - if (composioConnectData.hidden) return null - return ( - - ) - } - const toolTitle = getToolDisplayName(item) - const errorText = item.status === 'error' ? 'Tool error' : '' - const output = normalizeToolOutput(item.result, item.status) - const input = normalizeToolInput(item.input) - return ( - onToolOpenChangeForTab?.(tabId, item.id, open)} - > - - - - - - ) + return renderToolCallItem(item, tabId) } if (isErrorMessage(item)) { @@ -501,29 +520,23 @@ export function ChatSidebar({ ) : ( <> - {tabState.conversation.map((item) => { - const rendered = renderConversationItem(item, tab.id) - if (isToolCall(item) && onPermissionResponse) { - const permRequest = tabState.allPermissionRequests.get(item.id) - if (permRequest) { - const response = tabState.permissionResponses.get(item.id) || null - return ( - - {rendered} - onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} - onApproveSession={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')} - onApproveAlways={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')} - onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} - isProcessing={isActive && isProcessing} - response={response} - /> - - ) - } + {groupConversationItems(tabState.conversation).map((block) => { + if (block.kind === 'tool-group') { + return ( + onToolGroupOpenChangeForTab?.(tab.id, block.key, open)} + allPermissionRequests={tabState.allPermissionRequests} + permissionResponses={tabState.permissionResponses} + isProcessing={isActive && isProcessing} + onPermissionResponse={onPermissionResponse} + renderToolCall={(tool) => renderToolCallItem(tool, tab.id)} + /> + ) } - return rendered + return renderConversationItem(block.item, tab.id) })} {onAskHumanResponse && Array.from(tabState.pendingAskHumanRequests.values()).map((request) => ( diff --git a/apps/x/apps/renderer/src/lib/chat-conversation.ts b/apps/x/apps/renderer/src/lib/chat-conversation.ts index 68c8366d..d7819e00 100644 --- a/apps/x/apps/renderer/src/lib/chat-conversation.ts +++ b/apps/x/apps/renderer/src/lib/chat-conversation.ts @@ -37,6 +37,17 @@ export interface ErrorMessage { export type ConversationItem = ChatMessage | ToolCall | ErrorMessage export type PermissionResponse = 'approve' | 'deny' +export type ConversationRenderBlock = + | { + kind: 'item' + key: string + item: ChatMessage | ErrorMessage + } + | { + kind: 'tool-group' + key: string + tools: ToolCall[] + } export type ChatTabViewState = { runId: string | null @@ -68,6 +79,38 @@ export const isToolCall = (item: ConversationItem): item is ToolCall => 'name' i export const isErrorMessage = (item: ConversationItem): item is ErrorMessage => 'kind' in item && item.kind === 'error' +export const groupConversationItems = (items: ConversationItem[]): ConversationRenderBlock[] => { + const blocks: ConversationRenderBlock[] = [] + let pendingTools: ToolCall[] = [] + + const flushPendingTools = () => { + if (pendingTools.length === 0) return + blocks.push({ + kind: 'tool-group', + key: pendingTools[0].id, + tools: pendingTools, + }) + pendingTools = [] + } + + for (const item of items) { + if (isToolCall(item)) { + pendingTools.push(item) + continue + } + + flushPendingTools() + blocks.push({ + kind: 'item', + key: item.id, + item, + }) + } + + flushPendingTools() + return blocks +} + export const toToolState = (status: ToolCall['status']): ToolState => { switch (status) { case 'pending':