@hef2024/llmasaservice-ui 0.20.0 → 0.20.2

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.
@@ -11,6 +11,7 @@ import React, {
11
11
  useRef,
12
12
  useState,
13
13
  } from 'react';
14
+ import ReactDOMServer from 'react-dom/server';
14
15
  import { LLMAsAServiceCustomer, useLLM } from 'llmasaservice-client';
15
16
  import ReactMarkdown from 'react-markdown';
16
17
  import remarkGfm from 'remark-gfm';
@@ -19,6 +20,7 @@ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
19
20
  import materialDark from 'react-syntax-highlighter/dist/esm/styles/prism/material-dark.js';
20
21
  import materialLight from 'react-syntax-highlighter/dist/esm/styles/prism/material-light.js';
21
22
  import { Button, ScrollArea, Tooltip } from './components/ui';
23
+ import ToolInfoModal from './components/ui/ToolInfoModal';
22
24
  import './AIChatPanel.css';
23
25
 
24
26
  // ============================================================================
@@ -86,9 +88,34 @@ export interface AIChatPanelProps {
86
88
  totalContextTokens?: number;
87
89
  maxContextTokens?: number;
88
90
  enableContextDetailView?: boolean;
91
+ disabledSectionIds?: Set<string>;
92
+ onToggleSection?: (sectionId: string, enabled: boolean) => void;
89
93
 
90
94
  // Callback when a new conversation is created via API
91
95
  onConversationCreated?: (conversationId: string) => void;
96
+
97
+ // UI Customization Props (from ChatPanel)
98
+ cssUrl?: string;
99
+ markdownClass?: string;
100
+ width?: string;
101
+ height?: string;
102
+ scrollToEnd?: boolean;
103
+ prismStyle?: any; // PrismStyle type from react-syntax-highlighter
104
+
105
+ // Email & Save Props
106
+ showSaveButton?: boolean;
107
+ showEmailButton?: boolean;
108
+ messages?: { role: "user" | "assistant"; content: string }[];
109
+
110
+ // Call-to-Action Props
111
+ showCallToAction?: boolean;
112
+ callToActionButtonText?: string;
113
+ callToActionEmailAddress?: string;
114
+ callToActionEmailSubject?: string;
115
+
116
+ // Customer Email Capture Props
117
+ customerEmailCaptureMode?: "HIDE" | "OPTIONAL" | "REQUIRED";
118
+ customerEmailCapturePlaceholder?: string;
92
119
  }
93
120
 
