@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,1725 @@
1
+ /**
2
+ * AIChatPanel - A modern chat interface using shadcn-style components
3
+ *
4
+ * This component provides the chat functionality for AIAgentPanel,
5
+ * using consistent shadcn-style UI components.
6
+ */
7
+ import React, {
8
+ useCallback,
9
+ useEffect,
10
+ useMemo,
11
+ useRef,
12
+ useState,
13
+ } from 'react';
14
+ import { LLMAsAServiceCustomer, useLLM } from 'llmasaservice-client';
15
+ import ReactMarkdown from 'react-markdown';
16
+ import remarkGfm from 'remark-gfm';
17
+ import rehypeRaw from 'rehype-raw';
18
+ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
19
+ import materialDark from 'react-syntax-highlighter/dist/esm/styles/prism/material-dark.js';
20
+ import materialLight from 'react-syntax-highlighter/dist/esm/styles/prism/material-light.js';
21
+ import { Button, ScrollArea, Tooltip } from './components/ui';
22
+ import './AIChatPanel.css';
23
+
24
+ // ============================================================================
25
+ // Types
26
+ // ============================================================================
27
+
28
+ /**
29
+ * Agent option for the agent selector dropdown
30
+ */
31
+ export interface AgentOption {
32
+ value: string;
33
+ label: string;
34
+ description?: string;
35
+ icon?: React.ReactNode;
36
+ avatarUrl?: string;
37
+ }
38
+
39
+ export interface AIChatPanelProps {
40
+ project_id: string;
41
+ initialPrompt?: string;
42
+ initialMessage?: string;
43
+ title?: string;
44
+ placeholder?: string;
45
+ hideInitialPrompt?: boolean;
46
+ customer?: LLMAsAServiceCustomer;
47
+ data?: { key: string; data: string }[];
48
+ thumbsUpClick?: (callId: string) => void;
49
+ thumbsDownClick?: (callId: string) => void;
50
+ theme?: 'light' | 'dark';
51
+ url?: string | null;
52
+ service?: string | null;
53
+ historyChangedCallback?: (history: Record<string, { content: string; callId: string }>) => void;
54
+ responseCompleteCallback?: (callId: string, prompt: string, response: string) => void;
55
+ onLoadingChange?: (isLoading: boolean) => void;
56
+ promptTemplate?: string;
57
+ actions?: {
58
+ pattern: string;
59
+ type?: string;
60
+ markdown?: string;
61
+ callback?: (match: string, groups: any[]) => void;
62
+ clickCode?: string;
63
+ style?: string;
64
+ }[];
65
+ showNewConversationButton?: boolean;
66
+ followOnQuestions?: string[];
67
+ clearFollowOnQuestionsNextPrompt?: boolean;
68
+ followOnPrompt?: string;
69
+ showPoweredBy?: boolean;
70
+ agent?: string | null;
71
+ conversation?: string | null;
72
+ initialHistory?: Record<string, { content: string; callId: string }>;
73
+ hideRagContextInPrompt?: boolean;
74
+ createConversationOnFirstChat?: boolean;
75
+ mcpServers?: any[];
76
+ progressiveActions?: boolean;
77
+
78
+ // Agent selector props (Cursor-style at bottom of input)
79
+ agentOptions?: AgentOption[];
80
+ currentAgentId?: string;
81
+ onAgentChange?: (agentId: string) => void;
82
+ agentsLoading?: boolean;
83
+
84
+ // Context viewer props
85
+ contextSections?: ContextSection[];
86
+ totalContextTokens?: number;
87
+ maxContextTokens?: number;
88
+ enableContextDetailView?: boolean;
89
+ }
90
+
91
+ /**
92
+ * Context section for the context viewer
93
+ */
94
+ export interface ContextSection {
95
+ id: string;
96
+ title: string;
97
+ data: Record<string, unknown>;
98
+ tokens?: number;
99
+ }
100
+
101
+ interface HistoryEntry {
102
+ content: string;
103
+ callId: string;
104
+ toolCalls?: any[];
105
+ toolResponses?: any[];
106
+ }
107
+
108
+ interface ThinkingBlock {
109
+ type: 'reasoning' | 'searching';
110
+ content: string;
111
+ index: number;
112
+ }
113
+
114
+ // ============================================================================
115
+ // Icons
116
+ // ============================================================================
117
+
118
+ const SendIcon = () => (
119
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ai-chat-icon">
120
+ <path d="M10 14l11-11M21 3l-6.5 18a.55.55 0 0 1-1 0l-3.5-7-7-3.5a.55.55 0 0 1 0-1L21 3" />
121
+ </svg>
122
+ );
123
+
124
+ const ArrowUpIcon = () => (
125
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ai-chat-icon-sm">
126
+ <path d="M12 19V5M5 12l7-7 7 7" />
127
+ </svg>
128
+ );
129
+
130
+ const StopIcon = () => (
131
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="ai-chat-icon">
132
+ <rect x="6" y="6" width="12" height="12" rx="2" />
133
+ </svg>
134
+ );
135
+
136
+ const CopyIcon = () => (
137
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ai-chat-icon-sm">
138
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
139
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
140
+ </svg>
141
+ );
142
+
143
+ const ThumbsUpIcon = () => (
144
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ai-chat-icon-sm">
145
+ <path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3" />
146
+ </svg>
147
+ );
148
+
149
+ const ThumbsDownIcon = () => (
150
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ai-chat-icon-sm">
151
+ <path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17" />
152
+ </svg>
153
+ );
154
+
155
+ const RefreshIcon = () => (
156
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ai-chat-icon-sm">
157
+ <path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
158
+ <path d="M21 3v5h-5" />
159
+ <path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
160
+ <path d="M8 16H3v5" />
161
+ </svg>
162
+ );
163
+
164
+ const ChevronDownIcon = () => (
165
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ai-chat-icon-sm">
166
+ <path d="m6 9 6 6 6-6" />
167
+ </svg>
168
+ );
169
+
170
+ const ChevronUpIcon = () => (
171
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ai-chat-icon-sm">
172
+ <path d="m18 15-6-6-6 6" />
173
+ </svg>
174
+ );
175
+
176
+ const AgentIcon = () => (
177
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ai-chat-icon-sm">
178
+ <path d="M12 8V4H8" />
179
+ <rect width="16" height="12" x="4" y="8" rx="2" />
180
+ <path d="M2 14h2" />
181
+ <path d="M20 14h2" />
182
+ <path d="M15 13v2" />
183
+ <path d="M9 13v2" />
184
+ </svg>
185
+ );
186
+
187
+ const CheckIcon = () => (
188
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ai-chat-icon-sm">
189
+ <polyline points="20 6 9 17 4 12" />
190
+ </svg>
191
+ );
192
+
193
+ const BrainIcon = () => (
194
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ai-chat-icon-sm">
195
+ <path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2Z" />
196
+ <path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2Z" />
197
+ </svg>
198
+ );
199
+
200
+ const SearchIcon = () => (
201
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ai-chat-icon-sm">
202
+ <circle cx="11" cy="11" r="8" />
203
+ <path d="m21 21-4.3-4.3" />
204
+ </svg>
205
+ );
206
+
207
+ const ContextViewerIcon = () => (
208
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ai-chat-icon-sm">
209
+ <path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
210
+ <polyline points="14 2 14 8 20 8" />
211
+ <line x1="16" x2="8" y1="13" y2="13" />
212
+ <line x1="16" x2="8" y1="17" y2="17" />
213
+ <line x1="10" x2="8" y1="9" y2="9" />
214
+ </svg>
215
+ );
216
+
217
+ const ChevronRightSmallIcon = () => (
218
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ai-chat-icon-xs">
219
+ <path d="m9 18 6-6-6-6" />
220
+ </svg>
221
+ );
222
+
223
+ const LLMAsAServiceLogo = () => (
224
+ <svg width="16" height="16" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg">
225
+ <ellipse cx="14.0868" cy="59.2146" rx="7.8261" ry="7.7854" fill="#2487D8" />
226
+ <ellipse cx="24.9013" cy="43.0776" rx="6.11858" ry="6.08676" fill="#2487D8" />
227
+ <ellipse cx="45.391" cy="43.0776" rx="6.11858" ry="6.08676" fill="#2487D8" />
228
+ <ellipse cx="65.8813" cy="43.0776" rx="6.11858" ry="6.08676" fill="#2487D8" />
229
+ <ellipse cx="35.1461" cy="26.5327" rx="4.41103" ry="4.3878" fill="#2487D8" />
230
+ <ellipse cx="55.6364" cy="26.5327" rx="4.41103" ry="4.3878" fill="#2487D8" />
231
+ <ellipse cx="45.391" cy="10.3959" rx="2.70351" ry="2.68919" fill="#2487D8" />
232
+ </svg>
233
+ );
234
+
235
+ // ============================================================================
236
+ // Isolated Input Component - Prevents full re-renders on every keystroke
237
+ // ============================================================================
238
+
239
+ interface ChatInputProps {
240
+ placeholder: string;
241
+ idle: boolean;
242
+ onSubmit: (text: string) => void;
243
+ onStop: () => void;
244
+ agentOptions: AgentOption[];
245
+ currentAgentId?: string;
246
+ onAgentChange?: (agentId: string) => void;
247
+ agentsLoading: boolean;
248
+ currentAgentLabel?: string;
249
+ currentAgentAvatarUrl?: string;
250
+ // Context viewer props
251
+ contextSections?: ContextSection[];
252
+ totalContextTokens?: number;
253
+ maxContextTokens?: number;
254
+ enableContextDetailView?: boolean;
255
+ onContextViewerToggle?: () => void;
256
+ }
257
+
258
+ const ChatInput = React.memo<ChatInputProps>(({
259
+ placeholder,
260
+ idle,
261
+ onSubmit,
262
+ onStop,
263
+ agentOptions,
264
+ currentAgentId,
265
+ onAgentChange,
266
+ agentsLoading,
267
+ currentAgentLabel,
268
+ currentAgentAvatarUrl,
269
+ contextSections = [],
270
+ totalContextTokens = 0,
271
+ maxContextTokens = 8000,
272
+ enableContextDetailView = false,
273
+ }) => {
274
+ const [inputValue, setInputValue] = useState('');
275
+ const [dropdownOpen, setDropdownOpen] = useState(false);
276
+ const [contextViewerOpen, setContextViewerOpen] = useState(false);
277
+ const [contextViewMode, setContextViewMode] = useState<'summary' | 'detail'>('summary');
278
+ const textareaRef = useRef<HTMLTextAreaElement | null>(null);
279
+ const containerRef = useRef<HTMLDivElement | null>(null);
280
+ const contextPopoverRef = useRef<HTMLDivElement | null>(null);
281
+
282
+ // Auto-resize textarea
283
+ const autoResize = useCallback(() => {
284
+ const textarea = textareaRef.current;
285
+ if (textarea) {
286
+ textarea.style.height = 'auto';
287
+ textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
288
+ }
289
+ }, []);
290
+
291
+ // Handle submit
292
+ const handleSubmit = useCallback(() => {
293
+ const trimmed = inputValue.trim();
294
+ if (trimmed && idle) {
295
+ onSubmit(trimmed);
296
+ setInputValue('');
297
+ // Reset textarea height
298
+ if (textareaRef.current) {
299
+ textareaRef.current.style.height = 'auto';
300
+ }
301
+ }
302
+ }, [inputValue, idle, onSubmit]);
303
+
304
+ // Close dropdown on outside click
305
+ useEffect(() => {
306
+ const handleClickOutside = (event: MouseEvent) => {
307
+ if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
308
+ setDropdownOpen(false);
309
+ }
310
+ };
311
+ if (dropdownOpen) {
312
+ document.addEventListener('mousedown', handleClickOutside);
313
+ return () => document.removeEventListener('mousedown', handleClickOutside);
314
+ }
315
+ }, [dropdownOpen]);
316
+
317
+ // Close context popover on outside click
318
+ useEffect(() => {
319
+ const handleClickOutside = (event: MouseEvent) => {
320
+ if (contextPopoverRef.current && !contextPopoverRef.current.contains(event.target as Node)) {
321
+ setContextViewerOpen(false);
322
+ setContextViewMode('summary');
323
+ }
324
+ };
325
+ if (contextViewerOpen) {
326
+ document.addEventListener('mousedown', handleClickOutside);
327
+ return () => document.removeEventListener('mousedown', handleClickOutside);
328
+ }
329
+ }, [contextViewerOpen]);
330
+
331
+ // Format tokens for display
332
+ const formatTokens = (tokens: number): string => {
333
+ if (tokens >= 1000) {
334
+ return `${(tokens / 1000).toFixed(1)}K`;
335
+ }
336
+ return tokens.toString();
337
+ };
338
+
339
+ // Calculate token percentage
340
+ const tokenPercentage = maxContextTokens > 0 ? Math.min((totalContextTokens / maxContextTokens) * 100, 100) : 0;
341
+ const isOverLimit = totalContextTokens > maxContextTokens;
342
+
343
+ // Detect content format (JSON, XML, or Markdown)
344
+ const detectFormat = (data: Record<string, unknown>): 'json' | 'xml' | 'markdown' => {
345
+ const str = JSON.stringify(data);
346
+ if (str.includes('<?xml') || str.includes('</')) return 'xml';
347
+ // Default to JSON for objects
348
+ return 'json';
349
+ };
350
+
351
+ // Format content for display
352
+ const formatContent = (data: Record<string, unknown>, format: 'json' | 'xml' | 'markdown'): string => {
353
+ if (format === 'json') {
354
+ return JSON.stringify(data, null, 2);
355
+ }
356
+ // For XML or markdown, if it's stored as a string in the data, return it
357
+ if (typeof data === 'string') return data;
358
+ return JSON.stringify(data, null, 2);
359
+ };
360
+
361
+ return (
362
+ <div
363
+ className={`ai-chat-panel__input-container ${dropdownOpen ? 'ai-chat-panel__input-container--dropdown-open' : ''}`}
364
+ ref={containerRef}
365
+ >
366
+ <div className="ai-chat-panel__input">
367
+ <textarea
368
+ ref={textareaRef}
369
+ className="ai-chat-input"
370
+ placeholder={placeholder}
371
+ value={inputValue}
372
+ onChange={(e) => {
373
+ setInputValue(e.target.value);
374
+ setTimeout(autoResize, 0);
375
+ }}
376
+ onKeyDown={(e) => {
377
+ if (e.key === 'Enter' && !e.shiftKey) {
378
+ e.preventDefault();
379
+ handleSubmit();
380
+ }
381
+ }}
382
+ rows={1}
383
+ />
384
+ </div>
385
+
386
+ {/* Footer row with agent selector and send button */}
387
+ <div className="ai-chat-panel__input-footer">
388
+ {agentOptions.length > 0 ? (
389
+ <div className="ai-chat-agent-selector">
390
+ <button
391
+ className="ai-chat-agent-selector__trigger"
392
+ onClick={() => setDropdownOpen(!dropdownOpen)}
393
+ disabled={agentsLoading}
394
+ >
395
+ {currentAgentAvatarUrl ? (
396
+ <img
397
+ src={currentAgentAvatarUrl}
398
+ alt={currentAgentLabel || 'Agent'}
399
+ className="ai-chat-agent-selector__avatar"
400
+ />
401
+ ) : (
402
+ <AgentIcon />
403
+ )}
404
+ <span className="ai-chat-agent-selector__label">
405
+ {agentsLoading ? 'Loading...' : currentAgentLabel || 'Select agent'}
406
+ </span>
407
+ {dropdownOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
408
+ </button>
409
+ </div>
410
+ ) : (
411
+ <div className="ai-chat-panel__input-footer-spacer" />
412
+ )}
413
+
414
+ {/* Context viewer pill - Cursor style */}
415
+ {contextSections.length > 0 && (
416
+ <div className="ai-chat-context-pill-wrapper">
417
+ <button
418
+ className={`ai-chat-context-pill ${contextViewerOpen ? 'ai-chat-context-pill--active' : ''} ${isOverLimit ? 'ai-chat-context-pill--warning' : ''}`}
419
+ onClick={(e) => {
420
+ e.preventDefault();
421
+ e.stopPropagation();
422
+ console.log('[ContextViewer] Button clicked, current state:', contextViewerOpen);
423
+ setContextViewerOpen(!contextViewerOpen);
424
+ if (!contextViewerOpen) {
425
+ setContextViewMode('summary');
426
+ }
427
+ }}
428
+ type="button"
429
+ title="View context"
430
+ >
431
+ <span className="ai-chat-context-pill__label">context: {contextSections.length} {contextSections.length === 1 ? 'section' : 'sections'}</span>
432
+ </button>
433
+
434
+ {/* Context popover - positioned relative to pill wrapper */}
435
+ {contextViewerOpen && (
436
+ <div
437
+ className={`ai-chat-context-popover ${contextViewMode === 'detail' ? 'ai-chat-context-popover--detail' : ''}`}
438
+ ref={contextPopoverRef}
439
+ onClick={(e) => e.stopPropagation()}
440
+ >
441
+ {/* Summary view */}
442
+ {contextViewMode === 'summary' && (
443
+ <div className="ai-chat-context-popover__summary">
444
+ <div className="ai-chat-context-popover__header">
445
+ <span className="ai-chat-context-popover__title">Context</span>
446
+ <button
447
+ className="ai-chat-context-popover__close"
448
+ onClick={() => setContextViewerOpen(false)}
449
+ type="button"
450
+ >
451
+ ×
452
+ </button>
453
+ </div>
454
+ <div className="ai-chat-context-popover__token-bar">
455
+ <div className="ai-chat-context-popover__token-info">
456
+ <span className={isOverLimit ? 'ai-chat-context-popover__tokens--warning' : ''}>
457
+ {formatTokens(totalContextTokens)} tokens
458
+ </span>
459
+ <span className="ai-chat-context-popover__token-limit">
460
+ / {formatTokens(maxContextTokens)} limit
461
+ </span>
462
+ </div>
463
+ <div className="ai-chat-context-popover__progress">
464
+ <div
465
+ className={`ai-chat-context-popover__progress-bar ${isOverLimit ? 'ai-chat-context-popover__progress-bar--warning' : ''}`}
466
+ style={{ width: `${Math.min(tokenPercentage, 100)}%` }}
467
+ />
468
+ </div>
469
+ </div>
470
+ <div className="ai-chat-context-popover__sections">
471
+ {contextSections.map((section) => (
472
+ <div
473
+ key={section.id}
474
+ className={`ai-chat-context-popover__section-item ${enableContextDetailView ? 'ai-chat-context-popover__section-item--clickable' : ''}`}
475
+ onClick={() => {
476
+ if (enableContextDetailView) {
477
+ setContextViewMode('detail');
478
+ }
479
+ }}
480
+ >
481
+ <span className="ai-chat-context-popover__section-icon">📄</span>
482
+ <span className="ai-chat-context-popover__section-title">{section.title}</span>
483
+ <span className="ai-chat-context-popover__section-tokens">
484
+ {section.tokens || Math.ceil(JSON.stringify(section.data).length / 4)}
485
+ </span>
486
+ </div>
487
+ ))}
488
+ </div>
489
+ {enableContextDetailView && (
490
+ <button
491
+ className="ai-chat-context-popover__expand-btn"
492
+ onClick={() => setContextViewMode('detail')}
493
+ type="button"
494
+ >
495
+ View details →
496
+ </button>
497
+ )}
498
+ </div>
499
+ )}
500
+
501
+ {/* Detail view */}
502
+ {contextViewMode === 'detail' && enableContextDetailView && (
503
+ <div className="ai-chat-context-popover__detail">
504
+ <div className="ai-chat-context-popover__header">
505
+ <button
506
+ className="ai-chat-context-popover__back"
507
+ onClick={() => setContextViewMode('summary')}
508
+ type="button"
509
+ >
510
+ ← Back
511
+ </button>
512
+ <span className="ai-chat-context-popover__title">Context Details</span>
513
+ <button
514
+ className="ai-chat-context-popover__close"
515
+ onClick={() => setContextViewerOpen(false)}
516
+ type="button"
517
+ >
518
+ ×
519
+ </button>
520
+ </div>
521
+ <div className="ai-chat-context-popover__token-bar">
522
+ <div className="ai-chat-context-popover__token-info">
523
+ <span className={isOverLimit ? 'ai-chat-context-popover__tokens--warning' : ''}>
524
+ {formatTokens(totalContextTokens)} tokens
525
+ </span>
526
+ <span className="ai-chat-context-popover__token-limit">
527
+ / {formatTokens(maxContextTokens)} limit
528
+ </span>
529
+ </div>
530
+ <div className="ai-chat-context-popover__progress">
531
+ <div
532
+ className={`ai-chat-context-popover__progress-bar ${isOverLimit ? 'ai-chat-context-popover__progress-bar--warning' : ''}`}
533
+ style={{ width: `${Math.min(tokenPercentage, 100)}%` }}
534
+ />
535
+ </div>
536
+ </div>
537
+ <div className="ai-chat-context-popover__detail-sections">
538
+ {contextSections.map((section) => {
539
+ const format = detectFormat(section.data);
540
+ return (
541
+ <details key={section.id} className="ai-chat-context-popover__detail-section" open>
542
+ <summary className="ai-chat-context-popover__detail-section-header">
543
+ <span className="ai-chat-context-popover__detail-section-title">{section.title}</span>
544
+ <span className="ai-chat-context-popover__detail-section-meta">
545
+ <code>{`{{${section.id}}}`}</code>
546
+ <span>~{section.tokens || Math.ceil(JSON.stringify(section.data).length / 4)}</span>
547
+ </span>
548
+ </summary>
549
+ <pre className="ai-chat-context-popover__detail-content">
550
+ <code>{formatContent(section.data, format)}</code>
551
+ </pre>
552
+ </details>
553
+ );
554
+ })}
555
+ </div>
556
+ </div>
557
+ )}
558
+ </div>
559
+ )}
560
+ </div>
561
+ )}
562
+
563
+ <button
564
+ className={`ai-chat-send-button ${idle && !inputValue.trim() ? 'ai-chat-send-button--disabled' : ''} ${!idle ? 'ai-chat-send-button--stop' : ''}`}
565
+ onClick={() => idle ? handleSubmit() : onStop()}
566
+ disabled={idle && !inputValue.trim()}
567
+ >
568
+ {idle ? <ArrowUpIcon /> : <StopIcon />}
569
+ </button>
570
+ </div>
571
+
572
+ {/* Dropdown appears inside the container at the bottom */}
573
+ {agentOptions.length > 0 && dropdownOpen && (
574
+ <div className="ai-chat-agent-selector__dropdown-inside">
575
+ {agentOptions.map((option) => (
576
+ <button
577
+ key={option.value}
578
+ className={`ai-chat-agent-selector__option ${
579
+ option.value === currentAgentId ? 'ai-chat-agent-selector__option--selected' : ''
580
+ }`}
581
+ onClick={() => {
582
+ if (onAgentChange && option.value !== currentAgentId) {
583
+ onAgentChange(option.value);
584
+ }
585
+ setDropdownOpen(false);
586
+ }}
587
+ >
588
+ {option.avatarUrl ? (
589
+ <img
590
+ src={option.avatarUrl}
591
+ alt={option.label}
592
+ className="ai-chat-agent-selector__option-avatar"
593
+ />
594
+ ) : option.icon ? (
595
+ <span className="ai-chat-agent-selector__option-icon">{option.icon}</span>
596
+ ) : null}
597
+ <span className="ai-chat-agent-selector__option-content">
598
+ <span className="ai-chat-agent-selector__option-label">{option.label}</span>
599
+ {option.description && (
600
+ <span className="ai-chat-agent-selector__option-description">{option.description}</span>
601
+ )}
602
+ </span>
603
+ {option.value === currentAgentId && (
604
+ <span className="ai-chat-agent-selector__option-check">
605
+ <CheckIcon />
606
+ </span>
607
+ )}
608
+ </button>
609
+ ))}
610
+ </div>
611
+ )}
612
+ </div>
613
+ );
614
+ });
615
+
616
+ ChatInput.displayName = 'ChatInput';
617
+
618
+ // ============================================================================
619
+ // Main Component
620
+ // ============================================================================
621
+
622
+ const AIChatPanel: React.FC<AIChatPanelProps> = ({
623
+ project_id,
624
+ initialPrompt = '',
625
+ title = '',
626
+ placeholder = 'Type a message',
627
+ hideInitialPrompt = true,
628
+ customer = {},
629
+ data = [],
630
+ thumbsUpClick,
631
+ thumbsDownClick,
632
+ theme = 'light',
633
+ url = null,
634
+ initialMessage = '',
635
+ service = null,
636
+ historyChangedCallback,
637
+ responseCompleteCallback,
638
+ onLoadingChange,
639
+ promptTemplate = '',
640
+ actions = [],
641
+ showNewConversationButton = true,
642
+ followOnQuestions = [],
643
+ clearFollowOnQuestionsNextPrompt = false,
644
+ followOnPrompt = '',
645
+ showPoweredBy = true,
646
+ agent = null,
647
+ conversation = null,
648
+ initialHistory = {},
649
+ hideRagContextInPrompt = true,
650
+ createConversationOnFirstChat = true,
651
+ mcpServers = [],
652
+ progressiveActions = true,
653
+ agentOptions = [],
654
+ currentAgentId,
655
+ onAgentChange,
656
+ agentsLoading = false,
657
+ contextSections = [],
658
+ totalContextTokens = 0,
659
+ maxContextTokens = 8000,
660
+ enableContextDetailView = false,
661
+ }) => {
662
+ // ============================================================================
663
+ // State
664
+ // ============================================================================
665
+ const [lastController, setLastController] = useState(new AbortController());
666
+ const [lastMessages, setLastMessages] = useState<any[]>([]);
667
+ const [history, setHistory] = useState<Record<string, HistoryEntry>>(initialHistory);
668
+ const [isLoading, setIsLoading] = useState(false);
669
+ const [lastPrompt, setLastPrompt] = useState<string | null>(null);
670
+ const [lastKey, setLastKey] = useState<string | null>(null);
671
+ const [currentConversation, setCurrentConversation] = useState<string | null>(conversation);
672
+ const [followOnQuestionsState, setFollowOnQuestionsState] = useState(followOnQuestions);
673
+ const [thinkingBlocks, setThinkingBlocks] = useState<ThinkingBlock[]>([]);
674
+ const [currentThinkingIndex, setCurrentThinkingIndex] = useState(0);
675
+ const [newConversationConfirm, setNewConversationConfirm] = useState(false);
676
+ const [justReset, setJustReset] = useState(false);
677
+ const [copiedCallId, setCopiedCallId] = useState<string | null>(null);
678
+
679
+ // Refs
680
+ const bottomRef = useRef<HTMLDivElement | null>(null);
681
+ const responseAreaRef = useRef<HTMLDivElement | null>(null);
682
+
683
+ // Auto-scroll state - tracks if user has manually scrolled during streaming
684
+ const [userHasScrolled, setUserHasScrolled] = useState(false);
685
+ const lastScrollTopRef = useRef<number>(0);
686
+ const scrollRAFRef = useRef<number | null>(null);
687
+ const lastScrollTimeRef = useRef<number>(0);
688
+
689
+ // === NEW: Clean history management refs ===
690
+ // Track previous idle state to detect transitions (false→true = completion)
691
+ const prevIdleRef = useRef<boolean>(true);
692
+ // Track if we've already notified for the current response (prevent double-notify)
693
+ const hasNotifiedCompletionRef = useRef<boolean>(true);
694
+ // Store the latest processed history for callbacks (doesn't trigger re-renders)
695
+ const latestHistoryRef = useRef<Record<string, HistoryEntry>>(initialHistory);
696
+
697
+ // ============================================================================
698
+ // useLLM Hook
699
+ // ============================================================================
700
+ const llmResult = useLLM({
701
+ project_id,
702
+ customer: customer as LLMAsAServiceCustomer | undefined,
703
+ ...(url && { url }),
704
+ ...(service && { group_id: service }),
705
+ ...(agent && { agent }),
706
+ ...(mcpServers && mcpServers.length > 0 && { mcp_servers: mcpServers }),
707
+ });
708
+
709
+ const {
710
+ send,
711
+ response,
712
+ idle,
713
+ lastCallId,
714
+ stop,
715
+ setResponse,
716
+ } = llmResult;
717
+
718
+ // Tool-related properties (may not exist on all versions of useLLM)
719
+ const toolList = (llmResult as any).toolList || [];
720
+ const toolsLoading = (llmResult as any).toolsLoading || false;
721
+ const toolsFetchError = (llmResult as any).toolsFetchError || null;
722
+
723
+ // Refs to track latest values for cleanup and callbacks (must be after useLLM)
724
+ const historyCallbackRef = useRef(historyChangedCallback);
725
+ const responseCompleteCallbackRef = useRef(responseCompleteCallback);
726
+ const responseRef = useRef(response);
727
+ const lastKeyRef = useRef(lastKey);
728
+ const lastCallIdRef = useRef(lastCallId);
729
+ const lastPromptRef = useRef(lastPrompt);
730
+
731
+ // Keep refs in sync (these don't cause re-renders)
732
+ historyCallbackRef.current = historyChangedCallback;
733
+ responseCompleteCallbackRef.current = responseCompleteCallback;
734
+ responseRef.current = response;
735
+ lastKeyRef.current = lastKey;
736
+ lastCallIdRef.current = lastCallId;
737
+ lastPromptRef.current = lastPrompt;
738
+
739
+ // ============================================================================
740
+ // Memoized Values
741
+ // ============================================================================
742
+ const prismStyle = useMemo(
743
+ () => (theme === 'light' ? materialLight : materialDark),
744
+ [theme]
745
+ );
746
+
747
+ // Browser info for context (matches ChatPanel)
748
+ const browserInfo = useMemo(() => {
749
+ if (typeof window === 'undefined') return null;
750
+ return {
751
+ currentTimeUTC: new Date().toISOString(),
752
+ userTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
753
+ userLanguage: navigator.language,
754
+ };
755
+ }, []);
756
+
757
+ // Data with extras (matches ChatPanel's dataWithExtras)
758
+ const dataWithExtras = useCallback(() => {
759
+ return [
760
+ ...data,
761
+ { key: '--customer_id', data: customer?.customer_id ?? '' },
762
+ { key: '--customer_name', data: customer?.customer_name ?? '' },
763
+ { key: '--customer_user_id', data: customer?.customer_user_id ?? '' },
764
+ { key: '--customer_user_name', data: customer?.customer_user_name ?? '' },
765
+ { key: '--customer_user_email', data: customer?.customer_user_email ?? '' },
766
+ { key: '--currentTimeUTC', data: browserInfo?.currentTimeUTC ?? '' },
767
+ { key: '--userTimezone', data: browserInfo?.userTimezone ?? '' },
768
+ { key: '--userLanguage', data: browserInfo?.userLanguage ?? '' },
769
+ ];
770
+ }, [data, customer, browserInfo]);
771
+
772
+ // Current agent info for display
773
+ const currentAgentInfo = useMemo(() => {
774
+ if (!currentAgentId || agentOptions.length === 0) return { label: null, avatarUrl: null };
775
+ const currentAgent = agentOptions.find(a => a.value === currentAgentId);
776
+ return {
777
+ label: currentAgent?.label || currentAgentId,
778
+ avatarUrl: currentAgent?.avatarUrl || null,
779
+ };
780
+ }, [currentAgentId, agentOptions]);
781
+
782
+ const currentAgentLabel = currentAgentInfo.label;
783
+ const currentAgentAvatarUrl = currentAgentInfo.avatarUrl;
784
+
785
+ // ============================================================================
786
+ // Callbacks
787
+ // ============================================================================
788
+
789
+ // Clean content for thinking block display
790
+ const cleanContentForDisplay = useCallback((content: string): string => {
791
+ let cleaned = content
792
+ .replace(/\*\*(.*?)\*\*/g, '$1')
793
+ .replace(/\*(.*?)\*/g, '$1')
794
+ .replace(/\n+/g, ' ')
795
+ .replace(/\s+/g, ' ')
796
+ .trim();
797
+ if (cleaned.length > 100) {
798
+ cleaned = cleaned.substring(0, 100) + '...';
799
+ }
800
+ return cleaned || 'Thinking';
801
+ }, []);
802
+
803
+ // Process thinking tags from response
804
+ const processThinkingTags = useCallback((text: string): { cleanedText: string; blocks: ThinkingBlock[] } => {
805
+ const blocks: ThinkingBlock[] = [];
806
+ let index = 0;
807
+
808
+ // Extract reasoning blocks
809
+ const reasoningRegex = /<reasoning>([\s\S]*?)<\/reasoning>/gi;
810
+ let match;
811
+ while ((match = reasoningRegex.exec(text)) !== null) {
812
+ blocks.push({ type: 'reasoning', content: match[1] ?? '', index: index++ });
813
+ }
814
+
815
+ // Extract searching blocks
816
+ const searchingRegex = /<searching>([\s\S]*?)<\/searching>/gi;
817
+ while ((match = searchingRegex.exec(text)) !== null) {
818
+ blocks.push({ type: 'searching', content: match[1] ?? '', index: index++ });
819
+ }
820
+
821
+ // Sort by position in original text
822
+ blocks.sort((a, b) => a.index - b.index);
823
+
824
+ // Clean the text
825
+ let cleanedText = text
826
+ .replace(/<reasoning>[\s\S]*?<\/reasoning>/gi, '')
827
+ .replace(/<searching>[\s\S]*?<\/searching>/gi, '')
828
+ .trim();
829
+
830
+ return { cleanedText, blocks };
831
+ }, []);
832
+
833
+ // Built-in action for agent suggestion cards
834
+ // Pattern: [SUGGEST_AGENT:agent-id|Agent Name|Brief reason]
835
+ const AGENT_SUGGESTION_ACTION = {
836
+ pattern: '\\[SUGGEST_AGENT:([^|\\]]+)\\|([^|\\]]+)\\|([^\\]]+)\\]',
837
+ markdown: '<agent-suggestion data-agent-id="$1" data-agent-name="$2" data-reason="$3"></agent-suggestion>',
838
+ };
839
+
840
+ // Process actions in content
841
+ const processActions = useCallback((content: string): string => {
842
+ // Combine built-in actions with user-provided actions
843
+ const allActions = [AGENT_SUGGESTION_ACTION, ...(actions || [])];
844
+
845
+ let processed = content;
846
+ allActions.forEach((action) => {
847
+ try {
848
+ const regex = new RegExp(action.pattern, 'g');
849
+ if (action.markdown) {
850
+ processed = processed.replace(regex, action.markdown);
851
+ }
852
+ } catch (e) {
853
+ console.error('Invalid action pattern:', action.pattern);
854
+ }
855
+ });
856
+
857
+ return processed;
858
+ }, [actions]);
859
+
860
+ // Format prompt for display - strips timestamp prefix and RAG context
861
+ const formatPromptForDisplay = useCallback((prompt: string): string => {
862
+ let displayPrompt = prompt;
863
+
864
+ // Strip timestamp prefix (matches ChatPanel)
865
+ const isoTimestampRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z:/;
866
+ if (isoTimestampRegex.test(displayPrompt)) {
867
+ const colonIndex = displayPrompt.indexOf(':', 19);
868
+ displayPrompt = displayPrompt.substring(colonIndex + 1);
869
+ } else if (/^\d+:/.test(displayPrompt)) {
870
+ const colonIndex = displayPrompt.indexOf(':');
871
+ displayPrompt = displayPrompt.substring(colonIndex + 1);
872
+ }
873
+
874
+ // Strip RAG context if enabled
875
+ if (hideRagContextInPrompt) {
876
+ const contextIndex = displayPrompt.indexOf('---context---');
877
+ if (contextIndex !== -1) {
878
+ displayPrompt = displayPrompt.substring(0, contextIndex).trim();
879
+ }
880
+ }
881
+
882
+ return displayPrompt;
883
+ }, [hideRagContextInPrompt]);
884
+
885
+ // Copy to clipboard
886
+ const copyToClipboard = useCallback(async (text: string, callId: string) => {
887
+ try {
888
+ // Strip markdown/HTML for clipboard
889
+ const cleanText = text
890
+ .replace(/<[^>]*>/g, '')
891
+ .replace(/\*\*(.*?)\*\*/g, '$1')
892
+ .replace(/\*(.*?)\*/g, '$1')
893
+ .replace(/#{1,6}\s/g, '')
894
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
895
+
896
+ await navigator.clipboard.writeText(cleanText);
897
+ setCopiedCallId(callId);
898
+ setTimeout(() => setCopiedCallId(null), 2000);
899
+ } catch (err) {
900
+ console.error('Failed to copy:', err);
901
+ }
902
+ }, []);
903
+
904
+ // Scroll to bottom - throttled using RAF to prevent layout thrashing
905
+ const scrollToBottom = useCallback(() => {
906
+ // Cancel any pending scroll
907
+ if (scrollRAFRef.current) {
908
+ cancelAnimationFrame(scrollRAFRef.current);
909
+ }
910
+
911
+ // Throttle to max once per 100ms to prevent performance issues during streaming
912
+ const now = Date.now();
913
+ if (now - lastScrollTimeRef.current < 100) {
914
+ // Schedule for next frame instead
915
+ scrollRAFRef.current = requestAnimationFrame(() => {
916
+ bottomRef.current?.scrollIntoView({ behavior: 'auto' });
917
+ lastScrollTimeRef.current = Date.now();
918
+ });
919
+ return;
920
+ }
921
+
922
+ // Use 'auto' instead of 'smooth' during streaming for better performance
923
+ bottomRef.current?.scrollIntoView({ behavior: 'auto' });
924
+ lastScrollTimeRef.current = now;
925
+ }, []);
926
+
927
+ // Continue chat (send message) - matches ChatPanel behavior exactly
928
+ // promptText is now required - comes from the isolated ChatInput component
929
+ const continueChat = useCallback((promptText: string) => {
930
+ // Clear thinking blocks for new response
931
+ setThinkingBlocks([]);
932
+ setCurrentThinkingIndex(0);
933
+
934
+ // Reset scroll tracking for new message - enable auto-scroll
935
+ setUserHasScrolled(false);
936
+
937
+ // Handle stop if not idle (matches ChatPanel)
938
+ if (!idle) {
939
+ stop(lastController);
940
+ setHistory((prevHistory) => ({
941
+ ...prevHistory,
942
+ [lastKey ?? '']: {
943
+ content: processThinkingTags(response).cleanedText + '\n\n(response cancelled)',
944
+ callId: lastCallId || '',
945
+ },
946
+ }));
947
+ return;
948
+ }
949
+
950
+ if (clearFollowOnQuestionsNextPrompt) {
951
+ setFollowOnQuestionsState([]);
952
+ }
953
+
954
+ const promptToSend = promptText;
955
+
956
+ if (!promptToSend || !promptToSend.trim()) return;
957
+
958
+ setIsLoading(true);
959
+
960
+ // Build messagesAndHistory from history (matches ChatPanel)
961
+ const messagesAndHistory: { role: string; content: string }[] = [];
962
+ Object.entries(history).forEach(([historyPrompt, historyEntry]) => {
963
+ // Strip timestamp prefix from prompt before using it (matches ChatPanel)
964
+ let promptForHistory = historyPrompt;
965
+ const isoTimestampRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z:/;
966
+ if (isoTimestampRegex.test(historyPrompt)) {
967
+ const colonIndex = historyPrompt.indexOf(':', 19);
968
+ promptForHistory = historyPrompt.substring(colonIndex + 1);
969
+ } else if (/^\d+:/.test(historyPrompt)) {
970
+ const colonIndex = historyPrompt.indexOf(':');
971
+ promptForHistory = historyPrompt.substring(colonIndex + 1);
972
+ }
973
+
974
+ messagesAndHistory.push({ role: 'user', content: promptForHistory });
975
+ messagesAndHistory.push({ role: 'assistant', content: historyEntry.content });
976
+ });
977
+
978
+ // Generate unique key using ISO timestamp prefix + prompt (matches ChatPanel)
979
+ const timestamp = new Date().toISOString();
980
+ const promptKey = `${timestamp}:${promptToSend.trim()}`;
981
+
982
+ // Set history entry before sending (matches ChatPanel)
983
+ setHistory((prevHistory) => ({
984
+ ...prevHistory,
985
+ [promptKey]: { content: '', callId: '' },
986
+ }));
987
+
988
+ // Build the full prompt - only apply template for first message (matches ChatPanel)
989
+ let fullPromptToSend = promptToSend.trim();
990
+ if (Object.keys(history).length === 0 && promptTemplate) {
991
+ fullPromptToSend = promptTemplate.replace('{{prompt}}', fullPromptToSend);
992
+ }
993
+
994
+ // Add follow-on prompt
995
+ if (followOnPrompt) {
996
+ fullPromptToSend += `\n\n${followOnPrompt}`;
997
+ }
998
+
999
+ const newController = new AbortController();
1000
+ setLastController(newController);
1001
+
1002
+ // Pass data array to send() for template replacement (e.g., {{Context}})
1003
+ // Pass service (group_id) and customer data just like ChatPanel does
1004
+ send(
1005
+ fullPromptToSend,
1006
+ messagesAndHistory,
1007
+ [
1008
+ ...dataWithExtras(),
1009
+ { key: '--messages', data: messagesAndHistory.length.toString() },
1010
+ ],
1011
+ true, // stream
1012
+ true, // includeHistory
1013
+ service, // group_id from agent config
1014
+ currentConversation,
1015
+ newController
1016
+ );
1017
+
1018
+ setLastPrompt(promptToSend.trim());
1019
+ setLastMessages(messagesAndHistory);
1020
+ setLastKey(promptKey);
1021
+
1022
+ // Scroll to bottom after adding the new prompt to show it immediately
1023
+ // Use setTimeout to ensure the DOM has updated with the new history entry
1024
+ setTimeout(() => {
1025
+ scrollToBottom();
1026
+ }, 0);
1027
+ }, [
1028
+ idle,
1029
+ stop,
1030
+ lastController,
1031
+ lastKey,
1032
+ response,
1033
+ lastCallId,
1034
+ processThinkingTags,
1035
+ clearFollowOnQuestionsNextPrompt,
1036
+ history,
1037
+ promptTemplate,
1038
+ followOnPrompt,
1039
+ send,
1040
+ service,
1041
+ currentConversation,
1042
+ dataWithExtras,
1043
+ scrollToBottom,
1044
+ ]);
1045
+
1046
+ // Handle suggestion click - directly sends like ChatPanel does
1047
+ const handleSuggestionClick = useCallback((question: string) => {
1048
+ continueChat(question);
1049
+ }, [continueChat]);
1050
+
1051
+ // Handle stop - stable callback for ChatInput
1052
+ const handleStop = useCallback(() => {
1053
+ stop(lastController);
1054
+ }, [stop, lastController]);
1055
+
1056
+ // Reset conversation
1057
+ const handleNewConversation = useCallback(() => {
1058
+ if (!newConversationConfirm) {
1059
+ setNewConversationConfirm(true);
1060
+ setTimeout(() => setNewConversationConfirm(false), 3000);
1061
+ return;
1062
+ }
1063
+
1064
+ setNewConversationConfirm(false);
1065
+
1066
+ if (!idle) {
1067
+ stop(lastController);
1068
+ }
1069
+
1070
+ setResponse('');
1071
+ setHistory({});
1072
+ latestHistoryRef.current = {}; // Keep ref in sync
1073
+ hasNotifiedCompletionRef.current = true; // Prevent stale notifications
1074
+ setLastMessages([]);
1075
+ setLastPrompt(null);
1076
+ setLastKey(null);
1077
+ setIsLoading(false);
1078
+ setCurrentConversation(null);
1079
+ setFollowOnQuestionsState(followOnQuestions);
1080
+ setThinkingBlocks([]);
1081
+ setCurrentThinkingIndex(0);
1082
+ setJustReset(true);
1083
+ setLastController(new AbortController());
1084
+ setUserHasScrolled(false);
1085
+
1086
+ setTimeout(() => {
1087
+ setJustReset(false);
1088
+ responseAreaRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
1089
+ }, 100);
1090
+ }, [newConversationConfirm, idle, stop, lastController, setResponse, followOnQuestions]);
1091
+
1092
+ // ============================================================================
1093
+ // Effects - CLEAN DESIGN: No overlapping effects, explicit triggers only
1094
+ // ============================================================================
1095
+
1096
+ // Effect 1: Process response for DISPLAY ONLY (no callbacks here)
1097
+ // Updates state for rendering, stores in ref for later callback use
1098
+ useEffect(() => {
1099
+ if (!response || !lastKey || justReset) return;
1100
+
1101
+ const { cleanedText, blocks } = processThinkingTags(response);
1102
+ const processedContent = processActions(cleanedText);
1103
+
1104
+ // Update display state
1105
+ setThinkingBlocks(blocks);
1106
+
1107
+ // Update history state for display AND store in ref for callbacks
1108
+ setHistory((prev) => {
1109
+ const newHistory = { ...prev };
1110
+ newHistory[lastKey] = {
1111
+ content: processedContent,
1112
+ callId: lastCallId || '',
1113
+ };
1114
+ // Keep ref in sync for callbacks (this doesn't trigger re-renders)
1115
+ latestHistoryRef.current = newHistory;
1116
+ return newHistory;
1117
+ });
1118
+ }, [response, lastKey, lastCallId, processThinkingTags, processActions, justReset]);
1119
+
1120
+ // Effect 2: Handle response completion - SINGLE POINT for all completion logic
1121
+ // Triggers ONLY when idle transitions from false → true
1122
+ useEffect(() => {
1123
+ const wasStreaming = !prevIdleRef.current;
1124
+ const isNowIdle = idle;
1125
+
1126
+ // Update ref for next comparison
1127
+ prevIdleRef.current = idle;
1128
+
1129
+ // Only act on completion transition (was streaming, now idle)
1130
+ if (wasStreaming && isNowIdle && !hasNotifiedCompletionRef.current) {
1131
+ hasNotifiedCompletionRef.current = true;
1132
+
1133
+ // Get the latest values from refs (stable, not from closure)
1134
+ const currentHistory = latestHistoryRef.current;
1135
+ const currentLastKey = lastKeyRef.current;
1136
+ const currentLastCallId = lastCallIdRef.current;
1137
+ const currentLastPrompt = lastPromptRef.current;
1138
+
1139
+ // Notify history changed (once, on completion)
1140
+ if (historyCallbackRef.current && Object.keys(currentHistory).length > 0) {
1141
+ historyCallbackRef.current(currentHistory);
1142
+ }
1143
+
1144
+ // Notify response complete
1145
+ if (responseCompleteCallbackRef.current && currentLastKey && currentLastCallId) {
1146
+ const entry = currentHistory[currentLastKey];
1147
+ if (entry && entry.content) {
1148
+ responseCompleteCallbackRef.current(currentLastCallId, currentLastPrompt || '', entry.content);
1149
+ }
1150
+ }
1151
+ }
1152
+
1153
+ // Reset notification flag when starting a new stream
1154
+ if (!isNowIdle && hasNotifiedCompletionRef.current) {
1155
+ hasNotifiedCompletionRef.current = false;
1156
+ }
1157
+ }, [idle]); // ONLY depends on idle - no history, no callbacks in deps
1158
+
1159
+ // Auto-scroll to bottom - only while streaming and user hasn't manually scrolled
1160
+ useEffect(() => {
1161
+ // Only auto-scroll if:
1162
+ // 1. We're actively streaming (!idle)
1163
+ // 2. User hasn't manually scrolled up during this response
1164
+ // 3. We have content to show (response exists)
1165
+ if (!idle && !userHasScrolled && response) {
1166
+ scrollToBottom();
1167
+ }
1168
+ }, [response, scrollToBottom, idle, userHasScrolled]); // Removed history dependency
1169
+
1170
+ // Ref to track idle state for scroll handler (avoids stale closure)
1171
+ const idleRef = useRef(idle);
1172
+ idleRef.current = idle;
1173
+
1174
+ // Ref to track userHasScrolled to avoid redundant state updates
1175
+ const userHasScrolledRef = useRef(userHasScrolled);
1176
+ userHasScrolledRef.current = userHasScrolled;
1177
+
1178
+ // Detect user scroll to disable auto-scroll
1179
+ useEffect(() => {
1180
+ const scrollArea = responseAreaRef.current;
1181
+ if (!scrollArea) return;
1182
+
1183
+ // Get the actual scrollable element (ScrollArea wraps a viewport)
1184
+ const scrollViewport = scrollArea.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement;
1185
+ const scrollElement = scrollViewport || scrollArea;
1186
+
1187
+ const handleScroll = () => {
1188
+ // Skip if not streaming or already marked as user-scrolled
1189
+ if (idleRef.current || userHasScrolledRef.current) return;
1190
+
1191
+ const currentScrollTop = scrollElement.scrollTop;
1192
+ const scrollHeight = scrollElement.scrollHeight;
1193
+ const clientHeight = scrollElement.clientHeight;
1194
+
1195
+ // If user scrolled UP (away from bottom), disable auto-scroll
1196
+ // We consider "near bottom" as within 100px to account for smooth scrolling
1197
+ const isNearBottom = scrollHeight - currentScrollTop - clientHeight < 100;
1198
+
1199
+ // User scrolled up if:
1200
+ // 1. They scrolled upward (currentScrollTop < lastScrollTopRef.current)
1201
+ // 2. AND they're not near the bottom
1202
+ if (currentScrollTop < lastScrollTopRef.current && !isNearBottom) {
1203
+ setUserHasScrolled(true);
1204
+ }
1205
+
1206
+ lastScrollTopRef.current = currentScrollTop;
1207
+ };
1208
+
1209
+ scrollElement.addEventListener('scroll', handleScroll, { passive: true });
1210
+ return () => scrollElement.removeEventListener('scroll', handleScroll);
1211
+ }, []); // Empty deps - handler uses refs to get current values
1212
+
1213
+ // Update follow-on questions from props
1214
+ useEffect(() => {
1215
+ setFollowOnQuestionsState(followOnQuestions);
1216
+ }, [followOnQuestions]);
1217
+
1218
+ // Notify loading state changes
1219
+ useEffect(() => {
1220
+ const currentlyLoading = isLoading || !idle;
1221
+ if (onLoadingChange) {
1222
+ onLoadingChange(currentlyLoading);
1223
+ }
1224
+ }, [isLoading, idle, onLoadingChange]);
1225
+
1226
+ // Save history on unmount (for when switching conversations mid-stream)
1227
+ useEffect(() => {
1228
+ return () => {
1229
+ // Cancel any pending scroll animation frame
1230
+ if (scrollRAFRef.current) {
1231
+ cancelAnimationFrame(scrollRAFRef.current);
1232
+ }
1233
+
1234
+ // On unmount, save the current history including any partial response
1235
+ const currentHistory = { ...latestHistoryRef.current };
1236
+ const currentResponse = responseRef.current;
1237
+ const currentLastKey = lastKeyRef.current;
1238
+ const currentLastCallId = lastCallIdRef.current;
1239
+
1240
+ // If there's a response in progress, make sure it's saved
1241
+ if (currentLastKey && currentResponse) {
1242
+ currentHistory[currentLastKey] = {
1243
+ content: currentResponse + '\n\n(response interrupted)',
1244
+ callId: currentLastCallId || '',
1245
+ };
1246
+ }
1247
+
1248
+ // Notify parent of final history state (only if not already notified)
1249
+ if (historyCallbackRef.current && Object.keys(currentHistory).length > 0 && !hasNotifiedCompletionRef.current) {
1250
+ historyCallbackRef.current(currentHistory);
1251
+ }
1252
+ };
1253
+ }, []); // Empty deps - only run cleanup on unmount
1254
+
1255
+ // Auto-send initialPrompt when it changes (matches ChatPanel behavior)
1256
+ // IMPORTANT: Skip if we have initialHistory - that means we're loading an existing conversation
1257
+ useEffect(() => {
1258
+ // Don't auto-send initialPrompt if we loaded existing history
1259
+ // This prevents unwanted LLM calls when switching to loaded conversations
1260
+ const hasLoadedHistory = initialHistory && Object.keys(initialHistory).length > 0;
1261
+
1262
+ if (initialPrompt && initialPrompt !== '' && initialPrompt !== lastPrompt && !hasLoadedHistory) {
1263
+ setIsLoading(true);
1264
+ setThinkingBlocks([]);
1265
+ setCurrentThinkingIndex(0);
1266
+ setUserHasScrolled(false); // Enable auto-scroll for new prompt
1267
+
1268
+ const controller = new AbortController();
1269
+ setLastController(controller);
1270
+
1271
+ // Generate timestamp-prefixed key (matches ChatPanel)
1272
+ const timestamp = new Date().toISOString();
1273
+ const promptKey = `${timestamp}:${initialPrompt}`;
1274
+
1275
+ // Set history entry before sending (matches ChatPanel)
1276
+ setHistory({ [promptKey]: { content: '', callId: '' } });
1277
+
1278
+ // Build prompt with template
1279
+ let fullPrompt = initialPrompt;
1280
+ if (promptTemplate) {
1281
+ fullPrompt = promptTemplate.replace('{{prompt}}', initialPrompt);
1282
+ }
1283
+
1284
+ send(
1285
+ fullPrompt,
1286
+ [],
1287
+ [
1288
+ ...dataWithExtras(),
1289
+ { key: '--messages', data: '0' },
1290
+ ],
1291
+ true,
1292
+ true,
1293
+ service,
1294
+ currentConversation,
1295
+ controller
1296
+ );
1297
+
1298
+ setLastPrompt(initialPrompt);
1299
+ setLastMessages([]);
1300
+ setLastKey(promptKey);
1301
+ }
1302
+ }, [initialPrompt, initialHistory]);
1303
+
1304
+ // ============================================================================
1305
+ // Render Helpers
1306
+ // ============================================================================
1307
+
1308
+ // Code block component for markdown
1309
+ const CodeBlock = useCallback(({ node, inline, className, children, ...props }: any) => {
1310
+ const match = /language-(\w+)/.exec(className || '');
1311
+ return !inline && match ? (
1312
+ <SyntaxHighlighter
1313
+ style={prismStyle}
1314
+ language={match[1]}
1315
+ PreTag="div"
1316
+ {...props}
1317
+ >
1318
+ {String(children).replace(/\n$/, '')}
1319
+ </SyntaxHighlighter>
1320
+ ) : (
1321
+ <code className={className} {...props}>
1322
+ {children}
1323
+ </code>
1324
+ );
1325
+ }, [prismStyle]);
1326
+
1327
+ // Agent suggestion card component for inline agent handoff
1328
+ const AgentSuggestionCard = useCallback(({ agentId, agentName, reason }: {
1329
+ agentId: string;
1330
+ agentName: string;
1331
+ reason: string;
1332
+ }) => {
1333
+ if (!agentId || !onAgentChange) return null;
1334
+
1335
+ // Validate agent ID - must be a valid UUID or exist in agentOptions
1336
+ const isValidUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(agentId);
1337
+ const agentOption = agentOptions.find(opt => opt.value === agentId);
1338
+ const isKnownAgent = !!agentOption;
1339
+ const isValidAgent = isValidUUID || isKnownAgent;
1340
+
1341
+ // Look up avatar URL from agentOptions
1342
+ const avatarUrl = agentOption?.avatarUrl;
1343
+
1344
+ // Check if this is the currently active agent (already switched)
1345
+ const isCurrentAgent = currentAgentId === agentId;
1346
+
1347
+ // Don't render if agent ID looks like a placeholder (e.g., {agent-id}, $1, etc.)
1348
+ if (!isValidAgent || agentId.includes('{') || agentId.includes('$')) {
1349
+ // Render a disabled version
1350
+ // Use span instead of div to avoid nesting issues in markdown <p> tags
1351
+ return (
1352
+ <span className="ai-chat-agent-suggestion ai-chat-agent-suggestion--invalid">
1353
+ <span className="ai-chat-agent-suggestion__content">
1354
+ {avatarUrl ? (
1355
+ <img
1356
+ src={avatarUrl}
1357
+ alt={agentName}
1358
+ className="ai-chat-agent-suggestion__avatar"
1359
+ />
1360
+ ) : (
1361
+ <span className="ai-chat-agent-suggestion__icon">
1362
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1363
+ <path d="M12 8V4H8" />
1364
+ <rect width="16" height="12" x="4" y="8" rx="2" />
1365
+ <path d="M2 14h2" />
1366
+ <path d="M20 14h2" />
1367
+ <path d="M15 13v2" />
1368
+ <path d="M9 13v2" />
1369
+ </svg>
1370
+ </span>
1371
+ )}
1372
+ <span className="ai-chat-agent-suggestion__text">
1373
+ <span className="ai-chat-agent-suggestion__label">Suggested: {agentName}</span>
1374
+ {reason && <span className="ai-chat-agent-suggestion__reason">{reason}</span>}
1375
+ </span>
1376
+ </span>
1377
+ <span className="ai-chat-agent-suggestion__unavailable">Agent unavailable</span>
1378
+ </span>
1379
+ );
1380
+ }
1381
+
1382
+ // Already switched to this agent
1383
+ if (isCurrentAgent) {
1384
+ return (
1385
+ <span className="ai-chat-agent-suggestion ai-chat-agent-suggestion--switched">
1386
+ <span className="ai-chat-agent-suggestion__content">
1387
+ {avatarUrl ? (
1388
+ <img
1389
+ src={avatarUrl}
1390
+ alt={agentName}
1391
+ className="ai-chat-agent-suggestion__avatar"
1392
+ />
1393
+ ) : (
1394
+ <span className="ai-chat-agent-suggestion__icon ai-chat-agent-suggestion__icon--switched">
1395
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1396
+ <polyline points="20 6 9 17 4 12" />
1397
+ </svg>
1398
+ </span>
1399
+ )}
1400
+ <span className="ai-chat-agent-suggestion__text">
1401
+ <span className="ai-chat-agent-suggestion__label">Switched to {agentName}</span>
1402
+ {reason && <span className="ai-chat-agent-suggestion__reason">{reason}</span>}
1403
+ </span>
1404
+ </span>
1405
+ <span className="ai-chat-agent-suggestion__switched-badge">
1406
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
1407
+ <polyline points="20 6 9 17 4 12" />
1408
+ </svg>
1409
+ Active
1410
+ </span>
1411
+ </span>
1412
+ );
1413
+ }
1414
+
1415
+ return (
1416
+ <span className="ai-chat-agent-suggestion">
1417
+ <span className="ai-chat-agent-suggestion__content">
1418
+ {avatarUrl ? (
1419
+ <img
1420
+ src={avatarUrl}
1421
+ alt={agentName}
1422
+ className="ai-chat-agent-suggestion__avatar"
1423
+ />
1424
+ ) : (
1425
+ <span className="ai-chat-agent-suggestion__icon">
1426
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1427
+ <path d="M12 8V4H8" />
1428
+ <rect width="16" height="12" x="4" y="8" rx="2" />
1429
+ <path d="M2 14h2" />
1430
+ <path d="M20 14h2" />
1431
+ <path d="M15 13v2" />
1432
+ <path d="M9 13v2" />
1433
+ </svg>
1434
+ </span>
1435
+ )}
1436
+ <span className="ai-chat-agent-suggestion__text">
1437
+ <span className="ai-chat-agent-suggestion__label">Suggested: {agentName}</span>
1438
+ {reason && <span className="ai-chat-agent-suggestion__reason">{reason}</span>}
1439
+ </span>
1440
+ </span>
1441
+ <Button
1442
+ variant="default"
1443
+ size="sm"
1444
+ className="ai-chat-agent-suggestion__button"
1445
+ onClick={() => onAgentChange(agentId)}
1446
+ >
1447
+ Switch
1448
+ </Button>
1449
+ </span>
1450
+ );
1451
+ }, [onAgentChange, agentOptions, currentAgentId]);
1452
+
1453
+ // Markdown components including custom agent-suggestion element
1454
+ const markdownComponents = useMemo(() => ({
1455
+ code: CodeBlock,
1456
+ 'agent-suggestion': ({ node, ...props }: any) => {
1457
+ const agentId = props['data-agent-id'];
1458
+ const agentName = props['data-agent-name'];
1459
+ const reason = props['data-reason'];
1460
+ if (!agentId) return null;
1461
+ return (
1462
+ <AgentSuggestionCard
1463
+ agentId={agentId}
1464
+ agentName={agentName || agentId}
1465
+ reason={reason || ''}
1466
+ />
1467
+ );
1468
+ },
1469
+ }), [CodeBlock, AgentSuggestionCard]);
1470
+
1471
+ // Render thinking blocks
1472
+ const renderThinkingBlocks = useCallback(() => {
1473
+ if (thinkingBlocks.length === 0) return null;
1474
+
1475
+ const currentBlock = thinkingBlocks[currentThinkingIndex];
1476
+ if (!currentBlock) return null;
1477
+
1478
+ const isReasoning = currentBlock.type === 'reasoning';
1479
+ const icon = isReasoning ? <BrainIcon /> : <SearchIcon />;
1480
+ const title = isReasoning ? 'Reasoning' : 'Searching';
1481
+
1482
+ return (
1483
+ <div className="ai-chat-thinking">
1484
+ <div className="ai-chat-thinking__header">
1485
+ <span className="ai-chat-thinking__icon">{icon}</span>
1486
+ <span className="ai-chat-thinking__title">{title}</span>
1487
+ {thinkingBlocks.length > 1 && (
1488
+ <span className="ai-chat-thinking__nav">
1489
+ <button
1490
+ onClick={() => setCurrentThinkingIndex(Math.max(0, currentThinkingIndex - 1))}
1491
+ disabled={currentThinkingIndex === 0}
1492
+ >
1493
+
1494
+ </button>
1495
+ <span>{currentThinkingIndex + 1} / {thinkingBlocks.length}</span>
1496
+ <button
1497
+ onClick={() => setCurrentThinkingIndex(Math.min(thinkingBlocks.length - 1, currentThinkingIndex + 1))}
1498
+ disabled={currentThinkingIndex === thinkingBlocks.length - 1}
1499
+ >
1500
+
1501
+ </button>
1502
+ </span>
1503
+ )}
1504
+ </div>
1505
+ <div className="ai-chat-thinking__content">
1506
+ {cleanContentForDisplay(currentBlock.content)}
1507
+ </div>
1508
+ </div>
1509
+ );
1510
+ }, [thinkingBlocks, currentThinkingIndex, cleanContentForDisplay]);
1511
+
1512
+ // ============================================================================
1513
+ // Render
1514
+ // ============================================================================
1515
+
1516
+ const panelClasses = ['ai-chat-panel', theme === 'dark' ? 'dark-theme' : ''].filter(Boolean).join(' ');
1517
+
1518
+ return (
1519
+ <div className={panelClasses}>
1520
+ {/* Title */}
1521
+ {title && <div className="ai-chat-panel__title">{title}</div>}
1522
+
1523
+ {/* Messages Area */}
1524
+ <ScrollArea className="ai-chat-panel__messages" ref={responseAreaRef}>
1525
+ {/* Initial Message */}
1526
+ {initialMessage && (
1527
+ <div className="ai-chat-message ai-chat-message--assistant">
1528
+ <div className="ai-chat-message__content">
1529
+ <ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
1530
+ {initialMessage}
1531
+ </ReactMarkdown>
1532
+ </div>
1533
+ </div>
1534
+ )}
1535
+
1536
+ {/* History */}
1537
+ {Object.entries(history).map(([prompt, entry], index) => {
1538
+ const isLastEntry = index === Object.keys(history).length - 1;
1539
+ const { cleanedText } = processThinkingTags(entry.content);
1540
+
1541
+ return (
1542
+ <div key={index} className="ai-chat-entry">
1543
+ {/* User Message */}
1544
+ {!(hideInitialPrompt && index === 0) && (
1545
+ <div className="ai-chat-message ai-chat-message--user">
1546
+ <div className="ai-chat-message__content">
1547
+ {formatPromptForDisplay(prompt)}
1548
+ </div>
1549
+ </div>
1550
+ )}
1551
+
1552
+ {/* Assistant Response */}
1553
+ <div className="ai-chat-message ai-chat-message--assistant">
1554
+ <div className="ai-chat-message__content">
1555
+ {/* Streaming state */}
1556
+ {isLastEntry && (isLoading || !idle) && !justReset ? (
1557
+ <div className="ai-chat-streaming">
1558
+ {thinkingBlocks.length > 0 && renderThinkingBlocks()}
1559
+
1560
+ {cleanedText ? (
1561
+ <ReactMarkdown
1562
+ remarkPlugins={[remarkGfm]}
1563
+ rehypePlugins={[rehypeRaw]}
1564
+ components={markdownComponents}
1565
+ >
1566
+ {cleanedText}
1567
+ </ReactMarkdown>
1568
+ ) : (
1569
+ <div className="ai-chat-loading">
1570
+ <span>Thinking</span>
1571
+ <span className="ai-chat-loading__dots">
1572
+ <span className="ai-chat-loading__dot" />
1573
+ <span className="ai-chat-loading__dot" />
1574
+ <span className="ai-chat-loading__dot" />
1575
+ </span>
1576
+ </div>
1577
+ )}
1578
+ </div>
1579
+ ) : (
1580
+ <>
1581
+ {isLastEntry && thinkingBlocks.length > 0 && renderThinkingBlocks()}
1582
+ <ReactMarkdown
1583
+ remarkPlugins={[remarkGfm]}
1584
+ rehypePlugins={[rehypeRaw]}
1585
+ components={markdownComponents}
1586
+ >
1587
+ {cleanedText}
1588
+ </ReactMarkdown>
1589
+ </>
1590
+ )}
1591
+ </div>
1592
+
1593
+ {/* Action Buttons */}
1594
+ {idle && !isLoading && (
1595
+ <div className="ai-chat-message__actions">
1596
+ <Tooltip content={copiedCallId === entry.callId ? 'Copied!' : 'Copy'}>
1597
+ <Button
1598
+ variant="ghost"
1599
+ size="icon"
1600
+ onClick={() => copyToClipboard(entry.content, entry.callId)}
1601
+ >
1602
+ <CopyIcon />
1603
+ </Button>
1604
+ </Tooltip>
1605
+
1606
+ {thumbsUpClick && (
1607
+ <Tooltip content="Good response">
1608
+ <Button
1609
+ variant="ghost"
1610
+ size="icon"
1611
+ onClick={() => thumbsUpClick(entry.callId)}
1612
+ >
1613
+ <ThumbsUpIcon />
1614
+ </Button>
1615
+ </Tooltip>
1616
+ )}
1617
+
1618
+ {thumbsDownClick && (
1619
+ <Tooltip content="Poor response">
1620
+ <Button
1621
+ variant="ghost"
1622
+ size="icon"
1623
+ onClick={() => thumbsDownClick(entry.callId)}
1624
+ >
1625
+ <ThumbsDownIcon />
1626
+ </Button>
1627
+ </Tooltip>
1628
+ )}
1629
+ </div>
1630
+ )}
1631
+ </div>
1632
+ </div>
1633
+ );
1634
+ })}
1635
+
1636
+ {/* Follow-on Questions */}
1637
+ {followOnQuestionsState.length > 0 && idle && !isLoading && (
1638
+ <div className="ai-chat-suggestions">
1639
+ {followOnQuestionsState.map((question, index) => (
1640
+ <Button
1641
+ key={index}
1642
+ variant="outline"
1643
+ size="sm"
1644
+ onClick={() => handleSuggestionClick(question)}
1645
+ className="ai-chat-suggestions__button"
1646
+ >
1647
+ {question}
1648
+ </Button>
1649
+ ))}
1650
+ </div>
1651
+ )}
1652
+
1653
+ <div ref={bottomRef} />
1654
+ </ScrollArea>
1655
+
1656
+ {/* New Conversation Button */}
1657
+ {showNewConversationButton && (
1658
+ <div className="ai-chat-panel__new-conversation">
1659
+ <Button
1660
+ variant={newConversationConfirm ? 'destructive' : 'outline'}
1661
+ size="sm"
1662
+ onClick={handleNewConversation}
1663
+ className="ai-chat-new-conversation"
1664
+ >
1665
+ {newConversationConfirm ? 'Click to Confirm' : 'New Conversation'}
1666
+ </Button>
1667
+ </div>
1668
+ )}
1669
+
1670
+ {/* Input Area - Isolated component for performance */}
1671
+ <ChatInput
1672
+ placeholder={placeholder}
1673
+ idle={idle}
1674
+ onSubmit={continueChat}
1675
+ onStop={handleStop}
1676
+ agentOptions={agentOptions}
1677
+ currentAgentId={currentAgentId}
1678
+ onAgentChange={onAgentChange}
1679
+ agentsLoading={agentsLoading}
1680
+ currentAgentLabel={currentAgentLabel || undefined}
1681
+ currentAgentAvatarUrl={currentAgentAvatarUrl || undefined}
1682
+ contextSections={contextSections}
1683
+ totalContextTokens={totalContextTokens}
1684
+ maxContextTokens={maxContextTokens}
1685
+ enableContextDetailView={enableContextDetailView}
1686
+ />
1687
+
1688
+ {/* Footer */}
1689
+ {showPoweredBy && (
1690
+ <div className="ai-chat-panel__footer">
1691
+ {mcpServers && mcpServers.length > 0 && (
1692
+ <div className="ai-chat-tools-status">
1693
+ <span
1694
+ className={`ai-chat-tools-status__dot ${
1695
+ toolsLoading ? 'loading' : toolsFetchError ? 'error' : 'ready'
1696
+ }`}
1697
+ />
1698
+ <span className="ai-chat-tools-status__text">
1699
+ {toolsLoading
1700
+ ? 'tools loading...'
1701
+ : toolsFetchError
1702
+ ? 'tool fetch failed'
1703
+ : toolList.length > 0
1704
+ ? `${toolList.length} tools ready`
1705
+ : 'no tools found'}
1706
+ </span>
1707
+ </div>
1708
+ )}
1709
+
1710
+ <div className="ai-chat-powered-by">
1711
+ <span>brought to you by</span>
1712
+ <a href="https://llmasaservice.io" target="_blank" rel="noopener noreferrer">
1713
+ <LLMAsAServiceLogo />
1714
+ <span>llmasaservice.io</span>
1715
+ </a>
1716
+ </div>
1717
+ </div>
1718
+ )}
1719
+ </div>
1720
+ );
1721
+ };
1722
+
1723
+ // Memoize to prevent re-renders when parent state changes but our props don't
1724
+ export default React.memo(AIChatPanel);
1725
+