11// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22
3- import { useState , useRef , useEffect , useMemo } from 'react' ;
3+ import { useState , useRef , useEffect , useMemo , useCallback } from 'react' ;
44import { useChat } from '@ai-sdk/react' ;
55import { DefaultChatTransport } from 'ai' ;
66import type { UIMessage } from 'ai' ;
@@ -10,6 +10,9 @@ import {
1010} from 'lucide-react' ;
1111import { Button } from '@/components/ui/button' ;
1212import { ScrollArea } from '@/components/ui/scroll-area' ;
13+ import {
14+ Select , SelectContent , SelectItem , SelectTrigger , SelectValue ,
15+ } from '@/components/ui/select' ;
1316import { Tooltip , TooltipContent , TooltipProvider , TooltipTrigger } from '@/components/ui/tooltip' ;
1417import { cn } from '@/lib/utils' ;
1518import { useAiChatPanel , loadMessages , saveMessages } from '@/hooks/use-ai-chat-panel' ;
@@ -18,6 +21,18 @@ import { getApiBaseUrl } from '@/lib/config';
1821const PANEL_WIDTH = 380 ;
1922const COLLAPSED_WIDTH = 48 ;
2023
24+ /** @internal — exported for testing */
25+ export const AGENT_STORAGE_KEY = 'objectstack:ai-chat-agent' ;
26+ /** @internal — exported for testing */
27+ export const GENERAL_CHAT_VALUE = '__general__' ;
28+
29+ /** Summary returned by GET /api/v1/ai/agents */
30+ interface AgentSummary {
31+ name : string ;
32+ label : string ;
33+ role : string ;
34+ }
35+
2136/**
2237 * Extract the text content from a UIMessage's parts array.
2338 */
@@ -69,6 +84,70 @@ function formatToolOutput(output: unknown, maxLen = 80): string {
6984 return raw . length > maxLen ? raw . slice ( 0 , maxLen ) + '…' : raw ;
7085}
7186
87+ /**
88+ * Build the chat API URL for the given agent selection.
89+ * @internal — exported for testing
90+ */
91+ export function chatApiUrl ( baseUrl : string , agentName : string | null ) : string {
92+ if ( ! agentName || agentName === GENERAL_CHAT_VALUE ) {
93+ return `${ baseUrl } /api/v1/ai/chat` ;
94+ }
95+ return `${ baseUrl } /api/v1/ai/agents/${ agentName } /chat` ;
96+ }
97+
98+ /**
99+ * Load persisted agent selection from localStorage.
100+ * @internal — exported for testing
101+ */
102+ export function loadSelectedAgent ( ) : string {
103+ try {
104+ return localStorage . getItem ( AGENT_STORAGE_KEY ) ?? GENERAL_CHAT_VALUE ;
105+ } catch {
106+ return GENERAL_CHAT_VALUE ;
107+ }
108+ }
109+
110+ /**
111+ * Persist agent selection to localStorage.
112+ * @internal — exported for testing
113+ */
114+ export function saveSelectedAgent ( agent : string ) : void {
115+ try {
116+ localStorage . setItem ( AGENT_STORAGE_KEY , agent ) ;
117+ } catch {
118+ // silently ignore
119+ }
120+ }
121+
122+ /**
123+ * Hook to fetch the list of available agents from the server.
124+ */
125+ function useAgentList ( baseUrl : string ) {
126+ const [ agents , setAgents ] = useState < AgentSummary [ ] > ( [ ] ) ;
127+ const [ loading , setLoading ] = useState ( true ) ;
128+
129+ useEffect ( ( ) => {
130+ let cancelled = false ;
131+ setLoading ( true ) ;
132+
133+ fetch ( `${ baseUrl } /api/v1/ai/agents` , { credentials : 'include' } )
134+ . then ( ( res ) => ( res . ok ? res . json ( ) : { agents : [ ] } ) )
135+ . then ( ( data : { agents ?: AgentSummary [ ] } ) => {
136+ if ( ! cancelled ) setAgents ( data . agents ?? [ ] ) ;
137+ } )
138+ . catch ( ( ) => {
139+ if ( ! cancelled ) setAgents ( [ ] ) ;
140+ } )
141+ . finally ( ( ) => {
142+ if ( ! cancelled ) setLoading ( false ) ;
143+ } ) ;
144+
145+ return ( ) => { cancelled = true ; } ;
146+ } , [ baseUrl ] ) ;
147+
148+ return { agents, loading } ;
149+ }
150+
72151// ── Tool Invocation State Labels ────────────────────────────────────
73152
74153interface ToolInvocationDisplayProps {
@@ -199,15 +278,17 @@ function ToolInvocationDisplay({ part, onApprove, onDeny }: ToolInvocationDispla
199278export function AiChatPanel ( ) {
200279 const { isOpen, setOpen, toggle } = useAiChatPanel ( ) ;
201280 const [ input , setInput ] = useState ( '' ) ;
281+ const [ selectedAgent , setSelectedAgent ] = useState < string > ( loadSelectedAgent ) ;
202282 const scrollRef = useRef < HTMLDivElement > ( null ) ;
203283 const inputRef = useRef < HTMLTextAreaElement > ( null ) ;
204284 const baseUrl = getApiBaseUrl ( ) ;
285+ const { agents, loading : agentsLoading } = useAgentList ( baseUrl ) ;
205286
206287 const initialMessages = useMemo ( ( ) => loadMessages ( ) as UIMessage [ ] , [ ] ) ;
207288
208289 const transport = useMemo (
209- ( ) => new DefaultChatTransport ( { api : ` ${ baseUrl } /api/v1/ai/chat` } ) ,
210- [ baseUrl ] ,
290+ ( ) => new DefaultChatTransport ( { api : chatApiUrl ( baseUrl , selectedAgent ) } ) ,
291+ [ baseUrl , selectedAgent ] ,
211292 ) ;
212293
213294 const { messages, sendMessage, setMessages, status, error, addToolApprovalResponse } = useChat ( {
@@ -243,6 +324,14 @@ export function AiChatPanel() {
243324 saveMessages ( [ ] ) ;
244325 } ;
245326
327+ const handleAgentChange = useCallback ( ( value : string ) => {
328+ setSelectedAgent ( value ) ;
329+ saveSelectedAgent ( value ) ;
330+ // Clear conversation when switching agents to avoid context confusion
331+ setMessages ( [ ] ) ;
332+ saveMessages ( [ ] ) ;
333+ } , [ setMessages ] ) ;
334+
246335 const handleSend = ( ) => {
247336 const text = input . trim ( ) ;
248337 if ( ! text || isStreaming ) return ;
@@ -300,27 +389,54 @@ export function AiChatPanel() {
300389 style = { { width : PANEL_WIDTH } }
301390 >
302391 { /* ── Header ── */ }
303- < div className = "flex h-12 shrink-0 items-center justify-between border-b px-3" >
304- < div className = "flex items-center gap-2 text-sm font-semibold" >
305- < Bot className = "h-4 w-4 text-primary" />
306- AI Chat
392+ < div className = "shrink-0 border-b" >
393+ < div className = "flex h-12 items-center justify-between px-3" >
394+ < div className = "flex items-center gap-2 text-sm font-semibold" >
395+ < Bot className = "h-4 w-4 text-primary" />
396+ AI Chat
397+ </ div >
398+ < div className = "flex items-center gap-1" >
399+ < TooltipProvider >
400+ < Tooltip >
401+ < TooltipTrigger asChild >
402+ < Button variant = "ghost" size = "icon" className = "h-7 w-7" onClick = { clearHistory } >
403+ < Trash2 className = "h-3.5 w-3.5" />
404+ < span className = "sr-only" > Clear chat</ span >
405+ </ Button >
406+ </ TooltipTrigger >
407+ < TooltipContent > < p > Clear history</ p > </ TooltipContent >
408+ </ Tooltip >
409+ </ TooltipProvider >
410+ < Button variant = "ghost" size = "icon" className = "h-7 w-7" onClick = { ( ) => setOpen ( false ) } >
411+ < X className = "h-4 w-4" />
412+ < span className = "sr-only" > Close</ span >
413+ </ Button >
414+ </ div >
307415 </ div >
308- < div className = "flex items-center gap-1" >
309- < TooltipProvider >
310- < Tooltip >
311- < TooltipTrigger asChild >
312- < Button variant = "ghost" size = "icon" className = "h-7 w-7" onClick = { clearHistory } >
313- < Trash2 className = "h-3.5 w-3.5" />
314- < span className = "sr-only" > Clear chat</ span >
315- </ Button >
316- </ TooltipTrigger >
317- < TooltipContent > < p > Clear history</ p > </ TooltipContent >
318- </ Tooltip >
319- </ TooltipProvider >
320- < Button variant = "ghost" size = "icon" className = "h-7 w-7" onClick = { ( ) => setOpen ( false ) } >
321- < X className = "h-4 w-4" />
322- < span className = "sr-only" > Close</ span >
323- </ Button >
416+ { /* ── Agent Selector ── */ }
417+ < div className = "px-3 pb-2" >
418+ < Select
419+ value = { selectedAgent }
420+ onValueChange = { handleAgentChange }
421+ disabled = { agentsLoading || isStreaming }
422+ >
423+ < SelectTrigger
424+ data-testid = "agent-selector"
425+ className = "h-8 text-xs"
426+ >
427+ < SelectValue placeholder = "Select agent…" />
428+ </ SelectTrigger >
429+ < SelectContent >
430+ < SelectItem value = { GENERAL_CHAT_VALUE } >
431+ General Chat
432+ </ SelectItem >
433+ { agents . map ( ( a ) => (
434+ < SelectItem key = { a . name } value = { a . name } >
435+ { a . label }
436+ </ SelectItem >
437+ ) ) }
438+ </ SelectContent >
439+ </ Select >
324440 </ div >
325441 </ div >
326442
0 commit comments