integrate tool permission + ask human responses in UI

This commit is contained in:
Ramnique Singh 2026-01-20 16:21:33 +05:30
parent fdbd7343ec
commit 146c3d66bd
4 changed files with 507 additions and 10 deletions

View file

@ -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<RunListItem[]>([])
// Pending requests state
const [pendingPermissionRequests, setPendingPermissionRequests] = useState<Map<string, z.infer<typeof ToolPermissionRequestEvent>>>(new Map())
const [pendingAskHumanRequests, setPendingAskHumanRequests] = useState<Map<string, z.infer<typeof AskHumanRequestEvent>>>(new Map())
// Track ALL permission requests (for rendering with response status)
const [allPermissionRequests, setAllPermissionRequests] = useState<Map<string, z.infer<typeof ToolPermissionRequestEvent>>>(new Map())
// Track permission responses (toolCallId -> response)
const [permissionResponses, setPermissionResponses] = useState<Map<string, 'approve' | 'deny'>>(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<string, z.infer<typeof ToolPermissionRequestEvent>>()
const permResponseMap = new Map<string, 'approve' | 'deny'>()
const askHumanRequests = new Map<string, z.infer<typeof AskHumanRequestEvent>>()
const respondedAskHumanIds = new Set<string>()
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<string, z.infer<typeof ToolPermissionRequestEvent>>()
for (const [id, req] of allPermissionRequests.entries()) {
if (!permResponseMap.has(id)) {
pendingPerms.set(id, req)
}
}
const pendingAsks = new Map<string, z.infer<typeof AskHumanRequestEvent>>()
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() {
</ConversationEmptyState>
) : (
<>
{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 (
<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 */}
{Array.from(pendingAskHumanRequests.values()).map((request) => (
<AskHumanRequest
key={request.toolCallId}
query={request.query}
onSubmit={(response) => handleAskHumanResponse(request.toolCallId, request.subflow, response)}
isProcessing={isProcessing}
/>
))}
{currentReasoning && (
<Reasoning isStreaming>
@ -1406,6 +1606,12 @@ function App() {
recentFiles={recentWikiFiles}
visibleFiles={visibleKnowledgeFiles}
selectedPath={selectedPath}
pendingPermissionRequests={pendingPermissionRequests}
pendingAskHumanRequests={pendingAskHumanRequests}
allPermissionRequests={allPermissionRequests}
permissionResponses={permissionResponses}
onPermissionResponse={handlePermissionResponse}
onAskHumanResponse={handleAskHumanResponse}
/>
)}
</SidebarProvider>

View file

@ -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<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
const canSubmit = Boolean(response.trim()) && !isProcessing;
return (
<div
className={cn(
"not-prose mb-4 w-full rounded-md border border-blue-500/50 bg-blue-50/50 dark:bg-blue-950/20",
className
)}
{...props}
>
<div className="p-4 space-y-4">
<div className="flex items-start gap-3">
<MessageCircleIcon className="size-5 text-blue-600 dark:text-blue-500 shrink-0 mt-0.5" />
<div className="flex-1 space-y-3">
<div>
<h3 className="font-semibold text-sm text-foreground mb-1">
Question from Agent
</h3>
<p className="text-sm text-foreground whitespace-pre-wrap">
{query}
</p>
</div>
<div className="space-y-2">
<Textarea
ref={textareaRef}
value={response}
onChange={(e) => setResponse(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your response..."
disabled={isProcessing}
rows={3}
className="resize-none"
/>
<div className="flex justify-end">
<Button
variant="default"
size="sm"
onClick={handleSubmit}
disabled={!canSubmit}
className="gap-2"
>
<ArrowUpIcon className="size-4" />
Send Response
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,145 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { AlertTriangleIcon, CheckCircleIcon, CheckIcon, XCircleIcon, XIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { ToolCallPart } from "@x/shared/dist/message.js";
import z from "zod";
export type PermissionRequestProps = ComponentProps<"div"> & {
toolCall: z.infer<typeof ToolCallPart>;
onApprove?: () => void;
onDeny?: () => void;
isProcessing?: boolean;
response?: 'approve' | 'deny' | null;
};
export const PermissionRequest = ({
className,
toolCall,
onApprove,
onDeny,
isProcessing = false,
response = null,
...props
}: PermissionRequestProps) => {
// Extract command from arguments if it's executeCommand
const command = toolCall.toolName === "executeCommand"
? (typeof toolCall.arguments === "object" && toolCall.arguments !== null && "command" in toolCall.arguments
? String(toolCall.arguments.command)
: JSON.stringify(toolCall.arguments))
: null;
const isResponded = response !== null;
const isApproved = response === 'approve';
return (
<div
className={cn(
"not-prose mb-4 w-full rounded-md border",
isResponded
? isApproved
? "border-green-500/50 bg-green-50/50 dark:bg-green-950/20"
: "border-red-500/50 bg-red-50/50 dark:bg-red-950/20"
: "border-amber-500/50 bg-amber-50/50 dark:bg-amber-950/20",
className
)}
{...props}
>
<div className="p-4 space-y-4">
<div className="flex items-start gap-3">
{isResponded ? (
isApproved ? (
<CheckCircleIcon className="size-5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
) : (
<XCircleIcon className="size-5 text-red-600 dark:text-red-500 shrink-0 mt-0.5" />
)
) : (
<AlertTriangleIcon className="size-5 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" />
)}
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<div className="flex-1">
<h3 className="font-semibold text-sm text-foreground">
{isResponded ? (isApproved ? "Permission Granted" : "Permission Denied") : "Permission Required"}
</h3>
<p className="text-sm text-muted-foreground mt-1">
{isResponded ? "Requested:" : "The agent wants to execute:"} <span className="font-mono font-medium">{toolCall.toolName}</span>
</p>
</div>
{isResponded && (
<Badge
variant="secondary"
className={cn(
"shrink-0",
isApproved
? "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-400"
: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-400"
)}
>
{isApproved ? (
<>
<CheckIcon className="size-3 mr-1" />
Approved
</>
) : (
<>
<XIcon className="size-3 mr-1" />
Denied
</>
)}
</Badge>
)}
</div>
{command && (
<div className="rounded-md border bg-background/50 p-3 mt-3">
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
Command
</p>
<pre className="whitespace-pre-wrap text-xs font-mono text-foreground break-all">
{command}
</pre>
</div>
)}
{!command && toolCall.arguments && (
<div className="rounded-md border bg-background/50 p-3 mt-3">
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
Arguments
</p>
<pre className="whitespace-pre-wrap text-xs font-mono text-foreground break-all">
{JSON.stringify(toolCall.arguments, null, 2)}
</pre>
</div>
)}
</div>
</div>
{!isResponded && (
<div className="flex items-center gap-2 pt-2">
<Button
variant="default"
size="sm"
onClick={onApprove}
disabled={isProcessing}
className="flex-1"
>
<CheckIcon className="size-4" />
Approve
</Button>
<Button
variant="destructive"
size="sm"
onClick={onDeny}
disabled={isProcessing}
className="flex-1"
>
<XIcon className="size-4" />
Deny
</Button>
</div>
)}
</div>
</div>
);
};

View file

@ -21,11 +21,16 @@ 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 { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input'
import { useMentionDetection } from '@/hooks/use-mention-detection'
import { MentionPopover } from '@/components/mention-popover'
import { toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
import { getMentionHighlightSegments } from '@/lib/mention-highlights'
import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js'
import z from 'zod'
import React from 'react'
interface ChatMessage {
id: string
@ -116,6 +121,12 @@ interface ChatSidebarProps {
recentFiles?: string[]
visibleFiles?: string[]
selectedPath?: string | null
pendingPermissionRequests?: Map<string, z.infer<typeof ToolPermissionRequestEvent>>
pendingAskHumanRequests?: Map<string, z.infer<typeof AskHumanRequestEvent>>
allPermissionRequests?: Map<string, z.infer<typeof ToolPermissionRequestEvent>>
permissionResponses?: Map<string, 'approve' | 'deny'>
onPermissionResponse?: (toolCallId: string, subflow: string[], response: 'approve' | 'deny') => void
onAskHumanResponse?: (toolCallId: string, subflow: string[], response: string) => void
}
export function ChatSidebar({
@ -134,6 +145,12 @@ export function ChatSidebar({
recentFiles = [],
visibleFiles = [],
selectedPath,
pendingPermissionRequests = new Map(),
pendingAskHumanRequests = new Map(),
allPermissionRequests = new Map(),
permissionResponses = new Map(),
onPermissionResponse,
onAskHumanResponse,
}: ChatSidebarProps) {
const [width, setWidth] = useState(defaultWidth)
const [isResizing, setIsResizing] = useState(false)
@ -465,7 +482,39 @@ export function ChatSidebar({
</ConversationEmptyState>
) : (
<>
{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) && 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 */}
{onAskHumanResponse && Array.from(pendingAskHumanRequests.values()).map((request) => (
<AskHumanRequest
key={request.toolCallId}
query={request.query}
onSubmit={(response) => onAskHumanResponse(request.toolCallId, request.subflow, response)}
isProcessing={isProcessing}
/>
))}
{currentReasoning && (
<Reasoning isStreaming>