mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-19 18:35:18 +02:00
integrate tool permission + ask human responses in UI
This commit is contained in:
parent
fdbd7343ec
commit
146c3d66bd
4 changed files with 507 additions and 10 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue