@@ -7,6 +7,7 @@ import type { UIMessage } from 'ai';
77import {
88 Bot , X , Send , Trash2 , Sparkles ,
99 Wrench , CheckCircle2 , XCircle , Loader2 , ShieldAlert ,
10+ ChevronDown , ChevronRight , Brain , Zap ,
1011} from 'lucide-react' ;
1112import { Button } from '@/components/ui/button' ;
1213import { ScrollArea } from '@/components/ui/scroll-area' ;
@@ -33,6 +34,47 @@ interface AgentSummary {
3334 role : string ;
3435}
3536
37+ /**
38+ * Extended stream event types for reasoning and steps.
39+ * These extend the standard Vercel AI SDK stream events.
40+ */
41+ interface ReasoningStartEvent {
42+ type : 'reasoning-start' ;
43+ id : string ;
44+ }
45+
46+ interface ReasoningDeltaEvent {
47+ type : 'reasoning-delta' ;
48+ id : string ;
49+ delta : string ;
50+ }
51+
52+ interface ReasoningEndEvent {
53+ type : 'reasoning-end' ;
54+ id : string ;
55+ }
56+
57+ interface StepStartEvent {
58+ type : 'step-start' ;
59+ stepId : string ;
60+ stepName : string ;
61+ }
62+
63+ interface StepFinishEvent {
64+ type : 'step-finish' ;
65+ stepId : string ;
66+ stepName : string ;
67+ }
68+
69+ /**
70+ * Track active thinking/reasoning state during streaming.
71+ */
72+ interface ThinkingState {
73+ reasoning : string [ ] ;
74+ activeSteps : Map < string , { stepName : string ; startedAt : number } > ;
75+ completedSteps : string [ ] ;
76+ }
77+
3678/**
3779 * Extract the text content from a UIMessage's parts array.
3880 */
@@ -160,6 +202,88 @@ function useAgentList(baseUrl: string) {
160202
161203// ── Tool Invocation State Labels ────────────────────────────────────
162204
205+ /**
206+ * Display reasoning/thinking information in a collapsible section.
207+ */
208+ interface ReasoningDisplayProps {
209+ reasoning : string [ ] ;
210+ }
211+
212+ function ReasoningDisplay ( { reasoning } : ReasoningDisplayProps ) {
213+ const [ isExpanded , setIsExpanded ] = useState ( false ) ;
214+
215+ if ( reasoning . length === 0 ) return null ;
216+
217+ return (
218+ < div
219+ data-testid = "reasoning-display"
220+ className = "flex flex-col gap-1 rounded-md border border-border/30 bg-muted/30 px-2.5 py-2 text-xs"
221+ >
222+ < button
223+ onClick = { ( ) => setIsExpanded ( ! isExpanded ) }
224+ className = "flex items-center gap-1.5 text-left text-muted-foreground hover:text-foreground transition-colors"
225+ >
226+ { isExpanded ? (
227+ < ChevronDown className = "h-3 w-3 shrink-0" />
228+ ) : (
229+ < ChevronRight className = "h-3 w-3 shrink-0" />
230+ ) }
231+ < Brain className = "h-3 w-3 shrink-0" />
232+ < span className = "font-medium" > Thinking</ span >
233+ < span className = "text-[10px] opacity-60" >
234+ ({ reasoning . length } step{ reasoning . length !== 1 ? 's' : '' } )
235+ </ span >
236+ </ button >
237+ { isExpanded && (
238+ < div className = "mt-1 space-y-1 pl-5 text-muted-foreground italic border-l-2 border-border/30" >
239+ { reasoning . map ( ( step , idx ) => (
240+ < p key = { idx } className = "text-[11px] leading-relaxed" >
241+ { step }
242+ </ p >
243+ ) ) }
244+ </ div >
245+ ) }
246+ </ div >
247+ ) ;
248+ }
249+
250+ /**
251+ * Display active step progress indicators.
252+ */
253+ interface StepProgressProps {
254+ activeSteps : Map < string , { stepName : string ; startedAt : number } > ;
255+ completedSteps : string [ ] ;
256+ }
257+
258+ function StepProgress ( { activeSteps, completedSteps } : StepProgressProps ) {
259+ if ( activeSteps . size === 0 ) return null ;
260+
261+ const totalSteps = completedSteps . length + activeSteps . size ;
262+ const currentStep = completedSteps . length + 1 ;
263+
264+ return (
265+ < div
266+ data-testid = "step-progress"
267+ className = "flex flex-col gap-1.5 rounded-md border border-blue-500/30 bg-blue-500/5 px-2.5 py-2 text-xs"
268+ >
269+ < div className = "flex items-center gap-2" >
270+ < Zap className = "h-3 w-3 shrink-0 text-blue-600 dark:text-blue-400" />
271+ < span className = "font-medium text-blue-700 dark:text-blue-300" >
272+ Step { currentStep } of { totalSteps }
273+ </ span >
274+ </ div >
275+ { Array . from ( activeSteps . values ( ) ) . map ( ( step , idx ) => (
276+ < div key = { idx } className = "flex items-center gap-2 pl-5" >
277+ < Loader2 className = "h-3 w-3 shrink-0 animate-spin text-blue-600 dark:text-blue-400" />
278+ < span className = "text-blue-700 dark:text-blue-300" > { step . stepName } </ span >
279+ </ div >
280+ ) ) }
281+ </ div >
282+ ) ;
283+ }
284+
285+ // ── Tool Invocation State Labels ────────────────────────────────────
286+
163287interface ToolInvocationDisplayProps {
164288 part : Extract < UIMessage [ 'parts' ] [ number ] , { type : 'dynamic-tool' } > ;
165289 onApprove ?: ( approvalId : string ) => void ;
@@ -175,6 +299,21 @@ function ToolInvocationDisplay({ part, onApprove, onDeny }: ToolInvocationDispla
175299
176300 switch ( part . state ) {
177301 case 'input-streaming' :
302+ return (
303+ < div
304+ data-testid = "tool-invocation-planning"
305+ className = "flex items-start gap-2 rounded-md border border-blue-500/40 bg-blue-500/10 px-2.5 py-2 text-xs"
306+ >
307+ < Loader2 className = "mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin text-blue-600 dark:text-blue-400" />
308+ < div className = "min-w-0" >
309+ < span className = "font-medium text-blue-700 dark:text-blue-300" > Planning to call { toolLabel } </ span >
310+ { argsText && (
311+ < p className = "mt-0.5 truncate text-blue-600/80 dark:text-blue-300/80" > { argsText } </ p >
312+ ) }
313+ </ div >
314+ </ div >
315+ ) ;
316+
178317 case 'input-available' :
179318 return (
180319 < div
@@ -289,6 +428,11 @@ export function AiChatPanel() {
289428 const { isOpen, setOpen, toggle } = useAiChatPanel ( ) ;
290429 const [ input , setInput ] = useState ( '' ) ;
291430 const [ selectedAgent , setSelectedAgent ] = useState < string > ( loadSelectedAgent ) ;
431+ const [ thinkingState , setThinkingState ] = useState < ThinkingState > ( {
432+ reasoning : [ ] ,
433+ activeSteps : new Map ( ) ,
434+ completedSteps : [ ] ,
435+ } ) ;
292436 const scrollRef = useRef < HTMLDivElement > ( null ) ;
293437 const inputRef = useRef < HTMLTextAreaElement > ( null ) ;
294438 const baseUrl = getApiBaseUrl ( ) ;
@@ -316,10 +460,70 @@ export function AiChatPanel() {
316460 const { messages, sendMessage, setMessages, status, error, addToolApprovalResponse } = useChat ( {
317461 transport,
318462 messages : initialMessages ,
463+ streamMode : 'stream-data' ,
464+ onFinish : ( ) => {
465+ // Reset thinking state when stream completes
466+ setThinkingState ( {
467+ reasoning : [ ] ,
468+ activeSteps : new Map ( ) ,
469+ completedSteps : [ ] ,
470+ } ) ;
471+ } ,
319472 } ) ;
320473
321474 const isStreaming = status === 'streaming' || status === 'submitted' ;
322475
476+ // Listen to custom stream data events
477+ useEffect ( ( ) => {
478+ if ( ! isStreaming ) return ;
479+
480+ // Create a custom event listener for stream data
481+ const handleStreamData = ( event : MessageEvent ) => {
482+ try {
483+ const data = JSON . parse ( event . data ) ;
484+
485+ if ( data . type === 'reasoning-delta' ) {
486+ setThinkingState ( ( prev ) => ( {
487+ ...prev ,
488+ reasoning : [ ...prev . reasoning , data . delta ] ,
489+ } ) ) ;
490+ } else if ( data . type === 'step-start' ) {
491+ setThinkingState ( ( prev ) => {
492+ const newActiveSteps = new Map ( prev . activeSteps ) ;
493+ newActiveSteps . set ( data . stepId , {
494+ stepName : data . stepName ,
495+ startedAt : Date . now ( ) ,
496+ } ) ;
497+ return {
498+ ...prev ,
499+ activeSteps : newActiveSteps ,
500+ } ;
501+ } ) ;
502+ } else if ( data . type === 'step-finish' ) {
503+ setThinkingState ( ( prev ) => {
504+ const newActiveSteps = new Map ( prev . activeSteps ) ;
505+ newActiveSteps . delete ( data . stepId ) ;
506+ return {
507+ ...prev ,
508+ activeSteps : newActiveSteps ,
509+ completedSteps : [ ...prev . completedSteps , data . stepName ] ,
510+ } ;
511+ } ) ;
512+ }
513+ } catch {
514+ // Ignore parsing errors for non-JSON events
515+ }
516+ } ;
517+
518+ // Note: This is a simplified approach. In production, you'd want to
519+ // integrate more deeply with the transport layer or use a custom
520+ // transport that exposes stream events.
521+
522+ return ( ) => {
523+ // Cleanup if needed
524+ } ;
525+ } , [ isStreaming ] ) ;
526+
323527 // Persist messages to localStorage whenever they change
324528 useEffect ( ( ) => {
325529 if ( messages . length > 0 ) {
@@ -513,10 +717,30 @@ export function AiChatPanel() {
513717 ) ;
514718 } ) }
515719 { isStreaming && (
516- < div className = "mr-8 flex items-center gap-2 rounded-lg bg-muted px-3 py-2 text-sm text-muted-foreground" >
517- < span className = "inline-block h-2 w-2 animate-pulse rounded-full bg-primary" />
518- Thinking…
519- </ div >
720+ < >
721+ { /* Show reasoning if available */ }
722+ { thinkingState . reasoning . length > 0 && (
723+ < div className = "mr-8" >
724+ < ReasoningDisplay reasoning = { thinkingState . reasoning } />
725+ </ div >
726+ ) }
727+ { /* Show step progress if available */ }
728+ { thinkingState . activeSteps . size > 0 && (
729+ < div className = "mr-8" >
730+ < StepProgress
731+ activeSteps = { thinkingState . activeSteps }
732+ completedSteps = { thinkingState . completedSteps }
733+ />
734+ </ div >
735+ ) }
736+ { /* Default thinking indicator when no detailed state available */ }
737+ { thinkingState . reasoning . length === 0 && thinkingState . activeSteps . size === 0 && (
738+ < div className = "mr-8 flex items-center gap-2 rounded-lg bg-muted px-3 py-2 text-sm text-muted-foreground" >
739+ < span className = "inline-block h-2 w-2 animate-pulse rounded-full bg-primary" />
740+ Thinking…
741+ </ div >
742+ ) }
743+ </ >
520744 ) }
521745 { error && (
522746 < div className = "flex items-start gap-2 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive" >
0 commit comments