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 { 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 } 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 {
|
import {
|
||||||
SidebarInset,
|
SidebarInset,
|
||||||
SidebarProvider,
|
SidebarProvider,
|
||||||
|
|
@ -387,6 +390,14 @@ function App() {
|
||||||
type RunListItem = { id: string; createdAt: string; agentId: string }
|
type RunListItem = { id: string; createdAt: string; agentId: string }
|
||||||
const [runs, setRuns] = useState<RunListItem[]>([])
|
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
|
// Load directory tree
|
||||||
const loadDirectory = useCallback(async () => {
|
const loadDirectory = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -565,10 +576,28 @@ function App() {
|
||||||
if (typeof msg.content === 'string') {
|
if (typeof msg.content === 'string') {
|
||||||
textContent = msg.content
|
textContent = msg.content
|
||||||
} else if (Array.isArray(msg.content)) {
|
} else if (Array.isArray(msg.content)) {
|
||||||
|
// Extract text parts
|
||||||
textContent = msg.content
|
textContent = msg.content
|
||||||
.filter((part: { type: string }) => part.type === 'text')
|
.filter((part: { type: string }) => part.type === 'text')
|
||||||
.map((part: { type: string; text?: string }) => part.text || '')
|
.map((part: { type: string; text?: string }) => part.text || '')
|
||||||
.join('')
|
.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) {
|
if (textContent) {
|
||||||
items.push({
|
items.push({
|
||||||
|
|
@ -582,6 +611,12 @@ function App() {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'tool-invocation': {
|
case 'tool-invocation': {
|
||||||
|
// 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 = {
|
const toolCall: ToolCall = {
|
||||||
id: event.toolCallId || `tool-${Date.now()}-${Math.random()}`,
|
id: event.toolCallId || `tool-${Date.now()}-${Math.random()}`,
|
||||||
name: event.toolName,
|
name: event.toolName,
|
||||||
|
|
@ -591,6 +626,7 @@ function App() {
|
||||||
}
|
}
|
||||||
toolCallMap.set(toolCall.id, toolCall)
|
toolCallMap.set(toolCall.id, toolCall)
|
||||||
items.push(toolCall)
|
items.push(toolCall)
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'tool-result': {
|
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
|
// Set the conversation and runId
|
||||||
setConversation(items)
|
setConversation(items)
|
||||||
setRunId(id)
|
setRunId(id)
|
||||||
setCurrentAssistantMessage('')
|
setCurrentAssistantMessage('')
|
||||||
setCurrentReasoning('')
|
setCurrentReasoning('')
|
||||||
setMessage('')
|
setMessage('')
|
||||||
|
setPendingPermissionRequests(pendingPerms)
|
||||||
|
setPendingAskHumanRequests(pendingAsks)
|
||||||
|
setAllPermissionRequests(allPermissionRequests)
|
||||||
|
setPermissionResponses(permResponseMap)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load run:', err)
|
console.error('Failed to load run:', err)
|
||||||
}
|
}
|
||||||
|
|
@ -771,6 +844,54 @@ function App() {
|
||||||
break
|
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':
|
case 'error':
|
||||||
setIsProcessing(false)
|
setIsProcessing(false)
|
||||||
console.error('Run error:', event.error)
|
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(() => {
|
const handleNewChat = useCallback(() => {
|
||||||
setConversation([])
|
setConversation([])
|
||||||
setCurrentAssistantMessage('')
|
setCurrentAssistantMessage('')
|
||||||
|
|
@ -846,6 +1010,10 @@ function App() {
|
||||||
setRunId(null)
|
setRunId(null)
|
||||||
setMessage('')
|
setMessage('')
|
||||||
setModelUsage(null)
|
setModelUsage(null)
|
||||||
|
setPendingPermissionRequests(new Map())
|
||||||
|
setPendingAskHumanRequests(new Map())
|
||||||
|
setAllPermissionRequests(new Map())
|
||||||
|
setPermissionResponses(new Map())
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleChatInputSubmit = (text: string) => {
|
const handleChatInputSubmit = (text: string) => {
|
||||||
|
|
@ -1343,7 +1511,39 @@ function App() {
|
||||||
</ConversationEmptyState>
|
</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 && (
|
{currentReasoning && (
|
||||||
<Reasoning isStreaming>
|
<Reasoning isStreaming>
|
||||||
|
|
@ -1406,6 +1606,12 @@ function App() {
|
||||||
recentFiles={recentWikiFiles}
|
recentFiles={recentWikiFiles}
|
||||||
visibleFiles={visibleKnowledgeFiles}
|
visibleFiles={visibleKnowledgeFiles}
|
||||||
selectedPath={selectedPath}
|
selectedPath={selectedPath}
|
||||||
|
pendingPermissionRequests={pendingPermissionRequests}
|
||||||
|
pendingAskHumanRequests={pendingAskHumanRequests}
|
||||||
|
allPermissionRequests={allPermissionRequests}
|
||||||
|
permissionResponses={permissionResponses}
|
||||||
|
onPermissionResponse={handlePermissionResponse}
|
||||||
|
onAskHumanResponse={handleAskHumanResponse}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</SidebarProvider>
|
</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 { 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 } 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 { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input'
|
||||||
import { useMentionDetection } from '@/hooks/use-mention-detection'
|
import { useMentionDetection } from '@/hooks/use-mention-detection'
|
||||||
import { MentionPopover } from '@/components/mention-popover'
|
import { MentionPopover } from '@/components/mention-popover'
|
||||||
import { toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
|
import { toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
|
||||||
import { getMentionHighlightSegments } from '@/lib/mention-highlights'
|
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 {
|
interface ChatMessage {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -116,6 +121,12 @@ interface ChatSidebarProps {
|
||||||
recentFiles?: string[]
|
recentFiles?: string[]
|
||||||
visibleFiles?: string[]
|
visibleFiles?: string[]
|
||||||
selectedPath?: string | null
|
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({
|
export function ChatSidebar({
|
||||||
|
|
@ -134,6 +145,12 @@ export function ChatSidebar({
|
||||||
recentFiles = [],
|
recentFiles = [],
|
||||||
visibleFiles = [],
|
visibleFiles = [],
|
||||||
selectedPath,
|
selectedPath,
|
||||||
|
pendingPermissionRequests = new Map(),
|
||||||
|
pendingAskHumanRequests = new Map(),
|
||||||
|
allPermissionRequests = new Map(),
|
||||||
|
permissionResponses = new Map(),
|
||||||
|
onPermissionResponse,
|
||||||
|
onAskHumanResponse,
|
||||||
}: ChatSidebarProps) {
|
}: ChatSidebarProps) {
|
||||||
const [width, setWidth] = useState(defaultWidth)
|
const [width, setWidth] = useState(defaultWidth)
|
||||||
const [isResizing, setIsResizing] = useState(false)
|
const [isResizing, setIsResizing] = useState(false)
|
||||||
|
|
@ -465,7 +482,39 @@ export function ChatSidebar({
|
||||||
</ConversationEmptyState>
|
</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 && (
|
{currentReasoning && (
|
||||||
<Reasoning isStreaming>
|
<Reasoning isStreaming>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue