@hef2024/llmasaservice-ui 0.19.1 → 0.20.1

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
  // ============================================================================
@@ -89,6 +91,29 @@ export interface AIChatPanelProps {
89
91
 
90
92
  // Callback when a new conversation is created via API
91
93
  onConversationCreated?: (conversationId: string) => void;
94
+
95
+ // UI Customization Props (from ChatPanel)
96
+ cssUrl?: string;
97
+ markdownClass?: string;
98
+ width?: string;
99
+ height?: string;
100
+ scrollToEnd?: boolean;
101
+ prismStyle?: any; // PrismStyle type from react-syntax-highlighter
102
+
103
+ // Email & Save Props
104
+ showSaveButton?: boolean;
105
+ showEmailButton?: boolean;
106
+ messages?: { role: "user" | "assistant"; content: string }[];
107
+
108
+ // Call-to-Action Props
109
+ showCallToAction?: boolean;
110
+ callToActionButtonText?: string;
111
+ callToActionEmailAddress?: string;
112
+ callToActionEmailSubject?: string;
113
+
114
+ // Customer Email Capture Props
115
+ customerEmailCaptureMode?: "HIDE" | "OPTIONAL" | "REQUIRED";
116
+ customerEmailCapturePlaceholder?: string;
92
117
  }
93
118
 