94
121
  /**
@@ -270,6 +297,8 @@ interface ChatInputProps {
270
297
  totalContextTokens?: number;
271
298
  maxContextTokens?: number;
272
299
  enableContextDetailView?: boolean;
300
+ disabledSectionIds?: Set<string>;
301
+ onToggleSection?: (sectionId: string, enabled: boolean) => void;
273
302
  onContextViewerToggle?: () => void;
274
303
  }
275
304
 
@@ -288,11 +317,14 @@ const ChatInput = React.memo<ChatInputProps>(({
288
317
  totalContextTokens = 0,
289
318
  maxContextTokens = 8000,
290
319
  enableContextDetailView = false,
320
+ disabledSectionIds = new Set(),
321
+ onToggleSection,
291
322
  }) => {
292
323
  const [inputValue, setInputValue] = useState('');
293
324
  const [dropdownOpen, setDropdownOpen] = useState(false);
294
325
  const [contextViewerOpen, setContextViewerOpen] = useState(false);
295
326
  const [contextViewMode, setContextViewMode] = useState<'summary' | 'detail'>('summary');
327
+ const [expandedSectionId, setExpandedSectionId] = useState<string | null>(null);
296
328
  const textareaRef = useRef<HTMLTextAreaElement | null>(null);
297
329
  const containerRef = useRef<HTMLDivElement | null>(null);
298
330
  const contextPopoverRef = useRef<HTMLDivElement | null>(null);
@@ -338,6 +370,7 @@ const ChatInput = React.memo<ChatInputProps>(({
338
370
  if (contextPopoverRef.current && !contextPopoverRef.current.contains(event.target as Node)) {
339
371
  setContextViewerOpen(false);
340
372
  setContextViewMode('summary');
373
+ setExpandedSectionId(null);
341
374
  }
342
375
  };
343
376
  if (contextViewerOpen) {
@@ -441,6 +474,9 @@ const ChatInput = React.memo<ChatInputProps>(({
441
474
  setContextViewerOpen(!contextViewerOpen);
442
475
  if (!contextViewerOpen) {
443
476
  setContextViewMode('summary');
477
+ setExpandedSectionId(null);
478
+ } else {
479
+ setExpandedSectionId(null);
444
480
  }
445
481
  }}
446
482
  type="button"
@@ -463,7 +499,10 @@ const ChatInput = React.memo<ChatInputProps>(({
463
499
  <span className="ai-chat-context-popover__title">Context</span>
464
500
  <button
465
501
  className="ai-chat-context-popover__close"
466
- onClick={() => setContextViewerOpen(false)}
502
+ onClick={() => {
503
+ setContextViewerOpen(false);
504
+ setExpandedSectionId(null);
505
+ }}
467
506
  type="button"
468
507
  >
469
508
  ×
@@ -492,6 +531,7 @@ const ChatInput = React.memo<ChatInputProps>(({
492
531
  className={`ai-chat-context-popover__section-item ${enableContextDetailView ? 'ai-chat-context-popover__section-item--clickable' : ''}`}
493
532
  onClick={() => {
494
533
  if (enableContextDetailView) {
534
+ setExpandedSectionId(section.id);
495
535
  setContextViewMode('detail');
496
536
  }
497
537
  }}
@@ -507,7 +547,10 @@ const ChatInput = React.memo<ChatInputProps>(({
507
547
  {enableContextDetailView && (
508
548
  <button
509
549
  className="ai-chat-context-popover__expand-btn"
510
- onClick={() => setContextViewMode('detail')}
550
+ onClick={() => {
551
+ setExpandedSectionId(null);
552
+ setContextViewMode('detail');
553
+ }}
511
554
  type="button"
512
555
  >
513
556
  View details →
@@ -522,7 +565,10 @@ const ChatInput = React.memo<ChatInputProps>(({
522
565
  <div className="ai-chat-context-popover__header">
523
566
  <button
524
567
  className="ai-chat-context-popover__back"
525
- onClick={() => setContextViewMode('summary')}
568
+ onClick={() => {
569
+ setContextViewMode('summary');
570
+ setExpandedSectionId(null);
571
+ }}
526
572
  type="button"
527
573
  >
528
574
  ← Back
@@ -530,7 +576,10 @@ const ChatInput = React.memo<ChatInputProps>(({
530
576
  <span className="ai-chat-context-popover__title">Context Details</span>
531
577
  <button
532
578
  className="ai-chat-context-popover__close"
533
- onClick={() => setContextViewerOpen(false)}
579
+ onClick={() => {
580
+ setContextViewerOpen(false);
581
+ setExpandedSectionId(null);
582
+ }}
534
583
  type="button"
535
584
  >
536
585
  ×
@@ -555,10 +604,35 @@ const ChatInput = React.memo<ChatInputProps>(({
555
604
  <div className="ai-chat-context-popover__detail-sections">
556
605
  {contextSections.map((section) => {
557
606
  const format = detectFormat(section.data);
607
+ const isEnabled = !disabledSectionIds.has(section.id);
558
608
  return (
559
- <details key={section.id} className="ai-chat-context-popover__detail-section" open>
609
+ <details
610
+ key={section.id}
611
+ className={`ai-chat-context-popover__detail-section ${!isEnabled ? 'ai-chat-context-popover__detail-section--disabled' : ''}`}
612
+ open={expandedSectionId === section.id}
613
+ >
560
614
  <summary className="ai-chat-context-popover__detail-section-header">
561
- <span className="ai-chat-context-popover__detail-section-title">{section.title}</span>
615
+ <div className="ai-chat-context-popover__detail-section-title-row">
616
+ <span className="ai-chat-context-popover__detail-section-title">{section.title}</span>
617
+ <label
618
+ className="ai-chat-context-toggle"
619
+ onClick={(e) => e.stopPropagation()}
620
+ title={isEnabled ? "Disable this context section" : "Enable this context section"}
621
+ >
622
+ <input
623
+ type="checkbox"
624
+ checked={isEnabled}
625
+ onChange={(e) => {
626
+ e.stopPropagation();
627
+ if (onToggleSection) {
628
+ onToggleSection(section.id, !isEnabled);
629
+ }
630
+ }}
631
+ className="ai-chat-context-toggle__input"
632
+ />
633
+ <span className="ai-chat-context-toggle__slider"></span>
634
+ </label>
635
+ </div>
562
636
  <span className="ai-chat-context-popover__detail-section-meta">
563
637
  <code>{`{{${section.id}}}`}</code>
564
638
  <span>~{section.tokens || Math.ceil(JSON.stringify(section.data).length / 4)}</span>
@@ -676,7 +750,28 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
676
750
  totalContextTokens = 0,
677
751
  maxContextTokens = 8000,
678
752
  enableContextDetailView = false,
753
+ disabledSectionIds: propDisabledSectionIds,
754
+ onToggleSection: propOnToggleSection,
679
755
  onConversationCreated,
756
+ // UI Customization Props
757
+ cssUrl,
758
+ markdownClass,
759
+ width,
760
+ height,
761
+ scrollToEnd = false,
762
+ prismStyle,
763
+ // Email & Save Props
764
+ showSaveButton = true,
765
+ showEmailButton = true,
766
+ messages = [],
767
+ // Call-to-Action Props
768
+ showCallToAction = false,
769
+ callToActionButtonText = 'Submit',
770
+ callToActionEmailAddress = '',
771
+ callToActionEmailSubject = 'Agent CTA submitted',
772
+ // Customer Email Capture Props
773
+ customerEmailCaptureMode = 'HIDE',
774
+ customerEmailCapturePlaceholder = 'Please enter your email...',
680
775
  }) => {
681
776
  // ============================================================================
682
777
  // API URL
@@ -703,6 +798,45 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
703
798
  const [feedbackCallId, setFeedbackCallId] = useState<{ callId: string; type: 'up' | 'down' } | null>(null);
704
799
  const [error, setError] = useState<{ message: string; code?: string } | null>(null);
705
800
 
801
+ // Email & Save state
802
+ const [emailSent, setEmailSent] = useState(false);
803
+
804
+ // Tool Info Modal state
805
+ const [isToolInfoModalOpen, setIsToolInfoModalOpen] = useState(false);
806
+ const [toolInfoData, setToolInfoData] = useState<{ calls: any[]; responses: any[] } | null>(null);
807
+
808
+ // Call-to-Action state
809
+ const [callToActionSent, setCallToActionSent] = useState(false);
810
+ const [CTAClickedButNoEmail, setCTAClickedButNoEmail] = useState(false);
811
+
812
+ // Customer Email Capture state
813
+ const [emailInput, setEmailInput] = useState(customer?.customer_user_email ?? '');
814
+ const [emailInputSet, setEmailInputSet] = useState(false);
815
+ const [emailValid, setEmailValid] = useState(true);
816
+ const [showEmailPanel, setShowEmailPanel] = useState(customerEmailCaptureMode !== 'HIDE');
817
+ const [emailClickedButNoEmail, setEmailClickedButNoEmail] = useState(false);
818
+
819
+ // Tool Approval state (for MCP tools)
820
+ const [pendingToolRequests, setPendingToolRequests] = useState<any[]>([]);
821
+ const [sessionApprovedTools, setSessionApprovedTools] = useState<string[]>([]);
822
+ const [alwaysApprovedTools, setAlwaysApprovedTools] = useState<string[]>([]);
823
+
824
+ // Context section toggle state (disabled sections)
825
+ // Use internal state only if prop is not provided
826
+ const [internalDisabledSectionIds, setInternalDisabledSectionIds] = useState<Set<string>>(new Set());
827
+ const disabledSectionIds = propDisabledSectionIds ?? internalDisabledSectionIds;
828
+
829
+ // Email capture mode effect - like ChatPanel
830
+ useEffect(() => {
831
+ setShowEmailPanel(customerEmailCaptureMode !== 'HIDE');
832
+
833
+ if (customerEmailCaptureMode === 'REQUIRED') {
834
+ if (!isEmailAddress(emailInput)) {
835
+ setEmailValid(false);
836
+ }
837
+ }
838
+ }, [customerEmailCaptureMode, emailInput]);
839
+
706
840
  // Refs
707
841
  const bottomRef = useRef<HTMLDivElement | null>(null);
708
842
  const responseAreaRef = useRef<HTMLDivElement | null>(null);
@@ -722,6 +856,8 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
722
856
  const latestHistoryRef = useRef<Record<string, HistoryEntry>>(initialHistory);
723
857
  // Track if we've sent the initial prompt (prevents loops)
724
858
  const initialPromptSentRef = useRef<boolean>(false);
859
+ // Track the last followOnPrompt to detect changes (for auto-submit trigger)
860
+ const lastFollowOnPromptRef = useRef<string>('');
725
861
 
726
862
  // Sync new entries from initialHistory into local history state
727
863
  // This allows parent components to inject messages (e.g., page-based agent suggestions)
@@ -796,9 +932,9 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
796
932
  // ============================================================================
797
933
  // Memoized Values
798
934
  // ============================================================================
799
- const prismStyle = useMemo(
800
- () => (theme === 'light' ? materialLight : materialDark),
801
- [theme]
935
+ const effectivePrismStyle = useMemo(
936
+ () => prismStyle ?? (theme === 'light' ? materialLight : materialDark),
937
+ [prismStyle, theme]
802
938
  );
803
939
 
804
940
  // Browser info for context (matches ChatPanel)
@@ -811,6 +947,24 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
811
947
  };
812
948
  }, []);
813
949
 
950
+ // Handle toggling context sections on/off
951
+ const handleToggleSection = useCallback((sectionId: string, enabled: boolean) => {
952
+ // Use prop callback if provided, otherwise use internal state
953
+ if (propOnToggleSection) {
954
+ propOnToggleSection(sectionId, enabled);
955
+ } else {
956
+ setInternalDisabledSectionIds(prev => {
957
+ const next = new Set(prev);
958
+ if (enabled) {
959
+ next.delete(sectionId);
960
+ } else {
961
+ next.add(sectionId);
962
+ }
963
+ return next;
964
+ });
965
+ }
966
+ }, [propOnToggleSection]);
967
+
814
968
  // Ensure a conversation exists before sending the first message
815
969
  // This creates a conversation on the server and returns the conversation ID
816
970
  const ensureConversation = useCallback(() => {
@@ -907,6 +1061,215 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
907
1061
  const currentAgentLabel = currentAgentInfo.label;
908
1062
  const currentAgentAvatarUrl = currentAgentInfo.avatarUrl;
909
1063
 
1064
+ // ============================================================================
1065
+ // Helper Functions
1066
+ // ============================================================================
1067
+
1068
+ // Email validation helper
1069
+ const isEmailAddress = (email: string): boolean => {
1070
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
1071
+ return emailRegex.test(email);
1072
+ };
1073
+
1074
+ // Convert conversation history to standalone HTML file
1075
+ // Convert markdown to HTML - like ChatPanel
1076
+ const convertMarkdownToHTML = (markdown: string): string => {
1077
+ const html = ReactDOMServer.renderToStaticMarkup(
1078
+ <div className={markdownClass}>
1079
+ <ReactMarkdown
1080
+ remarkPlugins={[remarkGfm]}
1081
+ rehypePlugins={[rehypeRaw]}
1082
+ >
1083
+ {markdown}
1084
+ </ReactMarkdown>
1085
+ </div>
1086
+ );
1087
+ return html;
1088
+ };
1089
+
1090
+ // Convert conversation history to HTML - like ChatPanel
1091
+ const convertHistoryToHTML = (history: Record<string, HistoryEntry>): string => {
1092
+ const stylesheet = `
1093
+ <style>
1094
+ .conversation-history {
1095
+ font-family: Arial, sans-serif;
1096
+ line-height: 1.5;
1097
+ }
1098
+ .history-entry {
1099
+ margin-bottom: 15px;
1100
+ }
1101
+ .prompt-container, .response-container {
1102
+ margin-bottom: 10px;
1103
+ }
1104
+ .prompt, .response {
1105
+ display: block;
1106
+ margin: 5px 0;
1107
+ padding: 10px;
1108
+ border-radius: 5px;
1109
+ max-width: 80%;
1110
+ }
1111
+ .prompt {
1112
+ background-color: #efefef;
1113
+ margin-left: 0;
1114
+ }
1115
+ .response {
1116
+ background-color: #f0fcfd;
1117
+ margin-left: 25px;
1118
+ }
1119
+ </style>
1120
+ `;
1121
+
1122
+ let html = `
1123
+ <html>
1124
+ <head>
1125
+ ${stylesheet}
1126
+ </head>
1127
+ <body>
1128
+ <h1>Conversation History (${new Date().toLocaleString()})</h1>
1129
+ <div class="conversation-history">
1130
+ `;
1131
+
1132
+ Object.entries(history).forEach(([prompt, response], index) => {
1133
+ if (hideInitialPrompt && index === 0) {
1134
+ html += `
1135
+ <div class="history-entry">
1136
+ <div class="response-container">
1137
+ <div class="response">${convertMarkdownToHTML(response.content)}</div>
1138
+ </div>
1139
+ </div>
1140
+ `;
1141
+ } else {
1142
+ html += `
1143
+ <div class="history-entry">
1144
+ <div class="prompt-container">
1145
+ <div class="prompt">${convertMarkdownToHTML(
1146
+ formatPromptForDisplay(prompt)
1147
+ )}</div>
1148
+ </div>
1149
+ <div class="response-container">
1150
+ <div class="response">${convertMarkdownToHTML(response.content)}</div>
1151
+ </div>
1152
+ </div>
1153
+ `;
1154
+ }
1155
+ });
1156
+
1157
+ html += `
1158
+ </div>
1159
+ </body>
1160
+ </html>
1161
+ `;
1162
+
1163
+ return html;
1164
+ };
1165
+
1166
+ // Save HTML to file - like ChatPanel
1167
+ const saveHTMLToFile = (html: string, filename: string) => {
1168
+ const blob = new Blob([html], { type: 'text/html' });
1169
+ const link = document.createElement('a');
1170
+ link.href = URL.createObjectURL(blob);
1171
+ link.download = filename;
1172
+ document.body.appendChild(link);
1173
+ link.click();
1174
+ document.body.removeChild(link);
1175
+ };
1176
+
1177
+ // Download conversation as HTML file
1178
+ const saveAsHTMLFile = useCallback(() => {
1179
+ saveHTMLToFile(
1180
+ convertHistoryToHTML(history),
1181
+ `conversation-${new Date().toISOString()}.html`
1182
+ );
1183
+ interactionClicked(lastCallId || '', 'save');
1184
+ }, [history, lastCallId]);
1185
+
1186
+ const handleSendEmail = (to: string, from: string) => {
1187
+ sendConversationsViaEmail(to, `Conversation History from ${title}`, from);
1188
+ interactionClicked(lastCallId || '', 'email', to);
1189
+ setEmailSent(true);
1190
+ };
1191
+
1192
+ const sendConversationsViaEmail = async (
1193
+ to: string,
1194
+ subject: string = `Conversation History from ${title}`,
1195
+ from: string = ''
1196
+ ) => {
1197
+ fetch(`${publicAPIUrl}/share/email`, {
1198
+ method: 'POST',
1199
+ headers: {
1200
+ 'Content-Type': 'application/json',
1201
+ },
1202
+ body: JSON.stringify({
1203
+ to: to,
1204
+ from: from,
1205
+ subject: subject,
1206
+ html: convertHistoryToHTML(history),
1207
+ project_id: project_id ?? '',
1208
+ customer: customer,
1209
+ history: history,
1210
+ title: title,
1211
+ }),
1212
+ });
1213
+
1214
+ await interactionClicked(lastCallId || '', 'email', from);
1215
+ };
1216
+
1217
+
1218
+ // Send CTA email
1219
+ const sendCallToActionEmail = useCallback(async (from: string) => {
1220
+ try {
1221
+ await fetch(`${publicAPIUrl}/share/email`, {
1222
+ method: 'POST',
1223
+ headers: {
1224
+ 'Content-Type': 'application/json',
1225
+ },
1226
+ body: JSON.stringify({
1227
+ to: callToActionEmailAddress,
1228
+ from: from,
1229
+ subject: `${callToActionEmailSubject} from ${from}`,
1230
+ html: convertHistoryToHTML(history),
1231
+ project_id: project_id ?? '',
1232
+ customer: customer,
1233
+ history: history,
1234
+ title: title,
1235
+ }),
1236
+ });
1237
+
1238
+ await interactionClicked(lastCallId || '', 'cta', from);
1239
+ setCallToActionSent(true);
1240
+ } catch (err) {
1241
+ console.error('[AIChatPanel] Failed to send CTA email:', err);
1242
+ }
1243
+ }, [history, title, project_id, customer, lastCallId, publicAPIUrl, callToActionEmailAddress, callToActionEmailSubject]);
1244
+
1245
+ // Check if button should be disabled due to email capture requirements
1246
+ const isDisabledDueToNoEmail = useCallback(() => {
1247
+ if (customerEmailCaptureMode === 'REQUIRED' && !emailInputSet) {
1248
+ return true;
1249
+ }
1250
+ return false;
1251
+ }, [customerEmailCaptureMode, emailInputSet]);
1252
+
1253
+ // Handle tool approval for MCP tools
1254
+ const handleToolApproval = useCallback((toolName: string, scope: 'once' | 'session' | 'always') => {
1255
+ if (scope === 'session' || scope === 'always') {
1256
+ setSessionApprovedTools((prev) => Array.from(new Set([...prev, toolName])));
1257
+ }
1258
+ if (scope === 'always') {
1259
+ setAlwaysApprovedTools((prev) => Array.from(new Set([...prev, toolName])));
1260
+ }
1261
+
1262
+ // Remove approved tool from pending list
1263
+ setPendingToolRequests((prev) => prev.filter((r) => r.toolName !== toolName));
1264
+
1265
+ console.log(`[AIChatPanel] Tool "${toolName}" approved with scope: ${scope}`);
1266
+ }, []);
1267
+
1268
+ // Get unique tool names from pending requests
1269
+ const getUniqueToolNames = useCallback(() => {
1270
+ return Array.from(new Set(pendingToolRequests.map((r) => r.toolName)));
1271
+ }, [pendingToolRequests]);
1272
+
910
1273
  // ============================================================================
911
1274
  // Callbacks
912
1275
  // ============================================================================
@@ -1226,11 +1589,6 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1226
1589
  fullPromptToSend = promptTemplate.replace('{{prompt}}', fullPromptToSend);
1227
1590
  }
1228
1591
 
1229
- // Add follow-on prompt
1230
- if (followOnPrompt) {
1231
- fullPromptToSend += `\n\n${followOnPrompt}`;
1232
- }
1233
-
1234
1592
  const newController = new AbortController();
1235
1593
  setLastController(newController);
1236
1594
 
@@ -1314,7 +1672,6 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1314
1672
  clearFollowOnQuestionsNextPrompt,
1315
1673
  history,
1316
1674
  promptTemplate,
1317
- followOnPrompt,
1318
1675
  send,
1319
1676
  service,
1320
1677
  ensureConversation,
@@ -1447,12 +1804,13 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1447
1804
  useEffect(() => {
1448
1805
  // Only auto-scroll if:
1449
1806
  // 1. We're actively streaming (!idle)
1450
- // 2. User hasn't manually scrolled up during this response
1807
+ // 2. User hasn't manually scrolled up during this response (or scrollToEnd prop is true)
1451
1808
  // 3. We have content to show (response exists)
1452
- if (!idle && !userHasScrolled && response) {
1809
+ const shouldAutoScroll = scrollToEnd || !userHasScrolled;
1810
+ if (!idle && shouldAutoScroll && response) {
1453
1811
  scrollToBottom();
1454
1812
  }
1455
- }, [response, scrollToBottom, idle, userHasScrolled]); // Removed history dependency
1813
+ }, [response, scrollToBottom, idle, userHasScrolled, scrollToEnd]); // Removed history dependency
1456
1814
 
1457
1815
  // Ref to track idle state for scroll handler (avoids stale closure)
1458
1816
  const idleRef = useRef(idle);
@@ -1548,6 +1906,15 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1548
1906
  }
1549
1907
  }, [initialPrompt, continueChat]);
1550
1908
 
1909
+ // Auto-send followOnPrompt when it changes (like ChatPanel)
1910
+ // This allows parent components to programmatically submit prompts to the current conversation
1911
+ useEffect(() => {
1912
+ if (followOnPrompt && followOnPrompt !== '' && followOnPrompt !== lastFollowOnPromptRef.current) {
1913
+ lastFollowOnPromptRef.current = followOnPrompt;
1914
+ continueChat(followOnPrompt);
1915
+ }
1916
+ }, [followOnPrompt, continueChat]);
1917
+
1551
1918
  // Monitor for errors from useLLM hook
1552
1919
  useEffect(() => {
1553
1920
  if (llmError && llmError.trim()) {
@@ -1594,6 +1961,50 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1594
1961
  }
1595
1962
  }, [llmError, lastKey, lastCallId]);
1596
1963
 
1964
+ // Dynamic CSS Injection
1965
+ useEffect(() => {
1966
+ // Clean up any previously added CSS from this component
1967
+ const existingLinks = document.querySelectorAll(
1968
+ 'link[data-source="ai-chat-panel"]'
1969
+ );
1970
+ existingLinks.forEach((link) => link.parentNode?.removeChild(link));
1971
+
1972
+ const existingStyles = document.querySelectorAll(
1973
+ 'style[data-source="ai-chat-panel"]'
1974
+ );
1975
+ existingStyles.forEach((style) => style.parentNode?.removeChild(style));
1976
+
1977
+ if (cssUrl) {
1978
+ if (cssUrl.startsWith('http://') || cssUrl.startsWith('https://')) {
1979
+ // If it's a URL, create a link element
1980
+ const link = document.createElement('link');
1981
+ link.href = cssUrl;
1982
+ link.rel = 'stylesheet';
1983
+ link.setAttribute('data-source', 'ai-chat-panel');
1984
+ document.head.appendChild(link);
1985
+ } else {
1986
+ // If it's a CSS string, create a style element
1987
+ const style = document.createElement('style');
1988
+ style.textContent = cssUrl;
1989
+ style.setAttribute('data-source', 'ai-chat-panel');
1990
+ document.head.appendChild(style);
1991
+ }
1992
+ }
1993
+
1994
+ // Clean up when component unmounts
1995
+ return () => {
1996
+ const links = document.querySelectorAll(
1997
+ 'link[data-source="ai-chat-panel"]'
1998
+ );
1999
+ links.forEach((link) => link.parentNode?.removeChild(link));
2000
+
2001
+ const styles = document.querySelectorAll(
2002
+ 'style[data-source="ai-chat-panel"]'
2003
+ );
2004
+ styles.forEach((style) => style.parentNode?.removeChild(style));
2005
+ };
2006
+ }, [cssUrl]);
2007
+
1597
2008
  // ============================================================================
1598
2009
  // Render Helpers
1599
2010
  // ============================================================================
@@ -1603,7 +2014,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1603
2014
  const match = /language-(\w+)/.exec(className || '');
1604
2015
  return !inline && match ? (
1605
2016
  <SyntaxHighlighter
1606
- style={prismStyle}
2017
+ style={effectivePrismStyle}
1607
2018
  language={match[1]}
1608
2019
  PreTag="div"
1609
2020
  {...props}
@@ -1615,7 +2026,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1615
2026
  {children}
1616
2027
  </code>
1617
2028
  );
1618
- }, [prismStyle]);
2029
+ }, [effectivePrismStyle]);
1619
2030
 
1620
2031
  // Agent suggestion card component for inline agent handoff
1621
2032
  const AgentSuggestionCard = React.memo(({ agentId, agentName, reason }: {
@@ -1824,7 +2235,13 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1824
2235
  const panelClasses = ['ai-chat-panel', theme === 'dark' ? 'dark-theme' : ''].filter(Boolean).join(' ');
1825
2236
 
1826
2237
  return (
1827
- <div className={panelClasses}>
2238
+ <div
2239
+ className={panelClasses}
2240
+ style={{
2241
+ ...(width && { width }),
2242
+ ...(height && { height })
2243
+ }}
2244
+ >
1828
2245
  {/* Title */}
