@mobileai/react-native 0.9.10 → 0.9.11

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 (74) hide show
  1. package/README.md +11 -0
  2. package/lib/module/components/AIAgent.js +513 -36
  3. package/lib/module/components/AIAgent.js.map +1 -1
  4. package/lib/module/components/AgentChatBar.js +320 -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 +39 -13
  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/support/EscalationSocket.js +46 -7
  19. package/lib/module/support/EscalationSocket.js.map +1 -1
  20. package/lib/module/support/SupportChatModal.js +516 -0
  21. package/lib/module/support/SupportChatModal.js.map +1 -0
  22. package/lib/module/support/TicketStore.js +93 -0
  23. package/lib/module/support/TicketStore.js.map +1 -0
  24. package/lib/module/support/escalateTool.js +39 -13
  25. package/lib/module/support/escalateTool.js.map +1 -1
  26. package/lib/module/support/index.js.map +1 -1
  27. package/lib/typescript/src/components/AIAgent.d.ts +24 -1
  28. package/lib/typescript/src/components/AIAgent.d.ts.map +1 -1
  29. package/lib/typescript/src/components/AgentChatBar.d.ts +24 -2
  30. package/lib/typescript/src/components/AgentChatBar.d.ts.map +1 -1
  31. package/lib/typescript/src/config/endpoints.d.ts +18 -0
  32. package/lib/typescript/src/config/endpoints.d.ts.map +1 -0
  33. package/lib/typescript/src/core/systemPrompt.d.ts +4 -13
  34. package/lib/typescript/src/core/systemPrompt.d.ts.map +1 -1
  35. package/lib/typescript/src/core/types.d.ts +1 -1
  36. package/lib/typescript/src/core/types.d.ts.map +1 -1
  37. package/lib/typescript/src/hooks/useAction.d.ts +2 -2
  38. package/lib/typescript/src/hooks/useAction.d.ts.map +1 -1
  39. package/lib/typescript/src/index.d.ts +1 -1
  40. package/lib/typescript/src/index.d.ts.map +1 -1
  41. package/lib/typescript/src/services/AudioInputService.d.ts.map +1 -1
  42. package/lib/typescript/src/services/telemetry/TelemetryService.d.ts.map +1 -1
  43. package/lib/typescript/src/services/telemetry/device.d.ts +15 -4
  44. package/lib/typescript/src/services/telemetry/device.d.ts.map +1 -1
  45. package/lib/typescript/src/support/EscalationSocket.d.ts +7 -1
  46. package/lib/typescript/src/support/EscalationSocket.d.ts.map +1 -1
  47. package/lib/typescript/src/support/SupportChatModal.d.ts +19 -0
  48. package/lib/typescript/src/support/SupportChatModal.d.ts.map +1 -0
  49. package/lib/typescript/src/support/TicketStore.d.ts +34 -0
  50. package/lib/typescript/src/support/TicketStore.d.ts.map +1 -0
  51. package/lib/typescript/src/support/escalateTool.d.ts +15 -1
  52. package/lib/typescript/src/support/escalateTool.d.ts.map +1 -1
  53. package/lib/typescript/src/support/index.d.ts +1 -1
  54. package/lib/typescript/src/support/index.d.ts.map +1 -1
  55. package/lib/typescript/src/support/types.d.ts +15 -0
  56. package/lib/typescript/src/support/types.d.ts.map +1 -1
  57. package/package.json +5 -1
  58. package/src/components/AIAgent.tsx +507 -36
  59. package/src/components/AgentChatBar.tsx +355 -9
  60. package/src/config/endpoints.ts +22 -0
  61. package/src/core/systemPrompt.ts +126 -100
  62. package/src/core/types.ts +1 -1
  63. package/src/hooks/useAction.ts +2 -2
  64. package/src/index.ts +1 -0
  65. package/src/services/AudioInputService.ts +9 -0
  66. package/src/services/flags/FlagService.ts +1 -1
  67. package/src/services/telemetry/TelemetryService.ts +40 -13
  68. package/src/services/telemetry/device.ts +88 -11
  69. package/src/support/EscalationSocket.ts +47 -8
  70. package/src/support/SupportChatModal.tsx +527 -0
  71. package/src/support/TicketStore.ts +100 -0
  72. package/src/support/escalateTool.ts +47 -13
  73. package/src/support/index.ts +1 -0
  74. package/src/support/types.ts +14 -0
