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 { 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 (
<Tool key={item.id}>
<ToolHeader
title={item.name}
type={`tool-${item.name}`}
state={toToolState(item.status)}
/>
<ToolContent>
<ToolInput input={input} />
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 (
<Tool key={item.id}>
<ToolHeader
title={display.title}
subtitle={display.subtitle}
type={`tool-${item.name}`}
state={toToolState(item.status)}
/>
<ToolContent>
<ToolInput input={input} />
{output !== null ? (
<ToolOutput output={output} errorText={errorText} />
) : 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(
<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 (
<TooltipProvider delayDuration={0}>
<SidebarSectionProvider defaultSection="tasks" onSectionChange={handleSectionChange}>
@ -2228,35 +2310,13 @@ function App() {
What are we working on?
</div>
</ConversationEmptyState>
) : (
<>
{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 (
<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
})}
) : (
<>
{renderedConversationItems}
{/* Render pending ask-human requests */}
{Array.from(pendingAskHumanRequests.values()).map((request) => (
<AskHumanRequest
{/* Render pending ask-human requests */}
{Array.from(pendingAskHumanRequests.values()).map((request) => (
<AskHumanRequest
key={request.toolCallId}
query={request.query}
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) => (
<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}
/>
);
// 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<ToolUIPart["state"], string> = {
export const ToolStatusBadge = ({ status }: { status: ToolStatus }) => {
const labels: Record<ToolStatus, string> = {
"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<ToolUIPart["state"], ReactNode> = {
const icons: Record<ToolStatus, ReactNode> = {
"input-streaming": <CircleIcon className="size-4" />,
"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-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
@ -95,23 +102,33 @@ const getStatusBadge = (status: ToolUIPart["state"]) => {
export const ToolHeader = ({
className,
title,
subtitle,
type,
state,
...props
}: ToolHeaderProps) => (
<CollapsibleTrigger
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
)}
{...props}
>
<div className="flex items-center gap-2">
<WrenchIcon className="size-4 text-muted-foreground" />
<span className="font-medium text-sm">
{title ?? type.split("-").slice(1).join("-")}
</span>
{getStatusBadge(state)}
<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 ?? 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>
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>

View file

@ -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 (
<Tool key={item.id}>
<ToolHeader
title={item.name}
title={display.title}
subtitle={display.subtitle}
type={`tool-${item.name}`}
state={toToolState(item.status)}
/>
@ -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(
<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
return (
@ -501,29 +584,7 @@ export function ChatSidebar({
</ConversationEmptyState>
) : (
<>
{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 (
<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
})}
{renderedConversationItems}
{/* Render pending ask-human requests */}
{onAskHumanResponse && Array.from(pendingAskHumanRequests.values()).map((request) => (