94
119
  /**
@@ -235,6 +260,21 @@ const LLMAsAServiceLogo = () => (
235
260
  </svg>
236
261
  );
237
262
 
263
+ const AlertCircleIcon = () => (
264
+ <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">
265
+ <circle cx="12" cy="12" r="10" />
266
+ <line x1="12" x2="12" y1="8" y2="12" />
267
+ <line x1="12" x2="12.01" y1="16" y2="16" />
268
+ </svg>
269
+ );
270
+
271
+ const CloseIcon = () => (
272
+ <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">
273
+ <line x1="18" x2="6" y1="6" y2="18" />
274
+ <line x1="6" x2="18" y1="6" y2="18" />
275
+ </svg>
276
+ );
277
+
238
278
  // ============================================================================
239
279
  // Isolated Input Component - Prevents full re-renders on every keystroke
240
280
  // ============================================================================
@@ -662,6 +702,25 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
662
702
  maxContextTokens = 8000,
663
703
  enableContextDetailView = false,
664
704
  onConversationCreated,
705
+ // UI Customization Props
706
+ cssUrl,
707
+ markdownClass,
708
+ width,
709
+ height,
710
+ scrollToEnd = false,
711
+ prismStyle,
712
+ // Email & Save Props
713
+ showSaveButton = true,
714
+ showEmailButton = true,
715
+ messages = [],
716
+ // Call-to-Action Props
717
+ showCallToAction = false,
718
+ callToActionButtonText = 'Submit',
719
+ callToActionEmailAddress = '',
720
+ callToActionEmailSubject = 'Agent CTA submitted',
721
+ // Customer Email Capture Props
722
+ customerEmailCaptureMode = 'HIDE',
723
+ customerEmailCapturePlaceholder = 'Please enter your email...',
665
724
  }) => {
666
725
  // ============================================================================
667
726
  // API URL
@@ -685,6 +744,42 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
685
744
  const [newConversationConfirm, setNewConversationConfirm] = useState(false);
686
745
  const [justReset, setJustReset] = useState(false);
687
746
  const [copiedCallId, setCopiedCallId] = useState<string | null>(null);
747
+ const [feedbackCallId, setFeedbackCallId] = useState<{ callId: string; type: 'up' | 'down' } | null>(null);
748
+ const [error, setError] = useState<{ message: string; code?: string } | null>(null);
749
+
750
+ // Email & Save state
751
+ const [emailSent, setEmailSent] = useState(false);
752
+
753
+ // Tool Info Modal state
754
+ const [isToolInfoModalOpen, setIsToolInfoModalOpen] = useState(false);
755
+ const [toolInfoData, setToolInfoData] = useState<{ calls: any[]; responses: any[] } | null>(null);
756
+
757
+ // Call-to-Action state
758
+ const [callToActionSent, setCallToActionSent] = useState(false);
759
+ const [CTAClickedButNoEmail, setCTAClickedButNoEmail] = useState(false);
760
+
761
+ // Customer Email Capture state
762
+ const [emailInput, setEmailInput] = useState(customer?.customer_user_email ?? '');
763
+ const [emailInputSet, setEmailInputSet] = useState(false);
764
+ const [emailValid, setEmailValid] = useState(true);
765
+ const [showEmailPanel, setShowEmailPanel] = useState(customerEmailCaptureMode !== 'HIDE');
766
+ const [emailClickedButNoEmail, setEmailClickedButNoEmail] = useState(false);
767
+
768
+ // Tool Approval state (for MCP tools)
769
+ const [pendingToolRequests, setPendingToolRequests] = useState<any[]>([]);
770
+ const [sessionApprovedTools, setSessionApprovedTools] = useState<string[]>([]);
771
+ const [alwaysApprovedTools, setAlwaysApprovedTools] = useState<string[]>([]);
772
+
773
+ // Email capture mode effect - like ChatPanel
774
+ useEffect(() => {
775
+ setShowEmailPanel(customerEmailCaptureMode !== 'HIDE');
776
+
777
+ if (customerEmailCaptureMode === 'REQUIRED') {
778
+ if (!isEmailAddress(emailInput)) {
779
+ setEmailValid(false);
780
+ }
781
+ }
782
+ }, [customerEmailCaptureMode, emailInput]);
688
783
 
689
784
  // Refs
690
785
  const bottomRef = useRef<HTMLDivElement | null>(null);
@@ -705,6 +800,8 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
705
800
  const latestHistoryRef = useRef<Record<string, HistoryEntry>>(initialHistory);
706
801
  // Track if we've sent the initial prompt (prevents loops)
707
802
  const initialPromptSentRef = useRef<boolean>(false);
803
+ // Track the last followOnPrompt to detect changes (for auto-submit trigger)
804
+ const lastFollowOnPromptRef = useRef<string>('');
708
805
 
709
806
  // Sync new entries from initialHistory into local history state
710
807
  // This allows parent components to inject messages (e.g., page-based agent suggestions)
@@ -752,6 +849,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
752
849
  lastCallId,
753
850
  stop,
754
851
  setResponse,
852
+ error: llmError,
755
853
  } = llmResult;
756
854
 
757
855
  // Tool-related properties (may not exist on all versions of useLLM)
@@ -778,9 +876,9 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
778
876
  // ============================================================================
779
877
  // Memoized Values
780
878
  // ============================================================================
781
- const prismStyle = useMemo(
782
- () => (theme === 'light' ? materialLight : materialDark),
783
- [theme]
879
+ const effectivePrismStyle = useMemo(
880
+ () => prismStyle ?? (theme === 'light' ? materialLight : materialDark),
881
+ [prismStyle, theme]
784
882
  );
785
883
 
786
884
  // Browser info for context (matches ChatPanel)
@@ -889,6 +987,215 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
889
987
  const currentAgentLabel = currentAgentInfo.label;
890
988
  const currentAgentAvatarUrl = currentAgentInfo.avatarUrl;
891
989
 
990
+ // ============================================================================
991
+ // Helper Functions
992
+ // ============================================================================
993
+
994
+ // Email validation helper
995
+ const isEmailAddress = (email: string): boolean => {
996
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
997
+ return emailRegex.test(email);
998
+ };
999
+
1000
+ // Convert conversation history to standalone HTML file
1001
+ // Convert markdown to HTML - like ChatPanel
1002
+ const convertMarkdownToHTML = (markdown: string): string => {
1003
+ const html = ReactDOMServer.renderToStaticMarkup(
1004
+ <div className={markdownClass}>
1005
+ <ReactMarkdown
1006
+ remarkPlugins={[remarkGfm]}
1007
+ rehypePlugins={[rehypeRaw]}
1008
+ >
1009
+ {markdown}
1010
+ </ReactMarkdown>
1011
+ </div>
1012
+ );
1013
+ return html;
1014
+ };
1015
+
1016
+ // Convert conversation history to HTML - like ChatPanel
1017
+ const convertHistoryToHTML = (history: Record<string, HistoryEntry>): string => {
1018
+ const stylesheet = `
1019
+ <style>
1020
+ .conversation-history {
1021
+ font-family: Arial, sans-serif;
1022
+ line-height: 1.5;
1023
+ }
1024
+ .history-entry {
1025
+ margin-bottom: 15px;
1026
+ }
1027
+ .prompt-container, .response-container {
1028
+ margin-bottom: 10px;
1029
+ }
1030
+ .prompt, .response {
1031
+ display: block;
1032
+ margin: 5px 0;
1033
+ padding: 10px;
1034
+ border-radius: 5px;
1035
+ max-width: 80%;
1036
+ }
1037
+ .prompt {
1038
+ background-color: #efefef;
1039
+ margin-left: 0;
1040
+ }
1041
+ .response {
1042
+ background-color: #f0fcfd;
1043
+ margin-left: 25px;
1044
+ }
1045
+ </style>
1046
+ `;
1047
+
1048
+ let html = `
1049
+ <html>
1050
+ <head>
1051
+ ${stylesheet}
1052
+ </head>
1053
+ <body>
1054
+ <h1>Conversation History (${new Date().toLocaleString()})</h1>
1055
+ <div class="conversation-history">
1056
+ `;
1057
+
1058
+ Object.entries(history).forEach(([prompt, response], index) => {
1059
+ if (hideInitialPrompt && index === 0) {
1060
+ html += `
1061
+ <div class="history-entry">
1062
+ <div class="response-container">
1063
+ <div class="response">${convertMarkdownToHTML(response.content)}</div>
1064
+ </div>
1065
+ </div>
1066
+ `;
1067
+ } else {
1068
+ html += `
1069
+ <div class="history-entry">
1070
+ <div class="prompt-container">
1071
+ <div class="prompt">${convertMarkdownToHTML(
1072
+ formatPromptForDisplay(prompt)
1073
+ )}</div>
1074
+ </div>
1075
+ <div class="response-container">
1076
+ <div class="response">${convertMarkdownToHTML(response.content)}</div>
1077
+ </div>
1078
+ </div>
1079
+ `;
1080
+ }
1081
+ });
1082
+
1083
+ html += `
1084
+ </div>
1085
+ </body>
1086
+ </html>
1087
+ `;
1088
+
1089
+ return html;
1090
+ };
1091
+
1092
+ // Save HTML to file - like ChatPanel
1093
+ const saveHTMLToFile = (html: string, filename: string) => {
1094
+ const blob = new Blob([html], { type: 'text/html' });
1095
+ const link = document.createElement('a');
1096
+ link.href = URL.createObjectURL(blob);
1097
+ link.download = filename;
1098
+ document.body.appendChild(link);
1099
+ link.click();
1100
+ document.body.removeChild(link);
1101
+ };
1102
+
1103
+ // Download conversation as HTML file
1104
+ const saveAsHTMLFile = useCallback(() => {
1105
+ saveHTMLToFile(
1106
+ convertHistoryToHTML(history),
1107
+ `conversation-${new Date().toISOString()}.html`
1108
+ );
1109
+ interactionClicked(lastCallId || '', 'save');
1110
+ }, [history, lastCallId]);
1111
+
1112
+ const handleSendEmail = (to: string, from: string) => {
1113
+ sendConversationsViaEmail(to, `Conversation History from ${title}`, from);
1114
+ interactionClicked(lastCallId || '', 'email', to);
1115
+ setEmailSent(true);
1116
+ };
1117
+
1118
+ const sendConversationsViaEmail = async (
1119
+ to: string,
1120
+ subject: string = `Conversation History from ${title}`,
1121
+ from: string = ''
1122
+ ) => {
1123
+ fetch(`${publicAPIUrl}/share/email`, {
1124
+ method: 'POST',
1125
+ headers: {
1126
+ 'Content-Type': 'application/json',
1127
+ },
1128
+ body: JSON.stringify({
1129
+ to: to,
1130
+ from: from,
1131
+ subject: subject,
1132
+ html: convertHistoryToHTML(history),
1133
+ project_id: project_id ?? '',
1134
+ customer: customer,
1135
+ history: history,
1136
+ title: title,
1137
+ }),
1138
+ });
1139
+
1140
+ await interactionClicked(lastCallId || '', 'email', from);
1141
+ };
1142
+
1143
+
1144
+ // Send CTA email
1145
+ const sendCallToActionEmail = useCallback(async (from: string) => {
1146
+ try {
1147
+ await fetch(`${publicAPIUrl}/share/email`, {
1148
+ method: 'POST',
1149
+ headers: {
1150
+ 'Content-Type': 'application/json',
1151
+ },
1152
+ body: JSON.stringify({
1153
+ to: callToActionEmailAddress,
1154
+ from: from,
1155
+ subject: `${callToActionEmailSubject} from ${from}`,
1156
+ html: convertHistoryToHTML(history),
1157
+ project_id: project_id ?? '',
1158
+ customer: customer,
1159
+ history: history,
1160
+ title: title,
1161
+ }),
1162
+ });
1163
+
1164
+ await interactionClicked(lastCallId || '', 'cta', from);
1165
+ setCallToActionSent(true);
1166
+ } catch (err) {
1167
+ console.error('[AIChatPanel] Failed to send CTA email:', err);
1168
+ }
1169
+ }, [history, title, project_id, customer, lastCallId, publicAPIUrl, callToActionEmailAddress, callToActionEmailSubject]);
1170
+
1171
+ // Check if button should be disabled due to email capture requirements
1172
+ const isDisabledDueToNoEmail = useCallback(() => {
1173
+ if (customerEmailCaptureMode === 'REQUIRED' && !emailInputSet) {
1174
+ return true;
1175
+ }
1176
+ return false;
1177
+ }, [customerEmailCaptureMode, emailInputSet]);
1178
+
1179
+ // Handle tool approval for MCP tools
1180
+ const handleToolApproval = useCallback((toolName: string, scope: 'once' | 'session' | 'always') => {
1181
+ if (scope === 'session' || scope === 'always') {
1182
+ setSessionApprovedTools((prev) => Array.from(new Set([...prev, toolName])));
1183
+ }
1184
+ if (scope === 'always') {
1185
+ setAlwaysApprovedTools((prev) => Array.from(new Set([...prev, toolName])));
1186
+ }
1187
+
1188
+ // Remove approved tool from pending list
1189
+ setPendingToolRequests((prev) => prev.filter((r) => r.toolName !== toolName));
1190
+
1191
+ console.log(`[AIChatPanel] Tool "${toolName}" approved with scope: ${scope}`);
1192
+ }, []);
1193
+
1194
+ // Get unique tool names from pending requests
1195
+ const getUniqueToolNames = useCallback(() => {
1196
+ return Array.from(new Set(pendingToolRequests.map((r) => r.toolName)));
1197
+ }, [pendingToolRequests]);
1198
+
892
1199
  // ============================================================================
893
1200
  // Callbacks
894
1201
  // ============================================================================
@@ -989,6 +1296,50 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
989
1296
  return displayPrompt;
990
1297
  }, [hideRagContextInPrompt]);
991
1298
 
1299
+ // Built-in interaction tracking - reports to LLMAsAService API
1300
+ const interactionClicked = useCallback(async (
1301
+ callId: string,
1302
+ action: string,
1303
+ emailaddress: string = "",
1304
+ comment: string = ""
1305
+ ) => {
1306
+ console.log(`[AIChatPanel] Interaction: ${action} for callId: ${callId}`);
1307
+
1308
+ // Ensure conversation exists
1309
+ const convId = currentConversation || await ensureConversation();
1310
+
1311
+ // Use the callId parameter or fallback to conversation ID
1312
+ const finalCallId = callId || convId;
1313
+
1314
+ // Get email from customer data
1315
+ const isEmailAddress = (str: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str);
1316
+ const email = emailaddress && emailaddress !== ""
1317
+ ? emailaddress
1318
+ : isEmailAddress(customer?.customer_user_email ?? "")
1319
+ ? customer?.customer_user_email
1320
+ : isEmailAddress(customer?.customer_id ?? "")
1321
+ ? customer?.customer_id
1322
+ : "";
1323
+
1324
+ // Send feedback to API
1325
+ try {
1326
+ await fetch(`${publicAPIUrl}/feedback/${finalCallId}/${action}`, {
1327
+ method: "POST",
1328
+ headers: {
1329
+ "Content-Type": "application/json",
1330
+ },
1331
+ body: JSON.stringify({
1332
+ project_id: project_id ?? "",
1333
+ conversation_id: convId ?? "",
1334
+ email: email,
1335
+ comment: comment,
1336
+ }),
1337
+ });
1338
+ } catch (err) {
1339
+ console.error('[AIChatPanel] Failed to send feedback:', err);
1340
+ }
1341
+ }, [currentConversation, ensureConversation, customer, project_id, publicAPIUrl]);
1342
+
992
1343
  // Copy to clipboard
993
1344
  const copyToClipboard = useCallback(async (text: string, callId: string) => {
994
1345
  try {
@@ -1003,10 +1354,46 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1003
1354
  await navigator.clipboard.writeText(cleanText);
1004
1355
  setCopiedCallId(callId);
1005
1356
  setTimeout(() => setCopiedCallId(null), 2000);
1357
+
1358
+ // Report to API (built-in)
1359
+ await interactionClicked(callId, "copy");
1006
1360
  } catch (err) {
1007
1361
  console.error('Failed to copy:', err);
1008
1362
  }
1009
- }, []);
1363
+ }, [interactionClicked]);
1364
+
1365
+ // Handle thumbs up/down with visual feedback
1366
+ const handleThumbsUp = useCallback(async (callId: string) => {
1367
+ console.log('[AIChatPanel] Thumbs up clicked:', callId);
1368
+
1369
+ // Show visual feedback
1370
+ setFeedbackCallId({ callId, type: 'up' });
1371
+ setTimeout(() => setFeedbackCallId(null), 2000);
1372
+
1373
+ // Report to API (built-in)
1374
+ await interactionClicked(callId, "thumbsup");
1375
+
1376
+ // Call external callback if provided
1377
+ if (thumbsUpClick) {
1378
+ thumbsUpClick(callId);
1379
+ }
1380
+ }, [thumbsUpClick, interactionClicked]);
1381
+
1382
+ const handleThumbsDown = useCallback(async (callId: string) => {
1383
+ console.log('[AIChatPanel] Thumbs down clicked:', callId);
1384
+
1385
+ // Show visual feedback
1386
+ setFeedbackCallId({ callId, type: 'down' });
1387
+ setTimeout(() => setFeedbackCallId(null), 2000);
1388
+
1389
+ // Report to API (built-in)
1390
+ await interactionClicked(callId, "thumbsdown");
1391
+
1392
+ // Call external callback if provided
1393
+ if (thumbsDownClick) {
1394
+ thumbsDownClick(callId);
1395
+ }
1396
+ }, [thumbsDownClick, interactionClicked]);
1010
1397
 
1011
1398
  // Scroll to bottom - throttled using RAF to prevent layout thrashing
1012
1399
  const scrollToBottom = useCallback(() => {
@@ -1039,6 +1426,9 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1039
1426
  setThinkingBlocks([]);
1040
1427
  setCurrentThinkingIndex(0);
1041
1428
 
1429
+ // Clear any previous errors
1430
+ setError(null);
1431
+
1042
1432
  // Reset scroll tracking for new message - enable auto-scroll
1043
1433
  setUserHasScrolled(false);
1044
1434
 
@@ -1125,11 +1515,6 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1125
1515
  fullPromptToSend = promptTemplate.replace('{{prompt}}', fullPromptToSend);
1126
1516
  }
1127
1517
 
1128
- // Add follow-on prompt
1129
- if (followOnPrompt) {
1130
- fullPromptToSend += `\n\n${followOnPrompt}`;
1131
- }
1132
-
1133
1518
  const newController = new AbortController();
1134
1519
  setLastController(newController);
1135
1520
 
@@ -1147,7 +1532,48 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1147
1532
  true, // includeHistory
1148
1533
  service, // group_id from agent config
1149
1534
  convId, // Use the conversation ID from ensureConversation
1150
- newController
1535
+ newController,
1536
+ undefined, // onComplete
1537
+ (errorMsg: string) => {
1538
+ // Error callback - handle errors immediately
1539
+ console.log('[AIChatPanel] Error callback triggered:', errorMsg);
1540
+
1541
+ // Detect 413 Content Too Large error
1542
+ if (errorMsg.includes('413') || errorMsg.toLowerCase().includes('content too large')) {
1543
+ setError({
1544
+ message: 'The context is too large to process. Please start a new conversation or reduce the amount of context.',
1545
+ code: '413',
1546
+ });
1547
+ }
1548
+ // Detect other network errors
1549
+ else if (errorMsg.toLowerCase().includes('network error') || errorMsg.toLowerCase().includes('fetch')) {
1550
+ setError({
1551
+ message: 'Network error. Please check your connection and try again.',
1552
+ code: 'NETWORK_ERROR',
1553
+ });
1554
+ }
1555
+ // Generic error
1556
+ else {
1557
+ setError({
1558
+ message: errorMsg,
1559
+ code: 'UNKNOWN_ERROR',
1560
+ });
1561
+ }
1562
+
1563
+ // Reset loading state
1564
+ setIsLoading(false);
1565
+
1566
+ // Update history to show error
1567
+ if (promptKey) {
1568
+ setHistory((prev) => ({
1569
+ ...prev,
1570
+ [promptKey]: {
1571
+ content: `Error: ${errorMsg}`,
1572
+ callId: lastCallId || '',
1573
+ },
1574
+ }));
1575
+ }
1576
+ }
1151
1577
  );
1152
1578
 
1153
1579
  setLastMessages(messagesAndHistory);
@@ -1172,7 +1598,6 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1172
1598
  clearFollowOnQuestionsNextPrompt,
1173
1599
  history,
1174
1600
  promptTemplate,
1175
- followOnPrompt,
1176
1601
  send,
1177
1602
  service,
1178
1603
  ensureConversation,
@@ -1221,6 +1646,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1221
1646
  setJustReset(true);
1222
1647
  setLastController(new AbortController());
1223
1648
  setUserHasScrolled(false);
1649
+ setError(null); // Clear any errors
1224
1650
 
1225
1651
  setTimeout(() => {
1226
1652
  setJustReset(false);
@@ -1271,6 +1697,9 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1271
1697
  if (wasStreaming && isNowIdle && !hasNotifiedCompletionRef.current) {
1272
1698
  hasNotifiedCompletionRef.current = true;
1273
1699
 
1700
+ // Reset loading state on completion
1701
+ setIsLoading(false);
1702
+
1274
1703
  // Get the latest values from refs (stable, not from closure)
1275
1704
  const currentHistory = latestHistoryRef.current;
1276
1705
  const currentLastKey = lastKeyRef.current;
@@ -1301,12 +1730,13 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1301
1730
  useEffect(() => {
1302
1731
  // Only auto-scroll if:
1303
1732
  // 1. We're actively streaming (!idle)
1304
- // 2. User hasn't manually scrolled up during this response
1733
+ // 2. User hasn't manually scrolled up during this response (or scrollToEnd prop is true)
1305
1734
  // 3. We have content to show (response exists)
1306
- if (!idle && !userHasScrolled && response) {
1735
+ const shouldAutoScroll = scrollToEnd || !userHasScrolled;
1736
+ if (!idle && shouldAutoScroll && response) {
1307
1737
  scrollToBottom();
1308
1738
  }
1309
- }, [response, scrollToBottom, idle, userHasScrolled]); // Removed history dependency
1739
+ }, [response, scrollToBottom, idle, userHasScrolled, scrollToEnd]); // Removed history dependency
1310
1740
 
1311
1741
  // Ref to track idle state for scroll handler (avoids stale closure)
1312
1742
  const idleRef = useRef(idle);
@@ -1402,6 +1832,105 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1402
1832
  }
1403
1833
  }, [initialPrompt, continueChat]);
1404
1834
 
1835
+ // Auto-send followOnPrompt when it changes (like ChatPanel)
1836
+ // This allows parent components to programmatically submit prompts to the current conversation
1837
+ useEffect(() => {
1838
+ if (followOnPrompt && followOnPrompt !== '' && followOnPrompt !== lastFollowOnPromptRef.current) {
1839
+ lastFollowOnPromptRef.current = followOnPrompt;
1840
+ continueChat(followOnPrompt);
1841
+ }
1842
+ }, [followOnPrompt, continueChat]);
1843
+
1844
+ // Monitor for errors from useLLM hook
1845
+ useEffect(() => {
1846
+ if (llmError && llmError.trim()) {
1847
+ console.log('[AIChatPanel] Error detected:', llmError);
1848
+
1849
+ // Parse error message to detect specific error types
1850
+ const errorMessage = llmError;
1851
+
1852
+ // Detect 413 Content Too Large error
1853
+ if (errorMessage.includes('413') || errorMessage.toLowerCase().includes('content too large')) {
1854
+ setError({
1855
+ message: 'The context is too large to process. Please start a new conversation or reduce the amount of context.',
1856
+ code: '413',
1857
+ });
1858
+ }
1859
+ // Detect other network errors
1860
+ else if (errorMessage.toLowerCase().includes('network error') || errorMessage.toLowerCase().includes('fetch')) {
1861
+ setError({
1862
+ message: 'Network error. Please check your connection and try again.',
1863
+ code: 'NETWORK_ERROR',
1864
+ });
1865
+ }
1866
+ // Generic error
1867
+ else {
1868
+ setError({
1869
+ message: errorMessage,
1870
+ code: 'UNKNOWN_ERROR',
1871
+ });
1872
+ }
1873
+
1874
+ // Reset loading state
1875
+ setIsLoading(false);
1876
+
1877
+ // Update history to show error
1878
+ if (lastKey) {
1879
+ setHistory((prev) => ({
1880
+ ...prev,
1881
+ [lastKey]: {
1882
+ content: `Error: ${errorMessage}`,
1883
+ callId: lastCallId || '',
1884
+ },
1885
+ }));
1886
+ }
1887
+ }
1888
+ }, [llmError, lastKey, lastCallId]);
1889
+
1890
+ // Dynamic CSS Injection
1891
+ useEffect(() => {
1892
+ // Clean up any previously added CSS from this component
1893
+ const existingLinks = document.querySelectorAll(
1894
+ 'link[data-source="ai-chat-panel"]'
1895
+ );
1896
+ existingLinks.forEach((link) => link.parentNode?.removeChild(link));
1897
+
1898
+ const existingStyles = document.querySelectorAll(
1899
+ 'style[data-source="ai-chat-panel"]'
1900
+ );
1901
+ existingStyles.forEach((style) => style.parentNode?.removeChild(style));
1902
+
1903
+ if (cssUrl) {
1904
+ if (cssUrl.startsWith('http://') || cssUrl.startsWith('https://')) {
1905
+ // If it's a URL, create a link element
1906
+ const link = document.createElement('link');
1907
+ link.href = cssUrl;
1908
+ link.rel = 'stylesheet';
1909
+ link.setAttribute('data-source', 'ai-chat-panel');
1910
+ document.head.appendChild(link);
1911
+ } else {
1912
+ // If it's a CSS string, create a style element
1913
+ const style = document.createElement('style');
1914
+ style.textContent = cssUrl;
1915
+ style.setAttribute('data-source', 'ai-chat-panel');
1916
+ document.head.appendChild(style);
1917
+ }
1918
+ }
1919
+
1920
+ // Clean up when component unmounts
1921
+ return () => {
1922
+ const links = document.querySelectorAll(
1923
+ 'link[data-source="ai-chat-panel"]'
1924
+ );
1925
+ links.forEach((link) => link.parentNode?.removeChild(link));
1926
+
1927
+ const styles = document.querySelectorAll(
1928
+ 'style[data-source="ai-chat-panel"]'
1929
+ );
1930
+ styles.forEach((style) => style.parentNode?.removeChild(style));
1931
+ };
1932
+ }, [cssUrl]);
1933
+
1405
1934
  // ============================================================================
1406
1935
  // Render Helpers
1407
1936
  // ============================================================================
@@ -1411,7 +1940,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1411
1940
  const match = /language-(\w+)/.exec(className || '');
1412
1941
  return !inline && match ? (
1413
1942
  <SyntaxHighlighter
1414
- style={prismStyle}
1943
+ style={effectivePrismStyle}
1415
1944
  language={match[1]}
1416
1945
  PreTag="div"
1417
1946
  {...props}
@@ -1423,14 +1952,23 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1423
1952
  {children}
1424
1953
  </code>
1425
1954
  );
1426
- }, [prismStyle]);
1955
+ }, [effectivePrismStyle]);
1427
1956
 
1428
1957
  // Agent suggestion card component for inline agent handoff
1429
- const AgentSuggestionCard = useCallback(({ agentId, agentName, reason }: {
1958
+ const AgentSuggestionCard = React.memo(({ agentId, agentName, reason }: {
1430
1959
  agentId: string;
1431
1960
  agentName: string;
1432
1961
  reason: string;
1433
1962
  }) => {
1963
+ // Auto-scroll when the agent suggestion card appears
1964
+ useEffect(() => {
1965
+ // Small delay to ensure the card is fully rendered in the DOM
1966
+ const timer = setTimeout(() => {
1967
+ scrollToBottom();
1968
+ }, 100);
1969
+ return () => clearTimeout(timer);
1970
+ }, []); // Empty deps - only run on mount
1971
+
1434
1972
  if (!agentId || !onAgentChange) return null;
1435
1973
 
1436
1974
  // Validate agent ID - must be a valid UUID or exist in agentOptions
@@ -1547,15 +2085,15 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1547
2085
  onAgentChange(agentId);
1548
2086
  // Scroll to bottom after a brief delay to let React re-render
1549
2087
  setTimeout(() => {
1550
- bottomRef.current?.scrollIntoView({ behavior: 'auto' });
1551
- }, 50);
2088
+ scrollToBottom();
2089
+ }, 100);
1552
2090
  }}
1553
2091
  >
1554
2092
  Switch
1555
2093
  </Button>
1556
2094
  </span>
1557
2095
  );
1558
- }, [onAgentChange, agentOptions, currentAgentId]);
2096
+ });
1559
2097
 
1560
2098
  // Markdown components including custom agent-suggestion element
1561
2099
  const markdownComponents = useMemo(() => ({
@@ -1623,19 +2161,63 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1623
2161
  const panelClasses = ['ai-chat-panel', theme === 'dark' ? 'dark-theme' : ''].filter(Boolean).join(' ');
1624
2162
 
1625
2163
  return (
1626
- <div className={panelClasses}>
2164
+ <div
2165
+ className={panelClasses}
2166
+ style={{
2167
+ ...(width && { width }),
2168
+ ...(height && { height })
2169
+ }}
2170
+ >
1627
2171
  {/* Title */}
