@@ -4,7 +4,10 @@ import { useState, useRef, useEffect, useMemo } from 'react';
44import { useChat } from '@ai-sdk/react' ;
55import { DefaultChatTransport } from 'ai' ;
66import type { UIMessage } from 'ai' ;
7- import { Bot , X , Send , Trash2 , Sparkles } from 'lucide-react' ;
7+ import {
8+ Bot , X , Send , Trash2 , Sparkles ,
9+ Wrench , CheckCircle2 , XCircle , Loader2 , ShieldAlert ,
10+ } from 'lucide-react' ;
811import { Button } from '@/components/ui/button' ;
912import { ScrollArea } from '@/components/ui/scroll-area' ;
1013import { Tooltip , TooltipContent , TooltipProvider , TooltipTrigger } from '@/components/ui/tooltip' ;
@@ -25,6 +28,168 @@ function getMessageText(msg: UIMessage): string {
2528 . join ( '' ) ;
2629}
2730
31+ /**
32+ * Convert a snake_case tool name to a human-readable label.
33+ */
34+ function formatToolName ( name : string ) : string {
35+ return name
36+ . replace ( / _ / g, ' ' )
37+ . replace ( / \b \w / g, ( c ) => c . toUpperCase ( ) ) ;
38+ }
39+
40+ /**
41+ * Render a concise summary of tool input arguments.
42+ */
43+ function formatToolArgs ( input : unknown ) : string {
44+ if ( ! input || typeof input !== 'object' ) return '' ;
45+ const entries = Object . entries ( input as Record < string , unknown > ) ;
46+ if ( entries . length === 0 ) return '' ;
47+ return entries
48+ . slice ( 0 , 4 )
49+ . map ( ( [ k , v ] ) => {
50+ const val = typeof v === 'string' ? v : JSON . stringify ( v ) ;
51+ const display = typeof val === 'string' && val . length > 30 ? val . slice ( 0 , 30 ) + '…' : val ;
52+ return `${ k } : ${ display } ` ;
53+ } )
54+ . join ( ', ' ) ;
55+ }
56+
57+ /**
58+ * Type guard to check if a message part is a tool invocation (dynamic-tool).
59+ */
60+ function isToolPart ( part : UIMessage [ 'parts' ] [ number ] ) : part is Extract < UIMessage [ 'parts' ] [ number ] , { type : 'dynamic-tool' } > {
61+ return part . type === 'dynamic-tool' ;
62+ }
63+
64+ // ── Tool Invocation State Labels ────────────────────────────────────
65+
66+ interface ToolInvocationDisplayProps {
67+ part : Extract < UIMessage [ 'parts' ] [ number ] , { type : 'dynamic-tool' } > ;
68+ onApprove ?: ( approvalId : string ) => void ;
69+ onDeny ?: ( approvalId : string ) => void ;
70+ }
71+
72+ /**
73+ * Renders a single tool invocation part with appropriate status indicator.
74+ */
75+ function ToolInvocationDisplay ( { part, onApprove, onDeny } : ToolInvocationDisplayProps ) {
76+ const toolLabel = formatToolName ( part . toolName ) ;
77+ const argsText = formatToolArgs ( part . input ) ;
78+
79+ switch ( part . state ) {
80+ case 'input-streaming' :
81+ case 'input-available' :
82+ return (
83+ < div
84+ data-testid = "tool-invocation-calling"
85+ className = "flex items-start gap-2 rounded-md border border-border/50 bg-muted/50 px-2.5 py-2 text-xs"
86+ >
87+ < Loader2 className = "mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin text-primary" />
88+ < div className = "min-w-0" >
89+ < span className = "font-medium" > Calling { toolLabel } </ span >
90+ { argsText && (
91+ < p className = "mt-0.5 truncate text-muted-foreground" > { argsText } </ p >
92+ ) }
93+ </ div >
94+ </ div >
95+ ) ;
96+
97+ case 'approval-requested' :
98+ return (
99+ < div
100+ data-testid = "tool-invocation-confirm"
101+ className = "flex flex-col gap-2 rounded-md border border-yellow-500/40 bg-yellow-500/10 px-2.5 py-2 text-xs"
102+ >
103+ < div className = "flex items-start gap-2" >
104+ < ShieldAlert className = "mt-0.5 h-3.5 w-3.5 shrink-0 text-yellow-600 dark:text-yellow-400" />
105+ < div className = "min-w-0" >
106+ < span className = "font-medium" > Confirm: { toolLabel } </ span >
107+ { argsText && (
108+ < p className = "mt-0.5 text-muted-foreground" > { argsText } </ p >
109+ ) }
110+ </ div >
111+ </ div >
112+ { part . approval && onApprove && onDeny && (
113+ < div className = "flex gap-2 pl-5" >
114+ < Button
115+ size = "sm"
116+ variant = "outline"
117+ className = "h-6 px-2 text-xs"
118+ onClick = { ( ) => onApprove ( part . approval ! . id ) }
119+ >
120+ < CheckCircle2 className = "mr-1 h-3 w-3" />
121+ Approve
122+ </ Button >
123+ < Button
124+ size = "sm"
125+ variant = "ghost"
126+ className = "h-6 px-2 text-xs text-destructive hover:text-destructive"
127+ onClick = { ( ) => onDeny ( part . approval ! . id ) }
128+ >
129+ < XCircle className = "mr-1 h-3 w-3" />
130+ Deny
131+ </ Button >
132+ </ div >
133+ ) }
134+ </ div >
135+ ) ;
136+
137+ case 'output-available' :
138+ return (
139+ < div
140+ data-testid = "tool-invocation-result"
141+ className = "flex items-start gap-2 rounded-md border border-green-500/30 bg-green-500/10 px-2.5 py-2 text-xs"
142+ >
143+ < CheckCircle2 className = "mt-0.5 h-3.5 w-3.5 shrink-0 text-green-600 dark:text-green-400" />
144+ < div className = "min-w-0" >
145+ < span className = "font-medium" > { toolLabel } </ span >
146+ < p className = "mt-0.5 text-muted-foreground truncate" >
147+ { typeof part . output === 'string'
148+ ? part . output . length > 80 ? part . output . slice ( 0 , 80 ) + '…' : part . output
149+ : JSON . stringify ( part . output ) . slice ( 0 , 80 ) }
150+ </ p >
151+ </ div >
152+ </ div >
153+ ) ;
154+
155+ case 'output-error' :
156+ return (
157+ < div
158+ data-testid = "tool-invocation-error"
159+ className = "flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-2.5 py-2 text-xs"
160+ >
161+ < XCircle className = "mt-0.5 h-3.5 w-3.5 shrink-0 text-destructive" />
162+ < div className = "min-w-0" >
163+ < span className = "font-medium" > { toolLabel } failed</ span >
164+ < p className = "mt-0.5 text-destructive/80" > { part . errorText } </ p >
165+ </ div >
166+ </ div >
167+ ) ;
168+
169+ case 'output-denied' :
170+ return (
171+ < div
172+ data-testid = "tool-invocation-denied"
173+ className = "flex items-start gap-2 rounded-md border border-border/50 bg-muted/50 px-2.5 py-2 text-xs"
174+ >
175+ < XCircle className = "mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
176+ < span className = "font-medium text-muted-foreground" > { toolLabel } — denied</ span >
177+ </ div >
178+ ) ;
179+
180+ default :
181+ return (
182+ < div
183+ data-testid = "tool-invocation-unknown"
184+ className = "flex items-start gap-2 rounded-md border border-border/50 bg-muted/50 px-2.5 py-2 text-xs"
185+ >
186+ < Wrench className = "mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
187+ < span className = "font-medium" > { toolLabel } </ span >
188+ </ div >
189+ ) ;
190+ }
191+ }
192+
28193export function AiChatPanel ( ) {
29194 const { isOpen, setOpen, toggle } = useAiChatPanel ( ) ;
30195 const [ input , setInput ] = useState ( '' ) ;
@@ -39,7 +204,7 @@ export function AiChatPanel() {
39204 [ baseUrl ] ,
40205 ) ;
41206
42- const { messages, sendMessage, setMessages, status, error } = useChat ( {
207+ const { messages, sendMessage, setMessages, status, error, addToolApprovalResponse } = useChat ( {
43208 transport,
44209 messages : initialMessages ,
45210 } ) ;
@@ -167,12 +332,14 @@ export function AiChatPanel() {
167332 ) }
168333 { messages . map ( ( msg ) => {
169334 const text = getMessageText ( msg ) ;
170- if ( ! text && msg . role !== 'user' ) return null ;
335+ const toolParts = ( msg . parts ?? [ ] ) . filter ( isToolPart ) ;
336+ const hasContent = ! ! text || toolParts . length > 0 ;
337+ if ( ! hasContent && msg . role !== 'user' ) return null ;
171338 return (
172339 < div
173340 key = { msg . id }
174341 className = { cn (
175- 'flex flex-col gap-1 rounded-lg px-3 py-2 text-sm' ,
342+ 'flex flex-col gap-1.5 rounded-lg px-3 py-2 text-sm' ,
176343 msg . role === 'user'
177344 ? 'ml-8 bg-primary text-primary-foreground'
178345 : 'mr-8 bg-muted text-foreground' ,
@@ -181,7 +348,23 @@ export function AiChatPanel() {
181348 < span className = "text-[10px] font-medium opacity-60 uppercase" >
182349 { msg . role === 'user' ? 'You' : 'Assistant' }
183350 </ span >
184- < div className = "whitespace-pre-wrap break-words" > { text } </ div >
351+ { text && < div className = "whitespace-pre-wrap break-words" > { text } </ div > }
352+ { toolParts . map ( ( toolPart ) => (
353+ < ToolInvocationDisplay
354+ key = { toolPart . toolCallId }
355+ part = { toolPart }
356+ onApprove = { ( approvalId ) =>
357+ addToolApprovalResponse ( { id : approvalId , approved : true } )
358+ }
359+ onDeny = { ( approvalId ) =>
360+ addToolApprovalResponse ( {
361+ id : approvalId ,
362+ approved : false ,
363+ reason : 'User denied the operation' ,
364+ } )
365+ }
366+ />
367+ ) ) }
185368 </ div >
186369 ) ;
187370 } ) }
0 commit comments