New tool call UI: tool activity, tool display, and chat sidebar updates

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
tusharmagar 2026-02-10 15:26:52 +05:30
parent 27c1142bb5
commit 64903adcaf
5 changed files with 417 additions and 77 deletions

View file

@ -36,6 +36,7 @@ import {
import { Reasoning, ReasoningContent, ReasoningTrigger } from '@/components/ai-elements/reasoning'; import { Reasoning, ReasoningContent, ReasoningTrigger } from '@/components/ai-elements/reasoning';
import { Shimmer } from '@/components/ai-elements/shimmer'; import { Shimmer } from '@/components/ai-elements/shimmer';
import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool'; 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 { PermissionRequest } from '@/components/ai-elements/permission-request';
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'; import { AskHumanRequest } from '@/components/ai-elements/ask-human-request';
import { Suggestions } from '@/components/ai-elements/suggestions'; 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 { BackgroundTaskDetail } from '@/components/background-task-detail'
import { FileCardProvider } from '@/contexts/file-card-context' import { FileCardProvider } from '@/contexts/file-card-context'
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override' 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 { AgentScheduleConfig } from '@x/shared/dist/agent-schedule.js'
import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js' import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js'
@ -2037,19 +2039,21 @@ function App() {
) )
} }
if (isToolCall(item)) { if (isToolCall(item)) {
const errorText = item.status === 'error' ? 'Tool error' : '' const errorText = item.status === 'error' ? 'Tool error' : ''
const output = normalizeToolOutput(item.result, item.status) const output = normalizeToolOutput(item.result, item.status)
const input = normalizeToolInput(item.input) const input = normalizeToolInput(item.input)
return ( const display = getToolDisplay(item.name)
<Tool key={item.id}> return (
<ToolHeader <Tool key={item.id}>
title={item.name} <ToolHeader
type={`tool-${item.name}`} title={display.title}
state={toToolState(item.status)} subtitle={display.subtitle}
/> type={`tool-${item.name}`}
<ToolContent> state={toToolState(item.status)}
<ToolInput input={input} /> />
<ToolContent>
<ToolInput input={input} />
{output !== null ? ( {output !== null ? (
<ToolOutput output={output} errorText={errorText} /> <ToolOutput output={output} errorText={errorText} />
) : null} ) : null}
@ -2085,6 +2089,84 @@ function App() {
? backgroundTasks.find(t => t.name === selectedBackgroundTask) ? backgroundTasks.find(t => t.name === selectedBackgroundTask)
: null : 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(
<ToolActivity
key={`tool-activity-${group[0].id}`}
title={getToolGroupTitle(group.map((t) => 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(
<React.Fragment key={item.id}>
{rendered}
<PermissionRequest
toolCall={permRequest.toolCall}
onApprove={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
isProcessing={isProcessing}
response={response}
/>
</React.Fragment>
)
continue
}
}
nodes.push(rendered)
}
return nodes
})()
return ( return (
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
<SidebarSectionProvider defaultSection="tasks" onSectionChange={handleSectionChange}> <SidebarSectionProvider defaultSection="tasks" onSectionChange={handleSectionChange}>
@ -2228,35 +2310,13 @@ function App() {
What are we working on? What are we working on?
</div> </div>
</ConversationEmptyState> </ConversationEmptyState>
) : ( ) : (
<> <>
{conversation.map(item => { {renderedConversationItems}
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 (
<React.Fragment key={item.id}>
{rendered}
<PermissionRequest
toolCall={permRequest.toolCall}
onApprove={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
isProcessing={isProcessing}
response={response}
/>
</React.Fragment>
)
}
}
return rendered
})}
{/* Render pending ask-human requests */} {/* Render pending ask-human requests */}
{Array.from(pendingAskHumanRequests.values()).map((request) => ( {Array.from(pendingAskHumanRequests.values()).map((request) => (
<AskHumanRequest <AskHumanRequest
key={request.toolCallId} key={request.toolCallId}
query={request.query} query={request.query}
onResponse={(response) => handleAskHumanResponse(request.toolCallId, request.subflow, response)} onResponse={(response) => handleAskHumanResponse(request.toolCallId, request.subflow, response)}

View file

@ -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 (
<Collapsible
defaultOpen={defaultOpen}
className={cn("not-prose mb-2 w-full rounded-md border bg-background/50", className)}
>
<CollapsibleTrigger className="group flex w-full items-center justify-between gap-4 p-3">
<div className="flex min-w-0 items-center gap-2">
<WrenchIcon className="size-4 text-muted-foreground" />
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span className="min-w-0 truncate font-medium text-sm">{title}</span>
<Badge variant="secondary" className="rounded-full text-xs">
{items.length} step{items.length === 1 ? "" : "s"}
</Badge>
<ToolStatusBadge status={groupState} />
</div>
{summary ? (
<div className="mt-0.5 text-xs text-muted-foreground truncate">{summary}</div>
) : null}
</div>
</div>
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="border-t">
<div className="divide-y">
{items.map((item) => (
<Collapsible key={item.id} className="w-full">
<CollapsibleTrigger className="group flex w-full items-center justify-between gap-3 px-3 py-2 hover:bg-muted/30">
<div className="min-w-0">
<div className="min-w-0 truncate text-sm">{item.title}</div>
{item.subtitle ? (
<div className="mt-0.5 min-w-0 truncate text-xs text-muted-foreground font-mono">
{item.subtitle}
</div>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
<ToolStatusBadge status={item.state} />
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</div>
</CollapsibleTrigger>
<CollapsibleContent className="bg-background">
<ToolInput input={item.input ?? {}} className="p-3" />
<ToolOutput output={item.output} errorText={item.errorText} className="p-3 pt-0" />
</CollapsibleContent>
</Collapsible>
))}
</div>
</CollapsibleContent>
</Collapsible>
);
}

View file

@ -0,0 +1,95 @@
export type ToolDisplay = {
title: string
subtitle?: string
}
const SPECIAL_TITLES: Record<string, string> = {
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<string, string> = {
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'
}

View file

@ -49,23 +49,31 @@ export type ToolProps = ComponentProps<typeof Collapsible>;
export const Tool = ({ className, ...props }: ToolProps) => ( export const Tool = ({ className, ...props }: ToolProps) => (
<Collapsible <Collapsible
className={cn("not-prose mb-4 w-full rounded-md border", className)} className={cn("not-prose mb-2 w-full rounded-md border", className)}
{...props} {...props}
/> />
); );
// 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 = { export type ToolHeaderProps = {
title?: string; title?: string;
subtitle?: string;
type: ToolUIPart["type"]; type: ToolUIPart["type"];
state: ToolUIPart["state"]; state: ToolStatus;
className?: string; className?: string;
}; };
const getStatusBadge = (status: ToolUIPart["state"]) => { export const ToolStatusBadge = ({ status }: { status: ToolStatus }) => {
const labels: Record<ToolUIPart["state"], string> = { const labels: Record<ToolStatus, string> = {
"input-streaming": "Pending", "input-streaming": "Pending",
"input-available": "Running", "input-available": "Running",
// @ts-expect-error state only available in AI SDK v6
"approval-requested": "Awaiting Approval", "approval-requested": "Awaiting Approval",
"approval-responded": "Responded", "approval-responded": "Responded",
"output-available": "Completed", "output-available": "Completed",
@ -73,10 +81,9 @@ const getStatusBadge = (status: ToolUIPart["state"]) => {
"output-denied": "Denied", "output-denied": "Denied",
}; };
const icons: Record<ToolUIPart["state"], ReactNode> = { const icons: Record<ToolStatus, ReactNode> = {
"input-streaming": <CircleIcon className="size-4" />, "input-streaming": <CircleIcon className="size-4" />,
"input-available": <ClockIcon className="size-4 animate-pulse" />, "input-available": <ClockIcon className="size-4 animate-pulse" />,
// @ts-expect-error state only available in AI SDK v6
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />, "approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />, "approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
"output-available": <CheckCircleIcon className="size-4 text-green-600" />, "output-available": <CheckCircleIcon className="size-4 text-green-600" />,
@ -95,23 +102,33 @@ const getStatusBadge = (status: ToolUIPart["state"]) => {
export const ToolHeader = ({ export const ToolHeader = ({
className, className,
title, title,
subtitle,
type, type,
state, state,
...props ...props
}: ToolHeaderProps) => ( }: ToolHeaderProps) => (
<CollapsibleTrigger <CollapsibleTrigger
className={cn( className={cn(
"flex w-full items-center justify-between gap-4 p-3", "group flex w-full items-center justify-between gap-4 p-3",
className className
)} )}
{...props} {...props}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<WrenchIcon className="size-4 text-muted-foreground" /> <WrenchIcon className="size-4 text-muted-foreground" />
<span className="font-medium text-sm"> <div className="min-w-0">
{title ?? type.split("-").slice(1).join("-")} <div className="flex min-w-0 items-center gap-2">
</span> <span className="min-w-0 truncate font-medium text-sm">
{getStatusBadge(state)} {title ?? type.split("-").slice(1).join("-")}
</span>
<ToolStatusBadge status={state} />
</div>
{subtitle ? (
<div className="mt-0.5 text-xs text-muted-foreground font-mono truncate">
{subtitle}
</div>
) : null}
</div>
</div> </div>
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" /> <ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger> </CollapsibleTrigger>

View file

@ -22,6 +22,8 @@ import {
import { Reasoning, ReasoningContent, ReasoningTrigger } from '@/components/ai-elements/reasoning' import { Reasoning, ReasoningContent, ReasoningTrigger } from '@/components/ai-elements/reasoning'
import { Shimmer } from '@/components/ai-elements/shimmer' import { Shimmer } from '@/components/ai-elements/shimmer'
import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool' 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 { PermissionRequest } from '@/components/ai-elements/permission-request'
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request' import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'
import { Suggestions } from '@/components/ai-elements/suggestions' import { Suggestions } from '@/components/ai-elements/suggestions'
@ -410,10 +412,12 @@ export function ChatSidebar({
const errorText = item.status === 'error' ? 'Tool error' : '' const errorText = item.status === 'error' ? 'Tool error' : ''
const output = normalizeToolOutput(item.result, item.status) const output = normalizeToolOutput(item.result, item.status)
const input = normalizeToolInput(item.input) const input = normalizeToolInput(item.input)
const display = getToolDisplay(item.name)
return ( return (
<Tool key={item.id}> <Tool key={item.id}>
<ToolHeader <ToolHeader
title={item.name} title={display.title}
subtitle={display.subtitle}
type={`tool-${item.name}`} type={`tool-${item.name}`}
state={toToolState(item.status)} state={toToolState(item.status)}
/> />
@ -439,6 +443,85 @@ export function ChatSidebar({
return null 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(
<ToolActivity
key={`tool-activity-${group[0].id}`}
title={getToolGroupTitle(group.map((t) => 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(
<React.Fragment key={item.id}>
{rendered}
<PermissionRequest
toolCall={permRequest.toolCall}
onApprove={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
isProcessing={isProcessing}
response={response}
/>
</React.Fragment>
)
continue
}
}
nodes.push(rendered)
}
return nodes
})()
const displayWidth = isOpen ? width : 0 const displayWidth = isOpen ? width : 0
return ( return (
@ -501,29 +584,7 @@ export function ChatSidebar({
</ConversationEmptyState> </ConversationEmptyState>
) : ( ) : (
<> <>
{conversation.map(item => { {renderedConversationItems}
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 (
<React.Fragment key={item.id}>
{rendered}
<PermissionRequest
toolCall={permRequest.toolCall}
onApprove={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
isProcessing={isProcessing}
response={response}
/>
</React.Fragment>
)
}
}
return rendered
})}
{/* Render pending ask-human requests */} {/* Render pending ask-human requests */}
{onAskHumanResponse && Array.from(pendingAskHumanRequests.values()).map((request) => ( {onAskHumanResponse && Array.from(pendingAskHumanRequests.values()).map((request) => (