1829
2246
  {title && <div className="ai-chat-panel__title">{title}</div>}
1830
2247
 
@@ -1858,9 +2275,23 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1858
2275
  {initialMessage && (
1859
2276
  <div className="ai-chat-message ai-chat-message--assistant">
1860
2277
  <div className="ai-chat-message__content">
1861
- <ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
1862
- {initialMessage}
1863
- </ReactMarkdown>
2278
+ {markdownClass ? (
2279
+ <div className={markdownClass}>
2280
+ <ReactMarkdown
2281
+ remarkPlugins={[remarkGfm]}
2282
+ rehypePlugins={[rehypeRaw]}
2283
+ >
2284
+ {initialMessage}
2285
+ </ReactMarkdown>
2286
+ </div>
2287
+ ) : (
2288
+ <ReactMarkdown
2289
+ remarkPlugins={[remarkGfm]}
2290
+ rehypePlugins={[rehypeRaw]}
2291
+ >
2292
+ {initialMessage}
2293
+ </ReactMarkdown>
2294
+ )}
1864
2295
  </div>
1865
2296
  </div>
1866
2297
  )}
@@ -1895,13 +2326,25 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1895
2326
  {thinkingBlocks.length > 0 && renderThinkingBlocks()}
1896
2327
 
1897
2328
  {processedContent ? (
1898
- <ReactMarkdown
1899
- remarkPlugins={[remarkGfm]}
1900
- rehypePlugins={[rehypeRaw]}
1901
- components={markdownComponents}
1902
- >
1903
- {processedContent}
1904
- </ReactMarkdown>
2329
+ markdownClass ? (
2330
+ <div className={markdownClass}>
2331
+ <ReactMarkdown
2332
+ remarkPlugins={[remarkGfm]}
2333
+ rehypePlugins={[rehypeRaw]}
2334
+ components={markdownComponents}
2335
+ >
2336
+ {processedContent}
2337
+ </ReactMarkdown>
2338
+ </div>
2339
+ ) : (
2340
+ <ReactMarkdown
2341
+ remarkPlugins={[remarkGfm]}
2342
+ rehypePlugins={[rehypeRaw]}
2343
+ components={markdownComponents}
2344
+ >
2345
+ {processedContent}
2346
+ </ReactMarkdown>
2347
+ )
1905
2348
  ) : (
1906
2349
  <div className="ai-chat-loading">
1907
2350
  <span>Thinking</span>
@@ -1916,13 +2359,25 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1916
2359
  ) : (
1917
2360
  <>
1918
2361
  {isLastEntry && thinkingBlocks.length > 0 && renderThinkingBlocks()}
1919
- <ReactMarkdown
1920
- remarkPlugins={[remarkGfm]}
1921
- rehypePlugins={[rehypeRaw]}
1922
- components={markdownComponents}
1923
- >
1924
- {processedContent}
1925
- </ReactMarkdown>
2362
+ {markdownClass ? (
2363
+ <div className={markdownClass}>
2364
+ <ReactMarkdown
2365
+ remarkPlugins={[remarkGfm]}
2366
+ rehypePlugins={[rehypeRaw]}
2367
+ components={markdownComponents}
2368
+ >
2369
+ {processedContent}
2370
+ </ReactMarkdown>
2371
+ </div>
2372
+ ) : (
2373
+ <ReactMarkdown
2374
+ remarkPlugins={[remarkGfm]}
2375
+ rehypePlugins={[rehypeRaw]}
2376
+ components={markdownComponents}
2377
+ >
2378
+ {processedContent}
2379
+ </ReactMarkdown>
2380
+ )}
1926
2381
  </>
1927
2382
  )}
