/ src / components / chatrooms / chatroom-tool-request-banner.tsx
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&apos;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  }