From 146c3d66bd52b8ed46b1ba5992e3e9ac0294729e Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Tue, 20 Jan 2026 16:21:33 +0530 Subject: [PATCH] integrate tool permission + ask human responses in UI --- apps/x/apps/renderer/src/App.tsx | 224 +++++++++++++++++- .../ai-elements/ask-human-request.tsx | 97 ++++++++ .../ai-elements/permission-request.tsx | 145 ++++++++++++ .../renderer/src/components/chat-sidebar.tsx | 51 +++- 4 files changed, 507 insertions(+), 10 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/ai-elements/ask-human-request.tsx create mode 100644 apps/x/apps/renderer/src/components/ai-elements/permission-request.tsx diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 13cc4704..8ad3382b 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -36,6 +36,9 @@ 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 { PermissionRequest } from '@/components/ai-elements/permission-request'; +import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'; +import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js'; import { SidebarInset, SidebarProvider, @@ -387,6 +390,14 @@ function App() { type RunListItem = { id: string; createdAt: string; agentId: string } const [runs, setRuns] = useState([]) + // Pending requests state + const [pendingPermissionRequests, setPendingPermissionRequests] = useState>>(new Map()) + const [pendingAskHumanRequests, setPendingAskHumanRequests] = useState>>(new Map()) + // Track ALL permission requests (for rendering with response status) + const [allPermissionRequests, setAllPermissionRequests] = useState>>(new Map()) + // Track permission responses (toolCallId -> response) + const [permissionResponses, setPermissionResponses] = useState>(new Map()) + // Load directory tree const loadDirectory = useCallback(async () => { try { @@ -565,10 +576,28 @@ function App() { if (typeof msg.content === 'string') { textContent = msg.content } else if (Array.isArray(msg.content)) { + // Extract text parts textContent = msg.content .filter((part: { type: string }) => part.type === 'text') .map((part: { type: string; text?: string }) => part.text || '') .join('') + + // Also extract tool-call parts from assistant messages + if (msg.role === 'assistant') { + for (const part of msg.content) { + if (part.type === 'tool-call') { + const toolCall: ToolCall = { + id: part.toolCallId, + name: part.toolName, + input: normalizeToolInput(part.arguments), + status: 'pending', + timestamp: event.ts ? new Date(event.ts).getTime() : Date.now(), + } + toolCallMap.set(toolCall.id, toolCall) + items.push(toolCall) + } + } + } } if (textContent) { items.push({ @@ -582,15 +611,22 @@ function App() { break } case 'tool-invocation': { - const toolCall: ToolCall = { - id: event.toolCallId || `tool-${Date.now()}-${Math.random()}`, - name: event.toolName, - input: normalizeToolInput(event.input), - status: 'running', - timestamp: event.ts ? new Date(event.ts).getTime() : Date.now(), + // Update existing tool call status or create new one + const existingTool = event.toolCallId ? toolCallMap.get(event.toolCallId) : null + if (existingTool) { + existingTool.input = normalizeToolInput(event.input) + existingTool.status = 'running' + } else { + const toolCall: ToolCall = { + id: event.toolCallId || `tool-${Date.now()}-${Math.random()}`, + name: event.toolName, + input: normalizeToolInput(event.input), + status: 'running', + timestamp: event.ts ? new Date(event.ts).getTime() : Date.now(), + } + toolCallMap.set(toolCall.id, toolCall) + items.push(toolCall) } - toolCallMap.set(toolCall.id, toolCall) - items.push(toolCall) break } case 'tool-result': { @@ -609,12 +645,49 @@ function App() { } } + // Track permission requests and responses from history + const allPermissionRequests = new Map>() + const permResponseMap = new Map() + const askHumanRequests = new Map>() + const respondedAskHumanIds = new Set() + + for (const event of run.log) { + if (event.type === 'tool-permission-request') { + allPermissionRequests.set(event.toolCall.toolCallId, event) + } else if (event.type === 'tool-permission-response') { + permResponseMap.set(event.toolCallId, event.response) + } else if (event.type === 'ask-human-request') { + askHumanRequests.set(event.toolCallId, event) + } else if (event.type === 'ask-human-response') { + respondedAskHumanIds.add(event.toolCallId) + } + } + + // Separate pending vs responded permission requests + const pendingPerms = new Map>() + for (const [id, req] of allPermissionRequests.entries()) { + if (!permResponseMap.has(id)) { + pendingPerms.set(id, req) + } + } + + const pendingAsks = new Map>() + for (const [id, req] of askHumanRequests.entries()) { + if (!respondedAskHumanIds.has(id)) { + pendingAsks.set(id, req) + } + } + // Set the conversation and runId setConversation(items) setRunId(id) setCurrentAssistantMessage('') setCurrentReasoning('') setMessage('') + setPendingPermissionRequests(pendingPerms) + setPendingAskHumanRequests(pendingAsks) + setAllPermissionRequests(allPermissionRequests) + setPermissionResponses(permResponseMap) } catch (err) { console.error('Failed to load run:', err) } @@ -771,6 +844,54 @@ function App() { break } + case 'tool-permission-request': { + const key = event.toolCall.toolCallId + setPendingPermissionRequests(prev => { + const next = new Map(prev) + next.set(key, event) + return next + }) + setAllPermissionRequests(prev => { + const next = new Map(prev) + next.set(key, event) + return next + }) + break + } + + case 'tool-permission-response': { + setPendingPermissionRequests(prev => { + const next = new Map(prev) + next.delete(event.toolCallId) + return next + }) + setPermissionResponses(prev => { + const next = new Map(prev) + next.set(event.toolCallId, event.response) + return next + }) + break + } + + case 'ask-human-request': { + const key = event.toolCallId + setPendingAskHumanRequests(prev => { + const next = new Map(prev) + next.set(key, event) + return next + }) + break + } + + case 'ask-human-response': { + setPendingAskHumanRequests(prev => { + const next = new Map(prev) + next.delete(event.toolCallId) + return next + }) + break + } + case 'error': setIsProcessing(false) console.error('Run error:', event.error) @@ -839,6 +960,49 @@ function App() { } } + const handlePermissionResponse = useCallback(async (toolCallId: string, subflow: string[], response: 'approve' | 'deny') => { + if (!runId) return + + // Optimistically update the UI immediately + setPermissionResponses(prev => { + const next = new Map(prev) + next.set(toolCallId, response) + return next + }) + setPendingPermissionRequests(prev => { + const next = new Map(prev) + next.delete(toolCallId) + return next + }) + + try { + await window.ipc.invoke('runs:authorizePermission', { + runId, + authorization: { subflow, toolCallId, response } + }) + } catch (error) { + console.error('Failed to authorize permission:', error) + // Revert the optimistic update on error + setPermissionResponses(prev => { + const next = new Map(prev) + next.delete(toolCallId) + return next + }) + } + }, [runId]) + + const handleAskHumanResponse = useCallback(async (toolCallId: string, subflow: string[], response: string) => { + if (!runId) return + try { + await window.ipc.invoke('runs:provideHumanInput', { + runId, + reply: { subflow, toolCallId, response } + }) + } catch (error) { + console.error('Failed to provide human input:', error) + } + }, [runId]) + const handleNewChat = useCallback(() => { setConversation([]) setCurrentAssistantMessage('') @@ -846,6 +1010,10 @@ function App() { setRunId(null) setMessage('') setModelUsage(null) + setPendingPermissionRequests(new Map()) + setPendingAskHumanRequests(new Map()) + setAllPermissionRequests(new Map()) + setPermissionResponses(new Map()) }, []) const handleChatInputSubmit = (text: string) => { @@ -1343,7 +1511,39 @@ function App() { ) : ( <> - {conversation.map(item => renderConversationItem(item))} + {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 + })} + + {/* Render pending ask-human requests */} + {Array.from(pendingAskHumanRequests.values()).map((request) => ( + handleAskHumanResponse(request.toolCallId, request.subflow, response)} + isProcessing={isProcessing} + /> + ))} {currentReasoning && ( @@ -1406,6 +1606,12 @@ function App() { recentFiles={recentWikiFiles} visibleFiles={visibleKnowledgeFiles} selectedPath={selectedPath} + pendingPermissionRequests={pendingPermissionRequests} + pendingAskHumanRequests={pendingAskHumanRequests} + allPermissionRequests={allPermissionRequests} + permissionResponses={permissionResponses} + onPermissionResponse={handlePermissionResponse} + onAskHumanResponse={handleAskHumanResponse} /> )} diff --git a/apps/x/apps/renderer/src/components/ai-elements/ask-human-request.tsx b/apps/x/apps/renderer/src/components/ai-elements/ask-human-request.tsx new file mode 100644 index 00000000..8c20f337 --- /dev/null +++ b/apps/x/apps/renderer/src/components/ai-elements/ask-human-request.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; +import { MessageCircleIcon, ArrowUpIcon } from "lucide-react"; +import type { ComponentProps } from "react"; +import { useState, useRef, useEffect } from "react"; + +export type AskHumanRequestProps = ComponentProps<"div"> & { + query: string; + onSubmit: (response: string) => void; + isProcessing?: boolean; +}; + +export const AskHumanRequest = ({ + className, + query, + onSubmit, + isProcessing = false, + ...props +}: AskHumanRequestProps) => { + const [response, setResponse] = useState(""); + const textareaRef = useRef(null); + + useEffect(() => { + // Auto-focus the textarea when component mounts + textareaRef.current?.focus(); + }, []); + + const handleSubmit = () => { + const trimmed = response.trim(); + if (trimmed && !isProcessing) { + onSubmit(trimmed); + setResponse(""); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }; + + const canSubmit = Boolean(response.trim()) && !isProcessing; + + return ( +
+
+
+ +
+
+

+ Question from Agent +

+

+ {query} +

+
+
+