feat: enhance tool call UI with improved state management and permission request handling in App and ChatSidebar components

This commit is contained in:
tusharmagar 2026-02-10 15:55:07 +05:30
parent 64903adcaf
commit ea2176db84
3 changed files with 157 additions and 81 deletions

View file

@ -35,7 +35,7 @@ import {
} from '@/components/ai-elements/prompt-input'; } from '@/components/ai-elements/prompt-input';
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, type ToolStatus } from '@/components/ai-elements/tool';
import { ToolActivity, type ToolActivityItem } from '@/components/ai-elements/tool-activity'; 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';
@ -1318,6 +1318,9 @@ function App() {
if (!userMessage) return if (!userMessage) return
setMessage('') setMessage('')
setIsProcessing(true)
setIsStopping(false)
setStopClickedAt(null)
const userMessageId = `user-${Date.now()}` const userMessageId = `user-${Date.now()}`
setConversation(prev => [...prev, { setConversation(prev => [...prev, {
@ -1336,7 +1339,11 @@ function App() {
}) })
currentRunId = run.id currentRunId = run.id
setRunId(currentRunId) setRunId(currentRunId)
runIdRef.current = currentRunId
isNewRun = true isNewRun = true
} else {
// Ensure event filtering accepts events for the active run immediately.
runIdRef.current = currentRunId
} }
// Read mentioned file contents and format message with XML context // Read mentioned file contents and format message with XML context
@ -1372,6 +1379,7 @@ function App() {
loadRuns() loadRuns()
} }
} catch (error) { } catch (error) {
setIsProcessing(false)
console.error('Failed to send message:', error) console.error('Failed to send message:', error)
} }
} }
@ -2006,6 +2014,21 @@ function App() {
} }
}, [isGraphOpen, knowledgeFilePaths]) }, [isGraphOpen, knowledgeFilePaths])
const getToolUiState = useCallback((toolCall: ToolCall): ToolStatus => {
const permRequest = allPermissionRequests.get(toolCall.id)
const response = permissionResponses.get(toolCall.id) ?? null
if (!permRequest) {
return toToolState(toolCall.status)
}
if (response === 'deny') return 'output-denied'
if (!response) return 'approval-requested'
if (toolCall.status === 'error') return 'output-error'
if (toolCall.status === 'completed') return toToolState(toolCall.status)
return 'approval-responded'
}, [allPermissionRequests, permissionResponses])
const renderConversationItem = (item: ConversationItem) => { const renderConversationItem = (item: ConversationItem) => {
if (isChatMessage(item)) { if (isChatMessage(item)) {
if (item.role === 'user') { if (item.role === 'user') {
@ -2039,24 +2062,39 @@ 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)
const display = getToolDisplay(item.name) const display = getToolDisplay(item.name)
return ( const permRequest = allPermissionRequests.get(item.id)
<Tool key={item.id}> const response = permissionResponses.get(item.id) ?? null
<ToolHeader
title={display.title} return (
subtitle={display.subtitle} <Tool key={item.id} defaultOpen={getToolUiState(item) === 'approval-requested'}>
type={`tool-${item.name}`} <ToolHeader
state={toToolState(item.status)} title={display.title}
/> subtitle={display.subtitle}
<ToolContent> type={`tool-${item.name}`}
<ToolInput input={input} /> state={getToolUiState(item)}
/>
<ToolContent>
<ToolInput input={input} />
{output !== null ? ( {output !== null ? (
<ToolOutput output={output} errorText={errorText} /> <ToolOutput output={output} errorText={errorText} />
) : null} ) : null}
{permRequest ? (
<div className="px-4 pb-4">
<PermissionRequest
className="mb-0"
toolCall={permRequest.toolCall}
onApprove={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
isProcessing={isProcessing}
response={response}
/>
</div>
) : null}
</ToolContent> </ToolContent>
</Tool> </Tool>
) )
@ -2075,6 +2113,15 @@ function App() {
} }
const hasConversation = conversation.length > 0 || currentAssistantMessage || currentReasoning const hasConversation = conversation.length > 0 || currentAssistantMessage || currentReasoning
const hasActiveToolCalls = conversation.some(
(item) => isToolCall(item) && (item.status === 'pending' || item.status === 'running')
)
const hasPendingRequests = pendingPermissionRequests.size > 0 || pendingAskHumanRequests.size > 0
const showThinkingIndicator =
(isProcessing || hasActiveToolCalls)
&& !hasPendingRequests
&& !currentAssistantMessage
&& !currentReasoning
const conversationContentClassName = hasConversation const conversationContentClassName = hasConversation
? "mx-auto w-full max-w-4xl pb-28" ? "mx-auto w-full max-w-4xl pb-28"
: "mx-auto w-full max-w-4xl min-h-full items-center justify-center pb-0" : "mx-auto w-full max-w-4xl min-h-full items-center justify-center pb-0"
@ -2095,29 +2142,38 @@ function App() {
const toActivityItem = (toolCall: ToolCall): ToolActivityItem => { const toActivityItem = (toolCall: ToolCall): ToolActivityItem => {
const display = getToolDisplay(toolCall.name) const display = getToolDisplay(toolCall.name)
const errorText = toolCall.status === 'error' ? 'Tool error' : '' const errorText = toolCall.status === 'error' ? 'Tool error' : ''
const permRequest = allPermissionRequests.get(toolCall.id)
const response = permissionResponses.get(toolCall.id) ?? null
return { return {
id: toolCall.id, id: toolCall.id,
title: display.title, title: display.title,
subtitle: display.subtitle, subtitle: display.subtitle,
state: toToolState(toolCall.status), state: getToolUiState(toolCall),
input: normalizeToolInput(toolCall.input), input: normalizeToolInput(toolCall.input),
output: normalizeToolOutput(toolCall.result, toolCall.status) as ToolUIPart['output'], output: normalizeToolOutput(toolCall.result, toolCall.status) as ToolUIPart['output'],
errorText, errorText,
defaultOpen: getToolUiState(toolCall) === 'approval-requested' || getToolUiState(toolCall) === 'output-error',
extra: permRequest ? (
<PermissionRequest
className="mb-0"
toolCall={permRequest.toolCall}
onApprove={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
isProcessing={isProcessing}
response={response}
/>
) : undefined,
} }
} }
for (let i = 0; i < conversation.length; i++) { for (let i = 0; i < conversation.length; i++) {
const item = conversation[i] const item = conversation[i]
// Group consecutive tool calls into a single compact "activity" block when there are no permission prompts. // Group consecutive tool calls into a single compact "activity" block.
if (isToolCall(item) && !allPermissionRequests.get(item.id)) { if (isToolCall(item)) {
const group: ToolCall[] = [item] const group: ToolCall[] = [item]
let j = i + 1 let j = i + 1
while ( while (j < conversation.length && isToolCall(conversation[j])) {
j < conversation.length
&& isToolCall(conversation[j])
&& !allPermissionRequests.get((conversation[j] as ToolCall).id)
) {
group.push(conversation[j] as ToolCall) group.push(conversation[j] as ToolCall)
j += 1 j += 1
} }
@ -2130,6 +2186,10 @@ function App() {
title={getToolGroupTitle(group.map((t) => t.name))} title={getToolGroupTitle(group.map((t) => t.name))}
items={group.map(toActivityItem)} items={group.map(toActivityItem)}
summary={titles} summary={titles}
defaultOpen={group.some((t) => {
const state = getToolUiState(t)
return state === 'approval-requested' || state === 'output-error'
})}
/> />
) )
i = j - 1 i = j - 1
@ -2140,27 +2200,6 @@ function App() {
const rendered = renderConversationItem(item) const rendered = renderConversationItem(item)
if (!rendered) continue 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) nodes.push(rendered)
} }
@ -2339,7 +2378,7 @@ function App() {
</Message> </Message>
)} )}
{isProcessing && !currentAssistantMessage && !currentReasoning && ( {showThinkingIndicator && (
<Message from="assistant"> <Message from="assistant">
<MessageContent> <MessageContent>
<Shimmer duration={1}>Thinking...</Shimmer> <Shimmer duration={1}>Thinking...</Shimmer>

View file

@ -21,6 +21,8 @@ export type ToolActivityItem = {
input: ToolUIPart["input"]; input: ToolUIPart["input"];
output: ToolUIPart["output"]; output: ToolUIPart["output"];
errorText: ToolUIPart["errorText"]; errorText: ToolUIPart["errorText"];
extra?: ReactNode;
defaultOpen?: boolean;
}; };
function getGroupState(items: ToolActivityItem[]): ToolStatus { function getGroupState(items: ToolActivityItem[]): ToolStatus {
@ -79,7 +81,7 @@ export function ToolActivity({
<CollapsibleContent className="border-t"> <CollapsibleContent className="border-t">
<div className="divide-y"> <div className="divide-y">
{items.map((item) => ( {items.map((item) => (
<Collapsible key={item.id} className="w-full"> <Collapsible key={item.id} className="w-full" defaultOpen={item.defaultOpen}>
<CollapsibleTrigger className="group flex w-full items-center justify-between gap-3 px-3 py-2 hover:bg-muted/30"> <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">
<div className="min-w-0 truncate text-sm">{item.title}</div> <div className="min-w-0 truncate text-sm">{item.title}</div>
@ -97,6 +99,9 @@ export function ToolActivity({
<CollapsibleContent className="bg-background"> <CollapsibleContent className="bg-background">
<ToolInput input={item.input ?? {}} className="p-3" /> <ToolInput input={item.input ?? {}} className="p-3" />
<ToolOutput output={item.output} errorText={item.errorText} className="p-3 pt-0" /> <ToolOutput output={item.output} errorText={item.errorText} className="p-3 pt-0" />
{item.extra ? (
<div className="p-3 pt-0">{item.extra}</div>
) : null}
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
))} ))}

View file

@ -21,7 +21,7 @@ import {
} from '@/components/ai-elements/message' } from '@/components/ai-elements/message'
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, type ToolStatus } from '@/components/ai-elements/tool'
import { ToolActivity, type ToolActivityItem } from '@/components/ai-elements/tool-activity' import { ToolActivity, type ToolActivityItem } from '@/components/ai-elements/tool-activity'
import { getToolDisplay, getToolGroupTitle } from '@/components/ai-elements/tool-display' 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'
@ -187,6 +187,21 @@ export function ChatSidebar({
const autoMentionRef = useRef<{ path: string; displayName: string } | null>(null) const autoMentionRef = useRef<{ path: string; displayName: string } | null>(null)
const lastSelectedPathRef = useRef<string | null>(null) const lastSelectedPathRef = useRef<string | null>(null)
const getToolUiState = useCallback((toolCall: ToolCall): ToolStatus => {
const permRequest = allPermissionRequests.get(toolCall.id)
const response = permissionResponses.get(toolCall.id) ?? null
if (!permRequest) {
return toToolState(toolCall.status)
}
if (response === 'deny') return 'output-denied'
if (!response) return 'approval-requested'
if (toolCall.status === 'error') return 'output-error'
if (toolCall.status === 'completed') return toToolState(toolCall.status)
return 'approval-responded'
}, [allPermissionRequests, permissionResponses])
// Build mention labels for highlighting (handles multi-word names like "AI Agents") // Build mention labels for highlighting (handles multi-word names like "AI Agents")
const mentionLabels = useMemo(() => { const mentionLabels = useMemo(() => {
if (knowledgeFiles.length === 0) return [] if (knowledgeFiles.length === 0) return []
@ -329,6 +344,18 @@ export function ChatSidebar({
}, [selectedPath, message, onMessageChange]) }, [selectedPath, message, onMessageChange])
const hasConversation = conversation.length > 0 || currentAssistantMessage || currentReasoning const hasConversation = conversation.length > 0 || currentAssistantMessage || currentReasoning
const hasActiveToolCalls = conversation.some(
(item) => isToolCall(item) && (item.status === 'pending' || item.status === 'running')
)
const hasPendingPermissionRequests = Array.from(allPermissionRequests.keys()).some(
(id) => !permissionResponses.has(id)
)
const showThinkingIndicator =
(isProcessing || hasActiveToolCalls)
&& !hasPendingPermissionRequests
&& pendingAskHumanRequests.size === 0
&& !currentAssistantMessage
&& !currentReasoning
const canSubmit = Boolean(message.trim()) && !isProcessing const canSubmit = Boolean(message.trim()) && !isProcessing
const handleSubmit = () => { const handleSubmit = () => {
@ -413,19 +440,33 @@ export function ChatSidebar({
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) const display = getToolDisplay(item.name)
const permRequest = allPermissionRequests.get(item.id)
const response = permissionResponses.get(item.id) ?? null
return ( return (
<Tool key={item.id}> <Tool key={item.id} defaultOpen={getToolUiState(item) === 'approval-requested'}>
<ToolHeader <ToolHeader
title={display.title} title={display.title}
subtitle={display.subtitle} subtitle={display.subtitle}
type={`tool-${item.name}`} type={`tool-${item.name}`}
state={toToolState(item.status)} state={getToolUiState(item)}
/> />
<ToolContent> <ToolContent>
<ToolInput input={input} /> <ToolInput input={input} />
{output !== null ? ( {output !== null ? (
<ToolOutput output={output} errorText={errorText} /> <ToolOutput output={output} errorText={errorText} />
) : null} ) : null}
{permRequest ? (
<div className="px-4 pb-4">
<PermissionRequest
className="mb-0"
toolCall={permRequest.toolCall}
onApprove={onPermissionResponse ? () => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve') : undefined}
onDeny={onPermissionResponse ? () => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny') : undefined}
isProcessing={isProcessing}
response={response}
/>
</div>
) : null}
</ToolContent> </ToolContent>
</Tool> </Tool>
) )
@ -449,31 +490,39 @@ export function ChatSidebar({
const toActivityItem = (toolCall: ToolCall): ToolActivityItem => { const toActivityItem = (toolCall: ToolCall): ToolActivityItem => {
const display = getToolDisplay(toolCall.name) const display = getToolDisplay(toolCall.name)
const errorText = toolCall.status === 'error' ? 'Tool error' : '' const errorText = toolCall.status === 'error' ? 'Tool error' : ''
const permRequest = allPermissionRequests.get(toolCall.id)
const response = permissionResponses.get(toolCall.id) ?? null
return { return {
id: toolCall.id, id: toolCall.id,
title: display.title, title: display.title,
subtitle: display.subtitle, subtitle: display.subtitle,
state: toToolState(toolCall.status), state: getToolUiState(toolCall),
input: normalizeToolInput(toolCall.input), input: normalizeToolInput(toolCall.input),
output: normalizeToolOutput(toolCall.result, toolCall.status) as ToolUIPart['output'], output: normalizeToolOutput(toolCall.result, toolCall.status) as ToolUIPart['output'],
errorText, errorText,
defaultOpen: getToolUiState(toolCall) === 'approval-requested' || getToolUiState(toolCall) === 'output-error',
extra: permRequest ? (
<PermissionRequest
className="mb-0"
toolCall={permRequest.toolCall}
onApprove={onPermissionResponse ? () => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve') : undefined}
onDeny={onPermissionResponse ? () => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny') : undefined}
isProcessing={isProcessing}
response={response}
/>
) : undefined,
} }
} }
for (let i = 0; i < conversation.length; i++) { for (let i = 0; i < conversation.length; i++) {
const item = conversation[i] const item = conversation[i]
const hasPermissionPrompt = isToolCall(item) && onPermissionResponse && allPermissionRequests.get(item.id) // Group consecutive tool calls into a single compact "activity" block.
if (isToolCall(item)) {
// Group consecutive tool calls into a single compact "activity" block when there are no permission prompts.
if (isToolCall(item) && !hasPermissionPrompt) {
const group: ToolCall[] = [item] const group: ToolCall[] = [item]
let j = i + 1 let j = i + 1
while (j < conversation.length && isToolCall(conversation[j])) { while (j < conversation.length && isToolCall(conversation[j])) {
const next = conversation[j] as ToolCall group.push(conversation[j] as ToolCall)
const nextHasPrompt = onPermissionResponse && allPermissionRequests.get(next.id)
if (nextHasPrompt) break
group.push(next)
j += 1 j += 1
} }
@ -485,6 +534,10 @@ export function ChatSidebar({
title={getToolGroupTitle(group.map((t) => t.name))} title={getToolGroupTitle(group.map((t) => t.name))}
items={group.map(toActivityItem)} items={group.map(toActivityItem)}
summary={titles} summary={titles}
defaultOpen={group.some((t) => {
const state = getToolUiState(t)
return state === 'approval-requested' || state === 'output-error'
})}
/> />
) )
i = j - 1 i = j - 1
@ -495,27 +548,6 @@ export function ChatSidebar({
const rendered = renderConversationItem(item) const rendered = renderConversationItem(item)
if (!rendered) continue 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) nodes.push(rendered)
} }
@ -611,7 +643,7 @@ export function ChatSidebar({
</Message> </Message>
)} )}
{isProcessing && !currentAssistantMessage && !currentReasoning && ( {showThinkingIndicator && (
<Message from="assistant"> <Message from="assistant">
<MessageContent> <MessageContent>
<Shimmer duration={1}>Thinking...</Shimmer> <Shimmer duration={1}>Thinking...</Shimmer>