@@ -29,12 +29,16 @@ 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 { SupportChatModal } from '../support/SupportChatModal';
41
+ import { ENDPOINTS } from '../config/endpoints';
38
42
 
39
43
  // ─── Context ───────────────────────────────────────────────────
40
44
 
@@ -190,6 +194,34 @@ interface AIAgentProps {
190
194
  * Proactive agent configuration (detects user hesitation)
191
195
  */
192
196
  proactiveHelp?: ProactiveHelpConfig;
197
+
198
+ // ── Support Configuration ────────────
199
+
200
+ /**
201
+ * Identity of the logged-in user.
202
+ * If provided, this enforces "one ticket per user" and shows the user profile
203
+ * in the Dashboard (name, email, plan, etc.).
204
+ */
205
+ userContext?: {
206
+ userId?: string;
207
+ name?: string;
208
+ email?: string;
209
+ phone?: string;
210
+ plan?: string;
211
+ custom?: Record<string, string | number | boolean>;
212
+ };
213
+
214
+ /**
215
+ * Device push token for offline support replies.
216
+ * Use '@react-native-firebase/messaging' or 'expo-notifications' to get this.
217
+ */
218
+ pushToken?: string;
219
+
220
+ /**
221
+ * The type of push token provided.
222
+ * "fcm" is recommended for universal bare/Expo support.
223
+ */
224
+ pushTokenType?: 'fcm' | 'expo' | 'apns';
193
225
  }
194
226
 
195
227
 
