@mobileai/react-native 0.9.10 → 0.9.12

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 (86) hide show
  1. package/README.md +11 -0
  2. package/lib/module/components/AIAgent.js +635 -39
  3. package/lib/module/components/AIAgent.js.map +1 -1
  4. package/lib/module/components/AgentChatBar.js +309 -13
  5. package/lib/module/components/AgentChatBar.js.map +1 -1
  6. package/lib/module/config/endpoints.js +22 -0
  7. package/lib/module/config/endpoints.js.map +1 -0
  8. package/lib/module/core/systemPrompt.js +126 -100
  9. package/lib/module/core/systemPrompt.js.map +1 -1
  10. package/lib/module/services/AudioInputService.js +9 -0
  11. package/lib/module/services/AudioInputService.js.map +1 -1
  12. package/lib/module/services/flags/FlagService.js +1 -1
  13. package/lib/module/services/flags/FlagService.js.map +1 -1
  14. package/lib/module/services/telemetry/TelemetryService.js +44 -15
  15. package/lib/module/services/telemetry/TelemetryService.js.map +1 -1
  16. package/lib/module/services/telemetry/device.js +80 -10
  17. package/lib/module/services/telemetry/device.js.map +1 -1
  18. package/lib/module/services/telemetry/deviceMetadata.js +10 -0
  19. package/lib/module/services/telemetry/deviceMetadata.js.map +1 -0
  20. package/lib/module/support/EscalationEventSource.js +168 -0
  21. package/lib/module/support/EscalationEventSource.js.map +1 -0
  22. package/lib/module/support/EscalationSocket.js +46 -7
  23. package/lib/module/support/EscalationSocket.js.map +1 -1
  24. package/lib/module/support/SupportChatModal.js +544 -0
  25. package/lib/module/support/SupportChatModal.js.map +1 -0
  26. package/lib/module/support/TicketStore.js +93 -0
  27. package/lib/module/support/TicketStore.js.map +1 -0
  28. package/lib/module/support/escalateTool.js +45 -13
  29. package/lib/module/support/escalateTool.js.map +1 -1
  30. package/lib/module/support/index.js +2 -0
  31. package/lib/module/support/index.js.map +1 -1
  32. package/lib/typescript/src/components/AIAgent.d.ts +24 -1
  33. package/lib/typescript/src/components/AIAgent.d.ts.map +1 -1
  34. package/lib/typescript/src/components/AgentChatBar.d.ts +24 -2
  35. package/lib/typescript/src/components/AgentChatBar.d.ts.map +1 -1
  36. package/lib/typescript/src/config/endpoints.d.ts +18 -0
  37. package/lib/typescript/src/config/endpoints.d.ts.map +1 -0
  38. package/lib/typescript/src/core/systemPrompt.d.ts +4 -13
  39. package/lib/typescript/src/core/systemPrompt.d.ts.map +1 -1
  40. package/lib/typescript/src/core/types.d.ts +1 -1
  41. package/lib/typescript/src/core/types.d.ts.map +1 -1
  42. package/lib/typescript/src/hooks/useAction.d.ts +2 -2
  43. package/lib/typescript/src/hooks/useAction.d.ts.map +1 -1
  44. package/lib/typescript/src/index.d.ts +1 -1
  45. package/lib/typescript/src/index.d.ts.map +1 -1
  46. package/lib/typescript/src/services/AudioInputService.d.ts.map +1 -1
  47. package/lib/typescript/src/services/telemetry/TelemetryService.d.ts +2 -1
  48. package/lib/typescript/src/services/telemetry/TelemetryService.d.ts.map +1 -1
  49. package/lib/typescript/src/services/telemetry/device.d.ts +15 -4
  50. package/lib/typescript/src/services/telemetry/device.d.ts.map +1 -1
  51. package/lib/typescript/src/services/telemetry/deviceMetadata.d.ts +6 -0
  52. package/lib/typescript/src/services/telemetry/deviceMetadata.d.ts.map +1 -0
  53. package/lib/typescript/src/support/EscalationEventSource.d.ts +38 -0
  54. package/lib/typescript/src/support/EscalationEventSource.d.ts.map +1 -0
  55. package/lib/typescript/src/support/EscalationSocket.d.ts +7 -1
  56. package/lib/typescript/src/support/EscalationSocket.d.ts.map +1 -1
  57. package/lib/typescript/src/support/SupportChatModal.d.ts +21 -0
  58. package/lib/typescript/src/support/SupportChatModal.d.ts.map +1 -0
  59. package/lib/typescript/src/support/TicketStore.d.ts +34 -0
  60. package/lib/typescript/src/support/TicketStore.d.ts.map +1 -0
  61. package/lib/typescript/src/support/escalateTool.d.ts +16 -1
  62. package/lib/typescript/src/support/escalateTool.d.ts.map +1 -1
  63. package/lib/typescript/src/support/index.d.ts +2 -1
  64. package/lib/typescript/src/support/index.d.ts.map +1 -1
  65. package/lib/typescript/src/support/types.d.ts +15 -0
  66. package/lib/typescript/src/support/types.d.ts.map +1 -1
  67. package/package.json +5 -1
  68. package/src/components/AIAgent.tsx +622 -38
  69. package/src/components/AgentChatBar.tsx +348 -9
  70. package/src/config/endpoints.ts +22 -0
  71. package/src/core/systemPrompt.ts +126 -100
  72. package/src/core/types.ts +1 -1
  73. package/src/hooks/useAction.ts +2 -2
  74. package/src/index.ts +1 -0
  75. package/src/services/AudioInputService.ts +9 -0
  76. package/src/services/flags/FlagService.ts +1 -1
  77. package/src/services/telemetry/TelemetryService.ts +46 -14
  78. package/src/services/telemetry/device.ts +88 -11
  79. package/src/services/telemetry/deviceMetadata.ts +13 -0
  80. package/src/support/EscalationEventSource.ts +190 -0
  81. package/src/support/EscalationSocket.ts +47 -8
  82. package/src/support/SupportChatModal.tsx +563 -0
  83. package/src/support/TicketStore.ts +100 -0
  84. package/src/support/escalateTool.ts +53 -13
  85. package/src/support/index.ts +2 -0
  86. package/src/support/types.ts +14 -0