1928
2383
  </div>
@@ -1974,6 +2429,27 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1974
2429
  </svg>
1975
2430
  )}
1976
2431
  </button>
2432
+
2433
+ {/* Tool Info Button - show if entry has tool data */}
2434
+ {(entry.toolCalls || entry.toolResponses) && (
2435
+ <button
2436
+ className="ai-chat-action-button"
2437
+ onClick={() => {
2438
+ setToolInfoData({
2439
+ calls: entry.toolCalls || [],
2440
+ responses: entry.toolResponses || [],
2441
+ });
2442
+ setIsToolInfoModalOpen(true);
2443
+ }}
2444
+ title="View tool information"
2445
+ >
2446
+ <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">
2447
+ <circle cx="12" cy="12" r="10" />
2448
+ <line x1="12" x2="12" y1="16" y2="12" />
2449
+ <line x1="12" x2="12.01" y1="8" y2="8" />
2450
+ </svg>
2451
+ </button>
2452
+ )}
1977
2453
  </div>
1978
2454
  )}
1979
2455
  </div>
@@ -2001,6 +2477,109 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2001
2477
  <div ref={bottomRef} />
2002
2478
  </ScrollArea>
2003
2479
 
2480
+ {/* Tool Approval Panel */}
2481
+ {pendingToolRequests.length > 0 && (
2482
+ <div className="ai-chat-approve-tools-panel">
2483
+ <div className="ai-chat-approve-tools-header">
2484
+ Tool Approval Required
2485
+ </div>
2486
+ <div className="ai-chat-approve-tools-description">
2487
+ The AI wants to use the following tools:
2488
+ </div>
2489
+ {getUniqueToolNames().map((toolName) => (
2490
+ <div key={toolName} className="ai-chat-approve-tool-item">
2491
+ <div className="ai-chat-approve-tool-name">{toolName}</div>
2492
+ <div className="ai-chat-approve-tools-buttons">
2493
+ <Button
2494
+ size="sm"
2495
+ variant="outline"
2496
+ className="ai-chat-approve-tools-button"
2497
+ onClick={() => handleToolApproval(toolName, 'once')}
2498
+ >
2499
+ Once
2500
+ </Button>
2501
+ <Button
2502
+ size="sm"
2503
+ variant="outline"
2504
+ className="ai-chat-approve-tools-button"
2505
+ onClick={() => handleToolApproval(toolName, 'session')}
2506
+ >
2507
+ This Session
2508
+ </Button>
2509
+ <Button
2510
+ size="sm"
2511
+ variant="default"
2512
+ className="ai-chat-approve-tools-button"
2513
+ onClick={() => handleToolApproval(toolName, 'always')}
2514
+ >
2515
+ Always
2516
+ </Button>
2517
+ </div>
2518
+ </div>
2519
+ ))}
2520
+ </div>
2521
+ )}
2522
+
2523
+ {/* Button Container - Save, Email, CTA */}
2524
+ {(showSaveButton || showEmailButton || showCallToAction) && (
2525
+ <div className="ai-chat-button-container">
2526
+ {showSaveButton && (
2527
+ <Button
2528
+ variant="outline"
2529
+ size="sm"
2530
+ onClick={saveAsHTMLFile}
2531
+ disabled={Object.keys(history).length === 0}
2532
+ className="ai-chat-save-button"
2533
+ >
2534
+ 💾 Save
2535
+ </Button>
2536
+ )}
2537
+
2538
+ {showEmailButton && (
2539
+ <Button
2540
+ variant="outline"
2541
+ size="sm"
2542
+ onClick={() => {
2543
+ if (isEmailAddress(emailInput)) {
2544
+ setEmailInputSet(true);
2545
+ setEmailValid(true);
2546
+ handleSendEmail(emailInput, emailInput);
2547
+ setEmailClickedButNoEmail(false);
2548
+ } else {
2549
+ setShowEmailPanel(true);
2550
+ setEmailValid(false);
2551
+ setEmailClickedButNoEmail(true);
2552
+ }
2553
+ }}
2554
+ disabled={Object.keys(history).length === 0 || isDisabledDueToNoEmail()}
2555
+ className="ai-chat-email-button"
2556
+ >
2557
+ 📧 Email Conversation{emailSent ? ' ✓' : ''}
2558
+ </Button>
2559
+ )}
2560
+
2561
+ {showCallToAction && (
2562
+ <Button
2563
+ variant={callToActionSent ? 'outline' : 'default'}
2564
+ size="sm"
2565
+ onClick={() => {
2566
+ if (customerEmailCaptureMode !== 'HIDE' && !emailInputSet) {
2567
+ setCTAClickedButNoEmail(true);
2568
+ setTimeout(() => setCTAClickedButNoEmail(false), 3000);
2569
+ return;
2570
+ }
2571
+ const fromEmail = emailInput || customer?.customer_user_email || '';
2572
+ sendCallToActionEmail(fromEmail);
2573
+ }}
2574
+ disabled={callToActionSent || Object.keys(history).length === 0}
2575
+ className="ai-chat-cta-button"
2576
+ >
2577
+ {callToActionSent ? '✓ Submitted' : callToActionButtonText}
2578
+ </Button>
2579
+ )}
2580
+ </div>
2581
+ )}
2582
+
2004
2583
  {/* New Conversation Button */}