1628
2172
  {title && <div className="ai-chat-panel__title">{title}</div>}
1629
2173
 
2174
+ {/* Error Banner */}
2175
+ {error && (
2176
+ <div className="ai-chat-error-banner">
2177
+ <div className="ai-chat-error-banner__icon">
2178
+ <AlertCircleIcon />
2179
+ </div>
2180
+ <div className="ai-chat-error-banner__content">
2181
+ <div className="ai-chat-error-banner__message">{error.message}</div>
2182
+ {error.code === '413' && (
2183
+ <div className="ai-chat-error-banner__hint">
2184
+ Try starting a new conversation or reducing the amount of information being sent.
2185
+ </div>
2186
+ )}
2187
+ </div>
2188
+ <button
2189
+ className="ai-chat-error-banner__close"
2190
+ onClick={() => setError(null)}
2191
+ aria-label="Dismiss error"
2192
+ >
2193
+ <CloseIcon />
2194
+ </button>
2195
+ </div>
2196
+ )}
2197
+
1630
2198
  {/* Messages Area */}
1631
2199
  <ScrollArea className="ai-chat-panel__messages" ref={responseAreaRef}>
1632
2200
  {/* Initial Message */}
1633
2201
  {initialMessage && (
1634
2202
  <div className="ai-chat-message ai-chat-message--assistant">
1635
2203
  <div className="ai-chat-message__content">
1636
- <ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
1637
- {initialMessage}
1638
- </ReactMarkdown>
2204
+ {markdownClass ? (
2205
+ <div className={markdownClass}>
2206
+ <ReactMarkdown
2207
+ remarkPlugins={[remarkGfm]}
2208
+ rehypePlugins={[rehypeRaw]}
2209
+ >
2210
+ {initialMessage}
2211
+ </ReactMarkdown>
2212
+ </div>
2213
+ ) : (
2214
+ <ReactMarkdown
2215
+ remarkPlugins={[remarkGfm]}
2216
+ rehypePlugins={[rehypeRaw]}
2217
+ >
2218
+ {initialMessage}
2219
+ </ReactMarkdown>
2220
+ )}
1639
2221
  </div>
1640
2222
  </div>
1641
2223
  )}