@@ -29,12 +29,17 @@ import { AudioInputService } from '../services/AudioInputService';
29
29
  import { AudioOutputService } from '../services/AudioOutputService';
30
30
  import { TelemetryService, bindTelemetryService } from '../services/telemetry';
31
31
  import { extractTouchLabel, checkRageClick } from '../services/telemetry/TouchAutoCapture';
32
+ import { initDeviceId, getDeviceId } from '../services/telemetry/device';
32
33
  import type { AgentConfig, AgentMode, ExecutionResult, ToolDefinition, AgentStep, TokenUsage, KnowledgeBaseConfig, ChatBarTheme, AIMessage, AIProviderName, ScreenMap, ProactiveHelpConfig } from '../core/types';
33
34
  import { AgentErrorBoundary } from './AgentErrorBoundary';
34
35
  import { HighlightOverlay } from './HighlightOverlay';
35
36
  import { IdleDetector } from '../core/IdleDetector';
36
37
  import { ProactiveHint } from './ProactiveHint';
37
38
  import { createEscalateTool } from '../support/escalateTool';
39
+ import { EscalationSocket } from '../support/EscalationSocket';
40
+ import { EscalationEventSource } from '../support/EscalationEventSource';
41
+ import { SupportChatModal } from '../support/SupportChatModal';
42
+ import { ENDPOINTS } from '../config/endpoints';
38
43
 
39
44
  // ─── Context ───────────────────────────────────────────────────
40
45
 
@@ -190,6 +195,34 @@ interface AIAgentProps {
190
195
  * Proactive agent configuration (detects user hesitation)
191
196
  */
192
197
  proactiveHelp?: ProactiveHelpConfig;
198
+
199
+ // ── Support Configuration ────────────
200
+
201
+ /**
202
+ * Identity of the logged-in user.
203
+ * If provided, this enforces "one ticket per user" and shows the user profile
204
+ * in the Dashboard (name, email, plan, etc.).
205
+ */
206
+ userContext?: {
207
+ userId?: string;
208
+ name?: string;
209
+ email?: string;
210
+ phone?: string;
211
+ plan?: string;
212
+ custom?: Record<string, string | number | boolean>;
213
+ };
214
+
215
+ /**
216
+ * Device push token for offline support replies.
217
+ * Use '@react-native-firebase/messaging' or 'expo-notifications' to get this.
218
+ */
219
+ pushToken?: string;
220
+
221
+ /**
222
+ * The type of push token provided.
223
+ * "fcm" is recommended for universal bare/Expo support.
224
+ */
225
+ pushTokenType?: 'fcm' | 'expo' | 'apns';
193
226
  }
194
227
 
195
228
 
