diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 712d5e0a..1d99df14 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -36,6 +36,7 @@ import { import { Reasoning, ReasoningContent, ReasoningTrigger } from '@/components/ai-elements/reasoning'; import { Shimmer } from '@/components/ai-elements/shimmer'; import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool'; +import { ToolActivity, type ToolActivityItem } from '@/components/ai-elements/tool-activity'; import { PermissionRequest } from '@/components/ai-elements/permission-request'; import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'; import { Suggestions } from '@/components/ai-elements/suggestions'; @@ -52,6 +53,7 @@ import { OnboardingModal } from '@/components/onboarding-modal' import { BackgroundTaskDetail } from '@/components/background-task-detail' import { FileCardProvider } from '@/contexts/file-card-context' import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override' +import { getToolDisplay, getToolGroupTitle } from '@/components/ai-elements/tool-display' import { AgentScheduleConfig } from '@x/shared/dist/agent-schedule.js' import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js' @@ -2037,19 +2039,21 @@ function App() { ) } - if (isToolCall(item)) { - const errorText = item.status === 'error' ? 'Tool error' : '' - const output = normalizeToolOutput(item.result, item.status) - const input = normalizeToolInput(item.input) - return ( - - - - + if (isToolCall(item)) { + const errorText = item.status === 'error' ? 'Tool error' : '' + const output = normalizeToolOutput(item.result, item.status) + const input = normalizeToolInput(item.input) + const display = getToolDisplay(item.name) + return ( + + + + {output !== null ? ( ) : null} @@ -2085,6 +2089,84 @@ function App() { ? backgroundTasks.find(t => t.name === selectedBackgroundTask) : null + const renderedConversationItems = (() => { + const nodes: React.ReactNode[] = [] + + const toActivityItem = (toolCall: ToolCall): ToolActivityItem => { + const display = getToolDisplay(toolCall.name) + const errorText = toolCall.status === 'error' ? 'Tool error' : '' + return { + id: toolCall.id, + title: display.title, + subtitle: display.subtitle, + state: toToolState(toolCall.status), + input: normalizeToolInput(toolCall.input), + output: normalizeToolOutput(toolCall.result, toolCall.status) as ToolUIPart['output'], + errorText, + } + } + + for (let i = 0; i < conversation.length; i++) { + const item = conversation[i] + + // Group consecutive tool calls into a single compact "activity" block when there are no permission prompts. + if (isToolCall(item) && !allPermissionRequests.get(item.id)) { + const group: ToolCall[] = [item] + let j = i + 1 + while ( + j < conversation.length + && isToolCall(conversation[j]) + && !allPermissionRequests.get((conversation[j] as ToolCall).id) + ) { + group.push(conversation[j] as ToolCall) + j += 1 + } + + if (group.length > 1) { + const titles = group.map((t) => getToolDisplay(t.name).title).join(' · ') + nodes.push( + t.name))} + items={group.map(toActivityItem)} + summary={titles} + /> + ) + i = j - 1 + continue + } + } + + const rendered = renderConversationItem(item) + if (!rendered) continue + + // If this is a tool call, check for permission request (pending or responded) + if (isToolCall(item)) { + const permRequest = allPermissionRequests.get(item.id) + if (permRequest) { + const response = permissionResponses.get(item.id) || null + nodes.push( + + {rendered} + handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} + onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} + isProcessing={isProcessing} + response={response} + /> + + ) + continue + } + } + + nodes.push(rendered) + } + + return nodes + })() + return ( @@ -2228,35 +2310,13 @@ function App() { What are we working on? - ) : ( - <> - {conversation.map(item => { - const rendered = renderConversationItem(item) - // If this is a tool call, check for permission request (pending or responded) - if (isToolCall(item)) { - const permRequest = allPermissionRequests.get(item.id) - if (permRequest) { - const response = permissionResponses.get(item.id) || null - return ( - - {rendered} - handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} - onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} - isProcessing={isProcessing} - response={response} - /> - - ) - } - } - return rendered - })} + ) : ( + <> + {renderedConversationItems} - {/* Render pending ask-human requests */} - {Array.from(pendingAskHumanRequests.values()).map((request) => ( - ( + handleAskHumanResponse(request.toolCallId, request.subflow, response)} diff --git a/apps/x/apps/renderer/src/components/ai-elements/tool-activity.tsx b/apps/x/apps/renderer/src/components/ai-elements/tool-activity.tsx new file mode 100644 index 00000000..ba9a407c --- /dev/null +++ b/apps/x/apps/renderer/src/components/ai-elements/tool-activity.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; +import type { ToolUIPart } from "ai"; +import { ChevronDownIcon, WrenchIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +import { ToolInput, ToolOutput, ToolStatusBadge, type ToolStatus } from "@/components/ai-elements/tool"; + +export type ToolActivityItem = { + id: string; + title: string; + subtitle?: string; + state: ToolStatus; + input: ToolUIPart["input"]; + output: ToolUIPart["output"]; + errorText: ToolUIPart["errorText"]; +}; + +function getGroupState(items: ToolActivityItem[]): ToolStatus { + const states = items.map((i) => i.state); + + if (states.includes("output-error")) return "output-error"; + if (states.includes("output-denied")) return "output-denied"; + if (states.includes("approval-requested")) return "approval-requested"; + if (states.includes("input-available")) return "input-available"; + if (states.includes("input-streaming")) return "input-streaming"; + if (states.includes("approval-responded")) return "approval-responded"; + return "output-available"; +} + +export type ToolActivityProps = { + title: string; + items: ToolActivityItem[]; + className?: string; + defaultOpen?: boolean; + summary?: ReactNode; +}; + +export function ToolActivity({ + title, + items, + className, + defaultOpen = false, + summary, +}: ToolActivityProps) { + const groupState = getGroupState(items); + + return ( + + +
+ +
+
+ {title} + + {items.length} step{items.length === 1 ? "" : "s"} + + +
+ {summary ? ( +
{summary}
+ ) : null} +
+
+ +
+ + +
+ {items.map((item) => ( + + +
+
{item.title}
+ {item.subtitle ? ( +
+ {item.subtitle} +
+ ) : null} +
+
+ + +
+
+ + + + +
+ ))} +
+
+
+ ); +} diff --git a/apps/x/apps/renderer/src/components/ai-elements/tool-display.ts b/apps/x/apps/renderer/src/components/ai-elements/tool-display.ts new file mode 100644 index 00000000..6ef327d5 --- /dev/null +++ b/apps/x/apps/renderer/src/components/ai-elements/tool-display.ts @@ -0,0 +1,95 @@ +export type ToolDisplay = { + title: string + subtitle?: string +} + +const SPECIAL_TITLES: Record = { + loadSkill: 'Understanding MCP tools', + listMcpServers: 'Listing MCP servers', + listMcpTools: 'Listing MCP tools', + listMcpResources: 'Listing MCP resources', + listMcpResourceTemplates: 'Listing MCP resource templates', + readMcpResource: 'Reading MCP resource', +} + +function toWords(name: string): string[] { + if (!name) return [] + + // Split on separators first (workspace:readFile, runs:list, etc.) + const normalized = name + .replace(/[:/_.-]+/g, ' ') + .trim() + + const parts: string[] = [] + for (const token of normalized.split(/\s+/)) { + // Split camelCase/PascalCase within each token + const camel = token + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + parts.push(...camel.split(/\s+/).filter(Boolean)) + } + + return parts +} + +function titleCase(words: string[]): string { + const acronyms = new Set(['mcp', 'url', 'id', 'api', 'ipc', 'json']) + return words + .map((w) => { + const lower = w.toLowerCase() + if (acronyms.has(lower)) return lower.toUpperCase() + if (w.length <= 1) return w.toUpperCase() + return w[0].toUpperCase() + w.slice(1) + }) + .join(' ') +} + +function verbToGerund(verb: string): string { + const lower = verb.toLowerCase() + const irregular: Record = { + run: 'Running', + get: 'Getting', + set: 'Setting', + list: 'Listing', + load: 'Loading', + read: 'Reading', + write: 'Writing', + create: 'Creating', + update: 'Updating', + delete: 'Deleting', + remove: 'Removing', + rename: 'Renaming', + open: 'Opening', + close: 'Closing', + toggle: 'Toggling', + fetch: 'Fetching', + search: 'Searching', + provide: 'Providing', + } + if (irregular[lower]) return irregular[lower] + return titleCase([lower + 'ing']) +} + +export function getToolDisplay(name: string): ToolDisplay { + const special = SPECIAL_TITLES[name] + if (special) return { title: special, subtitle: name } + + const words = toWords(name) + if (words.length === 0) return { title: 'Tool', subtitle: name } + + const [first, ...rest] = words + const title = + rest.length > 0 + ? `${verbToGerund(first)} ${titleCase(rest).replace(/\s+/g, ' ')}` + : titleCase(words) + + return { title, subtitle: name } +} + +export function getToolGroupTitle(toolNames: string[]): string { + const joined = toolNames.join(' ') + if (/mcp/i.test(joined)) return 'MCP activity' + if (toolNames.some((n) => /search/i.test(n))) return 'Search activity' + return 'Tool activity' +} + 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 d9453aa1..c215e804 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/tool.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/tool.tsx @@ -49,23 +49,31 @@ export type ToolProps = ComponentProps; export const Tool = ({ className, ...props }: ToolProps) => ( ); +// AI SDK v5's ToolUIPart["state"] is narrower than what we want to render in the UI. +// Keep this type permissive so we can display additional states when present. +export type ToolStatus = + | ToolUIPart["state"] + | "approval-requested" + | "approval-responded" + | "output-denied"; + export type ToolHeaderProps = { title?: string; + subtitle?: string; type: ToolUIPart["type"]; - state: ToolUIPart["state"]; + state: ToolStatus; className?: string; }; -const getStatusBadge = (status: ToolUIPart["state"]) => { - const labels: Record = { +export const ToolStatusBadge = ({ status }: { status: ToolStatus }) => { + const labels: Record = { "input-streaming": "Pending", "input-available": "Running", - // @ts-expect-error state only available in AI SDK v6 "approval-requested": "Awaiting Approval", "approval-responded": "Responded", "output-available": "Completed", @@ -73,10 +81,9 @@ const getStatusBadge = (status: ToolUIPart["state"]) => { "output-denied": "Denied", }; - const icons: Record = { + const icons: Record = { "input-streaming": , "input-available": , - // @ts-expect-error state only available in AI SDK v6 "approval-requested": , "approval-responded": , "output-available": , @@ -95,23 +102,33 @@ const getStatusBadge = (status: ToolUIPart["state"]) => { export const ToolHeader = ({ className, title, + subtitle, type, state, ...props }: ToolHeaderProps) => (
- - {title ?? type.split("-").slice(1).join("-")} - - {getStatusBadge(state)} +
+
+ + {title ?? type.split("-").slice(1).join("-")} + + +
+ {subtitle ? ( +
+ {subtitle} +
+ ) : null} +
diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index e0048781..cb5840ca 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -22,6 +22,8 @@ import { import { Reasoning, ReasoningContent, ReasoningTrigger } from '@/components/ai-elements/reasoning' import { Shimmer } from '@/components/ai-elements/shimmer' import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool' +import { ToolActivity, type ToolActivityItem } from '@/components/ai-elements/tool-activity' +import { getToolDisplay, getToolGroupTitle } from '@/components/ai-elements/tool-display' import { PermissionRequest } from '@/components/ai-elements/permission-request' import { AskHumanRequest } from '@/components/ai-elements/ask-human-request' import { Suggestions } from '@/components/ai-elements/suggestions' @@ -410,10 +412,12 @@ export function ChatSidebar({ const errorText = item.status === 'error' ? 'Tool error' : '' const output = normalizeToolOutput(item.result, item.status) const input = normalizeToolInput(item.input) + const display = getToolDisplay(item.name) return ( @@ -439,6 +443,85 @@ export function ChatSidebar({ return null } + const renderedConversationItems = (() => { + const nodes: React.ReactNode[] = [] + + const toActivityItem = (toolCall: ToolCall): ToolActivityItem => { + const display = getToolDisplay(toolCall.name) + const errorText = toolCall.status === 'error' ? 'Tool error' : '' + return { + id: toolCall.id, + title: display.title, + subtitle: display.subtitle, + state: toToolState(toolCall.status), + input: normalizeToolInput(toolCall.input), + output: normalizeToolOutput(toolCall.result, toolCall.status) as ToolUIPart['output'], + errorText, + } + } + + for (let i = 0; i < conversation.length; i++) { + const item = conversation[i] + + const hasPermissionPrompt = isToolCall(item) && onPermissionResponse && allPermissionRequests.get(item.id) + + // Group consecutive tool calls into a single compact "activity" block when there are no permission prompts. + if (isToolCall(item) && !hasPermissionPrompt) { + const group: ToolCall[] = [item] + let j = i + 1 + while (j < conversation.length && isToolCall(conversation[j])) { + const next = conversation[j] as ToolCall + const nextHasPrompt = onPermissionResponse && allPermissionRequests.get(next.id) + if (nextHasPrompt) break + group.push(next) + j += 1 + } + + if (group.length > 1) { + const titles = group.map((t) => getToolDisplay(t.name).title).join(' · ') + nodes.push( + t.name))} + items={group.map(toActivityItem)} + summary={titles} + /> + ) + i = j - 1 + continue + } + } + + const rendered = renderConversationItem(item) + if (!rendered) continue + + // If this is a tool call, check for permission request (pending or responded) + if (isToolCall(item) && onPermissionResponse) { + const permRequest = allPermissionRequests.get(item.id) + if (permRequest) { + const response = permissionResponses.get(item.id) || null + nodes.push( + + {rendered} + onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} + onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} + isProcessing={isProcessing} + response={response} + /> + + ) + continue + } + } + + nodes.push(rendered) + } + + return nodes + })() + const displayWidth = isOpen ? width : 0 return ( @@ -501,29 +584,7 @@ export function ChatSidebar({ ) : ( <> - {conversation.map(item => { - const rendered = renderConversationItem(item) - // If this is a tool call, check for permission request (pending or responded) - if (isToolCall(item) && onPermissionResponse) { - const permRequest = allPermissionRequests.get(item.id) - if (permRequest) { - const response = permissionResponses.get(item.id) || null - return ( - - {rendered} - onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} - onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} - isProcessing={isProcessing} - response={response} - /> - - ) - } - } - return rendered - })} + {renderedConversationItems} {/* Render pending ask-human requests */} {onAskHumanResponse && Array.from(pendingAskHumanRequests.values()).map((request) => (