@@ -1670,13 +2252,25 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1670
2252
  {thinkingBlocks.length > 0 && renderThinkingBlocks()}
1671
2253
 
1672
2254
  {processedContent ? (
1673
- <ReactMarkdown
1674
- remarkPlugins={[remarkGfm]}
1675
- rehypePlugins={[rehypeRaw]}
1676
- components={markdownComponents}
1677
- >
1678
- {processedContent}
1679
- </ReactMarkdown>
2255
+ markdownClass ? (
2256
+ <div className={markdownClass}>
2257
+ <ReactMarkdown
2258
+ remarkPlugins={[remarkGfm]}
2259
+ rehypePlugins={[rehypeRaw]}
2260
+ components={markdownComponents}
2261
+ >
2262
+ {processedContent}
2263
+ </ReactMarkdown>
2264
+ </div>
2265
+ ) : (
2266
+ <ReactMarkdown
2267
+ remarkPlugins={[remarkGfm]}
2268
+ rehypePlugins={[rehypeRaw]}
2269
+ components={markdownComponents}
2270
+ >
2271
+ {processedContent}
2272
+ </ReactMarkdown>
2273
+ )
1680
2274
  ) : (
1681
2275
  <div className="ai-chat-loading">
1682
2276
  <span>Thinking</span>
@@ -1691,52 +2285,96 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1691
2285
  ) : (
1692
2286
  <>
1693
2287
  {isLastEntry && thinkingBlocks.length > 0 && renderThinkingBlocks()}
1694
- <ReactMarkdown
1695
- remarkPlugins={[remarkGfm]}
1696
- rehypePlugins={[rehypeRaw]}
1697
- components={markdownComponents}
1698
- >
1699
- {processedContent}
1700
- </ReactMarkdown>
2288
+ {markdownClass ? (
2289
+ <div className={markdownClass}>
2290
+ <ReactMarkdown
2291
+ remarkPlugins={[remarkGfm]}
2292
+ rehypePlugins={[rehypeRaw]}
2293
+ components={markdownComponents}
2294
+ >
2295
+ {processedContent}
2296
+ </ReactMarkdown>
2297
+ </div>
2298
+ ) : (
2299
+ <ReactMarkdown
2300
+ remarkPlugins={[remarkGfm]}
2301
+ rehypePlugins={[rehypeRaw]}
2302
+ components={markdownComponents}
2303
+ >
2304
+ {processedContent}
2305
+ </ReactMarkdown>
2306
+ )}
1701
2307
  </>
1702
2308
  )}
