chatroom-tool-request-banner.tsx
1 'use client' 2 3 import { useState, useRef } from 'react' 4 import { useAppStore } from '@/stores/use-app-store' 5 import { useChatroomStore } from '@/stores/use-chatroom-store' 6 import { api } from '@/lib/app/api-client' 7 import { TOOL_LABELS } from '@/lib/tool-definitions' 8 import { getEnabledToolIds } from '@/lib/capability-selection' 9 10 interface Props { 11 agentId: string 12 agentName: string 13 text: string 14 toolOutputs?: string[] 15 } 16 17 export function ChatroomToolRequestBanner({ agentId, agentName, text, toolOutputs = [] }: Props) { 18 const loadAgents = useAppStore((s) => s.loadAgents) 19 const agents = useAppStore((s) => s.agents) 20 const [granted, setGranted] = useState<Set<string>>(new Set()) 21 const [denied, setDenied] = useState<Set<string>>(new Set()) 22 const continueSentRef = useRef(false) 23 24 const toolRequests: { toolId: string; reason: string }[] = [] 25 const seen = new Set<string>() 26 27 function extractFromText(t: string) { 28 try { 29 const jsonMatches = t.match(/\{"type"\s*:\s*"tool_request"[^}]*\}/g) 30 if (jsonMatches) { 31 for (const jm of jsonMatches) { 32 const parsed = JSON.parse(jm) 33 if (parsed.type === 'tool_request' && parsed.toolId && !seen.has(parsed.toolId)) { 34 seen.add(parsed.toolId) 35 toolRequests.push({ toolId: parsed.toolId, reason: parsed.reason || '' }) 36 } 37 } 38 } 39 } catch { /* ignore */ } 40 } 41 42 extractFromText(text) 43 for (const output of toolOutputs) extractFromText(output) 44 45 if (toolRequests.length === 0) return null 46 47 const agent = agents[agentId] 48 const agentTools = getEnabledToolIds(agent) 49 50 const handleGrant = async (toolId: string) => { 51 if (agentTools.includes(toolId)) { 52 setGranted((prev) => new Set(prev).add(toolId)) 53 return 54 } 55 const updated = [...agentTools, toolId] 56 await api('PUT', `/agents/${agentId}`, { tools: updated }) 57 await loadAgents() 58 const newGranted = new Set(granted).add(toolId) 59 setGranted(newGranted) 60 61 // Auto-continue: once all requested tools are granted, send @mention to continue 62 const allGranted = toolRequests.every( 63 (r) => newGranted.has(r.toolId) || updated.includes(r.toolId), 64 ) 65 if (allGranted && !continueSentRef.current) { 66 continueSentRef.current = true 67 setTimeout(() => { 68 const { streaming, sendMessage } = useChatroomStore.getState() 69 if (!streaming) { 70 sendMessage(`@${agentName.replace(/\s+/g, '')} Continue`) 71 } 72 }, 300) 73 } 74 } 75 76 const handleDeny = (toolId: string) => { 77 setDenied((prev) => new Set(prev).add(toolId)) 78 const label = TOOL_LABELS[toolId] || toolId 79 setTimeout(() => { 80 const { streaming, sendMessage } = useChatroomStore.getState() 81 if (!streaming) { 82 sendMessage(`@${agentName.replace(/\s+/g, '')} Tool access denied for ${label} — proceed without it.`) 83 } 84 }, 200) 85 } 86 87 return ( 88 <div className="max-w-[85%] flex flex-col gap-2 mt-2"> 89 {toolRequests.map(({ toolId, reason }) => { 90 const isGranted = granted.has(toolId) || agentTools.includes(toolId) 91 const isDenied = denied.has(toolId) 92 const label = TOOL_LABELS[toolId] || toolId 93 return ( 94 <div 95 key={toolId} 96 className="flex items-center gap-3 px-4 py-3 rounded-[12px] border border-amber-500/20 bg-amber-500/[0.06]" 97 style={{ animation: 'fade-in 0.2s ease' }} 98 > 99 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-amber-400 shrink-0"> 100 <path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" /> 101 </svg> 102 <div className="flex-1 min-w-0"> 103 <p className="text-[12px] text-text-2 font-600"> 104 <span className="text-accent-bright">{agentName}</span> requesting <span className="text-amber-400">{label}</span> 105 </p> 106 {reason && <p className="text-[11px] text-text-3/60 mt-0.5 truncate">{reason}</p>} 107 <p className="text-[10px] text-text-3/45 mt-1"> 108 Approving updates this agent's tool access and posts a follow-up continue message in the room. 109 </p> 110 </div> 111 {isGranted ? ( 112 <span className="text-[11px] text-emerald-400 font-600 shrink-0">Granted</span> 113 ) : isDenied ? ( 114 <span className="text-[11px] text-red-400 font-600 shrink-0">Denied</span> 115 ) : ( 116 <div className="flex gap-1.5 shrink-0"> 117 <button 118 onClick={() => handleGrant(toolId)} 119 className="px-3 py-1.5 rounded-[8px] bg-amber-500/20 hover:bg-amber-500/30 text-amber-300 text-[11px] font-600 border-none cursor-pointer transition-colors" 120 style={{ fontFamily: 'inherit' }} 121 > 122 Grant & Continue 123 </button> 124 <button 125 onClick={() => handleDeny(toolId)} 126 className="px-3 py-1.5 rounded-[8px] bg-red-500/15 hover:bg-red-500/25 text-red-400 text-[11px] font-600 border-none cursor-pointer transition-colors" 127 style={{ fontFamily: 'inherit' }} 128 > 129 Deny & Reply 130 </button> 131 </div> 132 )} 133 </div> 134 ) 135 })} 136 </div> 137 ) 138 }