diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 1d99df14..e59e7dcf 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -35,7 +35,7 @@ import { } from '@/components/ai-elements/prompt-input'; 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 { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput, type ToolStatus } 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'; @@ -1318,6 +1318,9 @@ function App() { if (!userMessage) return setMessage('') + setIsProcessing(true) + setIsStopping(false) + setStopClickedAt(null) const userMessageId = `user-${Date.now()}` setConversation(prev => [...prev, { @@ -1336,7 +1339,11 @@ function App() { }) currentRunId = run.id setRunId(currentRunId) + runIdRef.current = currentRunId 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 @@ -1372,6 +1379,7 @@ function App() { loadRuns() } } catch (error) { + setIsProcessing(false) console.error('Failed to send message:', error) } } @@ -2006,6 +2014,21 @@ function App() { } }, [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) => { if (isChatMessage(item)) { if (item.role === 'user') { @@ -2039,24 +2062,39 @@ function App() { ) } - 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 ( - - - - + 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) + const permRequest = allPermissionRequests.get(item.id) + const response = permissionResponses.get(item.id) ?? null + + return ( + + + + {output !== null ? ( ) : null} + {permRequest ? ( +
+ handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} + onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} + isProcessing={isProcessing} + response={response} + /> +
+ ) : null}
) @@ -2075,6 +2113,15 @@ function App() { } 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 ? "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" @@ -2095,29 +2142,38 @@ function App() { const toActivityItem = (toolCall: ToolCall): ToolActivityItem => { const display = getToolDisplay(toolCall.name) const errorText = toolCall.status === 'error' ? 'Tool error' : '' + const permRequest = allPermissionRequests.get(toolCall.id) + const response = permissionResponses.get(toolCall.id) ?? null return { id: toolCall.id, title: display.title, subtitle: display.subtitle, - state: toToolState(toolCall.status), + state: getToolUiState(toolCall), input: normalizeToolInput(toolCall.input), output: normalizeToolOutput(toolCall.result, toolCall.status) as ToolUIPart['output'], errorText, + defaultOpen: getToolUiState(toolCall) === 'approval-requested' || getToolUiState(toolCall) === 'output-error', + extra: permRequest ? ( + 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++) { 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)) { + // Group consecutive tool calls into a single compact "activity" block. + if (isToolCall(item)) { const group: ToolCall[] = [item] let j = i + 1 - while ( - j < conversation.length - && isToolCall(conversation[j]) - && !allPermissionRequests.get((conversation[j] as ToolCall).id) - ) { + while (j < conversation.length && isToolCall(conversation[j])) { group.push(conversation[j] as ToolCall) j += 1 } @@ -2130,6 +2186,10 @@ function App() { title={getToolGroupTitle(group.map((t) => t.name))} items={group.map(toActivityItem)} summary={titles} + defaultOpen={group.some((t) => { + const state = getToolUiState(t) + return state === 'approval-requested' || state === 'output-error' + })} /> ) i = j - 1 @@ -2140,27 +2200,6 @@ function App() { 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) } @@ -2339,7 +2378,7 @@ function App() { )} - {isProcessing && !currentAssistantMessage && !currentReasoning && ( + {showThinkingIndicator && ( Thinking... 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 index ba9a407c..a5110a3a 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/tool-activity.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/tool-activity.tsx @@ -21,6 +21,8 @@ export type ToolActivityItem = { input: ToolUIPart["input"]; output: ToolUIPart["output"]; errorText: ToolUIPart["errorText"]; + extra?: ReactNode; + defaultOpen?: boolean; }; function getGroupState(items: ToolActivityItem[]): ToolStatus { @@ -79,7 +81,7 @@ export function ToolActivity({
{items.map((item) => ( - +
{item.title}
@@ -97,6 +99,9 @@ export function ToolActivity({ + {item.extra ? ( +
{item.extra}
+ ) : null}
))} diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index cb5840ca..be7a6e21 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -21,7 +21,7 @@ import { } from '@/components/ai-elements/message' 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 { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput, type ToolStatus } 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' @@ -187,6 +187,21 @@ export function ChatSidebar({ const autoMentionRef = useRef<{ path: string; displayName: string } | null>(null) const lastSelectedPathRef = useRef(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") const mentionLabels = useMemo(() => { if (knowledgeFiles.length === 0) return [] @@ -329,6 +344,18 @@ export function ChatSidebar({ }, [selectedPath, message, onMessageChange]) 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 handleSubmit = () => { @@ -413,19 +440,33 @@ export function ChatSidebar({ const output = normalizeToolOutput(item.result, item.status) const input = normalizeToolInput(item.input) const display = getToolDisplay(item.name) + const permRequest = allPermissionRequests.get(item.id) + const response = permissionResponses.get(item.id) ?? null return ( - + {output !== null ? ( ) : null} + {permRequest ? ( +
+ onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve') : undefined} + onDeny={onPermissionResponse ? () => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny') : undefined} + isProcessing={isProcessing} + response={response} + /> +
+ ) : null}
) @@ -449,31 +490,39 @@ export function ChatSidebar({ const toActivityItem = (toolCall: ToolCall): ToolActivityItem => { const display = getToolDisplay(toolCall.name) const errorText = toolCall.status === 'error' ? 'Tool error' : '' + const permRequest = allPermissionRequests.get(toolCall.id) + const response = permissionResponses.get(toolCall.id) ?? null return { id: toolCall.id, title: display.title, subtitle: display.subtitle, - state: toToolState(toolCall.status), + state: getToolUiState(toolCall), input: normalizeToolInput(toolCall.input), output: normalizeToolOutput(toolCall.result, toolCall.status) as ToolUIPart['output'], errorText, + defaultOpen: getToolUiState(toolCall) === 'approval-requested' || getToolUiState(toolCall) === 'output-error', + extra: permRequest ? ( + 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++) { 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) { + // Group consecutive tool calls into a single compact "activity" block. + if (isToolCall(item)) { 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) + group.push(conversation[j] as ToolCall) j += 1 } @@ -485,6 +534,10 @@ export function ChatSidebar({ title={getToolGroupTitle(group.map((t) => t.name))} items={group.map(toActivityItem)} summary={titles} + defaultOpen={group.some((t) => { + const state = getToolUiState(t) + return state === 'approval-requested' || state === 'output-error' + })} /> ) i = j - 1 @@ -495,27 +548,6 @@ export function ChatSidebar({ 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) } @@ -611,7 +643,7 @@ export function ChatSidebar({ )} - {isProcessing && !currentAssistantMessage && !currentReasoning && ( + {showThinkingIndicator && ( Thinking...