@mobileai/react-native 0.9.18 → 0.9.19

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.
Files changed (80) hide show
  1. package/LICENSE +28 -20
  2. package/MobileAIFloatingOverlay.podspec +25 -0
  3. package/android/build.gradle +61 -0
  4. package/android/src/main/AndroidManifest.xml +3 -0
  5. package/android/src/main/java/com/mobileai/overlay/FloatingOverlayView.kt +151 -0
  6. package/android/src/main/java/com/mobileai/overlay/MobileAIOverlayPackage.kt +23 -0
  7. package/android/src/newarch/com/mobileai/overlay/FloatingOverlayViewManager.kt +45 -0
  8. package/android/src/oldarch/com/mobileai/overlay/FloatingOverlayViewManager.kt +29 -0
  9. package/ios/MobileAIFloatingOverlayComponentView.mm +73 -0
  10. package/lib/module/components/AIAgent.js +902 -136
  11. package/lib/module/components/AIConsentDialog.js +439 -0
  12. package/lib/module/components/AgentChatBar.js +828 -134
  13. package/lib/module/components/AgentOverlay.js +2 -1
  14. package/lib/module/components/DiscoveryTooltip.js +21 -9
  15. package/lib/module/components/FloatingOverlayWrapper.js +108 -0
  16. package/lib/module/components/Icons.js +123 -0
  17. package/lib/module/config/endpoints.js +12 -2
  18. package/lib/module/core/AgentRuntime.js +373 -27
  19. package/lib/module/core/FiberAdapter.js +56 -0
  20. package/lib/module/core/FiberTreeWalker.js +186 -80
  21. package/lib/module/core/IdleDetector.js +19 -0
  22. package/lib/module/core/NativeAlertInterceptor.js +191 -0
  23. package/lib/module/core/systemPrompt.js +203 -45
  24. package/lib/module/index.js +3 -0
  25. package/lib/module/providers/GeminiProvider.js +72 -56
  26. package/lib/module/providers/ProviderFactory.js +6 -2
  27. package/lib/module/services/AudioInputService.js +3 -12
  28. package/lib/module/services/AudioOutputService.js +1 -13
  29. package/lib/module/services/ConversationService.js +166 -0
  30. package/lib/module/services/MobileAIKnowledgeRetriever.js +41 -0
  31. package/lib/module/services/VoiceService.js +29 -8
  32. package/lib/module/services/telemetry/MobileAI.js +44 -0
  33. package/lib/module/services/telemetry/TelemetryService.js +13 -1
  34. package/lib/module/services/telemetry/TouchAutoCapture.js +44 -18
  35. package/lib/module/specs/FloatingOverlayNativeComponent.ts +19 -0
  36. package/lib/module/support/CSATSurvey.js +95 -12
  37. package/lib/module/support/EscalationSocket.js +70 -1
  38. package/lib/module/support/ReportedIssueEventSource.js +148 -0
  39. package/lib/module/support/escalateTool.js +4 -2
  40. package/lib/module/support/index.js +1 -0
  41. package/lib/module/support/reportIssueTool.js +127 -0
  42. package/lib/module/support/supportPrompt.js +77 -9
  43. package/lib/module/tools/guideTool.js +2 -1
  44. package/lib/module/tools/longPressTool.js +4 -3
  45. package/lib/module/tools/pickerTool.js +6 -4
  46. package/lib/module/tools/tapTool.js +12 -3
  47. package/lib/module/tools/typeTool.js +19 -10
  48. package/lib/module/utils/logger.js +175 -6
  49. package/lib/typescript/react-native.config.d.ts +11 -0
  50. package/lib/typescript/src/components/AIAgent.d.ts +28 -2
  51. package/lib/typescript/src/components/AIConsentDialog.d.ts +153 -0
  52. package/lib/typescript/src/components/AgentChatBar.d.ts +15 -2
  53. package/lib/typescript/src/components/DiscoveryTooltip.d.ts +3 -1
  54. package/lib/typescript/src/components/FloatingOverlayWrapper.d.ts +51 -0
  55. package/lib/typescript/src/components/Icons.d.ts +8 -0
  56. package/lib/typescript/src/config/endpoints.d.ts +5 -3
  57. package/lib/typescript/src/core/AgentRuntime.d.ts +4 -0
  58. package/lib/typescript/src/core/FiberAdapter.d.ts +25 -0
  59. package/lib/typescript/src/core/FiberTreeWalker.d.ts +2 -0
  60. package/lib/typescript/src/core/IdleDetector.d.ts +11 -0
  61. package/lib/typescript/src/core/NativeAlertInterceptor.d.ts +55 -0
  62. package/lib/typescript/src/core/types.d.ts +106 -1
  63. package/lib/typescript/src/index.d.ts +9 -4
  64. package/lib/typescript/src/providers/GeminiProvider.d.ts +6 -5
  65. package/lib/typescript/src/services/ConversationService.d.ts +55 -0
  66. package/lib/typescript/src/services/MobileAIKnowledgeRetriever.d.ts +9 -0
  67. package/lib/typescript/src/services/telemetry/MobileAI.d.ts +7 -0
  68. package/lib/typescript/src/services/telemetry/TelemetryService.d.ts +1 -1
  69. package/lib/typescript/src/services/telemetry/TouchAutoCapture.d.ts +9 -6
  70. package/lib/typescript/src/services/telemetry/types.d.ts +3 -1
  71. package/lib/typescript/src/specs/FloatingOverlayNativeComponent.d.ts +17 -0
  72. package/lib/typescript/src/support/EscalationSocket.d.ts +17 -0
  73. package/lib/typescript/src/support/ReportedIssueEventSource.d.ts +24 -0
  74. package/lib/typescript/src/support/escalateTool.d.ts +5 -0
  75. package/lib/typescript/src/support/index.d.ts +2 -1
  76. package/lib/typescript/src/support/reportIssueTool.d.ts +20 -0
  77. package/lib/typescript/src/support/types.d.ts +56 -1
  78. package/lib/typescript/src/utils/logger.d.ts +15 -0
  79. package/package.json +19 -5
  80. package/react-native.config.js +12 -0
@@ -17,6 +17,8 @@ import { createProvider } from "../providers/ProviderFactory.js";
17
17
  import { AgentContext } from "../hooks/useAction.js";
18
18
  import { AgentChatBar } from "./AgentChatBar.js";
19
19
  import { AgentOverlay } from "./AgentOverlay.js";
20
+ import { AIConsentDialog, useAIConsent } from "./AIConsentDialog.js";
21
+ import { FloatingOverlayWrapper } from "./FloatingOverlayWrapper.js";
20
22
  import { logger } from "../utils/logger.js";
21
23
  import { buildVoiceSystemPrompt } from "../core/systemPrompt.js";
22
24
  import { MCPBridge } from "../core/MCPBridge.js";
@@ -31,10 +33,14 @@ import { HighlightOverlay } from "./HighlightOverlay.js";
31
33
  import { IdleDetector } from "../core/IdleDetector.js";
32
34
  import { ProactiveHint } from "./ProactiveHint.js";
33
35
  import { createEscalateTool } from "../support/escalateTool.js";
36
+ import { createReportIssueTool } from "../support/reportIssueTool.js";
34
37
  import { EscalationSocket } from "../support/EscalationSocket.js";
35
38
  import { EscalationEventSource } from "../support/EscalationEventSource.js";
39
+ import { ReportedIssueEventSource } from "../support/ReportedIssueEventSource.js";
36
40
  import { SupportChatModal } from "../support/SupportChatModal.js";
37
41
  import { ENDPOINTS } from "../config/endpoints.js";
42
+ import * as ConversationService from "../services/ConversationService.js";
43
+ import { createMobileAIKnowledgeRetriever } from "../services/MobileAIKnowledgeRetriever.js";
38
44
 
39
45
  // ─── Context ───────────────────────────────────────────────────
40
46
 
