@hef2024/llmasaservice-ui 0.16.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1883 @@
1
+ import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react';
2
+ import AIChatPanel, { AgentOption } from './AIChatPanel';
3
+ import { useAgentRegistry } from './hooks/useAgentRegistry';
4
+ import { Button, Input, ScrollArea, Dialog, DialogFooter, Tooltip } from './components/ui';
5
+ import { LLMAsAServiceCustomer } from 'llmasaservice-client';
6
+ import './AIAgentPanel.css';
7
+
8
+ /**
9
+ * Context section for agent awareness
10
+ */
11
+ export interface ContextSection {
12
+ id: string;
13
+ title: string;
14
+ data: Record<string, unknown>;
15
+ tokens?: number;
16
+ }
17
+
18
+ /**
19
+ * Agent context passed to the panel
20
+ */
21
+ export interface AgentContext {
22
+ route?: string;
23
+ sections: ContextSection[];
24
+ totalTokens?: number;
25
+ }
26
+
27
+ /**
28
+ * API Conversation Summary from LLMAsAService
29
+ */
30
+ export interface APIConversationSummary {
31
+ conversationId: string;
32
+ title?: string;
33
+ summary?: string;
34
+ createdAt: string;
35
+ updatedAt: string;
36
+ agentId?: string;
37
+ messageCount?: number;
38
+ }
39
+
40
+ /**
41
+ * Active conversation state for multi-conversation support
42
+ */
43
+ export interface ActiveConversation {
44
+ conversationId: string;
45
+ agentId: string;
46
+ history: Record<string, { content: string; callId: string }>;
47
+ isLoading: boolean;
48
+ title: string;
49
+ }
50
+
51
+ /**
52
+ * Agent configuration with optional local overrides
53
+ */
54
+ export interface AgentConfig {
55
+ id: string;
56
+ localName?: string;
57
+ avatarUrl?: string;
58
+ }
59
+
60
+ /**
61
+ * Props for AIAgentPanel
62
+ */
63
+ export interface AIAgentPanelProps {
64
+ // Agent Configuration - can be string IDs or full config objects
65
+ agents: (string | AgentConfig)[];
66
+ defaultAgent?: string;
67
+
68
+ // Customer ID - REQUIRED for conversation history
69
+ customerId: string;
70
+
71
+ // API Key for authenticated endpoints (conversations, etc.)
72
+ apiKey?: string;
73
+
74
+ // Context Management
75
+ context?: AgentContext | null;
76
+ contextDataSources?: Record<string, unknown>;
77
+ sharedContextSections?: ContextSection[];
78
+ pageContextSections?: ContextSection[];
79
+ onContextChange?: (context: AgentContext) => void;
80
+ maxContextTokens?: number;
81
+ data?: { key: string; data: string }[];
82
+
83
+ // UI Configuration
84
+ theme?: 'light' | 'dark';
85
+ defaultCollapsed?: boolean;
86
+ defaultWidth?: number;
87
+ minWidth?: number;
88
+ maxWidth?: number;
89
+ position?: 'left' | 'right';
90
+ sidebarPosition?: 'left' | 'right';
91
+
92
+ // Agent Suggestions (inline agent handoff cards)
93
+ enableAgentSuggestions?: boolean;
94
+
95
+ // Context Viewer Configuration
96
+ enableContextDetailView?: boolean;
97
+
98
+ // Callbacks
99
+ onAgentSwitch?: (fromAgent: string, toAgent: string) => void;
100
+ onConversationChange?: (conversationId: string) => void;
101
+ historyChangedCallback?: (history: Record<string, { content: string; callId: string }>) => void;
102
+ responseCompleteCallback?: (callId: string, prompt: string, response: string) => void;
103
+ thumbsUpClick?: (callId: string) => void;
104
+ thumbsDownClick?: (callId: string) => void;
105
+
106
+ // Standard props
107
+ customer?: LLMAsAServiceCustomer;
108
+ url?: string;
109
+ showPoweredBy?: boolean;
110
+ conversation?: string | null;
111
+
112
+ // Actions/Regex
113
+ actions?: {
114
+ pattern: string;
115
+ type?: string;
116
+ markdown?: string;
117
+ callback?: (match: string, groups: any[]) => void;
118
+ clickCode?: string;
119
+ style?: string;
120
+ }[];
121
+
122
+ // Follow-on
123
+ followOnQuestions?: string[];
124
+ followOnPrompt?: string;
125
+
126
+ // Conversation history settings
127
+ historyListLimit?: number;
128
+ }
129
+
130
+ // Icons
131
+ const SearchIcon = () => (
132
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
133
+ <path d="M7.333 12.667A5.333 5.333 0 1 0 7.333 2a5.333 5.333 0 0 0 0 10.667ZM14 14l-2.9-2.9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
134
+ </svg>
135
+ );
136
+
137
+ const PlusIcon = () => (
138
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
139
+ <path d="M8 3.333v9.334M3.333 8h9.334" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
140
+ </svg>
141
+ );
142
+
143
+ const ChevronLeftIcon = () => (
144
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
145
+ <path d="M10 12L6 8l4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
146
+ </svg>
147
+ );
148
+
149
+ const ChevronRightIcon = () => (
150
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
151
+ <path d="M6 12l4-4-4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
152
+ </svg>
153
+ );
154
+
155
+ const MessageIcon = () => (
156
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
157
+ <path d="M14 10a1.333 1.333 0 0 1-1.333 1.333H4L2 14V3.333A1.333 1.333 0 0 1 3.333 2h9.334A1.333 1.333 0 0 1 14 3.333V10Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
158
+ </svg>
159
+ );
160
+
161
+ const TrashIcon = () => (
162
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
163
+ <path d="M1.75 3.5h10.5M4.667 3.5V2.333a1.167 1.167 0 0 1 1.166-1.166h2.334a1.167 1.167 0 0 1 1.166 1.166V3.5m1.75 0v8.167a1.167 1.167 0 0 1-1.166 1.166H4.083a1.167 1.167 0 0 1-1.166-1.166V3.5h8.166Z" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round"/>
164
+ </svg>
165
+ );
166
+
167
+ const ContextIcon = () => (
168
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
169
+ <path d="M2 4h12M2 8h12M2 12h8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
170
+ </svg>
171
+ );
172
+
173
+ const CloseIcon = () => (
174
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
175
+ <path d="M9 3L3 9M3 3l6 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
176
+ </svg>
177
+ );
178
+
179
+ const LoadingDotIcon = () => (
180
+ <span className="ai-agent-panel__loading-dot" />
181
+ );
182
+
183
+ const HistoryIcon = () => (
184
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
185
+ <path d="M8 4v4l2.5 1.5M14 8A6 6 0 1 1 2 8a6 6 0 0 1 12 0Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
186
+ </svg>
187
+ );
188
+
189
+ const SidebarIcon = () => (
190
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
191
+ <rect x="2" y="2" width="12" height="12" rx="2" stroke="currentColor" strokeWidth="1.5"/>
192
+ <path d="M6 2v12" stroke="currentColor" strokeWidth="1.5"/>
193
+ </svg>
194
+ );
195
+
196
+ /**
197
+ * AIAgentPanel - Cursor-inspired multi-agent panel
198
+ */
199
+ // Normalize conversation list payload from API
200
+ const normalizeConversationListPayload = (payload: any): APIConversationSummary[] => {
201
+ if (!payload) return [];
202
+
203
+ const conversations = Array.isArray(payload) ? payload : payload.conversations || [];
204
+
205
+ return conversations.map((conv: any) => {
206
+ // Get title, but filter out generic "Conversation" from API
207
+ let title = conv.title && conv.title !== 'Conversation' ? conv.title : '';
208
+ if (!title) {
209
+ title = conv.summary || extractTitleFromConversation(conv);
210
+ }
211
+
212
+ return {
213
+ conversationId: conv.conversationId || conv.id || conv.conversation_id,
214
+ title,
215
+ summary: conv.summary,
216
+ createdAt: conv.createdAt || conv.created_at || conv.timestamp || new Date().toISOString(),
217
+ updatedAt: conv.updatedAt || conv.updated_at || conv.lastUpdated || conv.createdAt || new Date().toISOString(),
218
+ agentId: conv.agentId || conv.agent_id,
219
+ messageCount: conv.messageCount || conv.message_count,
220
+ };
221
+ });
222
+ };
223
+
224
+ // Extract title from conversation data
225
+ const extractTitleFromConversation = (conv: any): string => {
226
+ // Try to get the first user message as the title
227
+ if (conv.messages && Array.isArray(conv.messages) && conv.messages.length > 0) {
228
+ const firstUserMessage = conv.messages.find((m: any) => m.role === 'user');
229
+ if (firstUserMessage?.content) {
230
+ const content = typeof firstUserMessage.content === 'string'
231
+ ? firstUserMessage.content
232
+ : firstUserMessage.content[0]?.text || '';
233
+ return content.length > 60 ? content.slice(0, 57) + '...' : content;
234
+ }
235
+ }
236
+
237
+ // Try prompts object (keys are the prompts)
238
+ if (conv.prompts && typeof conv.prompts === 'object') {
239
+ const firstPrompt = Object.keys(conv.prompts)[0];
240
+ if (firstPrompt) {
241
+ return firstPrompt.length > 60 ? firstPrompt.slice(0, 57) + '...' : firstPrompt;
242
+ }
243
+ }
244
+
245
+ // Try calls array - first call's prompt
246
+ if (conv.calls && Array.isArray(conv.calls) && conv.calls.length > 0) {
247
+ const firstCall = conv.calls[0];
248
+ if (firstCall.prompt) {
249
+ return firstCall.prompt.length > 60 ? firstCall.prompt.slice(0, 57) + '...' : firstCall.prompt;
250
+ }
251
+ }
252
+
253
+ // Try firstPrompt field (in case API provides it directly)
254
+ if (conv.firstPrompt || conv.first_prompt) {
255
+ const prompt = conv.firstPrompt || conv.first_prompt;
256
+ return prompt.length > 60 ? prompt.slice(0, 57) + '...' : prompt;
257
+ }
258
+
259
+ // Fall back to a human-readable timestamp
260
+ const timestamp = conv.createdAt || conv.created_at || conv.timestamp;
261
+ if (timestamp) {
262
+ const date = new Date(timestamp);
263
+ const now = new Date();
264
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
265
+ const chatDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
266
+
267
+ if (chatDate.getTime() === today.getTime()) {
268
+ // Today - show time
269
+ return `Chat from ${date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}`;
270
+ } else {
271
+ // Other day - show date
272
+ const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
273
+ if (chatDate.getTime() === yesterday.getTime()) {
274
+ return `Chat from yesterday`;
275
+ }
276
+ return `Chat from ${date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`;
277
+ }
278
+ }
279
+
280
+ return 'New chat';
281
+ };
282
+
283
+ // Group conversations by time
284
+ const groupConversationsByTime = (conversations: APIConversationSummary[], showAllGroups: boolean = false): { label: string; conversations: APIConversationSummary[]; count: number }[] => {
285
+ const now = new Date();
286
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
287
+ const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
288
+ const thisWeek = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
289
+ const thisMonth = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000);
290
+
291
+ const groups: Record<string, APIConversationSummary[]> = {
292
+ 'Today': [],
293
+ 'Yesterday': [],
294
+ 'This Week': [],
295
+ 'This Month': [],
296
+ 'Older': [],
297
+ };
298
+
299
+ conversations
300
+ .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
301
+ .forEach(conv => {
302
+ const date = new Date(conv.updatedAt);
303
+ if (date >= today) {
304
+ groups['Today']!.push(conv);
305
+ } else if (date >= yesterday) {
306
+ groups['Yesterday']!.push(conv);
307
+ } else if (date >= thisWeek) {
308
+ groups['This Week']!.push(conv);
309
+ } else if (date >= thisMonth) {
310
+ groups['This Month']!.push(conv);
311
+ } else {
312
+ groups['Older']!.push(conv);
313
+ }
314
+ });
315
+
316
+ return Object.entries(groups)
317
+ .filter(([_, convs]) => showAllGroups || convs.length > 0)
318
+ .map(([label, conversations]) => ({ label, conversations, count: conversations.length }));
319
+ };
320
+
321
+ // Empty arrays/objects to prevent recreating on every render
322
+ const EMPTY_ARRAY: never[] = [];
323
+ const EMPTY_HISTORY: Record<string, { content: string; callId: string }> = {};
324
+
325
+ /**
326
+ * Memoized wrapper for AIChatPanel to prevent unnecessary re-renders
327
+ */
328
+ interface ChatPanelWrapperProps {
329
+ activeConv: ActiveConversation;
330
+ currentConversationId: string | null;
331
+ getAgent: (id: string) => any;
332
+ url: string;
333
+ theme: 'light' | 'dark';
334
+ chatPanelData: { key: string; data: string }[];
335
+ handleHistoryChanged: (history: Record<string, { content: string; callId: string }>, conversationId?: string) => void;
336
+ handleLoadingChange: (isLoading: boolean, conversationId?: string) => void;
337
+ responseCompleteCallback?: (callId: string, prompt: string, response: string) => void;
338
+ thumbsUpClick?: (callId: string) => void;
339
+ thumbsDownClick?: (callId: string) => void;
340
+ effectiveCustomer: any;
341
+ showPoweredBy: boolean;
342
+ actions: any[];
343
+ followOnQuestions: string[];
344
+ followOnPrompt: string;
345
+ agentOptions: any[];
346
+ currentAgentId: string;
347
+ handleAgentSwitch: (agentId: string) => void;
348
+ agentsLoading: boolean;
349
+ // Context viewer props
350
+ contextSections: ContextSection[];
351
+ totalContextTokens: number;
352
+ maxContextTokens: number;
353
+ enableContextDetailView: boolean;
354
+ }
355
+
356
+ // Remove React.memo temporarily to debug - ChatPanelWrapper needs to re-render when agentId changes
357
+ const ChatPanelWrapper = (({
358
+ activeConv,
359
+ currentConversationId,
360
+ getAgent,
361
+ url,
362
+ theme,
363
+ chatPanelData,
364
+ handleHistoryChanged,
365
+ handleLoadingChange,
366
+ responseCompleteCallback,
367
+ thumbsUpClick,
368
+ thumbsDownClick,
369
+ effectiveCustomer,
370
+ showPoweredBy,
371
+ actions,
372
+ followOnQuestions,
373
+ followOnPrompt,
374
+ agentOptions,
375
+ currentAgentId,
376
+ handleAgentSwitch,
377
+ agentsLoading,
378
+ contextSections,
379
+ totalContextTokens,
380
+ maxContextTokens,
381
+ enableContextDetailView,
382
+ }) => {
383
+ const convAgentProfile = getAgent(activeConv.agentId);
384
+ const convAgentMetadata = convAgentProfile?.metadata;
385
+ const isVisible = currentConversationId === activeConv.conversationId;
386
+
387
+ // Memoize callbacks to prevent recreating on every render
388
+ const historyCallback = useCallback(
389
+ (history: Record<string, { content: string; callId: string }>) => {
390
+ handleHistoryChanged(history, activeConv.conversationId);
391
+ },
392
+ [handleHistoryChanged, activeConv.conversationId]
393
+ );
394
+
395
+ const loadingCallback = useCallback(
396
+ (loading: boolean) => {
397
+ handleLoadingChange(loading, activeConv.conversationId);
398
+ },
399
+ [handleLoadingChange, activeConv.conversationId]
400
+ );
401
+
402
+ // Compute follow-on questions - MUST update when agent switches
403
+ // Don't use useMemo - compute fresh every render to ensure it always reflects current agent
404
+ // The computation is cheap (just string split), and this guarantees updates
405
+ const agentStatus = convAgentProfile?.status;
406
+ const promptsString = convAgentMetadata?.displayFollowOnPrompts || '';
407
+
408
+ // Compute fresh every render - no memoization to avoid stale data
409
+ let effectiveFollowOnQuestions: string[];
410
+ if (followOnQuestions.length > 0) {
411
+ effectiveFollowOnQuestions = [...followOnQuestions];
412
+ } else {
413
+ const prompts = promptsString ? promptsString.split('|').filter((p: string) => p.trim()) : [];
414
+ effectiveFollowOnQuestions = [...prompts];
415
+ }
416
+
417
+ // Memoize mcpServers
418
+ const mcpServers = useMemo(() => {
419
+ return convAgentProfile?.mcpServers || EMPTY_ARRAY;
420
+ }, [convAgentProfile?.mcpServers]);
421
+
422
+ if (!convAgentMetadata) return null;
423
+
424
+ return (
425
+ <div
426
+ className="ai-agent-panel__chat-wrapper"
427
+ style={{ display: isVisible ? 'flex' : 'none' }}
428
+ >
429
+ <AIChatPanel
430
+ project_id={convAgentMetadata.projectId}
431
+ service={convAgentMetadata.groupId || null}
432
+ url={url}
433
+ title=""
434
+ theme={theme}
435
+ promptTemplate={convAgentMetadata.displayPromptTemplate || '{{prompt}}'}
436
+ initialMessage={
437
+ convAgentMetadata.displayStartMessageOrPrompt === 'message'
438
+ ? convAgentMetadata.displayInitialMessageOrPrompt
439
+ : undefined
440
+ }
441
+ initialPrompt={
442
+ convAgentMetadata.displayStartMessageOrPrompt === 'prompt'
443
+ ? convAgentMetadata.displayInitialMessageOrPrompt
444
+ : undefined
445
+ }
446
+ placeholder={convAgentMetadata.displayPlaceholder || 'Type a message...'}
447
+ hideInitialPrompt={convAgentMetadata.displayHideInitialPrompt ?? true}
448
+ data={chatPanelData}
449
+ agent={activeConv.agentId}
450
+ conversation={activeConv.conversationId.startsWith('new-') ? undefined : activeConv.conversationId}
451
+ initialHistory={activeConv.history || EMPTY_HISTORY}
452
+ historyChangedCallback={historyCallback}
453
+ onLoadingChange={loadingCallback}
454
+ responseCompleteCallback={responseCompleteCallback}
455
+ thumbsUpClick={thumbsUpClick}
456
+ thumbsDownClick={thumbsDownClick}
457
+ customer={effectiveCustomer}
458
+ showPoweredBy={showPoweredBy}
459
+ showNewConversationButton={false}
460
+ createConversationOnFirstChat={convAgentMetadata.createConversationOnFirstChat ?? true}
461
+ mcpServers={mcpServers}
462
+ actions={actions}
463
+ followOnQuestions={effectiveFollowOnQuestions}
464
+ followOnPrompt={followOnPrompt}
465
+ progressiveActions={true}
466
+ hideRagContextInPrompt={true}
467
+ agentOptions={agentOptions}
468
+ currentAgentId={currentAgentId}
469
+ onAgentChange={handleAgentSwitch}
470
+ agentsLoading={agentsLoading}
471
+ contextSections={contextSections}
472
+ totalContextTokens={totalContextTokens}
473
+ maxContextTokens={maxContextTokens}
474
+ enableContextDetailView={enableContextDetailView}
475
+ />
476
+ </div>
477
+ );
478
+ }) as React.FC<ChatPanelWrapperProps>;
479
+
480
+ ChatPanelWrapper.displayName = 'ChatPanelWrapper';
481
+
482
+ const AIAgentPanel: React.FC<AIAgentPanelProps> = ({
483
+ agents,
484
+ defaultAgent,
485
+ customerId,
486
+ apiKey,
487
+ context,
488
+ contextDataSources,
489
+ sharedContextSections = [],
490
+ pageContextSections = [],
491
+ onContextChange,
492
+ maxContextTokens = 8000,
493
+ data = [],
494
+ theme = 'light',
495
+ defaultCollapsed = false,
496
+ defaultWidth = 720,
497
+ minWidth = 520,
498
+ maxWidth = 1200,
499
+ position = 'right',
500
+ sidebarPosition = 'left',
501
+ enableAgentSuggestions = true,
502
+ enableContextDetailView = false,
503
+ onAgentSwitch,
504
+ onConversationChange,
505
+ historyChangedCallback,
506
+ responseCompleteCallback,
507
+ thumbsUpClick,
508
+ thumbsDownClick,
509
+ customer,
510
+ url = 'https://chat.llmasaservice.io',
511
+ showPoweredBy = true,
512
+ conversation,
513
+ actions = [],
514
+ followOnQuestions = [],
515
+ followOnPrompt = '',
516
+ historyListLimit = 50,
517
+ }) => {
518
+ // Panel state
519
+ const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
520
+ const [isHistoryCollapsed, setIsHistoryCollapsed] = useState(() => {
521
+ if (typeof window !== 'undefined') {
522
+ const saved = localStorage.getItem('ai-agent-panel-history-collapsed');
523
+ return saved === 'true';
524
+ }
525
+ return false;
526
+ });
527
+ const [panelWidth, setPanelWidth] = useState(() => {
528
+ if (typeof window !== 'undefined') {
529
+ const savedWidth = localStorage.getItem('ai-agent-panel-width');
530
+ if (savedWidth) {
531
+ const width = parseInt(savedWidth, 10);
532
+ if (width >= minWidth && width <= maxWidth) {
533
+ return width;
534
+ }
535
+ }
536
+ }
537
+ return defaultWidth;
538
+ });
539
+ const [isResizing, setIsResizing] = useState(false);
540
+ const [showSearch, setShowSearch] = useState(false);
541
+ const [showHandoffDialog, setShowHandoffDialog] = useState(false);
542
+ const [suggestedAgent, setSuggestedAgent] = useState<string | null>(null);
543
+ const panelRef = useRef<HTMLDivElement>(null);
544
+ const resizeRef = useRef<HTMLDivElement>(null);
545
+
546
+ // Normalize agents prop to extract IDs and local overrides
547
+ const { agentIds, localOverrides } = useMemo(() => {
548
+ const ids: string[] = [];
549
+ const overrides: Record<string, { localName?: string; avatarUrl?: string }> = {};
550
+
551
+ for (const agent of agents) {
552
+ if (typeof agent === 'string') {
553
+ ids.push(agent);
554
+ } else {
555
+ ids.push(agent.id);
556
+ if (agent.localName || agent.avatarUrl) {
557
+ overrides[agent.id] = {
558
+ localName: agent.localName,
559
+ avatarUrl: agent.avatarUrl,
560
+ };
561
+ }
562
+ }
563
+ }
564
+
565
+ return { agentIds: ids, localOverrides: overrides };
566
+ }, [agents]);
567
+
568
+ // Current agent state
569
+ const [currentAgentId, setCurrentAgentId] = useState<string>(
570
+ defaultAgent || agentIds[0] || ''
571
+ );
572
+
573
+ // API-based conversation state
574
+ const [apiConversations, setApiConversations] = useState<APIConversationSummary[]>([]);
575
+ const [conversationsLoading, setConversationsLoading] = useState(false);
576
+ const [conversationsError, setConversationsError] = useState<string | null>(null);
577
+ const [searchQuery, setSearchQuery] = useState('');
578
+
579
+ // Multi-conversation state
580
+ const [activeConversations, setActiveConversations] = useState<Map<string, ActiveConversation>>(new Map());
581
+ const [currentConversationId, setCurrentConversationId] = useState<string | null>(conversation || null);
582
+
583
+ // Store first prompts for conversations (conversationId -> firstPrompt)
584
+ const [conversationFirstPrompts, setConversationFirstPrompts] = useState<Record<string, string>>({});
585
+ const [loadingPrompts, setLoadingPrompts] = useState<Set<string>>(new Set());
586
+
587
+ // Loading state for conversation transcript
588
+ const [loadingConversationId, setLoadingConversationId] = useState<string | null>(null);
589
+
590
+ // Expand/collapse state for time sections
591
+ const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
592
+ 'Today': true,
593
+ 'Yesterday': true,
594
+ 'This Week': false,
595
+ 'This Month': false,
596
+ 'Older': false,
597
+ });
598
+
599
+ // Agent registry hook
600
+ const {
601
+ agents: agentProfiles,
602
+ isLoading: agentsLoading,
603
+ getAgent,
604
+ buildAgentAwarenessInstructions,
605
+ agentList,
606
+ } = useAgentRegistry(agentIds, { url, localOverrides });
607
+
608
+ // Ref to track if fetch is in progress (prevents duplicate calls)
609
+ const fetchInProgressRef = useRef(false);
610
+
611
+ // Ref to track the last agent we fetched for
612
+ const lastFetchedAgentRef = useRef<string | null>(null);
613
+
614
+ // Ref to track which conversations we've checked for prompts
615
+ const checkedPromptsRef = useRef<Set<string>>(new Set());
616
+
617
+ // Ref to track which conversations are currently being fetched (prevents duplicate requests)
618
+ const fetchingPromptsRef = useRef<Set<string>>(new Set());
619
+
620
+ // Ref to track failed requests with timestamps (prevents immediate retries on errors)
621
+ const failedPromptsRef = useRef<Map<string, { timestamp: number; status?: number }>>(new Map());
622
+
623
+ // Ref to access activeConversations without causing dependency issues
624
+ const activeConversationsRef = useRef<Map<string, ActiveConversation>>(activeConversations);
625
+ activeConversationsRef.current = activeConversations;
626
+
627
+ // Ref to access currentConversationId without causing dependency issues
628
+ const currentConversationIdRef = useRef<string | null>(currentConversationId);
629
+ currentConversationIdRef.current = currentConversationId;
630
+
631
+ // Fetch first prompt from a conversation
632
+ const fetchFirstPrompt = useCallback(async (conversationId: string, agentIdForConversation?: string) => {
633
+ // Skip if already checked/loaded
634
+ if (checkedPromptsRef.current.has(conversationId)) {
635
+ return;
636
+ }
637
+
638
+ // Skip if currently being fetched (prevents duplicate requests)
639
+ if (fetchingPromptsRef.current.has(conversationId)) {
640
+ return;
641
+ }
642
+
643
+ // Check if this request recently failed with a server error (502, 503, 504)
644
+ // Don't retry server errors immediately - wait at least 30 seconds
645
+ const failedRequest = failedPromptsRef.current.get(conversationId);
646
+ if (failedRequest) {
647
+ const timeSinceFailure = Date.now() - failedRequest.timestamp;
648
+ const isServerError = failedRequest.status && failedRequest.status >= 500 && failedRequest.status < 600;
649
+
650
+ // For server errors, wait 30 seconds before retrying
651
+ // For other errors, wait 5 seconds
652
+ const cooldownPeriod = isServerError ? 30000 : 5000;
653
+
654
+ if (timeSinceFailure < cooldownPeriod) {
655
+ return; // Still in cooldown period, don't retry
656
+ }
657
+ }
658
+
659
+ // Mark as currently fetching
660
+ fetchingPromptsRef.current.add(conversationId);
661
+
662
+ // Mark as loading
663
+ setLoadingPrompts(prev => new Set(prev).add(conversationId));
664
+
665
+ // Use the conversation's agentId if available, otherwise fall back to currentAgentId
666
+ const agentIdToUse = agentIdForConversation || currentAgentId;
667
+ const agentProfile = getAgent(agentIdToUse);
668
+ const projectId = agentProfile?.metadata?.projectId;
669
+
670
+ if (!apiKey || !projectId) {
671
+ fetchingPromptsRef.current.delete(conversationId);
672
+ setLoadingPrompts(prev => {
673
+ const next = new Set(prev);
674
+ next.delete(conversationId);
675
+ return next;
676
+ });
677
+ return;
678
+ }
679
+
680
+ try {
681
+ const url = `https://api.llmasaservice.io/conversations/${conversationId}/calls`;
682
+
683
+ const response = await fetch(url, {
684
+ headers: {
685
+ 'x-api-key': apiKey,
686
+ 'x-project-id': projectId,
687
+ },
688
+ });
689
+
690
+ if (!response.ok) {
691
+ // Track failed requests with status code
692
+ failedPromptsRef.current.set(conversationId, {
693
+ timestamp: Date.now(),
694
+ status: response.status,
695
+ });
696
+
697
+ // For server errors (5xx), don't retry immediately - the error will be logged but we won't spam
698
+ if (response.status >= 500 && response.status < 600) {
699
+ // Remove from fetching set but don't mark as checked, so it can retry after cooldown
700
+ fetchingPromptsRef.current.delete(conversationId);
701
+ setLoadingPrompts(prev => {
702
+ const next = new Set(prev);
703
+ next.delete(conversationId);
704
+ return next;
705
+ });
706
+ // Silently fail for server errors - don't log to avoid console spam
707
+ return;
708
+ }
709
+
710
+ throw new Error(`Failed to fetch calls (${response.status})`);
711
+ }
712
+
713
+ // Success - mark as checked and clear any failure record
714
+ checkedPromptsRef.current.add(conversationId);
715
+ failedPromptsRef.current.delete(conversationId);
716
+
717
+ const payload = await response.json();
718
+
719
+ // Extract first prompt from calls array
720
+ if (Array.isArray(payload) && payload.length > 0) {
721
+ const firstCall = payload[0];
722
+ if (firstCall.prompt) {
723
+ const promptText = typeof firstCall.prompt === 'string'
724
+ ? firstCall.prompt
725
+ : firstCall.prompt[0]?.text || '';
726
+
727
+ if (promptText) {
728
+ // Truncate to 60 characters
729
+ const truncated = promptText.length > 60 ? promptText.slice(0, 57) + '...' : promptText;
730
+ setConversationFirstPrompts(prev => ({
731
+ ...prev,
732
+ [conversationId]: truncated,
733
+ }));
734
+ }
735
+ }
736
+ }
737
+ } catch (error: any) {
738
+ // Only log non-server errors to avoid console spam
739
+ if (!error.message || !error.message.includes('50')) {
740
+ console.error('Failed to fetch first prompt:', error);
741
+ }
742
+
743
+ // Track failed requests
744
+ failedPromptsRef.current.set(conversationId, {
745
+ timestamp: Date.now(),
746
+ });
747
+ } finally {
748
+ // Always remove from fetching set
749
+ fetchingPromptsRef.current.delete(conversationId);
750
+ setLoadingPrompts(prev => {
751
+ const next = new Set(prev);
752
+ next.delete(conversationId);
753
+ return next;
754
+ });
755
+ }
756
+ }, [apiKey, currentAgentId, getAgent]);
757
+
758
+ // Fetch conversations from API
759
+ const fetchConversations = useCallback(async (agentId: string, signal?: AbortSignal) => {
760
+ // Get the agent profile to access projectId
761
+ const agentProfile = getAgent(agentId);
762
+ const projectId = agentProfile?.metadata?.projectId;
763
+
764
+ if (!agentId || !apiKey || !projectId) {
765
+ setApiConversations([]);
766
+ return;
767
+ }
768
+
769
+ // Prevent duplicate fetches
770
+ if (fetchInProgressRef.current) {
771
+ return;
772
+ }
773
+ fetchInProgressRef.current = true;
774
+
775
+ setConversationsLoading(true);
776
+ setConversationsError(null);
777
+
778
+ try {
779
+ console.log('fetchConversations - customerId:', customerId);
780
+ const url = `https://api.llmasaservice.io/conversations?customer_id=${customerId}`;
781
+ console.log('fetchConversations - URL:', url);
782
+
783
+ const response = await fetch(url, {
784
+ signal,
785
+ headers: {
786
+ 'x-api-key': apiKey,
787
+ 'x-project-id': projectId,
788
+ },
789
+ });
790
+
791
+ if (!response.ok) {
792
+ throw new Error(`Failed to fetch conversations (${response.status})`);
793
+ }
794
+
795
+ const payload = await response.json();
796
+ const normalized = normalizeConversationListPayload(payload);
797
+
798
+ if (!signal?.aborted) {
799
+ setApiConversations(normalized);
800
+
801
+ // Fetch first prompts for Today and Yesterday conversations
802
+ const now = new Date();
803
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
804
+ const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
805
+
806
+ normalized.forEach(conv => {
807
+ const date = new Date(conv.updatedAt);
808
+ if (date >= yesterday && !checkedPromptsRef.current.has(conv.conversationId)) {
809
+ // Fetch first prompt for Today and Yesterday conversations
810
+ fetchFirstPrompt(conv.conversationId, conv.agentId);
811
+ }
812
+ });
813
+ }
814
+ } catch (error: any) {
815
+ if (!signal?.aborted) {
816
+ setConversationsError(error.message || 'Failed to load conversations');
817
+ }
818
+ } finally {
819
+ fetchInProgressRef.current = false;
820
+ if (!signal?.aborted) {
821
+ setConversationsLoading(false);
822
+ }
823
+ }
824
+ }, [apiKey, customerId, getAgent, fetchFirstPrompt]);
825
+
826
+ // Helper to strip context/template data from a prompt - keeps only the user's actual message
827
+ const stripContextFromPrompt = useCallback((prompt: string): string => {
828
+ let cleanPrompt = prompt;
829
+
830
+ // Strip ---context--- block and everything after it
831
+ const contextIndex = cleanPrompt.indexOf('---context---');
832
+ if (contextIndex !== -1) {
833
+ cleanPrompt = cleanPrompt.substring(0, contextIndex).trim();
834
+ }
835
+
836
+ // Strip timestamp prefix (ISO format: 2024-01-01T00:00:00.000Z:)
837
+ const isoTimestampRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z:/;
838
+ if (isoTimestampRegex.test(cleanPrompt)) {
839
+ const colonIndex = cleanPrompt.indexOf(':', 19);
840
+ cleanPrompt = cleanPrompt.substring(colonIndex + 1);
841
+ } else if (/^\d+:/.test(cleanPrompt)) {
842
+ const colonIndex = cleanPrompt.indexOf(':');
843
+ cleanPrompt = cleanPrompt.substring(colonIndex + 1);
844
+ }
845
+
846
+ return cleanPrompt.trim();
847
+ }, []);
848
+
849
+ // Load a specific conversation's transcript (or switch to it if already active)
850
+ const loadConversationTranscript = useCallback(async (conversationId: string, agentIdForConversation?: string, title?: string) => {
851
+ // Check if conversation is already in activeConversations (use ref to avoid dependency)
852
+ const existingActive = activeConversationsRef.current.get(conversationId);
853
+ if (existingActive) {
854
+ // Just switch to it, no API call needed
855
+ setCurrentConversationId(conversationId);
856
+ if (onConversationChange) {
857
+ onConversationChange(conversationId);
858
+ }
859
+ return;
860
+ }
861
+
862
+ // Use the conversation's agentId if available, otherwise fall back to currentAgentId
863
+ const agentIdToUse = agentIdForConversation || currentAgentId;
864
+ const agentProfile = getAgent(agentIdToUse);
865
+ const projectId = agentProfile?.metadata?.projectId;
866
+
867
+ if (!apiKey || !projectId) {
868
+ setConversationsError('Missing API key or project ID');
869
+ return;
870
+ }
871
+
872
+ // Set loading state
873
+ setLoadingConversationId(conversationId);
874
+
875
+ try {
876
+ console.log('loadConversationTranscript - conversationId:', conversationId);
877
+ const url = `https://api.llmasaservice.io/conversations/${conversationId}/calls`;
878
+ console.log('loadConversationTranscript - URL:', url);
879
+
880
+ const response = await fetch(url, {
881
+ headers: {
882
+ 'x-api-key': apiKey,
883
+ 'x-project-id': projectId,
884
+ },
885
+ });
886
+
887
+ if (!response.ok) {
888
+ throw new Error(`Failed to load conversation (${response.status})`);
889
+ }
890
+
891
+ const payload = await response.json();
892
+ console.log('loadConversationTranscript - API response:', payload);
893
+
894
+ // The /calls endpoint returns an array of calls
895
+ // Build history from all calls in chronological order
896
+ // IMPORTANT: Strip context/template data from prompts to prevent token bloat
897
+ const history: Record<string, { content: string; callId: string }> = {};
898
+ let firstPrompt: string | null = null;
899
+
900
+ if (Array.isArray(payload) && payload.length > 0) {
901
+ // Parse all calls in order
902
+ payload.forEach((call: any, index: number) => {
903
+ if (call.prompt && call.response) {
904
+ // Get the raw prompt text
905
+ const rawPrompt = typeof call.prompt === 'string'
906
+ ? call.prompt
907
+ : call.prompt[0]?.text || '';
908
+
909
+ // Strip context/template data - keep only the user's actual message
910
+ const cleanedPrompt = stripContextFromPrompt(rawPrompt);
911
+
912
+ // Extract first prompt for display in list
913
+ if (index === 0 && cleanedPrompt) {
914
+ firstPrompt = cleanedPrompt.length > 60 ? cleanedPrompt.slice(0, 57) + '...' : cleanedPrompt;
915
+ }
916
+
917
+ // Use timestamp-prefixed key to maintain order and uniqueness
918
+ const timestamp = call.createdAt || call.created_at || new Date().toISOString();
919
+ const historyKey = `${timestamp}:${cleanedPrompt}`;
920
+
921
+ history[historyKey] = {
922
+ content: call.response,
923
+ callId: call.id || '',
924
+ };
925
+ }
926
+ });
927
+ }
928
+
929
+ console.log('loadConversationTranscript - parsed history:', history);
930
+
931
+ // Update first prompt in state if we found one
932
+ if (firstPrompt) {
933
+ setConversationFirstPrompts(prev => ({
934
+ ...prev,
935
+ [conversationId]: firstPrompt!,
936
+ }));
937
+ }
938
+
939
+ // Add to active conversations
940
+ const conversationTitle = title || firstPrompt || 'Conversation';
941
+ setActiveConversations(prev => {
942
+ const next = new Map(prev);
943
+ next.set(conversationId, {
944
+ conversationId,
945
+ agentId: agentIdToUse,
946
+ history,
947
+ isLoading: false,
948
+ title: conversationTitle,
949
+ });
950
+ return next;
951
+ });
952
+
953
+ setCurrentConversationId(conversationId);
954
+
955
+ if (onConversationChange) {
956
+ onConversationChange(conversationId);
957
+ }
958
+
959
+ // Clear loading state on success
960
+ setLoadingConversationId(null);
961
+ } catch (error: any) {
962
+ console.error('Failed to load conversation:', error);
963
+ setConversationsError(error.message || 'Failed to load conversation');
964
+
965
+ // Clear loading state on error
966
+ setLoadingConversationId(null);
967
+ }
968
+ }, [apiKey, currentAgentId, getAgent, onConversationChange, stripContextFromPrompt]);
969
+
970
+
971
+ // Refresh conversations callback
972
+ const handleRefreshConversations = useCallback(() => {
973
+ fetchConversations(currentAgentId);
974
+ }, [currentAgentId, fetchConversations]);
975
+
976
+ // Fetch conversations on mount and when agent changes
977
+ useEffect(() => {
978
+ // Only fetch if agents are loaded and we have necessary data
979
+ if (!agentsLoading && currentAgentId && apiKey) {
980
+ // Check if agent is ready (has projectId)
981
+ const agentProfile = getAgent(currentAgentId);
982
+ const projectId = agentProfile?.metadata?.projectId;
983
+
984
+ // Only fetch once per agent, and only if agent is ready
985
+ if (projectId && lastFetchedAgentRef.current !== currentAgentId) {
986
+ lastFetchedAgentRef.current = currentAgentId;
987
+ fetchConversations(currentAgentId);
988
+ }
989
+ }
990
+ }, [agentsLoading, currentAgentId, apiKey, fetchConversations, getAgent]);
991
+
992
+ // Start new conversation
993
+ const handleNewConversation = useCallback(() => {
994
+ // Generate a temporary ID for the new conversation
995
+ const tempId = `new-${Date.now()}`;
996
+
997
+ // Add to active conversations with empty history
998
+ setActiveConversations(prev => {
999
+ const next = new Map(prev);
1000
+ next.set(tempId, {
1001
+ conversationId: tempId,
1002
+ agentId: currentAgentId,
1003
+ history: {},
1004
+ isLoading: false,
1005
+ title: 'New conversation',
1006
+ });
1007
+ return next;
1008
+ });
1009
+
1010
+ setCurrentConversationId(tempId);
1011
+ }, [currentAgentId]);
1012
+
1013
+ // Auto-start a new conversation when none exist and agent is ready
1014
+ useEffect(() => {
1015
+ const agentProfile = getAgent(currentAgentId);
1016
+ const isAgentReady = agentProfile?.metadata?.projectId;
1017
+
1018
+ if (isAgentReady && !agentsLoading && activeConversations.size === 0) {
1019
+ handleNewConversation();
1020
+ }
1021
+ }, [currentAgentId, agentsLoading, activeConversations.size, getAgent, handleNewConversation]);
1022
+
1023
+ // Close an active conversation
1024
+ const handleCloseConversation = useCallback((conversationId: string, e?: React.MouseEvent) => {
1025
+ if (e) {
1026
+ e.stopPropagation();
1027
+ }
1028
+
1029
+ setActiveConversations(prev => {
1030
+ const next = new Map(prev);
1031
+ next.delete(conversationId);
1032
+ return next;
1033
+ });
1034
+
1035
+ // If closing the current conversation, switch to another active one or null
1036
+ if (currentConversationIdRef.current === conversationId) {
1037
+ const remaining = Array.from(activeConversationsRef.current.keys()).filter(id => id !== conversationId);
1038
+ const nextId = remaining.length > 0 ? remaining[0] ?? null : null;
1039
+ setCurrentConversationId(nextId);
1040
+ }
1041
+ }, []);
1042
+
1043
+ // Select conversation
1044
+ const handleSelectConversation = useCallback((conversationId: string) => {
1045
+ loadConversationTranscript(conversationId);
1046
+ }, [loadConversationTranscript]);
1047
+
1048
+ // Toggle section expansion
1049
+ const toggleSection = useCallback((sectionLabel: string) => {
1050
+ setExpandedSections(prev => {
1051
+ const isExpanding = !prev[sectionLabel];
1052
+
1053
+ // If expanding, fetch first prompts for conversations in this section
1054
+ if (isExpanding) {
1055
+ // Use a ref to get current conversations to avoid dependency issues
1056
+ const conversationsToCheck = apiConversations;
1057
+ const now = new Date();
1058
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
1059
+ const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
1060
+ const thisWeek = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
1061
+ const thisMonth = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000);
1062
+
1063
+ // Fetch prompts for conversations in this section
1064
+ conversationsToCheck.forEach(conv => {
1065
+ const date = new Date(conv.updatedAt);
1066
+ let shouldFetch = false;
1067
+
1068
+ if (sectionLabel === 'Today' && date >= today) {
1069
+ shouldFetch = true;
1070
+ } else if (sectionLabel === 'Yesterday' && date >= yesterday && date < today) {
1071
+ shouldFetch = true;
1072
+ } else if (sectionLabel === 'This Week' && date >= thisWeek && date < yesterday) {
1073
+ shouldFetch = true;
1074
+ } else if (sectionLabel === 'This Month' && date >= thisMonth && date < thisWeek) {
1075
+ shouldFetch = true;
1076
+ } else if (sectionLabel === 'Older' && date < thisMonth) {
1077
+ shouldFetch = true;
1078
+ }
1079
+
1080
+ if (shouldFetch && !checkedPromptsRef.current.has(conv.conversationId)) {
1081
+ fetchFirstPrompt(conv.conversationId, conv.agentId);
1082
+ }
1083
+ });
1084
+ }
1085
+
1086
+ return {
1087
+ ...prev,
1088
+ [sectionLabel]: isExpanding,
1089
+ };
1090
+ });
1091
+ }, [apiConversations, fetchFirstPrompt]);
1092
+
1093
+ // Group conversations
1094
+ const groupedConversations = useMemo(() => {
1095
+ let filtered = apiConversations;
1096
+
1097
+ // Filter by search query
1098
+ if (searchQuery) {
1099
+ const query = searchQuery.toLowerCase();
1100
+ filtered = filtered.filter(conv =>
1101
+ conv.title?.toLowerCase().includes(query) ||
1102
+ conv.summary?.toLowerCase().includes(query)
1103
+ );
1104
+ }
1105
+
1106
+ // Group all conversations, show all groups even if empty
1107
+ return groupConversationsByTime(filtered, true);
1108
+ }, [apiConversations, searchQuery]);
1109
+
1110
+ // Build effective customer object with required customerId
1111
+ const effectiveCustomer = useMemo(() => {
1112
+ return {
1113
+ ...customer,
1114
+ customer_id: customerId,
1115
+ };
1116
+ }, [customer, customerId]);
1117
+
1118
+
1119
+ // Resize start handler - attaches listeners directly
1120
+ const handleResizeStart = useCallback((e: React.MouseEvent) => {
1121
+ e.preventDefault();
1122
+ setIsResizing(true);
1123
+
1124
+ let currentWidth = panelWidth;
1125
+
1126
+ const handleMouseMove = (moveEvent: MouseEvent) => {
1127
+ const newWidth = position === 'right'
1128
+ ? window.innerWidth - moveEvent.clientX
1129
+ : moveEvent.clientX;
1130
+
1131
+ if (newWidth >= minWidth && newWidth <= maxWidth) {
1132
+ currentWidth = newWidth;
1133
+ setPanelWidth(newWidth);
1134
+ }
1135
+ };
1136
+
1137
+ const handleMouseUp = () => {
1138
+ setIsResizing(false);
1139
+ localStorage.setItem('ai-agent-panel-width', currentWidth.toString());
1140
+ document.removeEventListener('mousemove', handleMouseMove);
1141
+ document.removeEventListener('mouseup', handleMouseUp);
1142
+ };
1143
+
1144
+ document.addEventListener('mousemove', handleMouseMove);
1145
+ document.addEventListener('mouseup', handleMouseUp);
1146
+ }, [panelWidth, minWidth, maxWidth, position]);
1147
+
1148
+ // Get current agent profile
1149
+ const currentAgentProfile = useMemo(() => {
1150
+ return getAgent(currentAgentId);
1151
+ }, [currentAgentId, getAgent]);
1152
+
1153
+ const currentAgentMetadata = currentAgentProfile?.metadata;
1154
+
1155
+ // Get current active conversation
1156
+ const currentActiveConversation = useMemo(() => {
1157
+ if (!currentConversationId) return null;
1158
+ return activeConversations.get(currentConversationId) || null;
1159
+ }, [currentConversationId, activeConversations]);
1160
+
1161
+ // Get list of active conversations sorted by most recent
1162
+ const activeConversationsList = useMemo(() => {
1163
+ return Array.from(activeConversations.values());
1164
+ }, [activeConversations]);
1165
+
1166
+ // Build agent select options
1167
+ const agentOptions: AgentOption[] = useMemo(() => {
1168
+ return agentList.map((agent) => ({
1169
+ value: agent.id,
1170
+ label: agent.name,
1171
+ description: agent.description,
1172
+ avatarUrl: agent.avatarUrl,
1173
+ }));
1174
+ }, [agentList]);
1175
+
1176
+ // Merge context from all sources
1177
+ const mergedContext = useMemo(() => {
1178
+ const sections: ContextSection[] = [
1179
+ ...sharedContextSections,
1180
+ ...pageContextSections,
1181
+ ...(context?.sections || []),
1182
+ ];
1183
+
1184
+ // Add context data sources
1185
+ if (contextDataSources) {
1186
+ Object.entries(contextDataSources).forEach(([key, value]) => {
1187
+ sections.push({
1188
+ id: key,
1189
+ title: key,
1190
+ data: typeof value === 'object' ? (value as Record<string, unknown>) : { value },
1191
+ });
1192
+ });
1193
+ }
1194
+
1195
+ const totalTokens = sections.reduce((sum, s) => sum + (s.tokens || 0), 0);
1196
+
1197
+ return {
1198
+ route: context?.route,
1199
+ sections,
1200
+ totalTokens,
1201
+ };
1202
+ }, [context, sharedContextSections, pageContextSections, contextDataSources]);
1203
+
1204
+
1205
+ // Build data array for ChatPanel
1206
+ const chatPanelData = useMemo(() => {
1207
+ const contextData = mergedContext.sections.map((section) => ({
1208
+ key: section.id,
1209
+ data: JSON.stringify(section.data),
1210
+ }));
1211
+
1212
+ // Add agent awareness instructions (only if agent suggestions are enabled)
1213
+ if (enableAgentSuggestions) {
1214
+ const awarenessInstructions = buildAgentAwarenessInstructions(currentAgentId);
1215
+ if (awarenessInstructions) {
1216
+ contextData.push({
1217
+ key: 'agent_awareness',
1218
+ data: awarenessInstructions,
1219
+ });
1220
+ }
1221
+ }
1222
+
1223
+ return [...data, ...contextData];
1224
+ }, [data, mergedContext.sections, buildAgentAwarenessInstructions, currentAgentId, enableAgentSuggestions]);
1225
+
1226
+ // Handle agent switch - updates the agent for the current conversation without starting a new one
1227
+ const handleAgentSwitch = useCallback(
1228
+ (newAgentId: string) => {
1229
+ const oldAgentId = currentAgentId;
1230
+ setCurrentAgentId(newAgentId);
1231
+
1232
+ if (onAgentSwitch) {
1233
+ onAgentSwitch(oldAgentId, newAgentId);
1234
+ }
1235
+
1236
+ // Update the current conversation's agent ID (don't start a new conversation)
1237
+ if (currentConversationIdRef.current) {
1238
+ setActiveConversations(prev => {
1239
+ const existing = prev.get(currentConversationIdRef.current!);
1240
+ if (existing) {
1241
+ const next = new Map(prev);
1242
+ next.set(currentConversationIdRef.current!, {
1243
+ ...existing,
1244
+ agentId: newAgentId,
1245
+ });
1246
+ return next;
1247
+ }
1248
+ return prev;
1249
+ });
1250
+ }
1251
+ },
1252
+ [currentAgentId, onAgentSwitch]
1253
+ );
1254
+
1255
+ // Handle conversation select from sidebar
1256
+ const handleConversationSelect = useCallback(
1257
+ (conv: APIConversationSummary) => {
1258
+ // Determine which agent to use - prefer conversation's agent, but fall back if not available
1259
+ let agentIdToUse = conv.agentId || currentAgentId;
1260
+
1261
+ // Check if the conversation's agent exists
1262
+ const targetAgent = getAgent(agentIdToUse);
1263
+ if (!targetAgent && conv.agentId) {
1264
+ // Conversation's agent not found, fall back to current agent
1265
+ console.warn('Agent not found for conversation:', conv.agentId, 'Falling back to current agent:', currentAgentId);
1266
+ agentIdToUse = currentAgentId;
1267
+ }
1268
+
1269
+ // Final check - ensure we have a valid agent
1270
+ const finalAgent = getAgent(agentIdToUse);
1271
+ if (!finalAgent) {
1272
+ console.error('No valid agent available. Current:', currentAgentId, 'Available agents:', agentList.map(a => a.id));
1273
+ setConversationsError('No agent available');
1274
+ return;
1275
+ }
1276
+
1277
+ // Get title from first prompts or conv title
1278
+ const title = conversationFirstPrompts[conv.conversationId] || conv.title || 'Conversation';
1279
+
1280
+ // Switch to the agent for this conversation if different
1281
+ if (agentIdToUse !== currentAgentId) {
1282
+ setCurrentAgentId(agentIdToUse);
1283
+ // Wait for state update, then load transcript
1284
+ setTimeout(() => {
1285
+ loadConversationTranscript(conv.conversationId, agentIdToUse, title);
1286
+ }, 0);
1287
+ } else {
1288
+ // Same agent, load immediately
1289
+ loadConversationTranscript(conv.conversationId, agentIdToUse, title);
1290
+ }
1291
+ },
1292
+ [currentAgentId, loadConversationTranscript, getAgent, agentList, conversationFirstPrompts]
1293
+ );
1294
+
1295
+ // Handle history changes from ChatPanel
1296
+ const handleHistoryChanged = useCallback(
1297
+ (history: Record<string, { content: string; callId: string }>, conversationId?: string) => {
1298
+ const targetConversationId = conversationId || currentConversationIdRef.current;
1299
+
1300
+ // Update active conversation history
1301
+ if (targetConversationId) {
1302
+ setActiveConversations(prev => {
1303
+ const existing = prev.get(targetConversationId);
1304
+ if (existing) {
1305
+ const next = new Map(prev);
1306
+ // Derive title from first prompt if conversation is new
1307
+ let title = existing.title;
1308
+ if (title === 'New conversation' && Object.keys(history).length > 0) {
1309
+ const firstPrompt = Object.keys(history)[0];
1310
+ if (firstPrompt) {
1311
+ // Strip timestamp prefix
1312
+ let cleanPrompt = firstPrompt;
1313
+ const isoMatch = cleanPrompt.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z:(.+)/);
1314
+ if (isoMatch && isoMatch[1]) {
1315
+ cleanPrompt = isoMatch[1];
1316
+ }
1317
+ title = cleanPrompt.length > 60 ? cleanPrompt.slice(0, 57) + '...' : cleanPrompt;
1318
+ }
1319
+ }
1320
+ next.set(targetConversationId, {
1321
+ ...existing,
1322
+ history,
1323
+ title,
1324
+ });
1325
+ return next;
1326
+ }
1327
+ return prev;
1328
+ });
1329
+ }
1330
+
1331
+ // Check for agent handoff suggestion
1332
+ const lastEntry = Object.entries(history).pop();
1333
+ if (lastEntry) {
1334
+ const content = lastEntry[1].content;
1335
+ const handoffMatch = content.match(/\[SUGGEST_AGENT:([^\]]+)\]/);
1336
+ if (handoffMatch) {
1337
+ const suggestedId = handoffMatch[1];
1338
+ if (suggestedId && suggestedId !== currentAgentId && agents.includes(suggestedId)) {
1339
+ setSuggestedAgent(suggestedId);
1340
+ setShowHandoffDialog(true);
1341
+ }
1342
+ }
1343
+ }
1344
+
1345
+ if (historyChangedCallback) {
1346
+ historyChangedCallback(history);
1347
+ }
1348
+ },
1349
+ [
1350
+ currentAgentId,
1351
+ historyChangedCallback,
1352
+ agents,
1353
+ ]
1354
+ );
1355
+
1356
+ // Handle loading state changes from ChatPanel
1357
+ const handleLoadingChange = useCallback((isLoading: boolean, conversationId?: string) => {
1358
+ const targetConversationId = conversationId || currentConversationIdRef.current;
1359
+
1360
+ if (targetConversationId) {
1361
+ setActiveConversations(prev => {
1362
+ const existing = prev.get(targetConversationId);
1363
+ if (existing && existing.isLoading !== isLoading) {
1364
+ const next = new Map(prev);
1365
+ next.set(targetConversationId, {
1366
+ ...existing,
1367
+ isLoading,
1368
+ });
1369
+ return next;
1370
+ }
1371
+ return prev;
1372
+ });
1373
+ }
1374
+ }, []);
1375
+
1376
+ // Handle handoff confirmation
1377
+ const handleHandoffConfirm = useCallback(() => {
1378
+ if (suggestedAgent) {
1379
+ handleAgentSwitch(suggestedAgent);
1380
+ }
1381
+ setShowHandoffDialog(false);
1382
+ setSuggestedAgent(null);
1383
+ }, [suggestedAgent, handleAgentSwitch]);
1384
+
1385
+ const handleHandoffCancel = useCallback(() => {
1386
+ setShowHandoffDialog(false);
1387
+ setSuggestedAgent(null);
1388
+ }, []);
1389
+
1390
+ // Toggle collapse
1391
+ const toggleCollapse = useCallback(() => {
1392
+ setIsCollapsed((prev) => !prev);
1393
+ }, []);
1394
+
1395
+ // Toggle history collapse
1396
+ const toggleHistoryCollapse = useCallback(() => {
1397
+ setIsHistoryCollapsed((prev) => {
1398
+ const next = !prev;
1399
+ localStorage.setItem('ai-agent-panel-history-collapsed', String(next));
1400
+ return next;
1401
+ });
1402
+ }, []);
1403
+
1404
+ // Panel classes
1405
+ const panelClasses = [
1406
+ 'ai-agent-panel',
1407
+ theme === 'dark' ? 'dark-theme' : '',
1408
+ isCollapsed ? 'ai-agent-panel--collapsed' : '',
1409
+ position === 'left' ? 'ai-agent-panel--left' : 'ai-agent-panel--right',
1410
+ sidebarPosition === 'right' ? 'ai-agent-panel--sidebar-right' : 'ai-agent-panel--sidebar-left',
1411
+ ].filter(Boolean).join(' ');
1412
+
1413
+ // Collapsed view
1414
+ if (isCollapsed) {
1415
+ return (
1416
+ <div className={panelClasses} ref={panelRef}>
1417
+ <div
1418
+ className="ai-agent-panel__collapsed-bar"
1419
+ onClick={(e) => {
1420
+ // Only expand if clicking on the bar itself, not on buttons
1421
+ if (e.target === e.currentTarget) {
1422
+ toggleCollapse();
1423
+ }
1424
+ }}
1425
+ title="Click to expand"
1426
+ >
1427
+ {/* Expand button at top for discoverability */}
1428
+ <Tooltip content="Expand Panel" side="left">
1429
+ <Button variant="ghost" size="icon" onClick={toggleCollapse}>
1430
+ {position === 'right' ? <ChevronLeftIcon /> : <ChevronRightIcon />}
1431
+ </Button>
1432
+ </Tooltip>
1433
+
1434
+ <div className="ai-agent-panel__collapsed-divider" />
1435
+
1436
+ <Tooltip content="Search" side="left">
1437
+ <Button
1438
+ variant="ghost"
1439
+ size="icon"
1440
+ onClick={() => {
1441
+ setIsCollapsed(false);
1442
+ setShowSearch(true);
1443
+ }}
1444
+ >
1445
+ <SearchIcon />
1446
+ </Button>
1447
+ </Tooltip>
1448
+
1449
+ <Tooltip content="New Chat" side="left">
1450
+ <Button
1451
+ variant="ghost"
1452
+ size="icon"
1453
+ onClick={() => {
1454
+ setIsCollapsed(false);
1455
+ handleNewConversation();
1456
+ }}
1457
+ >
1458
+ <PlusIcon />
1459
+ </Button>
1460
+ </Tooltip>
1461
+
1462
+ <div className="ai-agent-panel__collapsed-divider" />
1463
+
1464
+ {agentList.map((agent) => {
1465
+ // Check if this agent has an active conversation
1466
+ const activeConvForAgent = activeConversationsList.find(
1467
+ (conv) => conv.agentId === agent.id
1468
+ );
1469
+ const hasActiveConversation = !!activeConvForAgent;
1470
+
1471
+ return (
1472
+ <Tooltip key={agent.id} content={agent.name} side="left">
1473
+ <Button
1474
+ variant={agent.id === currentAgentId ? 'secondary' : 'ghost'}
1475
+ size="icon"
1476
+ onClick={() => {
1477
+ setIsCollapsed(false);
1478
+ if (hasActiveConversation && activeConvForAgent) {
1479
+ // Switch to the existing conversation
1480
+ setCurrentConversationId(activeConvForAgent.conversationId);
1481
+ setCurrentAgentId(agent.id);
1482
+ if (onConversationChange) {
1483
+ onConversationChange(activeConvForAgent.conversationId);
1484
+ }
1485
+ } else {
1486
+ // Start a new conversation with this agent
1487
+ const tempId = `new-${Date.now()}`;
1488
+ setActiveConversations(prev => {
1489
+ const next = new Map(prev);
1490
+ next.set(tempId, {
1491
+ conversationId: tempId,
1492
+ agentId: agent.id,
1493
+ history: {},
1494
+ isLoading: false,
1495
+ title: 'New conversation',
1496
+ });
1497
+ return next;
1498
+ });
1499
+ setCurrentConversationId(tempId);
1500
+ setCurrentAgentId(agent.id);
1501
+ }
1502
+ }}
1503
+ className={`ai-agent-panel__agent-icon ${hasActiveConversation ? 'ai-agent-panel__agent-icon--active' : ''}`}
1504
+ >
1505
+ {agent.avatarUrl ? (
1506
+ <img
1507
+ src={agent.avatarUrl}
1508
+ alt={agent.name}
1509
+ className="ai-agent-panel__agent-avatar"
1510
+ />
1511
+ ) : (
1512
+ agent.name.charAt(0).toUpperCase()
1513
+ )}
1514
+ {hasActiveConversation && (
1515
+ <span className="ai-agent-panel__agent-active-indicator" />
1516
+ )}
1517
+ </Button>
1518
+ </Tooltip>
1519
+ );
1520
+ })}
1521
+
1522
+ <div className="ai-agent-panel__collapsed-spacer" />
1523
+ </div>
1524
+ </div>
1525
+ );
1526
+ }
1527
+
1528
+ // Expanded view
1529
+ return (
1530
+ <div
1531
+ className={panelClasses}
1532
+ ref={panelRef}
1533
+ style={{ width: `${panelWidth}px` }}
1534
+ >
1535
+ {/* Resize handle */}
1536
+ <div
1537
+ ref={resizeRef}
1538
+ className={`ai-agent-panel__resize-handle ai-agent-panel__resize-handle--${position}`}
1539
+ onMouseDown={handleResizeStart}
1540
+ role="separator"
1541
+ aria-orientation="vertical"
1542
+ aria-label="Resize panel"
1543
+ tabIndex={0}
1544
+ />
1545
+
1546
+ {/* Sidebar */}
1547
+ <div className={`ai-agent-panel__sidebar ${isHistoryCollapsed ? 'ai-agent-panel__sidebar--collapsed' : ''}`}>
1548
+ {isHistoryCollapsed ? (
1549
+ // Collapsed history bar
1550
+ <div
1551
+ className="ai-agent-panel__history-collapsed-bar"
1552
+ onClick={(e) => {
1553
+ // Only expand if clicking on the bar itself, not on buttons
1554
+ if (e.target === e.currentTarget) {
1555
+ toggleHistoryCollapse();
1556
+ }
1557
+ }}
1558
+ title="Click to expand history"
1559
+ >
1560
+ {/* Expand button at top for discoverability */}
1561
+ <Tooltip content="Expand History" side={sidebarPosition === 'left' ? 'right' : 'left'}>
1562
+ <Button
1563
+ variant="ghost"
1564
+ size="icon"
1565
+ onClick={toggleHistoryCollapse}
1566
+ >
1567
+ <ChevronRightIcon />
1568
+ </Button>
1569
+ </Tooltip>
1570
+
1571
+ <div className="ai-agent-panel__collapsed-divider" />
1572
+
1573
+ <Tooltip content="New Chat" side={sidebarPosition === 'left' ? 'right' : 'left'}>
1574
+ <Button
1575
+ variant="ghost"
1576
+ size="icon"
1577
+ onClick={handleNewConversation}
1578
+ >
1579
+ <PlusIcon />
1580
+ </Button>
1581
+ </Tooltip>
1582
+
1583
+ <div className="ai-agent-panel__collapsed-divider" />
1584
+
1585
+ {agentList.map((agent) => {
1586
+ // Check if this agent has an active conversation
1587
+ const activeConvForAgent = activeConversationsList.find(
1588
+ (conv) => conv.agentId === agent.id
1589
+ );
1590
+ const hasActiveConversation = !!activeConvForAgent;
1591
+
1592
+ return (
1593
+ <Tooltip key={agent.id} content={agent.name} side={sidebarPosition === 'left' ? 'right' : 'left'}>
1594
+ <Button
1595
+ variant={agent.id === currentAgentId ? 'secondary' : 'ghost'}
1596
+ size="icon"
1597
+ onClick={() => {
1598
+ if (hasActiveConversation && activeConvForAgent) {
1599
+ // Switch to the existing conversation
1600
+ setCurrentConversationId(activeConvForAgent.conversationId);
1601
+ setCurrentAgentId(agent.id);
1602
+ if (onConversationChange) {
1603
+ onConversationChange(activeConvForAgent.conversationId);
1604
+ }
1605
+ } else {
1606
+ // Start a new conversation with this agent
1607
+ const tempId = `new-${Date.now()}`;
1608
+ setActiveConversations(prev => {
1609
+ const next = new Map(prev);
1610
+ next.set(tempId, {
1611
+ conversationId: tempId,
1612
+ agentId: agent.id,
1613
+ history: {},
1614
+ isLoading: false,
1615
+ title: 'New conversation',
1616
+ });
1617
+ return next;
1618
+ });
1619
+ setCurrentConversationId(tempId);
1620
+ setCurrentAgentId(agent.id);
1621
+ }
1622
+ }}
1623
+ className={`ai-agent-panel__agent-icon ${hasActiveConversation ? 'ai-agent-panel__agent-icon--active' : ''}`}
1624
+ >
1625
+ {agent.avatarUrl ? (
1626
+ <img
1627
+ src={agent.avatarUrl}
1628
+ alt={agent.name}
1629
+ className="ai-agent-panel__agent-avatar"
1630
+ />
1631
+ ) : (
1632
+ agent.name.charAt(0).toUpperCase()
1633
+ )}
1634
+ {hasActiveConversation && (
1635
+ <span className="ai-agent-panel__agent-active-indicator" />
1636
+ )}
1637
+ </Button>
1638
+ </Tooltip>
1639
+ );
1640
+ })}
1641
+
1642
+ <div className="ai-agent-panel__history-collapsed-spacer" />
1643
+ </div>
1644
+ ) : (
1645
+ <>
1646
+ {/* Header */}
1647
+ <div className="ai-agent-panel__header">
1648
+ <div className="ai-agent-panel__header-actions">
1649
+ {showSearch ? (
1650
+ <Input
1651
+ placeholder="Search conversations..."
1652
+ value={searchQuery}
1653
+ onChange={(e) => setSearchQuery(e.target.value)}
1654
+ icon={<SearchIcon />}
1655
+ autoFocus
1656
+ onBlur={() => {
1657
+ if (!searchQuery) setShowSearch(false);
1658
+ }}
1659
+ />
1660
+ ) : (
1661
+ <>
1662
+ <Button
1663
+ variant="ghost"
1664
+ size="icon"
1665
+ onClick={() => setShowSearch(true)}
1666
+ >
1667
+ <SearchIcon />
1668
+ </Button>
1669
+ <Button
1670
+ variant="ghost"
1671
+ size="icon"
1672
+ onClick={handleNewConversation}
1673
+ >
1674
+ <PlusIcon />
1675
+ </Button>
1676
+ </>
1677
+ )}
1678
+ </div>
1679
+ <Tooltip content="Collapse History" side="bottom">
1680
+ <Button variant="ghost" size="icon" onClick={toggleHistoryCollapse}>
1681
+ <SidebarIcon />
1682
+ </Button>
1683
+ </Tooltip>
1684
+ <Button variant="ghost" size="icon" onClick={toggleCollapse}>
1685
+ {position === 'right' ? <ChevronRightIcon /> : <ChevronLeftIcon />}
1686
+ </Button>
1687
+ </div>
1688
+
1689
+ {/* Conversation list */}
1690
+ <ScrollArea className="ai-agent-panel__conversations">
1691
+ {/* Active Conversations Section */}
1692
+ {activeConversationsList.length > 0 && (
1693
+ <div className="ai-agent-panel__group ai-agent-panel__group--active">
1694
+ <div className="ai-agent-panel__group-label">
1695
+ <span>Active ({activeConversationsList.length})</span>
1696
+ </div>
1697
+ {activeConversationsList.map((activeConv) => (
1698
+ <div
1699
+ key={activeConv.conversationId}
1700
+ className={`ai-agent-panel__conversation ai-agent-panel__conversation--active-item ${
1701
+ currentConversationId === activeConv.conversationId
1702
+ ? 'ai-agent-panel__conversation--current'
1703
+ : ''
1704
+ }`}
1705
+ onClick={() => {
1706
+ setCurrentConversationId(activeConv.conversationId);
1707
+ if (onConversationChange) {
1708
+ onConversationChange(activeConv.conversationId);
1709
+ }
1710
+ }}
1711
+ >
1712
+ <div className="ai-agent-panel__conversation-content">
1713
+ <div className="ai-agent-panel__conversation-title">
1714
+ {activeConv.isLoading && <LoadingDotIcon />}
1715
+ <span>{activeConv.title}</span>
1716
+ </div>
1717
+ </div>
1718
+ <button
1719
+ className="ai-agent-panel__conversation-close"
1720
+ onClick={(e) => handleCloseConversation(activeConv.conversationId, e)}
1721
+ title="Close conversation"
1722
+ >
1723
+ <CloseIcon />
1724
+ </button>
1725
+ </div>
1726
+ ))}
1727
+ </div>
1728
+ )}
1729
+
1730
+ {/* History Section */}
1731
+ {conversationsLoading ? (
1732
+ <div className="ai-agent-panel__loading">
1733
+ <div className="ai-agent-panel__loading-spinner" />
1734
+ <span>Loading conversations...</span>
1735
+ </div>
1736
+ ) : conversationsError ? (
1737
+ <div className="ai-agent-panel__empty">
1738
+ <p>Error: {conversationsError}</p>
1739
+ <Button variant="secondary" size="sm" onClick={handleRefreshConversations}>
1740
+ Retry
1741
+ </Button>
1742
+ </div>
1743
+ ) : groupedConversations.length === 0 && activeConversationsList.length === 0 ? (
1744
+ <div className="ai-agent-panel__empty">
1745
+ <MessageIcon />
1746
+ <p>No conversations yet</p>
1747
+ <p className="ai-agent-panel__empty-hint">
1748
+ Start chatting to create your first conversation
1749
+ </p>
1750
+ </div>
1751
+ ) : (
1752
+ <>
1753
+ {activeConversationsList.length > 0 && groupedConversations.some(g => g.count > 0) && (
1754
+ <div className="ai-agent-panel__group-divider" />
1755
+ )}
1756
+ {groupedConversations.map((group) => (
1757
+ <div key={group.label} className="ai-agent-panel__group">
1758
+ <div
1759
+ className="ai-agent-panel__group-label ai-agent-panel__group-label--clickable"
1760
+ onClick={() => toggleSection(group.label)}
1761
+ >
1762
+ <span>{group.label} {group.count > 0 && `(${group.count})`}</span>
1763
+ <span className="ai-agent-panel__group-chevron">
1764
+ {expandedSections[group.label] ? '▼' : '▶'}
1765
+ </span>
1766
+ </div>
1767
+ {expandedSections[group.label] && group.conversations.length > 0 && group.conversations.map((conv) => {
1768
+ // Check if this conversation is already active
1769
+ const isActive = activeConversations.has(conv.conversationId);
1770
+ return (
1771
+ <div
1772
+ key={conv.conversationId}
1773
+ className={`ai-agent-panel__conversation ${
1774
+ currentConversationId === conv.conversationId
1775
+ ? 'ai-agent-panel__conversation--current'
1776
+ : ''
1777
+ } ${isActive ? 'ai-agent-panel__conversation--in-active' : ''}`}
1778
+ onClick={() => handleConversationSelect(conv)}
1779
+ >
1780
+ <div className="ai-agent-panel__conversation-content">
1781
+ <div className="ai-agent-panel__conversation-title">
1782
+ {isActive && <span className="ai-agent-panel__active-badge">●</span>}
1783
+ {conversationFirstPrompts[conv.conversationId] || conv.title}
1784
+ </div>
1785
+ </div>
1786
+ </div>
1787
+ );
1788
+ })}
1789
+ </div>
1790
+ ))}
1791
+ </>
1792
+ )}
1793
+ </ScrollArea>
1794
+
1795
+ </>
1796
+ )}
1797
+ </div>
1798
+
1799
+ {/* Chat area */}
1800
+ <div className="ai-agent-panel__chat">
1801
+ {/* Chat panels - one per active conversation, shown/hidden via CSS */}
1802
+ {activeConversationsList.map((activeConv) => (
1803
+ <ChatPanelWrapper
1804
+ key={`${activeConv.conversationId}-${activeConv.agentId}`}
1805
+ activeConv={activeConv}
1806
+ currentConversationId={currentConversationId}
1807
+ getAgent={getAgent}
1808
+ url={url}
1809
+ theme={theme}
1810
+ chatPanelData={chatPanelData}
1811
+ handleHistoryChanged={handleHistoryChanged}
1812
+ handleLoadingChange={handleLoadingChange}
1813
+ responseCompleteCallback={responseCompleteCallback}
1814
+ thumbsUpClick={thumbsUpClick}
1815
+ thumbsDownClick={thumbsDownClick}
1816
+ effectiveCustomer={effectiveCustomer}
1817
+ showPoweredBy={showPoweredBy}
1818
+ actions={actions}
1819
+ followOnQuestions={followOnQuestions}
1820
+ followOnPrompt={followOnPrompt}
1821
+ agentOptions={agentOptions}
1822
+ currentAgentId={currentAgentId}
1823
+ handleAgentSwitch={handleAgentSwitch}
1824
+ agentsLoading={agentsLoading}
1825
+ contextSections={mergedContext.sections}
1826
+ totalContextTokens={mergedContext.totalTokens || 0}
1827
+ maxContextTokens={maxContextTokens}
1828
+ enableContextDetailView={enableContextDetailView}
1829
+ />
1830
+ ))}
1831
+
1832
+ {/* Conversation loading overlay */}
1833
+ {loadingConversationId && (
1834
+ <div className="ai-agent-panel__conversation-loading-overlay">
1835
+ <div className="ai-agent-panel__loading-spinner" />
1836
+ <p>Loading conversation...</p>
1837
+ </div>
1838
+ )}
1839
+
1840
+ {/* Empty state when no conversation is selected */}
1841
+ {currentAgentMetadata && activeConversationsList.length === 0 && !loadingConversationId && (
1842
+ <div className="ai-agent-panel__empty-chat">
1843
+ <MessageIcon />
1844
+ <p>Select a conversation or start a new one</p>
1845
+ <Button variant="default" size="sm" onClick={handleNewConversation}>
1846
+ New Conversation
1847
+ </Button>
1848
+ </div>
1849
+ )}
1850
+
1851
+ {/* Agent loading state */}
1852
+ {agentsLoading && !currentAgentMetadata && (
1853
+ <div className="ai-agent-panel__loading">
1854
+ <div className="ai-agent-panel__loading-spinner" />
1855
+ <p>Loading agent...</p>
1856
+ </div>
1857
+ )}
1858
+ </div>
1859
+
1860
+ {/* Handoff confirmation dialog */}
1861
+ <Dialog
1862
+ isOpen={showHandoffDialog}
1863
+ onClose={handleHandoffCancel}
1864
+ title="Switch Agent?"
1865
+ description={`The current agent suggests transferring this conversation to ${
1866
+ suggestedAgent ? getAgent(suggestedAgent)?.metadata?.displayTitle || suggestedAgent : 'another agent'
1867
+ }.`}
1868
+ >
1869
+ <DialogFooter>
1870
+ <Button variant="outline" onClick={handleHandoffCancel}>
1871
+ Stay with current agent
1872
+ </Button>
1873
+ <Button onClick={handleHandoffConfirm}>
1874
+ Switch agent
1875
+ </Button>
1876
+ </DialogFooter>
1877
+ </Dialog>
1878
+ </div>
1879
+ );
1880
+ };
1881
+
1882
+ export default AIAgentPanel;
1883
+