@@ -239,6 +271,9 @@ export function AIAgent({
239
271
  analyticsProxyUrl,
240
272
  analyticsProxyHeaders,
241
273
  proactiveHelp,
274
+ userContext,
275
+ pushToken,
276
+ pushTokenType,
242
277
  }: AIAgentProps) {
243
278
  // Configure logger based on debug prop
244
279
  React.useEffect(() => {
@@ -252,7 +287,61 @@ export function AIAgent({
252
287
  const [isThinking, setIsThinking] = useState(false);
253
288
  const [statusText, setStatusText] = useState('');
254
289
  const [lastResult, setLastResult] = useState<ExecutionResult | null>(null);
290
+ const [lastUserMessage, setLastUserMessage] = useState<string | null>(null);
255
291
  const [messages, setMessages] = useState<AIMessage[]>([]);
292
+ const [chatScrollTrigger, setChatScrollTrigger] = useState(0);
293
+
294
+ // Increment scroll trigger when messages change to auto-scroll chat modal
295
+ useEffect(() => {
296
+ if (messages.length > 0) {
297
+ setChatScrollTrigger(prev => prev + 1);
298
+ }
299
+ }, [messages.length]);
300
+
301
+ // ── Support Modal State ──
302
+ const [tickets, setTickets] = useState<import('../support/types').SupportTicket[]>([]);
303
+ const [selectedTicketId, setSelectedTicketId] = useState<string | null>(null);
304
+ const [supportSocket, setSupportSocket] = useState<EscalationSocket | null>(null);
305
+ const [isLiveAgentTyping, setIsLiveAgentTyping] = useState(false);
306
+ const [autoExpandTrigger, setAutoExpandTrigger] = useState(0);
307
+ const [unreadCounts, setUnreadCounts] = useState<Record<string, number>>({});
308
+ // Ref mirrors selectedTicketId — lets socket callbacks access current value
309
+ // without stale closures (sockets are long-lived, closures capture old state).
310
+ const selectedTicketIdRef = useRef<string | null>(null);
311
+ useEffect(() => { selectedTicketIdRef.current = selectedTicketId; }, [selectedTicketId]);
312
+ // Cache of live sockets by ticketId — keeps sockets alive even when user
313
+ // navigates back to the ticket list, so new messages still trigger badge updates.
314
+ const pendingSocketsRef = useRef<Map<string, EscalationSocket>>(new Map());
315
+
316
+ const totalUnread = Object.values(unreadCounts).reduce((sum, count) => sum + count, 0);
317
+
318
+ const clearSupport = useCallback((ticketId?: string) => {
319
+ if (ticketId) {
320
+ // Remove specific ticket + its cached socket
321
+ const cached = pendingSocketsRef.current.get(ticketId);
322
+ if (cached) { cached.disconnect(); pendingSocketsRef.current.delete(ticketId); }
323
+ setTickets(prev => prev.filter(t => t.id !== ticketId));
324
+ setUnreadCounts(prev => { const n = { ...prev }; delete n[ticketId]; return n; });
325
+ if (selectedTicketId === ticketId) {
326
+ supportSocket?.disconnect();
327
+ setSupportSocket(null);
328
+ setSelectedTicketId(null);
329
+ setIsLiveAgentTyping(false);
330
+ setMessages([]);
331
+ }
332
+ } else {
333
+ // Clear all — disconnect every cached socket
334
+ pendingSocketsRef.current.forEach(s => s.disconnect());
335
+ pendingSocketsRef.current.clear();
336
+ supportSocket?.disconnect();
337
+ setSupportSocket(null);
338
+ setSelectedTicketId(null);
339
+ setTickets([]);
340
+ setUnreadCounts({});
341
+ setIsLiveAgentTyping(false);
342
+ setMode('text');
343
+ }
344
+ }, [supportSocket, selectedTicketId]);
256
345
 
257
346
  const clearMessages = useCallback(() => {
258
347
  setMessages([]);
@@ -276,21 +365,352 @@ export function AIAgent({
276
365
  }),
277
366
  getHistory: () =>
278
367
  messages.map((m) => ({ role: m.role, content: m.content })),
279
- onHumanReply: (reply: string) => {
280
- setMessages((prev) => [
281
- ...prev,
282
- {
283
- id: `human-${Date.now()}`,
284
- role: 'assistant' as const,
285
- content: `👤 Human Agent: ${reply}`,
286
- timestamp: Date.now(),
287
- },
288
- ]);
368
+ userContext,
369
+ pushToken,
370
+ pushTokenType,
371
+ onEscalationStarted: (tid, socket) => {
372
+ logger.info('AIAgent', '★★★ onEscalationStarted FIRED — ticketId:', tid);
373
+ // Cache the live socket so handleTicketSelect can reuse it without reconnecting
374
+ pendingSocketsRef.current.set(tid, socket);
375
+ setTickets(prev => {
376
+ if (prev.find(t => t.id === tid)) {
377
+ logger.info('AIAgent', '★★★ Ticket already in list, skipping add');
378
+ return prev;
379
+ }
380
+ const newList = [{ id: tid, reason: 'New escalation', screen: 'unknown', status: 'open', history: [], createdAt: new Date().toISOString(), wsUrl: '' }, ...prev];
381
+ logger.info('AIAgent', '★★★ Tickets updated, new length:', newList.length);
382
+ return newList;
383
+ });
384
+ // Switch to human mode so the ticket LIST is visible — do NOT auto-select
385
+ setMode('human');
386
+ setAutoExpandTrigger(prev => {
387
+ const next = prev + 1;
388
+ logger.info('AIAgent', '★★★ autoExpandTrigger:', prev, '→', next);
389
+ return next;
390
+ });
391
+ logger.info('AIAgent', '★★★ setMode("human") called from onEscalationStarted');
392
+ },
393
+ onHumanReply: (reply: string, ticketId?: string) => {
394
+ if (ticketId) {
395
+ // Always update the ticket's history (source of truth for ticket cards)
396
+ setTickets(prev => prev.map(t => {
397
+ if (t.id !== ticketId) return t;
398
+ return {
399
+ ...t,
400
+ history: [...(t.history || []), { role: 'live_agent', content: reply, timestamp: new Date().toISOString() }],
401
+ };
402
+ }));
403
+
404
+ // Route via ref: only push to messages[] if user is viewing THIS ticket
405
+ if (selectedTicketIdRef.current === ticketId) {
406
+ const humanMsg: AIMessage = {
407
+ id: `human-${Date.now()}`,
408
+ role: 'live_agent' as any,
409
+ content: reply,
410
+ timestamp: Date.now(),
411
+ };
412
+ setMessages((prev) => [...prev, humanMsg]);
413
+ setLastResult({ success: true, message: `👤 ${reply}`, steps: [] });
414
+ } else {
415
+ // Not viewing this ticket — increment unread badge
416
+ setUnreadCounts(prev => ({
417
+ ...prev,
418
+ [ticketId]: (prev[ticketId] || 0) + 1,
419
+ }));
420
+ }
421
+ }
422
+ },
423
+ onTypingChange: (isTyping: boolean) => {
424
+ setIsLiveAgentTyping(isTyping);
425
+ },
426
+ onTicketClosed: (ticketId?: string) => {
427
+ logger.info('AIAgent', 'Ticket closed by agent — removing from list');
428
+ if (ticketId) {
429
+ setUnreadCounts(prev => {
430
+ const next = { ...prev };
431
+ delete next[ticketId];
432
+ return next;
433
+ });
434
+ }
435
+ clearSupport(selectedTicketId ?? undefined);
289
436
  },
290
437
  });
291
438
  // eslint-disable-next-line react-hooks/exhaustive-deps
292
439
  }, [analyticsKey, navRef, customTools]);
293
440
 
441
+ // ─── Restore pending tickets on app start ──────────────────────
442
+ useEffect(() => {
443
+ if (!analyticsKey) return;
444
+
445
+ void (async () => {
446
+ try {
447
+ // Wait for the device ID to be initialised before reading it.
448
+ // getDeviceId() is synchronous but returns null on cold start until
449
+ // initDeviceId() resolves — awaiting here prevents an early bail-out
450
+ // that would leave the Human tab hidden after an app refresh.
451
+ await initDeviceId();
452
+ const deviceId = getDeviceId();
453
+
454
+ logger.info('AIAgent', '★ Restore check — analyticsKey:', !!analyticsKey, 'userId:', userContext?.userId, 'pushToken:', !!pushToken, 'deviceId:', deviceId);
455
+ if (!userContext?.userId && !pushToken && !deviceId) return;
456
+
457
+ const query = new URLSearchParams({ analyticsKey });
458
+ if (userContext?.userId) query.append('userId', userContext.userId);
459
+ if (pushToken) query.append('pushToken', pushToken);
460
+ if (deviceId) query.append('deviceId', deviceId);
461
+
462
+ const url = `${ENDPOINTS.escalation}/api/v1/escalations/mine?${query.toString()}`;
463
+ logger.info('AIAgent', '★ Restore — fetching:', url);
464
+ const res = await fetch(url);
465
+
466
+ logger.info('AIAgent', '★ Restore — response status:', res.status);
467
+ if (!res.ok) return;
468
+
469
+ const data = await res.json();
470
+ const fetchedTickets: import('../support/types').SupportTicket[] = data.tickets ?? [];
471
+ logger.info('AIAgent', '★ Restore — found', fetchedTickets.length, 'active tickets');
472
+
473
+ if (fetchedTickets.length === 0) return;
474
+
475
+ // Initialize unread counts from backend (set together with tickets for instant badge)
476
+ const initialUnreadCounts: Record<string, number> = {};
477
+ for (const ticket of fetchedTickets) {
478
+ if (ticket.unreadCount && ticket.unreadCount > 0) {
479
+ initialUnreadCounts[ticket.id] = ticket.unreadCount;
480
+ }
481
+ }
482
+ setTickets(fetchedTickets);
483
+ setUnreadCounts(initialUnreadCounts);
484
+
485
+ // Show the ticket list without auto-selecting — user taps in (Intercom-style).
486
+ // setMode switches the widget to human mode so the list is immediately visible.
487
+ setMode('human');
488
+ setAutoExpandTrigger(prev => prev + 1);
489
+
490
+ // If there is exactly one ticket, pre-wire its WebSocket so it is ready
491
+ // the moment the user taps the card (no extra connect delay).
492
+ if (fetchedTickets.length === 1) {
493
+ const ticket = fetchedTickets[0]!;
494
+
495
+ if (ticket.history?.length) {
496
+ const restored: AIMessage[] = ticket.history.map(
497
+ (entry: { role: string; content: string; timestamp?: string }, i: number) => ({
498
+ id: `restored-${ticket.id}-${i}`,
499
+ role: (entry.role === 'live_agent' ? 'assistant' : entry.role) as any,
500
+ content: entry.content,
501
+ timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now(),
502
+ })
503
+ );
504
+ setMessages(restored);
505
+ }
506
+
507
+ const socket = new EscalationSocket({
508
+ onReply: (reply: string) => {
509
+ const tid = ticket.id;
510
+ // Always update ticket history
511
+ setTickets(prev => prev.map(t => {
512
+ if (t.id !== tid) return t;
513
+ return {
514
+ ...t,
515
+ history: [...(t.history || []), { role: 'live_agent', content: reply, timestamp: new Date().toISOString() }],
516
+ };
517
+ }));
518
+
519
+ // Route via ref: only push to messages[] if user is viewing THIS ticket
520
+ if (selectedTicketIdRef.current === tid) {
521
+ const msg: AIMessage = {
522
+ id: `human-${Date.now()}`,
523
+ role: 'assistant',
524
+ content: reply,
525
+ timestamp: Date.now(),
526
+ };
527
+ setMessages((prev) => [...prev, msg]);
528
+ setLastResult({ success: true, message: `👤 ${reply}`, steps: [] });
529
+ } else {
530
+ setUnreadCounts(prev => ({
531
+ ...prev,
532
+ [tid]: (prev[tid] || 0) + 1,
533
+ }));
534
+ }
535
+ },
536
+ onTypingChange: setIsLiveAgentTyping,
537
+ onTicketClosed: () => clearSupport(ticket.id),
538
+ onError: (err) => logger.error('AIAgent', '★ Restored socket error:', err),
539
+ });
540
+ socket.connect(ticket.wsUrl);
541
+ // Cache in pendingSocketsRef so handleTicketSelect reuses it without reconnecting
542
+ pendingSocketsRef.current.set(ticket.id, socket);
543
+ logger.info('AIAgent', '★ Single ticket restored and socket cached:', ticket.id);
544
+ }
545
+ } catch (err) {
546
+ logger.error('AIAgent', '★ Failed to restore tickets:', err);
547
+ }
548
+ })();
549
+ // eslint-disable-next-line react-hooks/exhaustive-deps
550
+ }, [analyticsKey]);
551
+
552
+ // ─── Ticket selection handlers ────────────────────────────────
553
+ const handleTicketSelect = useCallback(async (ticketId: string) => {
554
+ const ticket = tickets.find(t => t.id === ticketId);
555
+ if (!ticket) return;
556
+
557
+ // Cache (not disconnect!) the previous ticket's socket so it keeps
558
+ // receiving messages in the background and can update unread counts.
559
+ if (supportSocket && selectedTicketId && selectedTicketId !== ticketId) {
560
+ pendingSocketsRef.current.set(selectedTicketId, supportSocket);
561
+ setSupportSocket(null);
562
+ }
563
+
564
+ setSelectedTicketId(ticketId);
565
+ setMode('human');
566
+
567
+ // Clear unread count when user opens a ticket
568
+ setUnreadCounts(prev => {
569
+ if (!prev[ticketId]) return prev;
570
+ const next = { ...prev };
571
+ delete next[ticketId];
572
+ return next;
573
+ });
574
+
575
+ // Mark ticket as read on backend (source of truth)
576
+ (async () => {
577
+ try {
578
+ await fetch(
579
+ `${ENDPOINTS.escalation}/api/v1/escalations/${ticketId}/read?analyticsKey=${analyticsKey}`,
580
+ { method: 'POST' }
581
+ );
582
+ logger.info('AIAgent', '★ Marked ticket as read:', ticketId);
583
+ } catch (err) {
584
+ logger.warn('AIAgent', '★ Failed to mark ticket as read:', err);
585
+ }
586
+ })();
587
+
588
+ // Trigger scroll to bottom when modal opens
589
+ setChatScrollTrigger(prev => prev + 1);
590
+
591
+ // Fetch latest history from server — this is the source of truth and catches
592
+ // any messages that arrived while the socket was disconnected (modal closed,
593
+ // app backgrounded, etc.)
594
+ try {
595
+ const res = await fetch(
596
+ `${ENDPOINTS.escalation}/api/v1/escalations/${ticketId}?analyticsKey=${analyticsKey}`
597
+ );
598
+ if (res.ok) {
599
+ const data = await res.json();
600
+ const history: Array<{ role: string; content: string; timestamp?: string }> =
601
+ Array.isArray(data.history) ? data.history : [];
602
+ const restored: AIMessage[] = history.map((entry, i) => ({
603
+ id: `restored-${ticketId}-${i}`,
604
+ role: (entry.role === 'live_agent' ? 'assistant' : entry.role) as any,
605
+ content: entry.content,
606
+ timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now(),
607
+ }));
608
+ setMessages(restored);
609
+ // Update ticket in local list with fresh history
610
+ if (data.wsUrl) {
611
+ setTickets(prev => prev.map(t => t.id === ticketId ? { ...t, history, wsUrl: data.wsUrl } : t));
612
+ }
613
+ } else {
614
+ // Fallback to local ticket history
615
+ if (ticket.history?.length) {
616
+ const restored: AIMessage[] = ticket.history.map(
617
+ (entry: { role: string; content: string; timestamp?: string }, i: number) => ({
618
+ id: `restored-${ticketId}-${i}`,
619
+ role: (entry.role === 'live_agent' ? 'assistant' : entry.role) as any,
620
+ content: entry.content,
621
+ timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now(),
622
+ })
623
+ );
624
+ setMessages(restored);
625
+ } else {
626
+ setMessages([]);
627
+ }
628
+ }
629
+ } catch (err) {
630
+ logger.warn('AIAgent', '★ Failed to fetch ticket history, using local:', err);
631
+ if (ticket.history?.length) {
632
+ const restored: AIMessage[] = ticket.history.map(
633
+ (entry: { role: string; content: string; timestamp?: string }, i: number) => ({
634
+ id: `restored-${ticketId}-${i}`,
635
+ role: (entry.role === 'live_agent' ? 'assistant' : entry.role) as any,
636
+ content: entry.content,
637
+ timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now(),
638
+ })
639
+ );
640
+ setMessages(restored);
641
+ } else {
642
+ setMessages([]);
643
+ }
644
+ }
645
+
646
+ // Reuse the already-connected socket if escalation just happened,
647
+ // otherwise create a fresh connection from the ticket's stored wsUrl.
648
+ const cached = pendingSocketsRef.current.get(ticketId);
649
+ if (cached) {
650
+ pendingSocketsRef.current.delete(ticketId);
651
+ setSupportSocket(cached);
652
+ logger.info('AIAgent', '★ Reusing cached escalation socket for ticket:', ticketId);
653
+ return;
654
+ }
655
+
656
+ const socket = new EscalationSocket({
657
+ onReply: (reply: string) => {
658
+ // Always update ticket history
659
+ setTickets(prev => prev.map(t => {
660
+ if (t.id !== ticketId) return t;
661
+ return {
662
+ ...t,
663
+ history: [...(t.history || []), { role: 'live_agent', content: reply, timestamp: new Date().toISOString() }],
664
+ };
665
+ }));
666
+
667
+ // Route via ref: only push to messages[] if user is viewing THIS ticket
668
+ if (selectedTicketIdRef.current === ticketId) {
669
+ const msg: AIMessage = {
670
+ id: `human-${Date.now()}`,
671
+ role: 'assistant',
672
+ content: reply,
673
+ timestamp: Date.now(),
674
+ };
675
+ setMessages(prev => [...prev, msg]);
676
+ setLastResult({ success: true, message: `👤 ${reply}`, steps: [] });
677
+ } else {
678
+ setUnreadCounts(prev => ({
679
+ ...prev,
680
+ [ticketId]: (prev[ticketId] || 0) + 1,
681
+ }));
682
+ }
683
+ },
684
+ onTypingChange: setIsLiveAgentTyping,
685
+ onTicketClosed: (closedTicketId?: string) => {
686
+ if (closedTicketId) {
687
+ setUnreadCounts(prev => {
688
+ const next = { ...prev };
689
+ delete next[closedTicketId];
690
+ return next;
691
+ });
692
+ }
693
+ clearSupport(ticketId);
694
+ },
695
+ onError: (err) => logger.error('AIAgent', '★ Socket error on select:', err),
696
+ });
697
+ socket.connect(ticket.wsUrl);
698
+ setSupportSocket(socket);
699
+ }, [tickets, supportSocket, selectedTicketId, analyticsKey, clearSupport]);
700
+
701
+ const handleBackToTickets = useCallback(() => {
702
+ // Cache socket in pendingSocketsRef instead of disconnecting —
703
+ // keeps the WS alive so new messages update unreadCounts in real time.
704
+ if (supportSocket && selectedTicketId) {
705
+ pendingSocketsRef.current.set(selectedTicketId, supportSocket);
706
+ logger.info('AIAgent', '★ Socket cached for ticket:', selectedTicketId, '— stays alive for badge updates');
707
+ }
708
+ setSupportSocket(null);
709
+ setSelectedTicketId(null);
710
+ setMessages([]);
711
+ setIsLiveAgentTyping(false);
712
+ }, [supportSocket, selectedTicketId]);
713
+
294
714
  const mergedCustomTools = useMemo(() => {
295
715
  if (!autoEscalateTool) return customTools;
296
716
  return { escalate_to_human: autoEscalateTool, ...customTools };
@@ -312,13 +732,13 @@ export function AIAgent({
312
732
  const screenPollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
313
733
  const lastAgentErrorRef = useRef<string | null>(null);
314
734
 
315
- // Compute available modes from props
316
735
  const availableModes: AgentMode[] = useMemo(() => {
317
736
  const modes: AgentMode[] = ['text'];
318
737
  if (enableVoice) modes.push('voice');
319
- logger.info('AIAgent', `Available modes: ${modes.join(', ')}`);
738
+ if (tickets.length > 0) modes.push('human');
739
+ logger.info('AIAgent', '★ availableModes recomputed:', modes, '| tickets:', tickets.length, '| ticketIds:', tickets.map(t => t.id));
320
740
  return modes;
321
- }, [enableVoice]);
741
+ }, [enableVoice, tickets]);
322
742
 
323
743
  // Ref-based resolver for ask_user — stays alive across renders
324
744
  const askUserResolverRef = useRef<((answer: string) => void) | null>(null);
@@ -405,21 +825,19 @@ export function AIAgent({
405
825
  return;
406
826
  }
407
827
 
408
- const telemetry = new TelemetryService({
409
- analyticsKey,
410
- analyticsProxyUrl,
411
- analyticsProxyHeaders,
412
- debug,
413
- });
414
- telemetryRef.current = telemetry;
415
- bindTelemetryService(telemetry);
416
- telemetry.start();
828
+ // Initialize persistent device ID before telemetry starts
829
+ initDeviceId().then(() => {
417
830
 
418
- return () => {
419
- telemetry.stop();
420
- telemetryRef.current = null;
421
- bindTelemetryService(null);
422
- };
831
+ const telemetry = new TelemetryService({
832
+ analyticsKey,
833
+ analyticsProxyUrl,
834
+ analyticsProxyHeaders,
835
+ debug,
836
+ });
837
+ telemetryRef.current = telemetry;
838
+ bindTelemetryService(telemetry);
839
+ telemetry.start();
840
+ }); // initDeviceId
423
841
  }, [analyticsKey, analyticsProxyUrl, analyticsProxyHeaders, debug]);
424
842
 
425
843
  // ─── Security warnings ──────────────────────────────────────
@@ -427,7 +845,7 @@ export function AIAgent({
427
845
  useEffect(() => {
428
846
  // @ts-ignore
429
847
  if (typeof __DEV__ !== 'undefined' && !__DEV__ && apiKey && !proxyUrl) {
430
- console.warn(
848
+ logger.warn(
431
849
  '[MobileAI] ⚠️ SECURITY WARNING: You are using `apiKey` directly in a production build. ' +
432
850
  'This exposes your LLM provider key in the app binary. ' +
433
851
  'Use `apiProxyUrl` to route requests through your backend instead. ' +
@@ -501,10 +919,10 @@ export function AIAgent({
501
919
 
502
920
  // ─── Voice/Live Service Initialization ──────────────────────
503
921
 
504
- // Initialize voice services when mode changes to voice or live
922
+ // Initialize voice services when mode changes to voice
505
923
  useEffect(() => {
506
- if (mode === 'text') {
507
- logger.info('AIAgent', 'Text mode — skipping voice service init');
924
+ if (mode !== 'voice') {
925
+ logger.info('AIAgent', `Mode ${mode} — skipping voice service init`);
508
926
  return;
509
927
  }
510
928
 
@@ -801,8 +1219,34 @@ export function AIAgent({
801
1219
  if (!message.trim() || isThinking) return;
802
1220
 
803
1221
  logger.info('AIAgent', `User message: "${message}"`);
1222
+ setLastUserMessage(message.trim());
1223
+
1224
+ // Intercom-style transparent intercept:
1225
+ // If we're connected to a human agent, all text input goes directly to them.
1226
+ if (selectedTicketId && supportSocket) {
1227
+ if (supportSocket.sendText(message)) {
1228
+ setMessages((prev) => [
1229
+ ...prev,
1230
+ { id: `user-${Date.now()}`, role: 'user', content: message.trim(), timestamp: Date.now() },
1231
+ ]);
1232
+
1233
+ setIsThinking(true);
1234
+ setStatusText('Sending to agent...');
1235
+ setTimeout(() => {
1236
+ setIsThinking(false);
1237
+ setStatusText('');
1238
+ }, 800);
1239
+ } else {
1240
+ setLastResult({
1241
+ success: false,
1242
+ message: 'Failed to send message to support agent. Connection lost.',
1243
+ steps: [],
1244
+ });
1245
+ }
1246
+ return;
1247
+ }
804
1248
 
805
- // Append user message
1249
+ // Append user message to AI thread
806
1250
  setMessages((prev) => [
807
1251
  ...prev,
808
1252
  {
@@ -859,9 +1303,16 @@ export function AIAgent({
859
1303
  });
860
1304
  }
861
1305
 
862
- setLastResult(result);
1306
+ logger.info('AIAgent', '★ handleSend — SETTING lastResult:', result.message.substring(0, 80), '| mode:', mode);
1307
+ logger.info('AIAgent', '★ handleSend — tickets:', tickets.length, 'selectedTicketId:', selectedTicketId);
1308
+
1309
+ // Don't overwrite lastResult if escalation already switched us to human mode
1310
+ // (mode in this closure is stale — the actual mode may have changed during async execution)
1311
+ const stepsHadEscalation = result.steps?.some(s => s.action.name === 'escalate_to_human');
1312
+ if (!stepsHadEscalation) {
1313
+ setLastResult(result);
1314
+ }
863
1315
 
864
- // Append assistant message
865
1316
  setMessages((prev) => [
866
1317
  ...prev,
867
1318
  {
@@ -990,8 +1441,9 @@ export function AIAgent({
990
1441
  onSend={handleSend}
991
1442
  isThinking={isThinking}
992
1443
  lastResult={lastResult}
1444
+ lastUserMessage={lastUserMessage}
993
1445
  language={'en'}
994
- onDismiss={() => setLastResult(null)}
1446
+ onDismiss={() => { setLastResult(null); setLastUserMessage(null); }}
995
1447
  theme={accentColor || theme ? {
996
1448
  ...(accentColor ? { primaryColor: accentColor } : {}),
997
1449
  ...theme,
@@ -999,12 +1451,13 @@ export function AIAgent({
999
1451
  availableModes={availableModes}
1000
1452
  mode={mode}
1001
1453
  onModeChange={(newMode) => {
1002
- logger.info('AIAgent', `Mode change: ${mode}${newMode}`);
1454
+ logger.info('AIAgent', '★ onModeChange:', mode, '', newMode, '| tickets:', tickets.length, 'selectedTicketId:', selectedTicketId);
1003
1455
  setMode(newMode);
1004
1456
  }}
1005
1457
  isMicActive={isMicActive}
1006
1458
  isSpeakerMuted={isSpeakerMuted}
1007
1459
  isAISpeaking={isAISpeaking}
1460
+ isAgentTyping={isLiveAgentTyping}
1008
1461
  onStopSession={stopVoiceSession}
1009
1462
  isVoiceConnected={isVoiceConnected}
1010
1463
  onMicToggle={(active) => {
@@ -1033,9 +1486,27 @@ export function AIAgent({
1033
1486
  audioOutputRef.current?.unmute();
1034
1487
  }
1035
1488
  }}
1489
+ tickets={tickets}
1490
+ selectedTicketId={selectedTicketId}
1491
+ onTicketSelect={handleTicketSelect}
1492
+ onBackToTickets={handleBackToTickets}
1493
+ autoExpandTrigger={autoExpandTrigger}
1494
+ unreadCounts={unreadCounts}
1495
+ totalUnread={totalUnread}
1036
1496
  />
1037
1497
  </ProactiveHint>
1038
1498
  )}
1499
+
1500
+ {/* Support chat modal — opens when user taps a ticket */}
1501
+ <SupportChatModal
1502
+ visible={mode === 'human' && !!selectedTicketId}
1503
+ messages={messages}
1504
+ onSend={handleSend}
1505
+ onClose={handleBackToTickets}
1506
+ isAgentTyping={isLiveAgentTyping}
1507
+ isThinking={isThinking}
1508
+ scrollToEndTrigger={chatScrollTrigger}
1509
+ />
1039
1510
  </View>
1040
1511
  </View>
1041
1512
  </AgentContext.Provider>