1703
2309
  </div>
1704
2310
 
1705
2311
  {/* Action Buttons */}
1706
- {idle && !isLoading && (
2312
+ {(!isLastEntry || !isLoading) && (
1707
2313
  <div className="ai-chat-message__actions">
1708
- <Tooltip content={copiedCallId === entry.callId ? 'Copied!' : 'Copy'}>
1709
- <Button
1710
- variant="ghost"
1711
- size="icon"
1712
- onClick={() => copyToClipboard(entry.content, entry.callId)}
1713
- >
1714
- <CopyIcon />
1715
- </Button>
1716
- </Tooltip>
2314
+ <button
2315
+ className="ai-chat-action-button"
2316
+ onClick={() => copyToClipboard(entry.content, entry.callId)}
2317
+ title={copiedCallId === entry.callId ? 'Copied!' : 'Copy'}
2318
+ >
2319
+ {copiedCallId === entry.callId ? (
2320
+ <span style={{ fontSize: '11px', fontWeight: 500 }}>Copied!</span>
2321
+ ) : (
2322
+ <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">
2323
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
2324
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
2325
+ </svg>
2326
+ )}
2327
+ </button>
1717
2328
 
1718
- {thumbsUpClick && (
1719
- <Tooltip content="Good response">
1720
- <Button
1721
- variant="ghost"
1722
- size="icon"
1723
- onClick={() => thumbsUpClick(entry.callId)}
1724
- >
1725
- <ThumbsUpIcon />
1726
- </Button>
1727
- </Tooltip>
1728
- )}
2329
+ <button
2330
+ className="ai-chat-action-button"
2331
+ onClick={() => handleThumbsUp(entry.callId)}
2332
+ title={feedbackCallId?.callId === entry.callId && feedbackCallId?.type === 'up' ? 'Thanks!' : 'Good response'}
2333
+ style={feedbackCallId?.callId === entry.callId && feedbackCallId?.type === 'up' ? { color: '#22c55e' } : undefined}
2334
+ >
2335
+ {feedbackCallId?.callId === entry.callId && feedbackCallId?.type === 'up' ? (
2336
+ <span style={{ fontSize: '11px', fontWeight: 500 }}>Thanks!</span>
2337
+ ) : (
2338
+ <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">
2339
+ <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" />
2340
+ </svg>
2341
+ )}
2342
+ </button>
1729
2343
 
1730
- {thumbsDownClick && (
1731
- <Tooltip content="Poor response">
1732
- <Button
1733
- variant="ghost"
1734
- size="icon"
1735
- onClick={() => thumbsDownClick(entry.callId)}
1736
- >
1737
- <ThumbsDownIcon />
1738
- </Button>
1739
- </Tooltip>
2344
+ <button
2345
+ className="ai-chat-action-button"
2346
+ onClick={() => handleThumbsDown(entry.callId)}
2347
+ title={feedbackCallId?.callId === entry.callId && feedbackCallId?.type === 'down' ? 'Thanks!' : 'Poor response'}
2348
+ style={feedbackCallId?.callId === entry.callId && feedbackCallId?.type === 'down' ? { color: '#ef4444' } : undefined}
2349
+ >
2350
+ {feedbackCallId?.callId === entry.callId && feedbackCallId?.type === 'down' ? (
2351
+ <span style={{ fontSize: '11px', fontWeight: 500 }}>Thanks!</span>
2352
+ ) : (
2353
+ <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">
2354
+ <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" />
2355
+ </svg>
2356
+ )}
2357
+ </button>
2358
+
2359
+ {/* Tool Info Button - show if entry has tool data */}
2360
+ {(entry.toolCalls || entry.toolResponses) && (
2361
+ <button
2362
+ className="ai-chat-action-button"
2363
+ onClick={() => {
2364
+ setToolInfoData({
2365
+ calls: entry.toolCalls || [],
2366
+ responses: entry.toolResponses || [],
2367
+ });
2368
+ setIsToolInfoModalOpen(true);
2369
+ }}
2370
+ title="View tool information"
2371
+ >
2372
+ <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">
2373
+ <circle cx="12" cy="12" r="10" />
2374
+ <line x1="12" x2="12" y1="16" y2="12" />
2375
+ <line x1="12" x2="12.01" y1="8" y2="8" />
2376
+ </svg>
2377
+ </button>
1740
2378
  )}
1741
2379
  </div>
1742
2380
  )}
@@ -1765,6 +2403,109 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1765
2403
  <div ref={bottomRef} />
1766
2404
  </ScrollArea>
1767
2405
 
2406
+ {/* Tool Approval Panel */}
2407
+ {pendingToolRequests.length > 0 && (
2408
+ <div className="ai-chat-approve-tools-panel">
2409
+ <div className="ai-chat-approve-tools-header">
2410
+ Tool Approval Required
2411
+ </div>
2412
+ <div className="ai-chat-approve-tools-description">
2413
+ The AI wants to use the following tools:
2414
+ </div>
2415
+ {getUniqueToolNames().map((toolName) => (
2416
+ <div key={toolName} className="ai-chat-approve-tool-item">
2417
+ <div className="ai-chat-approve-tool-name">{toolName}</div>
2418
+ <div className="ai-chat-approve-tools-buttons">
2419
+ <Button
2420
+ size="sm"
2421
+ variant="outline"
2422
+ className="ai-chat-approve-tools-button"
2423
+ onClick={() => handleToolApproval(toolName, 'once')}
2424
+ >
2425
+ Once
2426
+ </Button>
2427
+ <Button
2428
+ size="sm"
2429
+ variant="outline"
2430
+ className="ai-chat-approve-tools-button"
2431
+ onClick={() => handleToolApproval(toolName, 'session')}
2432
+ >
2433
+ This Session
2434
+ </Button>
2435
+ <Button
2436
+ size="sm"
2437
+ variant="default"
2438
+ className="ai-chat-approve-tools-button"
2439
+ onClick={() => handleToolApproval(toolName, 'always')}
2440
+ >
2441
+ Always
2442
+ </Button>
2443
+ </div>
2444
+ </div>
2445
+ ))}
2446
+ </div>
2447
+ )}
2448
+
2449
+ {/* Button Container - Save, Email, CTA */}
2450
+ {(showSaveButton || showEmailButton || showCallToAction) && (
2451
+ <div className="ai-chat-button-container">
2452
+ {showSaveButton && (
2453
+ <Button
2454
+ variant="outline"
2455
+ size="sm"
2456
+ onClick={saveAsHTMLFile}
2457
+ disabled={Object.keys(history).length === 0}
2458
+ className="ai-chat-save-button"
2459
+ >
2460
+ 💾 Save
2461
+ </Button>
2462
+ )}
2463
+
2464
+ {showEmailButton && (
2465
+ <Button
2466
+ variant="outline"
2467
+ size="sm"
2468
+ onClick={() => {
2469
+ if (isEmailAddress(emailInput)) {
2470
+ setEmailInputSet(true);
2471
+ setEmailValid(true);
2472
+ handleSendEmail(emailInput, emailInput);
2473
+ setEmailClickedButNoEmail(false);
2474
+ } else {
2475
+ setShowEmailPanel(true);
2476
+ setEmailValid(false);
2477
+ setEmailClickedButNoEmail(true);
2478
+ }
2479
+ }}
2480
+ disabled={Object.keys(history).length === 0 || isDisabledDueToNoEmail()}
2481
+ className="ai-chat-email-button"
2482
+ >
2483
+ 📧 Email Conversation{emailSent ? ' ✓' : ''}
2484
+ </Button>
2485
+ )}
2486
+
2487
+ {showCallToAction && (
2488
+ <Button
2489
+ variant={callToActionSent ? 'outline' : 'default'}
2490
+ size="sm"
2491
+ onClick={() => {
2492
+ if (customerEmailCaptureMode !== 'HIDE' && !emailInputSet) {
2493
+ setCTAClickedButNoEmail(true);
2494
+ setTimeout(() => setCTAClickedButNoEmail(false), 3000);
2495
+ return;
2496
+ }
2497
+ const fromEmail = emailInput || customer?.customer_user_email || '';
2498
+ sendCallToActionEmail(fromEmail);
2499
+ }}
2500
+ disabled={callToActionSent || Object.keys(history).length === 0}
2501
+ className="ai-chat-cta-button"
2502
+ >
2503
+ {callToActionSent ? '✓ Submitted' : callToActionButtonText}
2504
+ </Button>
2505
+ )}
2506
+ </div>
2507
+ )}
2508
+
1768
2509
  {/* New Conversation Button */}
1769
2510
  {showNewConversationButton && (
1770
2511
  <div className="ai-chat-panel__new-conversation">
@@ -1779,6 +2520,103 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1779
2520
  </div>
1780
2521
  )}
1781
2522
 
2523
+ {/* Customer Email Capture Panel */}
2524
+ {showEmailPanel && (
2525
+ <>
2526
+ {!emailValid && (
2527
+ <div className="ai-chat-email-input-message">
2528
+ {isDisabledDueToNoEmail()
2529
+ ? "Let's get started - please enter your email"
2530
+ : CTAClickedButNoEmail || emailClickedButNoEmail
2531
+ ? 'Sure, we just need an email address to contact you'
2532
+ : 'Email address is invalid'}
2533
+ </div>
2534
+ )}
2535
+ <div className="ai-chat-email-input-container">
2536
+ <input
2537
+ type="email"
2538
+ name="email"
2539
+ id="email"
2540
+ className={
2541
+ emailValid
2542
+ ? emailInputSet
2543
+ ? 'ai-chat-email-input-set'
2544
+ : 'ai-chat-email-input'
2545
+ : 'ai-chat-email-input-invalid'
2546
+ }
2547
+ placeholder={customerEmailCapturePlaceholder}
2548
+ value={emailInput}
2549
+ onChange={(e) => {
2550
+ const newEmail = e.target.value;
2551
+ setEmailInput(newEmail);
2552
+ // Reset validation while typing
2553
+ if (!emailInputSet) {
2554
+ if (customerEmailCaptureMode === 'REQUIRED' && newEmail !== '') {
2555
+ setEmailValid(isEmailAddress(newEmail));
2556
+ } else {
2557
+ setEmailValid(true);
2558
+ }
2559
+ }
2560
+ }}
2561
+ onBlur={() => {
2562
+ // Auto-validate and set email when field loses focus
2563
+ if (emailInput && isEmailAddress(emailInput) && !emailInputSet) {
2564
+ setEmailInputSet(true);
2565
+ setEmailValid(true);
2566
+ interactionClicked('', 'emailcapture', emailInput);
2567
+
2568
+ // Handle pending actions
2569
+ if (CTAClickedButNoEmail) {
2570
+ sendCallToActionEmail(emailInput);
2571
+ setCTAClickedButNoEmail(false);
2572
+ }
2573
+ if (emailClickedButNoEmail) {
2574
+ handleSendEmail(emailInput, emailInput);
2575
+ setEmailClickedButNoEmail(false);
2576
+ }
2577
+ } else if (customerEmailCaptureMode === 'REQUIRED' && emailInput !== '') {
2578
+ setEmailValid(isEmailAddress(emailInput));
2579
+ }
2580
+ }}
2581
+ onKeyDown={(e) => {
2582
+ if (e.key === 'Enter') {
2583
+ if (isEmailAddress(emailInput)) {
2584
+ setEmailInputSet(true);
2585
+ setEmailValid(true);
2586
+ interactionClicked('', 'emailcapture', emailInput);
2587
+
2588
+ // Handle pending actions
2589
+ if (CTAClickedButNoEmail) {
2590
+ sendCallToActionEmail(emailInput);
2591
+ setCTAClickedButNoEmail(false);
2592
+ }
2593
+ if (emailClickedButNoEmail) {
2594
+ handleSendEmail(emailInput, emailInput);
2595
+ setEmailClickedButNoEmail(false);
2596
+ }
2597
+ } else {
2598
+ setEmailValid(false);
2599
+ }
2600
+ }
2601
+ }}
2602
+ disabled={false}
2603
+ />
2604
+ {emailInputSet && (
2605
+ <button
2606
+ className="ai-chat-email-edit-button"
2607
+ onClick={() => {
2608
+ setEmailInputSet(false);
2609
+ setEmailValid(true);
2610
+ }}
2611
+ title="Edit email"
2612
+ >
2613
+
2614
+ </button>
2615
+ )}
2616
+ </div>
2617
+ </>
2618
+ )}
2619
+
1782
2620
  {/* Input Area - Isolated component for performance */}
1783
2621
  <ChatInput
1784
2622
  placeholder={placeholder}
@@ -1828,6 +2666,13 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1828
2666
  </div>
1829
2667
  </div>
1830
2668
  )}
2669
+
2670
+ {/* Modals */}
2671
+ <ToolInfoModal
2672
+ isOpen={isToolInfoModalOpen}
2673
+ onClose={() => setIsToolInfoModalOpen(false)}
2674
+ data={toolInfoData}
2675
+ />
1831
2676
  </div>
1832
2677
  );
1833
2678
  };