2005
2584
  {showNewConversationButton && (
2006
2585
  <div className="ai-chat-panel__new-conversation">
@@ -2015,6 +2594,103 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2015
2594
  </div>
2016
2595
  )}
2017
2596
 
2597
+ {/* Customer Email Capture Panel */}
2598
+ {showEmailPanel && (
2599
+ <>
2600
+ {!emailValid && (
2601
+ <div className="ai-chat-email-input-message">
2602
+ {isDisabledDueToNoEmail()
2603
+ ? "Let's get started - please enter your email"
2604
+ : CTAClickedButNoEmail || emailClickedButNoEmail
2605
+ ? 'Sure, we just need an email address to contact you'
2606
+ : 'Email address is invalid'}
2607
+ </div>
2608
+ )}
2609
+ <div className="ai-chat-email-input-container">
2610
+ <input
2611
+ type="email"
2612
+ name="email"
2613
+ id="email"
2614
+ className={
2615
+ emailValid
2616
+ ? emailInputSet
2617
+ ? 'ai-chat-email-input-set'
2618
+ : 'ai-chat-email-input'
2619
+ : 'ai-chat-email-input-invalid'
2620
+ }
2621
+ placeholder={customerEmailCapturePlaceholder}
2622
+ value={emailInput}
2623
+ onChange={(e) => {
2624
+ const newEmail = e.target.value;
2625
+ setEmailInput(newEmail);
2626
+ // Reset validation while typing
2627
+ if (!emailInputSet) {
2628
+ if (customerEmailCaptureMode === 'REQUIRED' && newEmail !== '') {
2629
+ setEmailValid(isEmailAddress(newEmail));
2630
+ } else {
2631
+ setEmailValid(true);
2632
+ }
2633
+ }
2634
+ }}
2635
+ onBlur={() => {
2636
+ // Auto-validate and set email when field loses focus
2637
+ if (emailInput && isEmailAddress(emailInput) && !emailInputSet) {
2638
+ setEmailInputSet(true);
2639
+ setEmailValid(true);
2640
+ interactionClicked('', 'emailcapture', emailInput);
2641
+
2642
+ // Handle pending actions
2643
+ if (CTAClickedButNoEmail) {
2644
+ sendCallToActionEmail(emailInput);
2645
+ setCTAClickedButNoEmail(false);
2646
+ }
2647
+ if (emailClickedButNoEmail) {
2648
+ handleSendEmail(emailInput, emailInput);
2649
+ setEmailClickedButNoEmail(false);
2650
+ }
2651
+ } else if (customerEmailCaptureMode === 'REQUIRED' && emailInput !== '') {
2652
+ setEmailValid(isEmailAddress(emailInput));
2653
+ }
2654
+ }}
2655
+ onKeyDown={(e) => {
2656
+ if (e.key === 'Enter') {
2657
+ if (isEmailAddress(emailInput)) {
2658
+ setEmailInputSet(true);
2659
+ setEmailValid(true);
2660
+ interactionClicked('', 'emailcapture', emailInput);
2661
+
2662
+ // Handle pending actions
2663
+ if (CTAClickedButNoEmail) {
2664
+ sendCallToActionEmail(emailInput);
2665
+ setCTAClickedButNoEmail(false);
2666
+ }
2667
+ if (emailClickedButNoEmail) {
2668
+ handleSendEmail(emailInput, emailInput);
2669
+ setEmailClickedButNoEmail(false);
2670
+ }
2671
+ } else {
2672
+ setEmailValid(false);
2673
+ }
2674
+ }
2675
+ }}
2676
+ disabled={false}
2677
+ />
2678
+ {emailInputSet && (
2679
+ <button
2680
+ className="ai-chat-email-edit-button"
2681
+ onClick={() => {
2682
+ setEmailInputSet(false);
2683
+ setEmailValid(true);
2684
+ }}
2685
+ title="Edit email"
2686
+ >
2687
+
2688
+ </button>
2689
+ )}
2690
+ </div>
2691
+ </>
2692
+ )}
2693
+
2018
2694
  {/* Input Area - Isolated component for performance */}
2019
2695
  <ChatInput
2020
2696
  placeholder={placeholder}
@@ -2031,6 +2707,8 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2031
2707
  totalContextTokens={totalContextTokens}
2032
2708
  maxContextTokens={maxContextTokens}
2033
2709
  enableContextDetailView={enableContextDetailView}
2710
+ disabledSectionIds={disabledSectionIds}
2711
+ onToggleSection={handleToggleSection}
2034
2712
  />
2035
2713
 
2036
2714
  {/* Footer */}
@@ -2064,6 +2742,13 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2064
2742
  </div>
2065
2743
  </div>
2066
2744
  )}
2745
+
2746
+ {/* Modals */}
2747
+ <ToolInfoModal
2748
+ isOpen={isToolInfoModalOpen}
2749
+ onClose={() => setIsToolInfoModalOpen(false)}
2750
+ data={toolInfoData}
2751
+ />
2067
2752
  </div>
2068
2753
  );
2069
2754
  };