@@ -239,6 +272,9 @@ export function AIAgent({
239
272
  analyticsProxyUrl,
240
273
  analyticsProxyHeaders,
241
274
  proactiveHelp,
275
+ userContext,
276
+ pushToken,
277
+ pushTokenType,
242
278
  }: AIAgentProps) {
243
279
  // Configure logger based on debug prop
244
280
  React.useEffect(() => {
@@ -252,13 +288,125 @@ export function AIAgent({
252
288
  const [isThinking, setIsThinking] = useState(false);
253
289
  const [statusText, setStatusText] = useState('');
254
290
  const [lastResult, setLastResult] = useState<ExecutionResult | null>(null);
291
+ const [lastUserMessage, setLastUserMessage] = useState<string | null>(null);
255
292
  const [messages, setMessages] = useState<AIMessage[]>([]);
293
+ const [chatScrollTrigger, setChatScrollTrigger] = useState(0);
294
+
295
+ // Increment scroll trigger when messages change to auto-scroll chat modal
296
+ useEffect(() => {
297
+ if (messages.length > 0) {
298
+ setChatScrollTrigger(prev => prev + 1);
299
+ }
300
+ }, [messages.length]);
301
+
302
+ // ── Support Modal State ──
303
+ const [tickets, setTickets] = useState<import('../support/types').SupportTicket[]>([]);
304
+ const [selectedTicketId, setSelectedTicketId] = useState<string | null>(null);
305
+ const [supportSocket, setSupportSocket] = useState<EscalationSocket | null>(null);
306
+ const [isLiveAgentTyping, setIsLiveAgentTyping] = useState(false);
307
+ const [autoExpandTrigger, setAutoExpandTrigger] = useState(0);
308
+ const [unreadCounts, setUnreadCounts] = useState<Record<string, number>>({});
309
+ // Ref mirrors selectedTicketId — lets socket callbacks access current value
310
+ // without stale closures (sockets are long-lived, closures capture old state).
311
+ const selectedTicketIdRef = useRef<string | null>(null);
312
+ useEffect(() => { selectedTicketIdRef.current = selectedTicketId; }, [selectedTicketId]);
313
+ // Cache of live sockets by ticketId — keeps sockets alive even when user
314
+ // navigates back to the ticket list, so new messages still trigger badge updates.
315
+ const pendingSocketsRef = useRef<Map<string, EscalationSocket>>(new Map());
316
+ // SSE connections per ticket — reliable fallback for ticket_closed events
317
+ // when the WebSocket is disconnected. EventSource auto-reconnects.
318
+ const sseRef = useRef<Map<string, EscalationEventSource>>(new Map());
319
+
320
+ const totalUnread = Object.values(unreadCounts).reduce((sum, count) => sum + count, 0);
321
+
322
+ // CRITICAL: clearSupport uses REFS and functional setters — never closure values.
323
+ // This function is captured by long-lived callbacks (escalation sockets, restored
324
+ // sockets) that may hold stale references. Using refs guarantees the current
325
+ // selectedTicketId and supportSocket are always read, not snapshot values.
326
+ const clearSupport = useCallback((ticketId?: string) => {
327
+ if (ticketId) {
328
+ // Remove specific ticket + its cached socket and SSE
329
+ const cached = pendingSocketsRef.current.get(ticketId);
330
+ if (cached) { cached.disconnect(); pendingSocketsRef.current.delete(ticketId); }
331
+ const sse = sseRef.current.get(ticketId);
332
+ if (sse) { sse.disconnect(); sseRef.current.delete(ticketId); }
333
+ setTickets(prev => prev.filter(t => t.id !== ticketId));
334
+ setUnreadCounts(prev => { const n = { ...prev }; delete n[ticketId]; return n; });
335
+
336
+ // If user was viewing this ticket, close the support modal + switch to ticket list
337
+ if (selectedTicketIdRef.current === ticketId) {
338
+ setSupportSocket(prev => { prev?.disconnect(); return null; });
339
+ setSelectedTicketId(null);
340
+ setIsLiveAgentTyping(false);
341
+ setMessages([]);
342
+ }
343
+
344
+ // If no tickets remain, switch back to text mode
345
+ setTickets(prev => {
346
+ if (prev.length === 0) {
347
+ setMode('text');
348
+ }
349
+ return prev;
350
+ });
351
+ } else {
352
+ // Clear all — disconnect every cached socket and SSE
353
+ pendingSocketsRef.current.forEach(s => s.disconnect());
354
+ pendingSocketsRef.current.clear();
355
+ sseRef.current.forEach(s => s.disconnect());
356
+ sseRef.current.clear();
357
+ setSupportSocket(prev => { prev?.disconnect(); return null; });
358
+ setSelectedTicketId(null);
359
+ setTickets([]);
360
+ setUnreadCounts({});
361
+ setIsLiveAgentTyping(false);
362
+ setMode('text');
363
+ }
364
+ }, []);
365
+
366
+ const openSSE = useCallback((ticketId: string) => {
367
+ if (sseRef.current.has(ticketId)) return;
368
+ if (!analyticsKey) return;
369
+
370
+ const sseUrl = `${ENDPOINTS.escalation}/api/v1/escalations/events?analyticsKey=${encodeURIComponent(analyticsKey)}&ticketId=${encodeURIComponent(ticketId)}`;
371
+ const sse = new EscalationEventSource({
372
+ url: sseUrl,
373
+ onTicketClosed: (tid) => {
374
+ logger.info('AIAgent', 'SSE: ticket_closed received for', tid);
375
+ setUnreadCounts(prev => {
376
+ const next = { ...prev };
377
+ delete next[tid];
378
+ return next;
379
+ });
380
+ clearSupport(tid);
381
+ },
382
+ onConnected: (tid) => {
383
+ logger.info('AIAgent', 'SSE: connected for ticket', tid);
384
+ },
385
+ });
386
+ sse.connect();
387
+ sseRef.current.set(ticketId, sse);
388
+ logger.info('AIAgent', 'SSE opened for ticket:', ticketId);
389
+ }, [analyticsKey, clearSupport]);
256
390
 
257
391
  const clearMessages = useCallback(() => {
258
392
  setMessages([]);
259
393
  setLastResult(null);
260
394
  }, []);
261
395
 
396
+ const getResolvedScreenName = useCallback(() => {
397
+ const routeName = (navRef as any)?.getCurrentRoute?.()?.name;
398
+ if (typeof routeName === 'string' && routeName.trim().length > 0) {
399
+ return routeName;
400
+ }
401
+
402
+ const telemetryScreen = telemetryRef.current?.screen;
403
+ if (typeof telemetryScreen === 'string' && telemetryScreen !== 'Unknown') {
404
+ return telemetryScreen;
405
+ }
406
+
407
+ return 'unknown';
408
+ }, [navRef]);
409
+
262
410
  // ─── Auto-create MobileAI escalation tool ─────────────────────
263
411
  // When analyticsKey is present and consumer hasn't provided their own
264
412
  // escalate_to_human tool, auto-wire the MobileAI platform provider.
@@ -270,26 +418,394 @@ export function AIAgent({
270
418
  config: { provider: 'mobileai' },
271
419
  analyticsKey,
272
420
  getContext: () => ({
273
- currentScreen: (navRef as any)?.getCurrentRoute?.()?.name ?? 'unknown',
421
+ currentScreen: getResolvedScreenName(),
274
422
  originalQuery: '',
275
423
  stepsBeforeEscalation: 0,
276
424
  }),
277
425
  getHistory: () =>
278
426
  messages.map((m) => ({ role: m.role, content: m.content })),
279
- onHumanReply: (reply: string) => {
280
- setMessages((prev) => [
281
- ...prev,
282
- {
427
+ getScreenFlow: () => telemetryRef.current?.getScreenFlow() ?? [],
428
+ userContext,
429
+ pushToken,
430
+ pushTokenType,
431
+ onEscalationStarted: (tid, socket) => {
432
+ logger.info('AIAgent', '★★★ onEscalationStarted FIRED — ticketId:', tid);
433
+ // Cache the live socket so handleTicketSelect can reuse it without reconnecting
434
+ pendingSocketsRef.current.set(tid, socket);
435
+ // Open SSE for reliable ticket_closed delivery
436
+ openSSE(tid);
437
+
438
+ const currentScreen = getResolvedScreenName();
439
+ setTickets(prev => {
440
+ if (prev.find(t => t.id === tid)) {
441
+ logger.info('AIAgent', '★★★ Ticket already in list, skipping add');
442
+ return prev;
443
+ }
444
+ const newList = [{ id: tid, reason: 'Connecting to agent...', screen: currentScreen, status: 'open', history: [], createdAt: new Date().toISOString(), wsUrl: '' }, ...prev];
445
+ logger.info('AIAgent', '★★★ Tickets updated, new length:', newList.length);
446
+ return newList;
447
+ });
448
+
449
+ // Fetch real ticket data from backend to replace the placeholder
450
+ void (async () => {
451
+ try {
452
+ const res = await fetch(`${ENDPOINTS.escalation}/api/v1/escalations/${tid}?analyticsKey=${encodeURIComponent(analyticsKey!)}`);
453
+ if (res.ok) {
454
+ const data = await res.json();
455
+ setTickets(prev => prev.map(t => {
456
+ if (t.id !== tid) return t;
457
+ return {
458
+ ...t,
459
+ reason: data.reason || t.reason,
460
+ screen: data.screen || t.screen,
461
+ status: data.status || t.status,
462
+ history: Array.isArray(data.history) ? data.history : t.history,
463
+ };
464
+ }));
465
+ }
466
+ } catch {
467
+ // Best-effort — placeholder is still usable
468
+ }
469
+ })();
470
+
471
+ // Switch to human mode so the ticket LIST is visible — do NOT auto-select
472
+ setMode('human');
473
+ setAutoExpandTrigger(prev => {
474
+ const next = prev + 1;
475
+ logger.info('AIAgent', '★★★ autoExpandTrigger:', prev, '→', next);
476
+ return next;
477
+ });
478
+ logger.info('AIAgent', '★★★ setMode("human") called from onEscalationStarted');
479
+ },
480
+ onHumanReply: (reply: string, ticketId?: string) => {
481
+ if (ticketId) {
482
+ // Always update the ticket's history (source of truth for ticket cards)
483
+ setTickets(prev => prev.map(t => {
484
+ if (t.id !== ticketId) return t;
485
+ return {
486
+ ...t,
487
+ history: [...(t.history || []), { role: 'live_agent', content: reply, timestamp: new Date().toISOString() }],
488
+ };
489
+ }));
490
+
491
+ // Route via ref: only push to messages[] if user is viewing THIS ticket
492
+ if (selectedTicketIdRef.current === ticketId) {
493
+ const humanMsg: AIMessage = {
494
+ id: `human-${Date.now()}`,
495
+ role: 'live_agent' as any,
496
+ content: reply,
497
+ timestamp: Date.now(),
498
+ };
499
+ setMessages((prev) => [...prev, humanMsg]);
500
+ setLastResult({ success: true, message: `👤 ${reply}`, steps: [] });
501
+ } else {
502
+ // Not viewing this ticket — increment unread badge
503
+ setUnreadCounts(prev => ({
504
+ ...prev,
505
+ [ticketId]: (prev[ticketId] || 0) + 1,
506
+ }));
507
+ }
508
+ }
509
+ },
510
+ onTypingChange: (isTyping: boolean) => {
511
+ setIsLiveAgentTyping(isTyping);
512
+ },
513
+ onTicketClosed: (ticketId?: string) => {
514
+ logger.info('AIAgent', 'Ticket closed by agent — removing from list');
515
+ if (ticketId) {
516
+ setUnreadCounts(prev => {
517
+ const next = { ...prev };
518
+ delete next[ticketId];
519
+ return next;
520
+ });
521
+ }
522
+ clearSupport(ticketId);
523
+ },
524
+ });
525
+ // eslint-disable-next-line react-hooks/exhaustive-deps
526
+ }, [analyticsKey, customTools, getResolvedScreenName, navRef, openSSE, userContext, pushToken, pushTokenType, messages, clearSupport]);
527
+
528
+ // ─── Restore pending tickets on app start ──────────────────────
529
+ useEffect(() => {
530
+ if (!analyticsKey) return;
531
+
532
+ void (async () => {
533
+ try {
534
+ // Wait for the device ID to be initialised before reading it.
535
+ // getDeviceId() is synchronous but returns null on cold start until
536
+ // initDeviceId() resolves — awaiting here prevents an early bail-out
537
+ // that would leave the Human tab hidden after an app refresh.
538
+ await initDeviceId();
539
+ const deviceId = getDeviceId();
540
+
541
+ logger.info('AIAgent', '★ Restore check — analyticsKey:', !!analyticsKey, 'userId:', userContext?.userId, 'pushToken:', !!pushToken, 'deviceId:', deviceId);
542
+ if (!userContext?.userId && !pushToken && !deviceId) return;
543
+
544
+ const query = new URLSearchParams({ analyticsKey });
545
+ if (userContext?.userId) query.append('userId', userContext.userId);
546
+ if (pushToken) query.append('pushToken', pushToken);
547
+ if (deviceId) query.append('deviceId', deviceId);
548
+
549
+ const url = `${ENDPOINTS.escalation}/api/v1/escalations/mine?${query.toString()}`;
550
+ logger.info('AIAgent', '★ Restore — fetching:', url);
551
+ const res = await fetch(url);
552
+
553
+ logger.info('AIAgent', '★ Restore — response status:', res.status);
554
+ if (!res.ok) return;
555
+
556
+ const data = await res.json();
557
+ const fetchedTickets: import('../support/types').SupportTicket[] = data.tickets ?? [];
558
+ logger.info('AIAgent', '★ Restore — found', fetchedTickets.length, 'active tickets');
559
+
560
+ if (fetchedTickets.length === 0) return;
561
+
562
+ // Initialize unread counts from backend (set together with tickets for instant badge)
563
+ const initialUnreadCounts: Record<string, number> = {};
564
+ for (const ticket of fetchedTickets) {
565
+ if (ticket.unreadCount && ticket.unreadCount > 0) {
566
+ initialUnreadCounts[ticket.id] = ticket.unreadCount;
567
+ }
568
+ }
569
+ setTickets(fetchedTickets);
570
+ setUnreadCounts(initialUnreadCounts);
571
+
572
+ // Show the ticket list without auto-selecting — user taps in (Intercom-style).
573
+ // setMode switches the widget to human mode so the list is immediately visible.
574
+ setMode('human');
575
+ setAutoExpandTrigger(prev => prev + 1);
576
+
577
+ // Open SSE for every restored ticket — reliable ticket_closed delivery
578
+ for (const t of fetchedTickets) {
579
+ openSSE(t.id);
580
+ }
581
+
582
+ // If there is exactly one ticket, pre-wire its WebSocket so it is ready
583
+ // the moment the user taps the card (no extra connect delay).
584
+ if (fetchedTickets.length === 1) {
585
+ const ticket = fetchedTickets[0]!;
586
+
587
+ if (ticket.history?.length) {
588
+ const restored: AIMessage[] = ticket.history.map(
589
+ (entry: { role: string; content: string; timestamp?: string }, i: number) => ({
590
+ id: `restored-${ticket.id}-${i}`,
591
+ role: (entry.role === 'live_agent' ? 'assistant' : entry.role) as any,
592
+ content: entry.content,
593
+ timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now(),
594
+ })
595
+ );
596
+ setMessages(restored);
597
+ }
598
+
599
+ const socket = new EscalationSocket({
600
+ onReply: (reply: string) => {
601
+ const tid = ticket.id;
602
+ // Always update ticket history
603
+ setTickets(prev => prev.map(t => {
604
+ if (t.id !== tid) return t;
605
+ return {
606
+ ...t,
607
+ history: [...(t.history || []), { role: 'live_agent', content: reply, timestamp: new Date().toISOString() }],
608
+ };
609
+ }));
610
+
611
+ // Route via ref: only push to messages[] if user is viewing THIS ticket
612
+ if (selectedTicketIdRef.current === tid) {
613
+ const msg: AIMessage = {
614
+ id: `human-${Date.now()}`,
615
+ role: 'assistant',
616
+ content: reply,
617
+ timestamp: Date.now(),
618
+ };
619
+ setMessages((prev) => [...prev, msg]);
620
+ setLastResult({ success: true, message: `👤 ${reply}`, steps: [] });
621
+ } else {
622
+ setUnreadCounts(prev => ({
623
+ ...prev,
624
+ [tid]: (prev[tid] || 0) + 1,
625
+ }));
626
+ }
627
+ },
628
+ onTypingChange: setIsLiveAgentTyping,
629
+ onTicketClosed: () => clearSupport(ticket.id),
630
+ onError: (err) => logger.error('AIAgent', '★ Restored socket error:', err),
631
+ });
632
+ socket.connect(ticket.wsUrl);
633
+ // Cache in pendingSocketsRef so handleTicketSelect reuses it without reconnecting
634
+ pendingSocketsRef.current.set(ticket.id, socket);
635
+ logger.info('AIAgent', '★ Single ticket restored and socket cached:', ticket.id);
636
+ }
637
+ } catch (err) {
638
+ logger.error('AIAgent', '★ Failed to restore tickets:', err);
639
+ }
640
+ })();
641
+ // eslint-disable-next-line react-hooks/exhaustive-deps
642
+ }, [analyticsKey]);
643
+
644
+ // ─── Ticket selection handlers ────────────────────────────────
645
+ const handleTicketSelect = useCallback(async (ticketId: string) => {
646
+ const ticket = tickets.find(t => t.id === ticketId);
647
+ if (!ticket) return;
648
+
649
+ // Cache (not disconnect!) the previous ticket's socket so it keeps
650
+ // receiving messages in the background and can update unread counts.
651
+ if (supportSocket && selectedTicketId && selectedTicketId !== ticketId) {
652
+ pendingSocketsRef.current.set(selectedTicketId, supportSocket);
653
+ setSupportSocket(null);
654
+ }
655
+
656
+ setSelectedTicketId(ticketId);
657
+ setMode('human');
658
+
659
+ // Clear unread count when user opens a ticket
660
+ setUnreadCounts(prev => {
661
+ if (!prev[ticketId]) return prev;
662
+ const next = { ...prev };
663
+ delete next[ticketId];
664
+ return next;
665
+ });
666
+
667
+ // Mark ticket as read on backend (source of truth)
668
+ (async () => {
669
+ try {
670
+ await fetch(
671
+ `${ENDPOINTS.escalation}/api/v1/escalations/${ticketId}/read?analyticsKey=${analyticsKey}`,
672
+ { method: 'POST' }
673
+ );
674
+ logger.info('AIAgent', '★ Marked ticket as read:', ticketId);
675
+ } catch (err) {
676
+ logger.warn('AIAgent', '★ Failed to mark ticket as read:', err);
677
+ }
678
+ })();
679
+
680
+ // Trigger scroll to bottom when modal opens
681
+ setChatScrollTrigger(prev => prev + 1);
682
+
683
+ // Fetch latest history from server — this is the source of truth and catches
684
+ // any messages that arrived while the socket was disconnected (modal closed,
685
+ // app backgrounded, etc.)
686
+ try {
687
+ const res = await fetch(
688
+ `${ENDPOINTS.escalation}/api/v1/escalations/${ticketId}?analyticsKey=${analyticsKey}`
689
+ );
690
+ if (res.ok) {
691
+ const data = await res.json();
692
+ const history: Array<{ role: string; content: string; timestamp?: string }> =
693
+ Array.isArray(data.history) ? data.history : [];
694
+ const restored: AIMessage[] = history.map((entry, i) => ({
695
+ id: `restored-${ticketId}-${i}`,
696
+ role: (entry.role === 'live_agent' ? 'assistant' : entry.role) as any,
697
+ content: entry.content,
698
+ timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now(),
699
+ }));
700
+ setMessages(restored);
701
+ // Update ticket in local list with fresh history
702
+ if (data.wsUrl) {
703
+ setTickets(prev => prev.map(t => t.id === ticketId ? { ...t, history, wsUrl: data.wsUrl } : t));
704
+ }
705
+ } else {
706
+ // Fallback to local ticket history
707
+ if (ticket.history?.length) {
708
+ const restored: AIMessage[] = ticket.history.map(
709
+ (entry: { role: string; content: string; timestamp?: string }, i: number) => ({
710
+ id: `restored-${ticketId}-${i}`,
711
+ role: (entry.role === 'live_agent' ? 'assistant' : entry.role) as any,
712
+ content: entry.content,
713
+ timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now(),
714
+ })
715
+ );
716
+ setMessages(restored);
717
+ } else {
718
+ setMessages([]);
719
+ }
720
+ }
721
+ } catch (err) {
722
+ logger.warn('AIAgent', '★ Failed to fetch ticket history, using local:', err);
723
+ if (ticket.history?.length) {
724
+ const restored: AIMessage[] = ticket.history.map(
725
+ (entry: { role: string; content: string; timestamp?: string }, i: number) => ({
726
+ id: `restored-${ticketId}-${i}`,
727
+ role: (entry.role === 'live_agent' ? 'assistant' : entry.role) as any,
728
+ content: entry.content,
729
+ timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now(),
730
+ })
731
+ );
732
+ setMessages(restored);
733
+ } else {
734
+ setMessages([]);
735
+ }
736
+ }
737
+
738
+ // Reuse the already-connected socket if escalation just happened,
739
+ // otherwise create a fresh connection from the ticket's stored wsUrl.
740
+ const cached = pendingSocketsRef.current.get(ticketId);
741
+ if (cached) {
742
+ pendingSocketsRef.current.delete(ticketId);
743
+ setSupportSocket(cached);
744
+ logger.info('AIAgent', '★ Reusing cached escalation socket for ticket:', ticketId);
745
+ return;
746
+ }
747
+
748
+ const socket = new EscalationSocket({
749
+ onReply: (reply: string) => {
750
+ // Always update ticket history
751
+ setTickets(prev => prev.map(t => {
752
+ if (t.id !== ticketId) return t;
753
+ return {
754
+ ...t,
755
+ history: [...(t.history || []), { role: 'live_agent', content: reply, timestamp: new Date().toISOString() }],
756
+ };
757
+ }));
758
+
759
+ // Route via ref: only push to messages[] if user is viewing THIS ticket
760
+ if (selectedTicketIdRef.current === ticketId) {
761
+ const msg: AIMessage = {
283
762
  id: `human-${Date.now()}`,
284
- role: 'assistant' as const,
285
- content: `👤 Human Agent: ${reply}`,
763
+ role: 'assistant',
764
+ content: reply,
286
765
  timestamp: Date.now(),
287
- },
288
- ]);
766
+ };
767
+ setMessages(prev => [...prev, msg]);
768
+ setLastResult({ success: true, message: `👤 ${reply}`, steps: [] });
769
+ } else {
770
+ setUnreadCounts(prev => ({
771
+ ...prev,
772
+ [ticketId]: (prev[ticketId] || 0) + 1,
773
+ }));
774
+ }
289
775
  },
776
+ onTypingChange: setIsLiveAgentTyping,
777
+ onTicketClosed: (closedTicketId?: string) => {
778
+ if (closedTicketId) {
779
+ setUnreadCounts(prev => {
780
+ const next = { ...prev };
781
+ delete next[closedTicketId];
782
+ return next;
783
+ });
784
+ }
785
+ clearSupport(ticketId);
786
+ },
787
+ onError: (err) => logger.error('AIAgent', '★ Socket error on select:', err),
290
788
  });
291
- // eslint-disable-next-line react-hooks/exhaustive-deps
292
- }, [analyticsKey, navRef, customTools]);
789
+ socket.connect(ticket.wsUrl);
790
+ setSupportSocket(socket);
791
+ }, [tickets, supportSocket, selectedTicketId, analyticsKey, clearSupport]);
792
+
793
+ const handleBackToTickets = useCallback(() => {
794
+ // Cache socket in pendingSocketsRef instead of disconnecting —
795
+ // keeps the WS alive so new messages update unreadCounts in real time.
796
+ const currentTicketId = selectedTicketIdRef.current;
797
+ // Use functional setter to read + cache the current socket without closure dependency
798
+ setSupportSocket(prev => {
799
+ if (prev && currentTicketId) {
800
+ pendingSocketsRef.current.set(currentTicketId, prev);
801
+ logger.info('AIAgent', '★ Socket cached for ticket:', currentTicketId, '— stays alive for badge updates');
802
+ }
803
+ return null;
804
+ });
805
+ setSelectedTicketId(null);
806
+ setMessages([]);
807
+ setIsLiveAgentTyping(false);
808
+ }, []); // No dependencies — uses refs/functional setters
293
809
 
294
810
  const mergedCustomTools = useMemo(() => {
295
811
  if (!autoEscalateTool) return customTools;
@@ -312,13 +828,13 @@ export function AIAgent({
312
828
  const screenPollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
313
829
  const lastAgentErrorRef = useRef<string | null>(null);
314
830
 
315
- // Compute available modes from props
316
831
  const availableModes: AgentMode[] = useMemo(() => {
317
832
  const modes: AgentMode[] = ['text'];
318
833
  if (enableVoice) modes.push('voice');
319
- logger.info('AIAgent', `Available modes: ${modes.join(', ')}`);
834
+ if (tickets.length > 0) modes.push('human');
835
+ logger.info('AIAgent', '★ availableModes recomputed:', modes, '| tickets:', tickets.length, '| ticketIds:', tickets.map(t => t.id));
320
836
  return modes;
321
- }, [enableVoice]);
837
+ }, [enableVoice, tickets]);
322
838
 
323
839
  // Ref-based resolver for ask_user — stays alive across renders
324
840
  const askUserResolverRef = useRef<((answer: string) => void) | null>(null);
@@ -405,29 +921,32 @@ export function AIAgent({
405
921
  return;
406
922
  }
407
923
 
408
- const telemetry = new TelemetryService({
409
- analyticsKey,
410
- analyticsProxyUrl,
411
- analyticsProxyHeaders,
412
- debug,
413
- });
414
- telemetryRef.current = telemetry;
415
- bindTelemetryService(telemetry);
416
- telemetry.start();
924
+ // Initialize persistent device ID before telemetry starts
925
+ initDeviceId().then(() => {
417
926
 
418
- return () => {
419
- telemetry.stop();
420
- telemetryRef.current = null;
421
- bindTelemetryService(null);
422
- };
423
- }, [analyticsKey, analyticsProxyUrl, analyticsProxyHeaders, debug]);
927
+ const telemetry = new TelemetryService({
928
+ analyticsKey,
929
+ analyticsProxyUrl,
930
+ analyticsProxyHeaders,
931
+ debug,
932
+ });
933
+ telemetryRef.current = telemetry;
934
+ bindTelemetryService(telemetry);
935
+ telemetry.start();
936
+
937
+ const initialRoute = navRef?.getCurrentRoute?.();
938
+ if (initialRoute?.name) {
939
+ telemetry.setScreen(initialRoute.name);
940
+ }
941
+ }); // initDeviceId
942
+ }, [analyticsKey, analyticsProxyUrl, analyticsProxyHeaders, bindTelemetryService, debug, navRef]);
424
943
 
425
944
  // ─── Security warnings ──────────────────────────────────────
426
945
 
427
946
  useEffect(() => {
428
947
  // @ts-ignore
429
948
  if (typeof __DEV__ !== 'undefined' && !__DEV__ && apiKey && !proxyUrl) {
430
- console.warn(
949
+ logger.warn(
431
950
  '[MobileAI] ⚠️ SECURITY WARNING: You are using `apiKey` directly in a production build. ' +
432
951
  'This exposes your LLM provider key in the app binary. ' +
433
952
  'Use `apiProxyUrl` to route requests through your backend instead. ' +
@@ -501,10 +1020,10 @@ export function AIAgent({
501
1020
 
502
1021
  // ─── Voice/Live Service Initialization ──────────────────────
503
1022
 
504
- // Initialize voice services when mode changes to voice or live
1023
+ // Initialize voice services when mode changes to voice
505
1024
  useEffect(() => {
506
- if (mode === 'text') {
507
- logger.info('AIAgent', 'Text mode — skipping voice service init');
1025
+ if (mode !== 'voice') {
1026
+ logger.info('AIAgent', `Mode ${mode} — skipping voice service init`);
508
1027
  return;
509
1028
  }
510
1029
 
@@ -801,8 +1320,45 @@ export function AIAgent({
801
1320
  if (!message.trim() || isThinking) return;
802
1321
 
803
1322
  logger.info('AIAgent', `User message: "${message}"`);
1323
+ setLastUserMessage(message.trim());
1324
+
1325
+ // Intercom-style transparent intercept:
1326
+ // If we're connected to a human agent, all text input goes directly to them.
1327
+ if (selectedTicketId && supportSocket) {
1328
+ // Gate: do not allow sending if the ticket is closed/resolved.
1329
+ const activeTicket = tickets.find(t => t.id === selectedTicketId);
1330
+ const CLOSED_STATUSES = ['closed', 'resolved'];
1331
+ if (activeTicket && CLOSED_STATUSES.includes(activeTicket.status)) {
1332
+ setLastResult({
1333
+ success: false,
1334
+ message: 'This conversation is closed. Please start a new request.',
1335
+ steps: [],
1336
+ });
1337
+ return;
1338
+ }
804
1339
 
805
- // Append user message
1340
+ if (supportSocket.sendText(message)) {
1341
+ setMessages((prev) => [
1342
+ ...prev,
1343
+ { id: `user-${Date.now()}`, role: 'user', content: message.trim(), timestamp: Date.now() },
1344
+ ]);
1345
+ setIsThinking(true);
1346
+ setStatusText('Sending to agent...');
1347
+ setTimeout(() => {
1348
+ setIsThinking(false);
1349
+ setStatusText('');
1350
+ }, 800);
1351
+ } else {
1352
+ setLastResult({
1353
+ success: false,
1354
+ message: 'Failed to send message to support agent. Connection lost.',
1355
+ steps: [],
1356
+ });
1357
+ }
1358
+ return;
1359
+ }
1360
+
1361
+ // Append user message to AI thread
806
1362
  setMessages((prev) => [
807
1363
  ...prev,
808
1364
  {
@@ -859,9 +1415,16 @@ export function AIAgent({
859
1415
  });
860
1416
  }
861
1417
 
862
- setLastResult(result);
1418
+ logger.info('AIAgent', '★ handleSend — SETTING lastResult:', result.message.substring(0, 80), '| mode:', mode);
1419
+ logger.info('AIAgent', '★ handleSend — tickets:', tickets.length, 'selectedTicketId:', selectedTicketId);
1420
+
1421
+ // Don't overwrite lastResult if escalation already switched us to human mode
1422
+ // (mode in this closure is stale — the actual mode may have changed during async execution)
1423
+ const stepsHadEscalation = result.steps?.some(s => s.action.name === 'escalate_to_human');
1424
+ if (!stepsHadEscalation) {
1425
+ setLastResult(result);
1426
+ }
863
1427
 
864
- // Append assistant message
865
1428
  setMessages((prev) => [
866
1429
  ...prev,
867
1430
  {
@@ -990,8 +1553,9 @@ export function AIAgent({
990
1553
  onSend={handleSend}
991
1554
  isThinking={isThinking}
992
1555
  lastResult={lastResult}
1556
+ lastUserMessage={lastUserMessage}
993
1557
  language={'en'}
994
- onDismiss={() => setLastResult(null)}
1558
+ onDismiss={() => { setLastResult(null); setLastUserMessage(null); }}
995
1559
  theme={accentColor || theme ? {
996
1560
  ...(accentColor ? { primaryColor: accentColor } : {}),
997
1561
  ...theme,
@@ -999,12 +1563,13 @@ export function AIAgent({
999
1563
  availableModes={availableModes}
1000
1564
  mode={mode}
1001
1565
  onModeChange={(newMode) => {
1002
- logger.info('AIAgent', `Mode change: ${mode}${newMode}`);
1566
+ logger.info('AIAgent', '★ onModeChange:', mode, '', newMode, '| tickets:', tickets.length, 'selectedTicketId:', selectedTicketId);
1003
1567
  setMode(newMode);
1004
1568
  }}
1005
1569
  isMicActive={isMicActive}
1006
1570
  isSpeakerMuted={isSpeakerMuted}
1007
1571
  isAISpeaking={isAISpeaking}
1572
+ isAgentTyping={isLiveAgentTyping}
1008
1573
  onStopSession={stopVoiceSession}
1009
1574
  isVoiceConnected={isVoiceConnected}
1010
1575
  onMicToggle={(active) => {
@@ -1033,9 +1598,28 @@ export function AIAgent({
1033
1598
  audioOutputRef.current?.unmute();
1034
1599
  }
1035
1600
  }}
1601
+ tickets={tickets}
1602
+ selectedTicketId={selectedTicketId}
1603
+ onTicketSelect={handleTicketSelect}
1604
+ onBackToTickets={handleBackToTickets}
1605
+ autoExpandTrigger={autoExpandTrigger}
1606
+ unreadCounts={unreadCounts}
1607
+ totalUnread={totalUnread}
1036
1608
  />
1037
1609
  </ProactiveHint>
1038
1610
  )}
1611
+
1612
+ {/* Support chat modal — opens when user taps a ticket */}
1613
+ <SupportChatModal
1614
+ visible={mode === 'human' && !!selectedTicketId}
1615
+ messages={messages}
1616
+ onSend={handleSend}
1617
+ onClose={handleBackToTickets}
1618
+ isAgentTyping={isLiveAgentTyping}
1619
+ isThinking={isThinking}
1620
+ scrollToEndTrigger={chatScrollTrigger}
1621
+ ticketStatus={tickets.find(t => t.id === selectedTicketId)?.status}
1622
+ />
1039
1623
  </View>
1040
1624
  </View>
1041
1625
  </AgentContext.Provider>