@@ -114,13 +120,32 @@ export function AIAgent({
114
120
  pushToken,
115
121
  pushTokenType,
116
122
  interactionMode,
117
- showDiscoveryTooltip: showDiscoveryTooltipProp = true
123
+ showDiscoveryTooltip: showDiscoveryTooltipProp = true,
124
+ discoveryTooltipMessage,
125
+ customerSuccess,
126
+ onboarding,
127
+ consent
118
128
  }) {
129
+ // Consent is ALWAYS required by default — only disabled if explicitly set to false.
130
+ // No consent prop at all → gate is active with default dialog config.
131
+ const consentRequired = consent?.required !== false;
132
+ const consentConfig = consent ?? {
133
+ required: true,
134
+ persist: false
135
+ };
136
+
137
+ // ─── AI Consent State (Apple Guideline 5.1.2(i)) ─────────────
138
+ const [hasConsented, grantConsent,, isConsentLoading] = useAIConsent(consentConfig.persist);
139
+ const [showConsentDialog, setShowConsentDialog] = useState(false);
140
+ const pendingConsentSendRef = useRef(null);
141
+ const pendingFollowUpAfterApprovalRef = useRef(null);
142
+ const consentGateActive = consentRequired && !hasConsented && !isConsentLoading;
119
143
  // Configure logger based on debug prop
120
144
  React.useEffect(() => {
121
145
  logger.setEnabled(debug);
122
146
  if (debug) {
123
147
  logger.info('AIAgent', '🔧 Debug logging enabled');
148
+ logger.info('AIAgent', `⚙️ Initial config: interactionMode=${interactionMode || 'copilot(default)'} enableVoice=${enableVoice} useScreenMap=${useScreenMap} analytics=${!!analyticsKey}`);
124
149
  }
125
150
  }, [debug]);
126
151
  const rootViewRef = useRef(null);
@@ -129,7 +154,22 @@ export function AIAgent({
129
154
  const [lastResult, setLastResult] = useState(null);
130
155
  const [lastUserMessage, setLastUserMessage] = useState(null);
131
156
  const [messages, setMessages] = useState([]);
157
+ const [supportMessages, setSupportMessages] = useState([]);
132
158
  const [chatScrollTrigger, setChatScrollTrigger] = useState(0);
159
+ // Mirror of messages for safe reading inside async callbacks (avoids setMessages abuse)
160
+ const messagesRef = useRef([]);
161
+
162
+ // ── Conversation History State ────────────────────────────────
163
+ const [conversations, setConversations] = useState([]);
164
+ const [isLoadingHistory, setIsLoadingHistory] = useState(false);
165
+ const activeConversationIdRef = useRef(null);
166
+ const lastSavedMessageCountRef = useRef(0);
167
+ const appendDebounceRef = useRef(null);
168
+
169
+ // Keep messagesRef always in sync — used by async save callbacks
170
+ useEffect(() => {
171
+ messagesRef.current = messages;
172
+ }, [messages]);
133
173
 
134
174
  // Increment scroll trigger when messages change to auto-scroll chat modal
135
175
  useEffect(() => {
@@ -145,6 +185,7 @@ export function AIAgent({
145
185
  const [isLiveAgentTyping, setIsLiveAgentTyping] = useState(false);
146
186
  const [autoExpandTrigger, setAutoExpandTrigger] = useState(0);
147
187
  const [unreadCounts, setUnreadCounts] = useState({});
188
+ const seenReportedIssueUpdatesRef = useRef(new Set());
148
189
  // Ref mirrors selectedTicketId — lets socket callbacks access current value
149
190
  // without stale closures (sockets are long-lived, closures capture old state).
150
191
  const selectedTicketIdRef = useRef(null);
@@ -157,6 +198,48 @@ export function AIAgent({
157
198
  // SSE connections per ticket — reliable fallback for ticket_closed events
158
199
  // when the WebSocket is disconnected. EventSource auto-reconnects.
159
200
  const sseRef = useRef(new Map());
201
+ const reportedIssuesSSERef = useRef(null);
202
+ const agentFrtFiredRef = useRef(false);
203
+ const humanFrtFiredRef = useRef({});
204
+
205
+ // ── Onboarding Journey State ────────────────────────────────
206
+ const [isOnboardingActive, setIsOnboardingActive] = useState(false);
207
+ const [currentOnboardingIndex, setCurrentOnboardingIndex] = useState(0);
208
+ useEffect(() => {
209
+ if (!onboarding?.enabled) return;
210
+ if (onboarding.firstLaunchOnly !== false) {
211
+ void (async () => {
212
+ try {
213
+ const AS = getTooltipStorage();
214
+ if (!AS) {
215
+ setIsOnboardingActive(true);
216
+ return;
217
+ }
218
+ const completed = await AS.getItem('@mobileai_onboarding_completed');
219
+ if (!completed) setIsOnboardingActive(true);
220
+ } catch {
221
+ setIsOnboardingActive(true);
222
+ }
223
+ })();
224
+ } else {
225
+ setIsOnboardingActive(true);
226
+ }
227
+ }, [onboarding?.enabled, onboarding?.firstLaunchOnly]);
228
+ const advanceOnboarding = useCallback(() => {
229
+ if (!onboarding?.steps) return;
230
+ if (currentOnboardingIndex >= onboarding.steps.length - 1) {
231
+ setIsOnboardingActive(false);
232
+ onboarding.onComplete?.();
233
+ void (async () => {
234
+ try {
235
+ const AS = getTooltipStorage();
236
+ await AS?.setItem('@mobileai_onboarding_completed', 'true');
237
+ } catch {/* graceful */}
238
+ })();
239
+ } else {
240
+ setCurrentOnboardingIndex(prev => prev + 1);
241
+ }
242
+ }, [onboarding, currentOnboardingIndex]);
160
243
  const totalUnread = Object.values(unreadCounts).reduce((sum, count) => sum + count, 0);
161
244
 
162
245
  // ── Discovery Tooltip (one-time) ──────────────────────────
@@ -277,6 +360,31 @@ export function AIAgent({
277
360
  setMessages([]);
278
361
  setLastResult(null);
279
362
  }, []);
363
+ const applyReportedIssueUpdates = useCallback((nextIssues, options) => {
364
+ const replayToChat = options?.replayToChat ?? true;
365
+ nextIssues.forEach(issue => {
366
+ const history = Array.isArray(issue.statusHistory) ? issue.statusHistory : [];
367
+ history.forEach(entry => {
368
+ if (!entry || typeof entry !== 'object') return;
369
+ const id = typeof entry.id === 'string' ? entry.id : null;
370
+ const message = typeof entry.message === 'string' ? entry.message : null;
371
+ if (!id || !message) return;
372
+ if (seenReportedIssueUpdatesRef.current.has(id)) return;
373
+ seenReportedIssueUpdatesRef.current.add(id);
374
+ if (!replayToChat) return;
375
+ const entryTimestamp = typeof entry.timestamp === 'string' ? new Date(entry.timestamp).getTime() : NaN;
376
+ setMessages(prev => {
377
+ if (prev.some(msg => msg.id === `reported-${id}`)) return prev;
378
+ return [...prev, {
379
+ id: `reported-${id}`,
380
+ role: 'assistant',
381
+ content: message,
382
+ timestamp: Number.isFinite(entryTimestamp) ? entryTimestamp : Date.now()
383
+ }];
384
+ });
385
+ });
386
+ });
387
+ }, []);
280
388
  const getResolvedScreenName = useCallback(() => {
281
389
  const routeName = navRef?.getCurrentRoute?.()?.name;
282
390
  if (typeof routeName === 'string' && routeName.trim().length > 0) {
@@ -288,6 +396,15 @@ export function AIAgent({
288
396
  }
289
397
  return 'unknown';
290
398
  }, [navRef]);
399
+ const resolvedKnowledgeBase = useMemo(() => {
400
+ if (knowledgeBase) return knowledgeBase;
401
+ if (!analyticsKey) return undefined;
402
+ return createMobileAIKnowledgeRetriever({
403
+ publishableKey: analyticsKey,
404
+ baseUrl: analyticsProxyUrl ?? ENDPOINTS.escalation,
405
+ headers: analyticsProxyHeaders
406
+ });
407
+ }, [analyticsKey, analyticsProxyHeaders, analyticsProxyUrl, knowledgeBase]);
291
408
 
292
409
  // ─── Auto-create MobileAI escalation tool ─────────────────────
293
410
  // When analyticsKey is present and consumer hasn't provided their own
@@ -310,6 +427,19 @@ export function AIAgent({
310
427
  role: m.role,
311
428
  content: m.content
312
429
  })),
430
+ getToolCalls: () => {
431
+ const toolCalls = [];
432
+ messages.forEach(m => {
433
+ if (m.result?.steps) {
434
+ m.result.steps.forEach(step => {
435
+ if (step.action && step.action.name !== 'done' && step.action.name !== 'agent_step' && step.action.name !== 'escalate_to_human') {
436
+ toolCalls.push(step.action);
437
+ }
438
+ });
439
+ }
440
+ });
441
+ return toolCalls;
442
+ },
313
443
  getScreenFlow: () => telemetryRef.current?.getScreenFlow() ?? [],
314
444
  userContext,
315
445
  pushToken,
@@ -372,6 +502,13 @@ export function AIAgent({
372
502
  },
373
503
  onHumanReply: (reply, ticketId) => {
374
504
  if (ticketId) {
505
+ if (!humanFrtFiredRef.current[ticketId]) {
506
+ humanFrtFiredRef.current[ticketId] = true;
507
+ telemetryRef.current?.track('human_first_response', {
508
+ ticketId
509
+ });
510
+ }
511
+
375
512
  // Always update the ticket's history (source of truth for ticket cards)
376
513
  setTickets(prev => prev.map(t => {
377
514
  if (t.id !== ticketId) return t;
@@ -393,7 +530,7 @@ export function AIAgent({
393
530
  content: reply,
394
531
  timestamp: Date.now()
395
532
  };
396
- setMessages(prev => [...prev, humanMsg]);
533
+ setSupportMessages(prev => [...prev, humanMsg]);
397
534
  setLastResult({
398
535
  success: true,
399
536
  message: `👤 ${reply}`,
@@ -427,6 +564,43 @@ export function AIAgent({
427
564
  });
428
565
  // eslint-disable-next-line react-hooks/exhaustive-deps
429
566
  }, [analyticsKey, customTools, getResolvedScreenName, navRef, openSSE, userContext, pushToken, pushTokenType, messages, clearSupport]);
567
+ const autoReportIssueTool = useMemo(() => {
568
+ if (!analyticsKey) return null;
569
+ if (customTools?.['report_issue']) return null;
570
+ return createReportIssueTool({
571
+ analyticsKey,
572
+ getCurrentScreen: getResolvedScreenName,
573
+ getHistory: () => messages.map(m => ({
574
+ role: m.role,
575
+ content: m.content
576
+ })),
577
+ getScreenFlow: () => telemetryRef.current?.getScreenFlow() ?? [],
578
+ userContext
579
+ });
580
+ }, [analyticsKey, customTools, getResolvedScreenName, messages, userContext]);
581
+
582
+ // ─── Load conversation history on mount ─────────────────────────
583
+ useEffect(() => {
584
+ if (!analyticsKey) return;
585
+ void (async () => {
586
+ try {
587
+ setIsLoadingHistory(true);
588
+ await initDeviceId();
589
+ const deviceId = getDeviceId();
590
+ const list = await ConversationService.fetchConversations({
591
+ analyticsKey,
592
+ userId: userContext?.userId,
593
+ deviceId: deviceId || undefined
594
+ });
595
+ setConversations(list);
596
+ } catch (err) {
597
+ logger.warn('AIAgent', 'Failed to load conversation history:', err);
598
+ } finally {
599
+ setIsLoadingHistory(false);
600
+ }
601
+ })();
602
+ // eslint-disable-next-line react-hooks/exhaustive-deps
603
+ }, [analyticsKey, userContext?.userId]);
430
604
 
431
605
  // ─── Restore pending tickets on app start ──────────────────────
432
606
  useEffect(() => {
@@ -531,7 +705,11 @@ export function AIAgent({
531
705
  onTicketClosed: () => clearSupport(ticket.id),
532
706
  onError: err => logger.error('AIAgent', '★ Restored socket error:', err)
533
707
  });
534
- socket.connect(ticket.wsUrl);
708
+ if (ticket.wsUrl) {
709
+ socket.connect(ticket.wsUrl);
710
+ } else {
711
+ logger.warn('AIAgent', '★ Restored ticket has no wsUrl — skipping socket connect:', ticket.id);
712
+ }
535
713
  // Cache in pendingSocketsRef so handleTicketSelect reuses it without reconnecting
536
714
  pendingSocketsRef.current.set(ticket.id, socket);
537
715
  logger.info('AIAgent', '★ Single ticket restored and socket cached:', ticket.id);
@@ -542,6 +720,61 @@ export function AIAgent({
542
720
  })();
543
721
  // eslint-disable-next-line react-hooks/exhaustive-deps
544
722
  }, [analyticsKey]);
723
+ useEffect(() => {
724
+ if (!analyticsKey) return;
725
+ let isCancelled = false;
726
+ const syncIssues = async () => {
727
+ try {
728
+ await initDeviceId();
729
+ const deviceId = getDeviceId();
730
+ if (!userContext?.userId && !deviceId) return;
731
+ const query = new URLSearchParams({
732
+ analyticsKey
733
+ });
734
+ if (userContext?.userId) query.append('userId', userContext.userId);
735
+ if (deviceId) query.append('deviceId', deviceId);
736
+ const res = await fetch(`${ENDPOINTS.escalation}/api/v1/reported-issues/mine?${query.toString()}`);
737
+ if (!res.ok || isCancelled) return;
738
+ const data = await res.json();
739
+ const nextIssues = Array.isArray(data.issues) ? data.issues : [];
740
+ applyReportedIssueUpdates(nextIssues, {
741
+ replayToChat: false
742
+ });
743
+ } catch (error) {
744
+ logger.warn('AIAgent', 'Failed to sync reported issues:', error);
745
+ }
746
+ };
747
+ void syncIssues();
748
+ void (async () => {
749
+ await initDeviceId();
750
+ const deviceId = getDeviceId();
751
+ if (!userContext?.userId && !deviceId) return;
752
+ const query = new URLSearchParams({
753
+ analyticsKey
754
+ });
755
+ if (userContext?.userId) query.append('userId', userContext.userId);
756
+ if (deviceId) query.append('deviceId', deviceId);
757
+ reportedIssuesSSERef.current?.disconnect();
758
+ const sse = new ReportedIssueEventSource({
759
+ url: `${ENDPOINTS.escalation}/api/v1/reported-issues/events?${query.toString()}`,
760
+ onIssueUpdate: issue => {
761
+ applyReportedIssueUpdates([issue], {
762
+ replayToChat: true
763
+ });
764
+ },
765
+ onError: error => {
766
+ logger.warn('AIAgent', 'Reported issue SSE error:', error.message);
767
+ }
768
+ });
769
+ sse.connect();
770
+ reportedIssuesSSERef.current = sse;
771
+ })();
772
+ return () => {
773
+ isCancelled = true;
774
+ reportedIssuesSSERef.current?.disconnect();
775
+ reportedIssuesSSERef.current = null;
776
+ };
777
+ }, [analyticsKey, applyReportedIssueUpdates, userContext?.userId]);
545
778
 
546
779
  // ─── Ticket selection handlers ────────────────────────────────
547
780
  const handleTicketSelect = useCallback(async ticketId => {
@@ -582,6 +815,11 @@ export function AIAgent({
582
815
  // Trigger scroll to bottom when modal opens
583
816
  setChatScrollTrigger(prev => prev + 1);
584
817
 
818
+ // Capture the fresh wsUrl returned by the server — it is the canonical value.
819
+ // The local `ticket` snapshot may have an empty wsUrl if it was a placeholder
820
+ // created before the WS URL was known (e.g. via onEscalationStarted).
821
+ let freshWsUrl = ticket.wsUrl;
822
+
585
823
  // Fetch latest history from server — this is the source of truth and catches
586
824
  // any messages that arrived while the socket was disconnected (modal closed,
587
825
  // app backgrounded, etc.)
@@ -589,6 +827,9 @@ export function AIAgent({
589
827
  const res = await fetch(`${ENDPOINTS.escalation}/api/v1/escalations/${ticketId}?analyticsKey=${analyticsKey}`);
590
828
  if (res.ok) {
591
829
  const data = await res.json();
830
+ if (data.wsUrl) {
831
+ freshWsUrl = data.wsUrl; // always prefer the live server value
832
+ }
592
833
  const history = Array.isArray(data.history) ? data.history : [];
593
834
  const restored = history.map((entry, i) => ({
594
835
  id: `restored-${ticketId}-${i}`,
@@ -596,8 +837,8 @@ export function AIAgent({
596
837
  content: entry.content,
597
838
  timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now()
598
839
  }));
599
- setMessages(restored);
600
- // Update ticket in local list with fresh history
840
+ setSupportMessages(restored);
841
+ // Update ticket in local list with fresh history + wsUrl
601
842
  if (data.wsUrl) {
602
843
  setTickets(prev => prev.map(t => t.id === ticketId ? {
603
844
  ...t,
@@ -614,9 +855,9 @@ export function AIAgent({
614
855
  content: entry.content,
615
856
  timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now()
616
857
  }));
617
- setMessages(restored);
858
+ setSupportMessages(restored);
618
859
  } else {
619
- setMessages([]);
860
+ setSupportMessages([]);
620
861
  }
621
862
  }
622
863
  } catch (err) {
@@ -628,9 +869,9 @@ export function AIAgent({
628
869
  content: entry.content,
629
870
  timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now()
630
871
  }));
631
- setMessages(restored);
872
+ setSupportMessages(restored);
632
873
  } else {
633
- setMessages([]);
874
+ setSupportMessages([]);
634
875
  }
635
876
  }
636
877
 
@@ -638,10 +879,26 @@ export function AIAgent({
638
879
  // otherwise create a fresh connection from the ticket's stored wsUrl.
639
880
  const cached = pendingSocketsRef.current.get(ticketId);
640
881
  if (cached) {
641
- pendingSocketsRef.current.delete(ticketId);
642
- setSupportSocket(cached);
643
- logger.info('AIAgent', '★ Reusing cached escalation socket for ticket:', ticketId);
644
- return;
882
+ // If the socket errored (not just cleanly disconnected), discard it and
883
+ // fall through to create a fresh one — reusing an errored socket causes
884
+ // sendText to silently return false "Connection lost" on every message.
885
+ if (cached.hasErrored) {
886
+ logger.warn('AIAgent', '★ Cached socket errored — discarding and creating fresh socket for ticket:', ticketId);
887
+ cached.disconnect();
888
+ pendingSocketsRef.current.delete(ticketId);
889
+ // Fall through to fresh socket creation below
890
+ } else {
891
+ pendingSocketsRef.current.delete(ticketId);
892
+ // If the cached socket was created before wsUrl was available (e.g. during
893
+ // on-mount restore), it may never have connected. Reconnect it now.
894
+ if (!cached.isConnected && freshWsUrl) {
895
+ logger.info('AIAgent', '★ Cached socket not connected — reconnecting with wsUrl:', freshWsUrl);
896
+ cached.connect(freshWsUrl);
897
+ }
898
+ setSupportSocket(cached);
899
+ logger.info('AIAgent', '★ Reusing cached escalation socket for ticket:', ticketId);
900
+ return;
901
+ }
645
902
  }
646
903
  const socket = new EscalationSocket({
647
904
  onReply: reply => {
@@ -666,7 +923,7 @@ export function AIAgent({
666
923
  content: reply,
667
924
  timestamp: Date.now()
668
925
  };
669
- setMessages(prev => [...prev, msg]);
926
+ setSupportMessages(prev => [...prev, msg]);
670
927
  setLastResult({
671
928
  success: true,
672
929
  message: `👤 ${reply}`,
@@ -694,7 +951,11 @@ export function AIAgent({
694
951
  },
695
952
  onError: err => logger.error('AIAgent', '★ Socket error on select:', err)
696
953
  });
697
- socket.connect(ticket.wsUrl);
954
+ if (freshWsUrl) {
955
+ socket.connect(freshWsUrl);
956
+ } else {
957
+ logger.warn('AIAgent', '★ Ticket has no wsUrl — skipping socket connect for ticket:', ticketId);
958
+ }
698
959
  setSupportSocket(socket);
699
960
  }, [tickets, supportSocket, selectedTicketId, analyticsKey, clearSupport]);
700
961
  const handleBackToTickets = useCallback(() => {
@@ -709,18 +970,23 @@ export function AIAgent({
709
970
  }
710
971
  return null;
711
972
  });
973
+ logger.info('AIAgent', '★ Back to tickets');
712
974
  setSelectedTicketId(null);
713
- setMessages([]);
975
+ setSupportMessages([]);
714
976
  setIsLiveAgentTyping(false);
715
977
  }, []); // No dependencies — uses refs/functional setters
716
978
 
717
979
  const mergedCustomTools = useMemo(() => {
718
- if (!autoEscalateTool) return customTools;
719
980
  return {
720
- escalate_to_human: autoEscalateTool,
981
+ ...(autoEscalateTool ? {
982
+ escalate_to_human: autoEscalateTool
983
+ } : {}),
984
+ ...(autoReportIssueTool ? {
985
+ report_issue: autoReportIssueTool
986
+ } : {}),
721
987
  ...customTools
722
988
  };
723
- }, [autoEscalateTool, customTools]);
989
+ }, [autoEscalateTool, autoReportIssueTool, customTools]);
724
990
 
725
991
  // ─── Voice/Live Mode State ──────────────────────────────────
726
992
  const [mode, setMode] = useState('text');
@@ -746,6 +1012,17 @@ export function AIAgent({
746
1012
 
747
1013
  // Ref-based resolver for ask_user — stays alive across renders
748
1014
  const askUserResolverRef = useRef(null);
1015
+ const pendingAskUserKindRef = useRef(null);
1016
+ // Tracks whether we're waiting for a BUTTON tap (not just any text answer).
1017
+ // Set true when kind='approval' is issued; cleared ONLY on actual button tap.
1018
+ // Forces kind='approval' on all subsequent ask_user calls until resolved.
1019
+ const pendingAppApprovalRef = useRef(false);
1020
+ // Stores a message typed by the user while the agent is still thinking (mid-approval flow).
1021
+ // Auto-resolved into the next ask_user call to prevent the message being lost.
1022
+ const queuedApprovalAnswerRef = useRef(null);
1023
+ const [pendingApprovalQuestion, setPendingApprovalQuestion] = useState(null);
1024
+ const overlayVisible = isThinking || !!pendingApprovalQuestion;
1025
+ const overlayStatusText = pendingApprovalQuestion ? 'Waiting for your approval...' : statusText;
749
1026
 
750
1027
  // ─── Create Runtime ──────────────────────────────────────────
751
1028
 
@@ -775,7 +1052,7 @@ export function AIAgent({
775
1052
  pathname,
776
1053
  onStatusUpdate: setStatusText,
777
1054
  onTokenUsage,
778
- knowledgeBase,
1055
+ knowledgeBase: resolvedKnowledgeBase,
779
1056
  knowledgeMaxTokens,
780
1057
  enableUIControl,
781
1058
  screenMap: useScreenMap ? screenMap : undefined,
@@ -783,13 +1060,65 @@ export function AIAgent({
783
1060
  maxCostUSD,
784
1061
  interactionMode,
785
1062
  // Block the agent loop until user responds
786
- onAskUser: mode === 'voice' ? undefined : question => {
1063
+ onAskUser: mode === 'voice' ? undefined : request => {
787
1064
  return new Promise(resolve => {
1065
+ const normalized = typeof request === 'string' ? {
1066
+ question: request,
1067
+ kind: 'freeform'
1068
+ } : request;
1069
+ const question = normalized.question;
1070
+ const kind = normalized.kind || 'freeform';
1071
+ logger.info('AIAgent', `❓ onAskUser invoked in ${mode} mode: "${question}"`);
1072
+ telemetryRef.current?.track('agent_trace', {
1073
+ stage: 'ask_user_prompt_rendered',
1074
+ question,
1075
+ mode,
1076
+ kind
1077
+ });
788
1078
  askUserResolverRef.current = resolve;
789
- // Show question in chat bar, allow user input
1079
+ logger.info('AIAgent', `📌 askUserResolverRef SET (resolver stored) | kind=${kind} | pendingAppApprovalRef=${pendingAppApprovalRef.current}`);
1080
+ // If we're already waiting for a button tap, force approval kind regardless
1081
+ // of what the model passed — the user must tap a button to proceed.
1082
+ const forcedKind = pendingAppApprovalRef.current ? 'approval' : kind;
1083
+ pendingAskUserKindRef.current = forcedKind;
1084
+ if (forcedKind === 'approval') {
1085
+ pendingAppApprovalRef.current = true;
1086
+ }
1087
+
1088
+ // If the user typed a message while we were thinking (queued answer),
1089
+ // resolve immediately with that message instead of blocking on a new prompt.
1090
+ const queued = queuedApprovalAnswerRef.current;
1091
+ if (queued !== null) {
1092
+ queuedApprovalAnswerRef.current = null;
1093
+ logger.info('AIAgent', `⚡ Auto-resolving ask_user with queued message: "${queued}"`);
1094
+ // Show the AI question in chat, clear the approval buttons (no resolver for them),
1095
+ // then immediately resolve the Promise with the queued message.
1096
+ setMessages(prev => [...prev, {
1097
+ id: `assistant-ask-${Date.now()}`,
1098
+ role: 'assistant',
1099
+ content: question,
1100
+ timestamp: Date.now(),
1101
+ promptKind: forcedKind === 'approval' ? 'approval' : undefined
1102
+ }]);
1103
+ askUserResolverRef.current = null;
1104
+ pendingAskUserKindRef.current = null;
1105
+ pendingAppApprovalRef.current = false; // CRITICAL FIX: Unlock the agent state
1106
+ setPendingApprovalQuestion(null); // clear any stale buttons — buttons with no resolver = dead tap
1107
+ resolve(queued);
1108
+ return;
1109
+ }
1110
+ setPendingApprovalQuestion(forcedKind === 'approval' ? question : null);
1111
+ // Add AI question to the message thread so it appears in chat
1112
+ setMessages(prev => [...prev, {
1113
+ id: `assistant-ask-${Date.now()}`,
1114
+ role: 'assistant',
1115
+ content: question,
1116
+ timestamp: Date.now(),
1117
+ promptKind: kind === 'approval' ? 'approval' : undefined
1118
+ }]);
790
1119
  setLastResult({
791
1120
  success: true,
792
- message: `❓ ${question}`,
1121
+ message: question,
793
1122
  steps: []
794
1123
  });
795
1124
  setIsThinking(false);
@@ -800,8 +1129,20 @@ export function AIAgent({
800
1129
  // so that AI-driven taps are never tracked as user_interaction events.
801
1130
  onToolExecute: active => {
802
1131
  telemetryRef.current?.setAgentActing(active);
1132
+ },
1133
+ onTrace: event => {
1134
+ telemetryRef.current?.track('agent_trace', {
1135
+ traceId: event.traceId,
1136
+ stage: event.stage,
1137
+ stepIndex: event.stepIndex,
1138
+ screenName: event.screenName,
1139
+ ...(event.data ?? {})
1140
+ });
803
1141
  }
804
- }), [mode, apiKey, proxyUrl, proxyHeaders, voiceProxyUrl, voiceProxyHeaders, model, maxSteps, interactiveBlacklist, interactiveWhitelist, onBeforeStep, onAfterStep, onBeforeTask, onAfterTask, transformScreenContent, customTools, instructions, stepDelay, mcpServerUrl, router, pathname, onTokenUsage, knowledgeBase, knowledgeMaxTokens, enableUIControl, screenMap, useScreenMap, maxTokenBudget, maxCostUSD]);
1142
+ }), [mode, apiKey, proxyUrl, proxyHeaders, voiceProxyUrl, voiceProxyHeaders, model, maxSteps, interactiveBlacklist, interactiveWhitelist, onBeforeStep, onAfterStep, onBeforeTask, onAfterTask, transformScreenContent, customTools, instructions, stepDelay, mcpServerUrl, router, pathname, onTokenUsage, resolvedKnowledgeBase, knowledgeMaxTokens, enableUIControl, screenMap, useScreenMap, maxTokenBudget, maxCostUSD, interactionMode]);
1143
+ useEffect(() => {
1144
+ logger.info('AIAgent', `⚙️ Runtime config recomputed: mode=${mode} interactionMode=${interactionMode || 'copilot(default)'} onAskUser=${mode !== 'voice'} mergedTools=${Object.keys(mergedCustomTools).join(', ') || '(none)'}`);
1145
+ }, [mode, interactionMode, mergedCustomTools]);
805
1146
  const provider = useMemo(() => createProvider(providerName, apiKey, model, proxyUrl, proxyHeaders), [providerName, apiKey, model, proxyUrl, proxyHeaders]);
806
1147
  const runtime = useMemo(() => new AgentRuntime(provider, config, rootViewRef.current, navRef),
807
1148
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -827,15 +1168,59 @@ export function AIAgent({
827
1168
  analyticsKey,
828
1169
  analyticsProxyUrl,
829
1170
  analyticsProxyHeaders,
830
- debug
1171
+ debug,
1172
+ onEvent: event => {
1173
+ // Proactive behavior triggers
1174
+ if (event.type === 'rage_tap' || event.type === 'error_screen' || event.type === 'repeated_navigation') {
1175
+ idleDetectorRef.current?.triggerBehavior(event.type, event.screen);
1176
+ }
1177
+
1178
+ // Customer Success features
1179
+ if (customerSuccess?.enabled && event.type === 'user_interaction' && event.data) {
1180
+ const action = String(event.data.label || event.data.action || '');
1181
+
1182
+ // Check milestones
1183
+ customerSuccess.successMilestones?.forEach(m => {
1184
+ if (m.action && m.action === action) {
1185
+ telemetry.track('health_signal', {
1186
+ type: 'success_milestone',
1187
+ milestone: m.name
1188
+ });
1189
+ }
1190
+ });
1191
+
1192
+ // Check key feature adoption
1193
+ customerSuccess.keyFeatures?.forEach(feature => {
1194
+ if (action.includes(feature) || action === feature) {
1195
+ telemetry.track('health_signal', {
1196
+ type: 'feature_adoption',
1197
+ feature
1198
+ });
1199
+ }
1200
+ });
1201
+ }
1202
+ }
831
1203
  });
832
1204
  telemetryRef.current = telemetry;
833
1205
  bindTelemetryService(telemetry);
834
1206
  telemetry.start();
835
- const initialRoute = navRef?.getCurrentRoute?.();
836
- if (initialRoute?.name) {
837
- telemetry.setScreen(initialRoute.name);
838
- }
1207
+ // NavigationContainer is a child of AIAgent, so navRef may not be
1208
+ // ready yet when this effect runs. Poll briefly until it's available.
1209
+ const resolveInitialScreen = () => {
1210
+ let attempts = 0;
1211
+ const maxAttempts = 15; // 15 × 200ms = 3s max wait
1212
+ const timer = setInterval(() => {
1213
+ attempts++;
1214
+ const route = navRef?.getCurrentRoute?.();
1215
+ if (route?.name) {
1216
+ telemetry.setScreen(route.name);
1217
+ clearInterval(timer);
1218
+ } else if (attempts >= maxAttempts) {
1219
+ clearInterval(timer);
1220
+ }
1221
+ }, 200);
1222
+ };
1223
+ resolveInitialScreen();
839
1224
  }); // initDeviceId
840
1225
  }, [analyticsKey, analyticsProxyUrl, analyticsProxyHeaders, bindTelemetryService, debug, navRef]);
841
1226
 
@@ -851,14 +1236,48 @@ export function AIAgent({
851
1236
  // Track screen changes via navRef
852
1237
  useEffect(() => {
853
1238
  if (!navRef?.addListener || !telemetryRef.current) return;
1239
+ const checkScreenMilestone = screenName => {
1240
+ telemetryRef.current?.setScreen(screenName);
1241
+ if (customerSuccess?.enabled) {
1242
+ customerSuccess.successMilestones?.forEach(m => {
1243
+ if (m.screen && m.screen === screenName) {
1244
+ telemetryRef.current?.track('health_signal', {
1245
+ type: 'success_milestone',
1246
+ milestone: m.name
1247
+ });
1248
+ }
1249
+ });
1250
+ }
1251
+ if (isOnboardingActive && onboarding?.steps) {
1252
+ const step = onboarding.steps[currentOnboardingIndex];
1253
+ if (step && step.screen === screenName) {
1254
+ telemetryRef.current?.track('onboarding_step', {
1255
+ step_index: currentOnboardingIndex,
1256
+ screen: screenName,
1257
+ action: step.action || 'view'
1258
+ });
1259
+
1260
+ // Pop the onboarding badge instantly
1261
+ setTimeout(() => {
1262
+ setProactiveBadgeText(step.message);
1263
+ setProactiveStage('badge');
1264
+ // Stop typical idle timers so it stays until dismissed or advanced
1265
+ idleDetectorRef.current?.dismiss();
1266
+
1267
+ // Auto advance logic
1268
+ advanceOnboarding();
1269
+ }, 300);
1270
+ }
1271
+ }
1272
+ };
854
1273
  const unsubscribe = navRef.addListener('state', () => {
855
1274
  const currentRoute = navRef.getCurrentRoute?.();
856
1275
  if (currentRoute?.name) {
857
- telemetryRef.current?.setScreen(currentRoute.name);
1276
+ checkScreenMilestone(currentRoute.name);
858
1277
  }
859
1278
  });
860
1279
  return () => unsubscribe?.();
861
- }, [navRef]);
1280
+ }, [navRef, customerSuccess, isOnboardingActive, onboarding, currentOnboardingIndex, advanceOnboarding]);
862
1281
 
863
1282
  // ─── MCP Bridge ──────────────────────────────────────────────
864
1283
 
@@ -895,7 +1314,8 @@ export function AIAgent({
895
1314
  setProactiveStage('badge');
896
1315
  },
897
1316
  onReset: () => setProactiveStage('hidden'),
898
- generateSuggestion: () => proactiveHelp?.generateSuggestion?.(telemetryRef.current?.screen || 'Home') || proactiveHelp?.badgeText || "Need help with this screen?"
1317
+ generateSuggestion: () => proactiveHelp?.generateSuggestion?.(telemetryRef.current?.screen || 'Home') || proactiveHelp?.badgeText || "Need help with this screen?",
1318
+ behaviorTriggers: proactiveHelp?.behaviorTriggers
899
1319
  });
900
1320
  return () => {
901
1321
  idleDetectorRef.current?.destroy();
@@ -940,7 +1360,7 @@ export function AIAgent({
940
1360
  if (!audioOutputRef.current) {
941
1361
  logger.info('AIAgent', 'Creating AudioOutputService...');
942
1362
  audioOutputRef.current = new AudioOutputService({
943
- onError: err => logger.error('AIAgent', `AudioOutput error: ${err}`)
1363
+ onError: err => logger.warn('AIAgent', `AudioOutput error/disabled: ${err}`)
944
1364
  });
945
1365
  // IMPORTANT: Must await initialize() BEFORE starting mic.
946
1366
  // initialize() calls setAudioSessionOptions which reconfigures the
@@ -960,7 +1380,7 @@ export function AIAgent({
960
1380
  logger.info('AIAgent', `🎤 onAudioChunk: ${chunk.length} chars, voiceService=${!!voiceServiceRef.current}, connected=${voiceServiceRef.current?.isConnected}`);
961
1381
  voiceServiceRef.current?.sendAudio(chunk);
962
1382
  },
963
- onError: err => logger.error('AIAgent', `AudioInput error: ${err}`),
1383
+ onError: err => logger.warn('AIAgent', `AudioInput error/disabled: ${err}`),
964
1384
  onPermissionDenied: () => logger.warn('AIAgent', 'Mic permission denied by user')
965
1385
  });
966
1386
  }
@@ -1060,6 +1480,11 @@ export function AIAgent({
1060
1480
  }
1061
1481
  toolLockRef.current = true;
1062
1482
  try {
1483
+ // Trigger visual 'thinking' overlay down to ChatBar so user knows action is happening
1484
+ setIsThinking(true);
1485
+ const toolNameFriendly = toolCall.name.replace(/_/g, ' ');
1486
+ setStatusText(`Executing ${toolNameFriendly}...`);
1487
+
1063
1488
  // Execute the tool via AgentRuntime and send result back to Gemini
1064
1489
  const result = await runtime.executeTool(toolCall.name, toolCall.args);
1065
1490
  logger.info('AIAgent', `🔧 Tool result for ${toolCall.name}: ${result}`);
@@ -1079,6 +1504,9 @@ export function AIAgent({
1079
1504
  logger.info('AIAgent', `📡 Tool response sent for ${toolCall.name} [id=${toolCall.id}]`);
1080
1505
  } finally {
1081
1506
  toolLockRef.current = false;
1507
+ setIsThinking(false);
1508
+ setStatusText('');
1509
+
1082
1510
  // Resume mic after tool response is sent
1083
1511
  if (voiceServiceRef.current?.isConnected) {
1084
1512
  audioInputRef.current?.start().then(ok => {
@@ -1185,6 +1613,21 @@ export function AIAgent({
1185
1613
 
1186
1614
  const handleSend = useCallback(async (message, options) => {
1187
1615
  if (!message.trim() || isThinking) return;
1616
+
1617
+ // ── Apple Guideline 5.1.2(i): Consent gate ──────────────────
1618
+ // If consent is required but not yet granted, show the consent dialog
1619
+ // instead of sending data to the AI provider.
1620
+ // EXCEPTION: bypass consent when talking to a human agent —
1621
+ // this is a person-to-person chat, not AI data processing.
1622
+ const isHumanAgentChat = !!(selectedTicketId && supportSocket);
1623
+ if (consentGateActive && !isHumanAgentChat) {
1624
+ pendingConsentSendRef.current = {
1625
+ message: message.trim(),
1626
+ options
1627
+ };
1628
+ setShowConsentDialog(true);
1629
+ return;
1630
+ }
1188
1631
  logger.info('AIAgent', `User message: "${message}"`);
1189
1632
  setLastUserMessage(message.trim());
1190
1633
 
@@ -1203,7 +1646,7 @@ export function AIAgent({
1203
1646
  return;
1204
1647
  }
1205
1648
  if (supportSocket.sendText(message)) {
1206
- setMessages(prev => [...prev, {
1649
+ setSupportMessages(prev => [...prev, {
1207
1650
  id: `user-${Date.now()}`,
1208
1651
  role: 'user',
1209
1652
  content: message.trim(),
@@ -1235,74 +1678,302 @@ export function AIAgent({
1235
1678
 
1236
1679
  // If there's a pending ask_user, resolve it instead of starting a new execution
1237
1680
  if (askUserResolverRef.current) {
1681
+ if (pendingAskUserKindRef.current === 'approval') {
1682
+ const resolver = askUserResolverRef.current;
1683
+ askUserResolverRef.current = null;
1684
+ pendingAskUserKindRef.current = null;
1685
+ pendingAppApprovalRef.current = false; // CRITICAL FIX: Unlock the agent state
1686
+ setPendingApprovalQuestion(null);
1687
+
1688
+ // Pass the user's conversational message directly back to the active prompt resolver.
1689
+ // It will NOT be treated as a rejection! It will be passed back to the LLM.
1690
+ telemetryRef.current?.track('agent_trace', {
1691
+ stage: 'approval_interrupted_by_user_question',
1692
+ message: message.trim()
1693
+ });
1694
+ setIsThinking(true);
1695
+ setStatusText('Answering your question...');
1696
+ setLastResult(null);
1697
+ resolver(message.trim());
1698
+ return;
1699
+ }
1238
1700
  const resolver = askUserResolverRef.current;
1239
1701
  askUserResolverRef.current = null;
1702
+ pendingAskUserKindRef.current = null;
1703
+ pendingAppApprovalRef.current = false; // CRITICAL FIX: Unlock the agent state
1704
+ setPendingApprovalQuestion(null);
1240
1705
  setIsThinking(true);
1241
1706
  setStatusText('Processing your answer...');
1242
1707
  setLastResult(null);
1708
+ logger.info('AIAgent', `✅ Resolving pending ask_user with: "${message.trim()}"`);
1709
+ telemetryRef.current?.track('agent_trace', {
1710
+ stage: 'ask_user_answer_submitted',
1711
+ answer: message.trim(),
1712
+ mode
1713
+ });
1243
1714
  resolver(message);
1244
1715
  return;
1245
1716
  }
1246
1717
 
1718
+ // Guard: if we're mid-approval flow (waiting for button tap) but no resolver exists yet
1719
+ // (agent is still thinking between ask_user calls), do NOT start a new task —
1720
+ // that would spawn two concurrent agent loops and freeze the app.
1721
+ // Instead, store the message as a queued answer that will auto-resolve on the next ask_user.
1722
+ if (pendingAppApprovalRef.current) {
1723
+ logger.warn('AIAgent', '⚠️ User typed during active approval flow — queuing message, not spawning new task');
1724
+ queuedApprovalAnswerRef.current = message.trim();
1725
+ return;
1726
+ }
1727
+
1247
1728
  // Normal execution — new task
1729
+ // Reset approval gate refs so previous conversations don't bleed state
1730
+ pendingAppApprovalRef.current = false;
1731
+ queuedApprovalAnswerRef.current = null;
1248
1732
  setIsThinking(true);
1249
1733
  setStatusText('Thinking...');
1250
1734
  setLastResult(null);
1735
+ logger.info('AIAgent', `📨 New user request received in ${mode} mode | interactionMode=${interactionMode || 'copilot(default)'} | text="${message.trim()}"`);
1251
1736
 
1252
1737
  // Telemetry: track agent request
1253
1738
  telemetryRef.current?.track('agent_request', {
1254
- query: message.trim()
1739
+ query: message.trim(),
1740
+ transcript: message.trim(),
1741
+ mode
1742
+ });
1743
+ telemetryRef.current?.track('agent_trace', {
1744
+ stage: 'request_received',
1745
+ query: message.trim(),
1746
+ mode,
1747
+ interactionMode: interactionMode || 'copilot'
1255
1748
  });
1256
1749
  try {
1750
+ // ─── Business-grade escalation policy ───
1751
+ const FRUSTRATION_REGEX = /\b(angry|frustrated|useless|terrible|hate|worst|ridiculous|awful)\b/i;
1752
+ const HIGH_RISK_ESCALATION_REGEX = /\b(human|agent|representative|supervisor|manager|refund|chargeback|charged|billing|payment|fraud|scam|lawsuit|attorney|lawyer|sue|legal|privacy|data breach|account locked|can't log in|cannot log in)\b/i;
1753
+ const escalateTool = customTools?.['escalate_to_human'] || autoEscalateTool;
1754
+ const priorFrustrationCount = messages.filter(m => m.role === 'user' && typeof m.content === 'string' && FRUSTRATION_REGEX.test(m.content)).length;
1755
+ if (escalateTool && !selectedTicketId) {
1756
+ if (HIGH_RISK_ESCALATION_REGEX.test(message)) {
1757
+ logger.warn('AIAgent', 'High-risk support signal detected — auto-escalating to human');
1758
+ telemetryRef.current?.track('business_escalation', {
1759
+ message,
1760
+ trigger: 'high_risk'
1761
+ });
1762
+ const escalationResult = await escalateTool.execute({
1763
+ reason: `Customer needs human support: ${message.trim()}`
1764
+ });
1765
+ const customerMessage = typeof escalationResult === 'string' && escalationResult.trim().length > 0 ? escalationResult.replace(/^ESCALATED:\s*/i, '') : 'Your request has been sent to our support team. A human agent will reply here as soon as possible.';
1766
+ const res = {
1767
+ success: true,
1768
+ message: customerMessage,
1769
+ steps: [{
1770
+ stepIndex: 0,
1771
+ reflection: {
1772
+ previousGoalEval: '',
1773
+ memory: '',
1774
+ plan: 'Escalate to human support for a high-risk or explicitly requested issue'
1775
+ },
1776
+ action: {
1777
+ name: 'escalate_to_human',
1778
+ input: {
1779
+ reason: 'business_escalation'
1780
+ },
1781
+ output: 'Escalated'
1782
+ }
1783
+ }]
1784
+ };
1785
+ setLastResult(res);
1786
+ setMessages(prev => [...prev, {
1787
+ id: `assistant-${Date.now()}`,
1788
+ role: 'assistant',
1789
+ content: res.message,
1790
+ timestamp: Date.now(),
1791
+ result: res
1792
+ }]);
1793
+ setIsThinking(false);
1794
+ setStatusText('');
1795
+ return;
1796
+ }
1797
+ if (FRUSTRATION_REGEX.test(message)) {
1798
+ const frustrationMessage = priorFrustrationCount > 0 ? "I'm sorry this has been frustrating. I can keep helping here, or I can connect you with a human support agent if you'd prefer." : "I'm sorry this has been frustrating. Tell me what went wrong, and I'll do my best to fix it.";
1799
+ const res = {
1800
+ success: true,
1801
+ message: frustrationMessage,
1802
+ steps: [{
1803
+ stepIndex: 0,
1804
+ reflection: {
1805
+ previousGoalEval: '',
1806
+ memory: '',
1807
+ plan: priorFrustrationCount > 0 ? 'Acknowledge repeated frustration and offer escalation without forcing a handoff' : 'Acknowledge first-time frustration and continue trying to resolve the issue'
1808
+ },
1809
+ action: {
1810
+ name: 'done',
1811
+ input: {},
1812
+ output: frustrationMessage
1813
+ }
1814
+ }]
1815
+ };
1816
+ setLastResult(res);
1817
+ setMessages(prev => [...prev, {
1818
+ id: `assistant-${Date.now()}`,
1819
+ role: 'assistant',
1820
+ content: frustrationMessage,
1821
+ timestamp: Date.now(),
1822
+ result: res
1823
+ }]);
1824
+ setIsThinking(false);
1825
+ setStatusText('');
1826
+ return;
1827
+ }
1828
+ }
1829
+
1257
1830
  // Ensure we have the latest Fiber tree ref
1258
1831
  runtime.updateRefs(rootViewRef.current, navRef);
1259
1832
  const result = await runtime.execute(message, messages);
1833
+ let normalizedResult = result;
1834
+ const reportStep = result.steps?.find(step => step.action.name === 'report_issue');
1835
+ if (reportStep && typeof reportStep.action.output === 'string') {
1836
+ const match = /^ISSUE_REPORTED:([^:]+):([^:]*):([\s\S]+)$/i.exec(reportStep.action.output);
1837
+ if (match) {
1838
+ const [, _issueId, historyId, customerMessage] = match;
1839
+ const resolvedCustomerMessage = customerMessage || reportStep.action.output;
1840
+ if (historyId) {
1841
+ seenReportedIssueUpdatesRef.current.add(historyId);
1842
+ }
1843
+ normalizedResult = {
1844
+ ...result,
1845
+ message: resolvedCustomerMessage,
1846
+ steps: result.steps.map(step => step === reportStep ? {
1847
+ ...step,
1848
+ action: {
1849
+ ...step.action,
1850
+ output: resolvedCustomerMessage
1851
+ }
1852
+ } : step)
1853
+ };
1854
+ }
1855
+ }
1260
1856
 
1261
1857
  // Telemetry: track agent completion and per-step details
1262
1858
  if (telemetryRef.current) {
1263
- for (const step of result.steps ?? []) {
1859
+ if (!agentFrtFiredRef.current) {
1860
+ agentFrtFiredRef.current = true;
1861
+ telemetryRef.current.track('agent_first_response');
1862
+ }
1863
+ for (const step of normalizedResult.steps ?? []) {
1264
1864
  telemetryRef.current.track('agent_step', {
1865
+ stepIndex: step.stepIndex,
1265
1866
  tool: step.action.name,
1266
1867
  args: step.action.input,
1267
- result: typeof step.action.output === 'string' ? step.action.output.substring(0, 200) : String(step.action.output)
1868
+ result: typeof step.action.output === 'string' ? step.action.output : String(step.action.output),
1869
+ plan: step.reflection.plan,
1870
+ memory: step.reflection.memory,
1871
+ previousGoalEval: step.reflection.previousGoalEval
1268
1872
  });
1269
1873
  }
1270
1874
  telemetryRef.current.track('agent_complete', {
1271
- success: result.success,
1272
- steps: result.steps?.length ?? 0,
1273
- tokens: result.tokenUsage?.totalTokens ?? 0,
1274
- cost: result.tokenUsage?.estimatedCostUSD ?? 0
1875
+ success: normalizedResult.success,
1876
+ steps: normalizedResult.steps?.length ?? 0,
1877
+ tokens: normalizedResult.tokenUsage?.totalTokens ?? 0,
1878
+ cost: normalizedResult.tokenUsage?.estimatedCostUSD ?? 0,
1879
+ response: normalizedResult.message,
1880
+ conversation: {
1881
+ user: message.trim(),
1882
+ assistant: normalizedResult.message
1883
+ }
1275
1884
  });
1276
1885
  }
1277
- logger.info('AIAgent', '★ handleSend — SETTING lastResult:', result.message.substring(0, 80), '| mode:', mode);
1886
+ logger.info('AIAgent', '★ handleSend — SETTING lastResult:', normalizedResult.message.substring(0, 80), '| mode:', mode);
1278
1887
  logger.info('AIAgent', '★ handleSend — tickets:', tickets.length, 'selectedTicketId:', selectedTicketId);
1279
1888
 
1280
1889
  // Don't overwrite lastResult if escalation already switched us to human mode
1281
1890
  // (mode in this closure is stale — the actual mode may have changed during async execution)
1282
- const stepsHadEscalation = result.steps?.some(s => s.action.name === 'escalate_to_human');
1891
+ const stepsHadEscalation = normalizedResult.steps?.some(s => s.action.name === 'escalate_to_human');
1283
1892
  if (!stepsHadEscalation) {
1284
- setLastResult(result);
1893
+ setLastResult(normalizedResult);
1285
1894
  }
1286
- setMessages(prev => [...prev, {
1895
+ const assistantMsg = {
1287
1896
  id: Date.now().toString() + Math.random(),
1288
1897
  role: 'assistant',
1289
- content: result.message,
1898
+ content: normalizedResult.message,
1290
1899
  timestamp: Date.now(),
1291
- result
1292
- }]);
1900
+ result: normalizedResult
1901
+ };
1902
+ setMessages(prev => [...prev, assistantMsg]);
1903
+
1904
+ // ── Persist to backend (debounced 600ms) ─────────────────────
1905
+ if (analyticsKey) {
1906
+ if (appendDebounceRef.current) clearTimeout(appendDebounceRef.current);
1907
+ appendDebounceRef.current = setTimeout(async () => {
1908
+ try {
1909
+ await initDeviceId();
1910
+ const deviceId = getDeviceId();
1911
+ // Read current messages directly from ref — never use setMessages() to read state
1912
+ const currentMsgs = messagesRef.current;
1913
+ const newMsgs = currentMsgs.slice(lastSavedMessageCountRef.current);
1914
+ if (newMsgs.length === 0) return;
1915
+ if (!activeConversationIdRef.current) {
1916
+ // First exchange — create a new conversation
1917
+ const id = await ConversationService.startConversation({
1918
+ analyticsKey: analyticsKey,
1919
+ userId: userContext?.userId,
1920
+ deviceId: deviceId || undefined,
1921
+ messages: newMsgs
1922
+ });
1923
+ if (id) {
1924
+ activeConversationIdRef.current = id;
1925
+ lastSavedMessageCountRef.current = currentMsgs.length;
1926
+ const newSummary = {
1927
+ id,
1928
+ title: newMsgs.find(m => m.role === 'user')?.content?.slice(0, 80) || 'New conversation',
1929
+ preview: assistantMsg.content.slice(0, 100),
1930
+ previewRole: 'assistant',
1931
+ messageCount: newMsgs.length,
1932
+ createdAt: Date.now(),
1933
+ updatedAt: Date.now()
1934
+ };
1935
+ setConversations(prev => [newSummary, ...prev]);
1936
+ logger.info('AIAgent', `Conversation created: ${id}`);
1937
+ }
1938
+ } else {
1939
+ // Subsequent turns — append only new messages
1940
+ await ConversationService.appendMessages({
1941
+ conversationId: activeConversationIdRef.current,
1942
+ analyticsKey: analyticsKey,
1943
+ messages: newMsgs
1944
+ });
1945
+ lastSavedMessageCountRef.current = currentMsgs.length;
1946
+ setConversations(prev => prev.map(c => c.id === activeConversationIdRef.current ? {
1947
+ ...c,
1948
+ preview: assistantMsg.content.slice(0, 100),
1949
+ updatedAt: Date.now(),
1950
+ messageCount: c.messageCount + newMsgs.length
1951
+ } : c));
1952
+ logger.info('AIAgent', `Conversation appended: ${activeConversationIdRef.current}`);
1953
+ }
1954
+ } catch (err) {
1955
+ logger.warn('AIAgent', 'Failed to persist conversation:', err);
1956
+ }
1957
+ }, 600);
1958
+ }
1293
1959
  if (options?.onResult) {
1294
- options.onResult(result);
1960
+ options.onResult(normalizedResult);
1295
1961
  } else {
1296
- onResult?.(result);
1962
+ onResult?.(normalizedResult);
1297
1963
  }
1298
- logger.info('AIAgent', `Result: ${result.success ? '✅' : '❌'} ${result.message}`);
1964
+ logger.info('AIAgent', `Result: ${normalizedResult.success ? '✅' : '❌'} ${normalizedResult.message}`);
1299
1965
  } catch (error) {
1300
1966
  logger.error('AIAgent', 'Execution failed:', error);
1301
1967
 
1302
1968
  // Telemetry: track agent failure
1303
1969
  telemetryRef.current?.track('agent_complete', {
1304
1970
  success: false,
1305
- error: error.message
1971
+ error: error.message,
1972
+ response: `Error: ${error.message}`,
1973
+ conversation: {
1974
+ user: message.trim(),
1975
+ assistant: `Error: ${error.message}`
1976
+ }
1306
1977
  });
1307
1978
  setLastResult({
1308
1979
  success: false,
@@ -1313,7 +1984,21 @@ export function AIAgent({
1313
1984
  setIsThinking(false);
1314
1985
  setStatusText('');
1315
1986
  }
1316
- }, [runtime, navRef, onResult, messages, isThinking]);
1987
+ }, [runtime, navRef, onResult, messages, isThinking, consentGateActive]);
1988
+ useEffect(() => {
1989
+ if (consentGateActive) return;
1990
+ const pending = pendingConsentSendRef.current;
1991
+ if (!pending) return;
1992
+ pendingConsentSendRef.current = null;
1993
+ void handleSend(pending.message, pending.options);
1994
+ }, [consentGateActive, handleSend]);
1995
+ useEffect(() => {
1996
+ if (isThinking) return;
1997
+ const pending = pendingFollowUpAfterApprovalRef.current;
1998
+ if (!pending) return;
1999
+ pendingFollowUpAfterApprovalRef.current = null;
2000
+ void handleSend(pending.message, pending.options);
2001
+ }, [isThinking, handleSend]);
1317
2002
 
1318
2003
  // ─── Context value (for useAI bridge) ─────────────────────────
1319
2004
 
@@ -1322,6 +2007,33 @@ export function AIAgent({
1322
2007
  setIsThinking(false);
1323
2008
  setStatusText('');
1324
2009
  }, [runtime]);
2010
+
2011
+ // ─── Conversation History Handlers ─────────────────────────────
2012
+
2013
+ const handleConversationSelect = useCallback(async conversationId => {
2014
+ if (!analyticsKey) return;
2015
+ try {
2016
+ const msgs = await ConversationService.fetchConversation({
2017
+ conversationId,
2018
+ analyticsKey
2019
+ });
2020
+ if (msgs) {
2021
+ activeConversationIdRef.current = conversationId;
2022
+ lastSavedMessageCountRef.current = msgs.length;
2023
+ setMessages(msgs);
2024
+ setLastResult(null);
2025
+ }
2026
+ } catch (err) {
2027
+ logger.warn('AIAgent', 'Failed to load conversation:', err);
2028
+ }
2029
+ }, [analyticsKey]);
2030
+ const handleNewConversation = useCallback(() => {
2031
+ activeConversationIdRef.current = null;
2032
+ lastSavedMessageCountRef.current = 0;
2033
+ setMessages([]);
2034
+ setLastResult(null);
2035
+ setLastUserMessage(null);
2036
+ }, []);
1325
2037
  const contextValue = useMemo(() => ({
1326
2038
  runtime,
1327
2039
  send: handleSend,
@@ -1348,7 +2060,7 @@ export function AIAgent({
1348
2060
  // Skip if the AI agent is currently executing a tool — those are
1349
2061
  // already tracked as `agent_step` events with full context.
1350
2062
  if (telemetryRef.current && !telemetryRef.current.isAgentActing) {
1351
- const label = extractTouchLabel(event.nativeEvent);
2063
+ const label = extractTouchLabel(event);
1352
2064
  if (label && label !== 'Unknown Element' && label !== '[pressable]') {
1353
2065
  telemetryRef.current.track('user_interaction', {
1354
2066
  type: 'tap',
@@ -1381,91 +2093,145 @@ export function AIAgent({
1381
2093
  },
1382
2094
  children: children
1383
2095
  })
1384
- }), /*#__PURE__*/_jsxs(View, {
1385
- style: styles.floatingLayer,
1386
- pointerEvents: "box-none",
1387
- children: [/*#__PURE__*/_jsx(HighlightOverlay, {}), /*#__PURE__*/_jsx(AgentOverlay, {
1388
- visible: isThinking,
1389
- statusText: statusText,
1390
- onCancel: handleCancel
1391
- }), showChatBar && /*#__PURE__*/_jsx(ProactiveHint, {
1392
- stage: proactiveStage,
1393
- badgeText: proactiveBadgeText,
1394
- onDismiss: () => idleDetectorRef.current?.dismiss(),
1395
- children: /*#__PURE__*/_jsx(AgentChatBar, {
2096
+ }), /*#__PURE__*/_jsx(FloatingOverlayWrapper, {
2097
+ fallbackStyle: styles.floatingLayer,
2098
+ children: /*#__PURE__*/_jsxs(View, {
2099
+ style: StyleSheet.absoluteFill,
2100
+ pointerEvents: "box-none",
2101
+ children: [/*#__PURE__*/_jsx(HighlightOverlay, {}), showChatBar && /*#__PURE__*/_jsx(ProactiveHint, {
2102
+ stage: proactiveStage,
2103
+ badgeText: proactiveBadgeText,
2104
+ onDismiss: () => idleDetectorRef.current?.dismiss(),
2105
+ children: /*#__PURE__*/_jsx(AgentChatBar, {
2106
+ onSend: handleSend,
2107
+ isThinking: isThinking,
2108
+ statusText: overlayStatusText,
2109
+ lastResult: lastResult,
2110
+ lastUserMessage: lastUserMessage,
2111
+ chatMessages: messages,
2112
+ pendingApprovalQuestion: pendingApprovalQuestion,
2113
+ onPendingApprovalAction: action => {
2114
+ const resolver = askUserResolverRef.current;
2115
+ logger.info('AIAgent', `🔘 Approval button tapped: action=${action} | resolver=${resolver ? 'EXISTS' : 'NULL'} | pendingApprovalQuestion="${pendingApprovalQuestion}" | pendingAppApprovalRef=${pendingAppApprovalRef.current}`);
2116
+ if (!resolver) {
2117
+ logger.error('AIAgent', '🚫 ABORT: resolver is null when button was tapped — this means ask_user Promise was already resolved without clearing the buttons. This is a state sync bug.');
2118
+ return;
2119
+ }
2120
+ askUserResolverRef.current = null;
2121
+ pendingAskUserKindRef.current = null;
2122
+ // Button was actually tapped — clear the approval gate and any queued message
2123
+ pendingAppApprovalRef.current = false;
2124
+ queuedApprovalAnswerRef.current = null;
2125
+ setPendingApprovalQuestion(null);
2126
+ const response = action === 'approve' ? '__APPROVAL_GRANTED__' : '__APPROVAL_REJECTED__';
2127
+ // Restore the thinking overlay so the user can see the agent working
2128
+ // after approval. onAskUser set isThinking=false when buttons appeared,
2129
+ // but the agent is still running — restore the visual indicator.
2130
+ if (action === 'approve') {
2131
+ setIsThinking(true);
2132
+ setStatusText('Working...');
2133
+ }
2134
+ telemetryRef.current?.track('agent_trace', {
2135
+ stage: 'approval_button_pressed',
2136
+ action
2137
+ });
2138
+ resolver(response);
2139
+ },
2140
+ language: 'en',
2141
+ onDismiss: () => {
2142
+ setLastResult(null);
2143
+ setLastUserMessage(null);
2144
+ },
2145
+ theme: accentColor || theme ? {
2146
+ ...(accentColor ? {
2147
+ primaryColor: accentColor
2148
+ } : {}),
2149
+ ...theme
2150
+ } : undefined,
2151
+ availableModes: availableModes,
2152
+ mode: mode,
2153
+ onModeChange: newMode => {
2154
+ logger.info('AIAgent', '★ onModeChange:', mode, '→', newMode, '| tickets:', tickets.length, 'selectedTicketId:', selectedTicketId);
2155
+ setMode(newMode);
2156
+ },
2157
+ isMicActive: isMicActive,
2158
+ isSpeakerMuted: isSpeakerMuted,
2159
+ isAISpeaking: isAISpeaking,
2160
+ isAgentTyping: isLiveAgentTyping,
2161
+ onStopSession: stopVoiceSession,
2162
+ isVoiceConnected: isVoiceConnected,
2163
+ onMicToggle: active => {
2164
+ if (active && !isVoiceConnected) {
2165
+ logger.warn('AIAgent', 'Cannot toggle mic — VoiceService not connected yet');
2166
+ return;
2167
+ }
2168
+ logger.info('AIAgent', `Mic toggle: ${active ? 'ON' : 'OFF'}`);
2169
+ setIsMicActive(active);
2170
+ if (active) {
2171
+ logger.info('AIAgent', 'Starting AudioInput...');
2172
+ audioInputRef.current?.start().then(ok => {
2173
+ logger.info('AIAgent', `AudioInput start result: ${ok}`);
2174
+ });
2175
+ } else {
2176
+ logger.info('AIAgent', 'Stopping AudioInput...');
2177
+ audioInputRef.current?.stop();
2178
+ }
2179
+ },
2180
+ onSpeakerToggle: muted => {
2181
+ logger.info('AIAgent', `Speaker toggle: ${muted ? 'MUTED' : 'UNMUTED'}`);
2182
+ setIsSpeakerMuted(muted);
2183
+ if (muted) {
2184
+ audioOutputRef.current?.mute();
2185
+ } else {
2186
+ audioOutputRef.current?.unmute();
2187
+ }
2188
+ },
2189
+ tickets: tickets,
2190
+ selectedTicketId: selectedTicketId,
2191
+ onTicketSelect: handleTicketSelect,
2192
+ onBackToTickets: handleBackToTickets,
2193
+ autoExpandTrigger: autoExpandTrigger,
2194
+ unreadCounts: unreadCounts,
2195
+ totalUnread: totalUnread,
2196
+ showDiscoveryTooltip: tooltipVisible,
2197
+ discoveryTooltipMessage: discoveryTooltipMessage,
2198
+ onTooltipDismiss: handleTooltipDismiss,
2199
+ conversations: conversations,
2200
+ isLoadingHistory: isLoadingHistory,
2201
+ onConversationSelect: handleConversationSelect,
2202
+ onNewConversation: handleNewConversation
2203
+ })
2204
+ }), /*#__PURE__*/_jsx(AgentOverlay, {
2205
+ visible: overlayVisible,
2206
+ statusText: overlayStatusText,
2207
+ onCancel: handleCancel
2208
+ }), /*#__PURE__*/_jsx(SupportChatModal, {
2209
+ visible: mode === 'human' && !!selectedTicketId,
2210
+ messages: supportMessages,
1396
2211
  onSend: handleSend,
2212
+ onClose: handleBackToTickets,
2213
+ isAgentTyping: isLiveAgentTyping,
1397
2214
  isThinking: isThinking,
1398
- lastResult: lastResult,
1399
- lastUserMessage: lastUserMessage,
2215
+ scrollToEndTrigger: chatScrollTrigger,
2216
+ ticketStatus: tickets.find(t => t.id === selectedTicketId)?.status
2217
+ }), /*#__PURE__*/_jsx(AIConsentDialog, {
2218
+ visible: showConsentDialog,
2219
+ provider: providerName,
2220
+ config: consentConfig,
1400
2221
  language: 'en',
1401
- onDismiss: () => {
1402
- setLastResult(null);
1403
- setLastUserMessage(null);
1404
- },
1405
- theme: accentColor || theme ? {
1406
- ...(accentColor ? {
1407
- primaryColor: accentColor
1408
- } : {}),
1409
- ...theme
1410
- } : undefined,
1411
- availableModes: availableModes,
1412
- mode: mode,
1413
- onModeChange: newMode => {
1414
- logger.info('AIAgent', '★ onModeChange:', mode, '→', newMode, '| tickets:', tickets.length, 'selectedTicketId:', selectedTicketId);
1415
- setMode(newMode);
2222
+ onConsent: async () => {
2223
+ await grantConsent();
2224
+ setShowConsentDialog(false);
2225
+ consentConfig.onConsent?.();
2226
+ logger.info('AIAgent', '✅ AI consent granted by user');
1416
2227
  },
1417
- isMicActive: isMicActive,
1418
- isSpeakerMuted: isSpeakerMuted,
1419
- isAISpeaking: isAISpeaking,
1420
- isAgentTyping: isLiveAgentTyping,
1421
- onStopSession: stopVoiceSession,
1422
- isVoiceConnected: isVoiceConnected,
1423
- onMicToggle: active => {
1424
- if (active && !isVoiceConnected) {
1425
- logger.warn('AIAgent', 'Cannot toggle mic — VoiceService not connected yet');
1426
- return;
1427
- }
1428
- logger.info('AIAgent', `Mic toggle: ${active ? 'ON' : 'OFF'}`);
1429
- setIsMicActive(active);
1430
- if (active) {
1431
- logger.info('AIAgent', 'Starting AudioInput...');
1432
- audioInputRef.current?.start().then(ok => {
1433
- logger.info('AIAgent', `AudioInput start result: ${ok}`);
1434
- });
1435
- } else {
1436
- logger.info('AIAgent', 'Stopping AudioInput...');
1437
- audioInputRef.current?.stop();
1438
- }
1439
- },
1440
- onSpeakerToggle: muted => {
1441
- logger.info('AIAgent', `Speaker toggle: ${muted ? 'MUTED' : 'UNMUTED'}`);
1442
- setIsSpeakerMuted(muted);
1443
- if (muted) {
1444
- audioOutputRef.current?.mute();
1445
- } else {
1446
- audioOutputRef.current?.unmute();
1447
- }
1448
- },
1449
- tickets: tickets,
1450
- selectedTicketId: selectedTicketId,
1451
- onTicketSelect: handleTicketSelect,
1452
- onBackToTickets: handleBackToTickets,
1453
- autoExpandTrigger: autoExpandTrigger,
1454
- unreadCounts: unreadCounts,
1455
- totalUnread: totalUnread,
1456
- showDiscoveryTooltip: tooltipVisible,
1457
- onTooltipDismiss: handleTooltipDismiss
1458
- })
1459
- }), /*#__PURE__*/_jsx(SupportChatModal, {
1460
- visible: mode === 'human' && !!selectedTicketId,
1461
- messages: messages,
1462
- onSend: handleSend,
1463
- onClose: handleBackToTickets,
1464
- isAgentTyping: isLiveAgentTyping,
1465
- isThinking: isThinking,
1466
- scrollToEndTrigger: chatScrollTrigger,
1467
- ticketStatus: tickets.find(t => t.id === selectedTicketId)?.status
1468
- })]
2228
+ onDecline: () => {
2229
+ setShowConsentDialog(false);
2230
+ consentConfig.onDecline?.();
2231
+ logger.info('AIAgent', '❌ AI consent declined by user');
2232
+ }
2233
+ })]
2234
+ })
1469
2235
  })]
1470
2236
  })
1471
2237
  });