@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
package/README.md CHANGED
@@ -198,6 +198,17 @@ Then rebuild: `npx expo prebuild && npx expo run:android` (or `run:ios`)
198
198
 
199
199
  </details>
200
200
 
201
+ <details>
202
+ <summary><b>💬 Human Support</b> — persist tickets and restore them across sessions</summary>
203
+
204
+ ```bash
205
+ npx expo install @react-native-async-storage/async-storage
206
+ ```
207
+
208
+ **Optional** but recommended when using human escalation support. Without it, support tickets are only visible during the current app session and won't be restored after the app restarts.
209
+
210
+ </details>
211
+
201
212
  ---
202
213
 
203
214
  ## 🚀 Quick Start
@@ -25,11 +25,15 @@ import { AudioInputService } from "../services/AudioInputService.js";
25
25
  import { AudioOutputService } from "../services/AudioOutputService.js";
26
26
  import { TelemetryService, bindTelemetryService } from "../services/telemetry/index.js";
27
27
  import { extractTouchLabel, checkRageClick } from "../services/telemetry/TouchAutoCapture.js";
28
+ import { initDeviceId, getDeviceId } from "../services/telemetry/device.js";
28
29
  import { AgentErrorBoundary } from "./AgentErrorBoundary.js";
29
30
  import { HighlightOverlay } from "./HighlightOverlay.js";
30
31
  import { IdleDetector } from "../core/IdleDetector.js";
31
32
  import { ProactiveHint } from "./ProactiveHint.js";
32
33
  import { createEscalateTool } from "../support/escalateTool.js";
34
+ import { EscalationSocket } from "../support/EscalationSocket.js";
35
+ import { SupportChatModal } from "../support/SupportChatModal.js";
36
+ import { ENDPOINTS } from "../config/endpoints.js";
33
37
 
34
38
  // ─── Context ───────────────────────────────────────────────────
35
39
 
@@ -79,7 +83,10 @@ export function AIAgent({
79
83
  analyticsKey,
80
84
  analyticsProxyUrl,
81
85
  analyticsProxyHeaders,
82
- proactiveHelp
86
+ proactiveHelp,
87
+ userContext,
88
+ pushToken,
89
+ pushTokenType
83
90
  }) {
84
91
  // Configure logger based on debug prop
85
92
  React.useEffect(() => {
@@ -92,7 +99,70 @@ export function AIAgent({
92
99
  const [isThinking, setIsThinking] = useState(false);
93
100
  const [statusText, setStatusText] = useState('');
94
101
  const [lastResult, setLastResult] = useState(null);
102
+ const [lastUserMessage, setLastUserMessage] = useState(null);
95
103
  const [messages, setMessages] = useState([]);
104
+ const [chatScrollTrigger, setChatScrollTrigger] = useState(0);
105
+
106
+ // Increment scroll trigger when messages change to auto-scroll chat modal
107
+ useEffect(() => {
108
+ if (messages.length > 0) {
109
+ setChatScrollTrigger(prev => prev + 1);
110
+ }
111
+ }, [messages.length]);
112
+
113
+ // ── Support Modal State ──
114
+ const [tickets, setTickets] = useState([]);
115
+ const [selectedTicketId, setSelectedTicketId] = useState(null);
116
+ const [supportSocket, setSupportSocket] = useState(null);
117
+ const [isLiveAgentTyping, setIsLiveAgentTyping] = useState(false);
118
+ const [autoExpandTrigger, setAutoExpandTrigger] = useState(0);
119
+ const [unreadCounts, setUnreadCounts] = useState({});
120
+ // Ref mirrors selectedTicketId — lets socket callbacks access current value
121
+ // without stale closures (sockets are long-lived, closures capture old state).
122
+ const selectedTicketIdRef = useRef(null);
123
+ useEffect(() => {
124
+ selectedTicketIdRef.current = selectedTicketId;
125
+ }, [selectedTicketId]);
126
+ // Cache of live sockets by ticketId — keeps sockets alive even when user
127
+ // navigates back to the ticket list, so new messages still trigger badge updates.
128
+ const pendingSocketsRef = useRef(new Map());
129
+ const totalUnread = Object.values(unreadCounts).reduce((sum, count) => sum + count, 0);
130
+ const clearSupport = useCallback(ticketId => {
131
+ if (ticketId) {
132
+ // Remove specific ticket + its cached socket
133
+ const cached = pendingSocketsRef.current.get(ticketId);
134
+ if (cached) {
135
+ cached.disconnect();
136
+ pendingSocketsRef.current.delete(ticketId);
137
+ }
138
+ setTickets(prev => prev.filter(t => t.id !== ticketId));
139
+ setUnreadCounts(prev => {
140
+ const n = {
141
+ ...prev
142
+ };
143
+ delete n[ticketId];
144
+ return n;
145
+ });
146
+ if (selectedTicketId === ticketId) {
147
+ supportSocket?.disconnect();
148
+ setSupportSocket(null);
149
+ setSelectedTicketId(null);
150
+ setIsLiveAgentTyping(false);
151
+ setMessages([]);
152
+ }
153
+ } else {
154
+ // Clear all — disconnect every cached socket
155
+ pendingSocketsRef.current.forEach(s => s.disconnect());
156
+ pendingSocketsRef.current.clear();
157
+ supportSocket?.disconnect();
158
+ setSupportSocket(null);
159
+ setSelectedTicketId(null);
160
+ setTickets([]);
161
+ setUnreadCounts({});
162
+ setIsLiveAgentTyping(false);
163
+ setMode('text');
164
+ }
165
+ }, [supportSocket, selectedTicketId]);
96
166
  const clearMessages = useCallback(() => {
97
167
  setMessages([]);
98
168
  setLastResult(null);
@@ -119,17 +189,373 @@ export function AIAgent({
119
189
  role: m.role,
120
190
  content: m.content
121
191
  })),
122
- onHumanReply: reply => {
123
- setMessages(prev => [...prev, {
124
- id: `human-${Date.now()}`,
125
- role: 'assistant',
126
- content: `👤 Human Agent: ${reply}`,
127
- timestamp: Date.now()
128
- }]);
192
+ userContext,
193
+ pushToken,
194
+ pushTokenType,
195
+ onEscalationStarted: (tid, socket) => {
196
+ logger.info('AIAgent', '★★★ onEscalationStarted FIRED — ticketId:', tid);
197
+ // Cache the live socket so handleTicketSelect can reuse it without reconnecting
198
+ pendingSocketsRef.current.set(tid, socket);
199
+ setTickets(prev => {
200
+ if (prev.find(t => t.id === tid)) {
201
+ logger.info('AIAgent', '★★★ Ticket already in list, skipping add');
202
+ return prev;
203
+ }
204
+ const newList = [{
205
+ id: tid,
206
+ reason: 'New escalation',
207
+ screen: 'unknown',
208
+ status: 'open',
209
+ history: [],
210
+ createdAt: new Date().toISOString(),
211
+ wsUrl: ''
212
+ }, ...prev];
213
+ logger.info('AIAgent', '★★★ Tickets updated, new length:', newList.length);
214
+ return newList;
215
+ });
216
+ // Switch to human mode so the ticket LIST is visible — do NOT auto-select
217
+ setMode('human');
218
+ setAutoExpandTrigger(prev => {
219
+ const next = prev + 1;
220
+ logger.info('AIAgent', '★★★ autoExpandTrigger:', prev, '→', next);
221
+ return next;
222
+ });
223
+ logger.info('AIAgent', '★★★ setMode("human") called from onEscalationStarted');
224
+ },
225
+ onHumanReply: (reply, ticketId) => {
226
+ if (ticketId) {
227
+ // Always update the ticket's history (source of truth for ticket cards)
228
+ setTickets(prev => prev.map(t => {
229
+ if (t.id !== ticketId) return t;
230
+ return {
231
+ ...t,
232
+ history: [...(t.history || []), {
233
+ role: 'live_agent',
234
+ content: reply,
235
+ timestamp: new Date().toISOString()
236
+ }]
237
+ };
238
+ }));
239
+
240
+ // Route via ref: only push to messages[] if user is viewing THIS ticket
241
+ if (selectedTicketIdRef.current === ticketId) {
242
+ const humanMsg = {
243
+ id: `human-${Date.now()}`,
244
+ role: 'live_agent',
245
+ content: reply,
246
+ timestamp: Date.now()
247
+ };
248
+ setMessages(prev => [...prev, humanMsg]);
249
+ setLastResult({
250
+ success: true,
251
+ message: `👤 ${reply}`,
252
+ steps: []
253
+ });
254
+ } else {
255
+ // Not viewing this ticket — increment unread badge
256
+ setUnreadCounts(prev => ({
257
+ ...prev,
258
+ [ticketId]: (prev[ticketId] || 0) + 1
259
+ }));
260
+ }
261
+ }
262
+ },
263
+ onTypingChange: isTyping => {
264
+ setIsLiveAgentTyping(isTyping);
265
+ },
266
+ onTicketClosed: ticketId => {
267
+ logger.info('AIAgent', 'Ticket closed by agent — removing from list');
268
+ if (ticketId) {
269
+ setUnreadCounts(prev => {
270
+ const next = {
271
+ ...prev
272
+ };
273
+ delete next[ticketId];
274
+ return next;
275
+ });
276
+ }
277
+ clearSupport(selectedTicketId ?? undefined);
129
278
  }
130
279
  });
131
280
  // eslint-disable-next-line react-hooks/exhaustive-deps
132
281
  }, [analyticsKey, navRef, customTools]);
282
+
283
+ // ─── Restore pending tickets on app start ──────────────────────
284
+ useEffect(() => {
285
+ if (!analyticsKey) return;
286
+ void (async () => {
287
+ try {
288
+ // Wait for the device ID to be initialised before reading it.
289
+ // getDeviceId() is synchronous but returns null on cold start until
290
+ // initDeviceId() resolves — awaiting here prevents an early bail-out
291
+ // that would leave the Human tab hidden after an app refresh.
292
+ await initDeviceId();
293
+ const deviceId = getDeviceId();
294
+ logger.info('AIAgent', '★ Restore check — analyticsKey:', !!analyticsKey, 'userId:', userContext?.userId, 'pushToken:', !!pushToken, 'deviceId:', deviceId);
295
+ if (!userContext?.userId && !pushToken && !deviceId) return;
296
+ const query = new URLSearchParams({
297
+ analyticsKey
298
+ });
299
+ if (userContext?.userId) query.append('userId', userContext.userId);
300
+ if (pushToken) query.append('pushToken', pushToken);
301
+ if (deviceId) query.append('deviceId', deviceId);
302
+ const url = `${ENDPOINTS.escalation}/api/v1/escalations/mine?${query.toString()}`;
303
+ logger.info('AIAgent', '★ Restore — fetching:', url);
304
+ const res = await fetch(url);
305
+ logger.info('AIAgent', '★ Restore — response status:', res.status);
306
+ if (!res.ok) return;
307
+ const data = await res.json();
308
+ const fetchedTickets = data.tickets ?? [];
309
+ logger.info('AIAgent', '★ Restore — found', fetchedTickets.length, 'active tickets');
310
+ if (fetchedTickets.length === 0) return;
311
+
312
+ // Initialize unread counts from backend (set together with tickets for instant badge)
313
+ const initialUnreadCounts = {};
314
+ for (const ticket of fetchedTickets) {
315
+ if (ticket.unreadCount && ticket.unreadCount > 0) {
316
+ initialUnreadCounts[ticket.id] = ticket.unreadCount;
317
+ }
318
+ }
319
+ setTickets(fetchedTickets);
320
+ setUnreadCounts(initialUnreadCounts);
321
+
322
+ // Show the ticket list without auto-selecting — user taps in (Intercom-style).
323
+ // setMode switches the widget to human mode so the list is immediately visible.
324
+ setMode('human');
325
+ setAutoExpandTrigger(prev => prev + 1);
326
+
327
+ // If there is exactly one ticket, pre-wire its WebSocket so it is ready
328
+ // the moment the user taps the card (no extra connect delay).
329
+ if (fetchedTickets.length === 1) {
330
+ const ticket = fetchedTickets[0];
331
+ if (ticket.history?.length) {
332
+ const restored = ticket.history.map((entry, i) => ({
333
+ id: `restored-${ticket.id}-${i}`,
334
+ role: entry.role === 'live_agent' ? 'assistant' : entry.role,
335
+ content: entry.content,
336
+ timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now()
337
+ }));
338
+ setMessages(restored);
339
+ }
340
+ const socket = new EscalationSocket({
341
+ onReply: reply => {
342
+ const tid = ticket.id;
343
+ // Always update ticket history
344
+ setTickets(prev => prev.map(t => {
345
+ if (t.id !== tid) return t;
346
+ return {
347
+ ...t,
348
+ history: [...(t.history || []), {
349
+ role: 'live_agent',
350
+ content: reply,
351
+ timestamp: new Date().toISOString()
352
+ }]
353
+ };
354
+ }));
355
+
356
+ // Route via ref: only push to messages[] if user is viewing THIS ticket
357
+ if (selectedTicketIdRef.current === tid) {
358
+ const msg = {
359
+ id: `human-${Date.now()}`,
360
+ role: 'assistant',
361
+ content: reply,
362
+ timestamp: Date.now()
363
+ };
364
+ setMessages(prev => [...prev, msg]);
365
+ setLastResult({
366
+ success: true,
367
+ message: `👤 ${reply}`,
368
+ steps: []
369
+ });
370
+ } else {
371
+ setUnreadCounts(prev => ({
372
+ ...prev,
373
+ [tid]: (prev[tid] || 0) + 1
374
+ }));
375
+ }
376
+ },
377
+ onTypingChange: setIsLiveAgentTyping,
378
+ onTicketClosed: () => clearSupport(ticket.id),
379
+ onError: err => logger.error('AIAgent', '★ Restored socket error:', err)
380
+ });
381
+ socket.connect(ticket.wsUrl);
382
+ // Cache in pendingSocketsRef so handleTicketSelect reuses it without reconnecting
383
+ pendingSocketsRef.current.set(ticket.id, socket);
384
+ logger.info('AIAgent', '★ Single ticket restored and socket cached:', ticket.id);
385
+ }
386
+ } catch (err) {
387
+ logger.error('AIAgent', '★ Failed to restore tickets:', err);
388
+ }
389
+ })();
390
+ // eslint-disable-next-line react-hooks/exhaustive-deps
391
+ }, [analyticsKey]);
392
+
393
+ // ─── Ticket selection handlers ────────────────────────────────
394
+ const handleTicketSelect = useCallback(async ticketId => {
395
+ const ticket = tickets.find(t => t.id === ticketId);
396
+ if (!ticket) return;
397
+
398
+ // Cache (not disconnect!) the previous ticket's socket so it keeps
399
+ // receiving messages in the background and can update unread counts.
400
+ if (supportSocket && selectedTicketId && selectedTicketId !== ticketId) {
401
+ pendingSocketsRef.current.set(selectedTicketId, supportSocket);
402
+ setSupportSocket(null);
403
+ }
404
+ setSelectedTicketId(ticketId);
405
+ setMode('human');
406
+
407
+ // Clear unread count when user opens a ticket
408
+ setUnreadCounts(prev => {
409
+ if (!prev[ticketId]) return prev;
410
+ const next = {
411
+ ...prev
412
+ };
413
+ delete next[ticketId];
414
+ return next;
415
+ });
416
+
417
+ // Mark ticket as read on backend (source of truth)
418
+ (async () => {
419
+ try {
420
+ await fetch(`${ENDPOINTS.escalation}/api/v1/escalations/${ticketId}/read?analyticsKey=${analyticsKey}`, {
421
+ method: 'POST'
422
+ });
423
+ logger.info('AIAgent', '★ Marked ticket as read:', ticketId);
424
+ } catch (err) {
425
+ logger.warn('AIAgent', '★ Failed to mark ticket as read:', err);
426
+ }
427
+ })();
428
+
429
+ // Trigger scroll to bottom when modal opens
430
+ setChatScrollTrigger(prev => prev + 1);
431
+
432
+ // Fetch latest history from server — this is the source of truth and catches
433
+ // any messages that arrived while the socket was disconnected (modal closed,
434
+ // app backgrounded, etc.)
435
+ try {
436
+ const res = await fetch(`${ENDPOINTS.escalation}/api/v1/escalations/${ticketId}?analyticsKey=${analyticsKey}`);
437
+ if (res.ok) {
438
+ const data = await res.json();
439
+ const history = Array.isArray(data.history) ? data.history : [];
440
+ const restored = history.map((entry, i) => ({
441
+ id: `restored-${ticketId}-${i}`,
442
+ role: entry.role === 'live_agent' ? 'assistant' : entry.role,
443
+ content: entry.content,
444
+ timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now()
445
+ }));
446
+ setMessages(restored);
447
+ // Update ticket in local list with fresh history
448
+ if (data.wsUrl) {
449
+ setTickets(prev => prev.map(t => t.id === ticketId ? {
450
+ ...t,
451
+ history,
452
+ wsUrl: data.wsUrl
453
+ } : t));
454
+ }
455
+ } else {
456
+ // Fallback to local ticket history
457
+ if (ticket.history?.length) {
458
+ const restored = ticket.history.map((entry, i) => ({
459
+ id: `restored-${ticketId}-${i}`,
460
+ role: entry.role === 'live_agent' ? 'assistant' : entry.role,
461
+ content: entry.content,
462
+ timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now()
463
+ }));
464
+ setMessages(restored);
465
+ } else {
466
+ setMessages([]);
467
+ }
468
+ }
469
+ } catch (err) {
470
+ logger.warn('AIAgent', '★ Failed to fetch ticket history, using local:', err);
471
+ if (ticket.history?.length) {
472
+ const restored = ticket.history.map((entry, i) => ({
473
+ id: `restored-${ticketId}-${i}`,
474
+ role: entry.role === 'live_agent' ? 'assistant' : entry.role,
475
+ content: entry.content,
476
+ timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now()
477
+ }));
478
+ setMessages(restored);
479
+ } else {
480
+ setMessages([]);
481
+ }
482
+ }
483
+
484
+ // Reuse the already-connected socket if escalation just happened,
485
+ // otherwise create a fresh connection from the ticket's stored wsUrl.
486
+ const cached = pendingSocketsRef.current.get(ticketId);
487
+ if (cached) {
488
+ pendingSocketsRef.current.delete(ticketId);
489
+ setSupportSocket(cached);
490
+ logger.info('AIAgent', '★ Reusing cached escalation socket for ticket:', ticketId);
491
+ return;
492
+ }
493
+ const socket = new EscalationSocket({
494
+ onReply: reply => {
495
+ // Always update ticket history
496
+ setTickets(prev => prev.map(t => {
497
+ if (t.id !== ticketId) return t;
498
+ return {
499
+ ...t,
500
+ history: [...(t.history || []), {
501
+ role: 'live_agent',
502
+ content: reply,
503
+ timestamp: new Date().toISOString()
504
+ }]
505
+ };
506
+ }));
507
+
508
+ // Route via ref: only push to messages[] if user is viewing THIS ticket
509
+ if (selectedTicketIdRef.current === ticketId) {
510
+ const msg = {
511
+ id: `human-${Date.now()}`,
512
+ role: 'assistant',
513
+ content: reply,
514
+ timestamp: Date.now()
515
+ };
516
+ setMessages(prev => [...prev, msg]);
517
+ setLastResult({
518
+ success: true,
519
+ message: `👤 ${reply}`,
520
+ steps: []
521
+ });
522
+ } else {
523
+ setUnreadCounts(prev => ({
524
+ ...prev,
525
+ [ticketId]: (prev[ticketId] || 0) + 1
526
+ }));
527
+ }
528
+ },
529
+ onTypingChange: setIsLiveAgentTyping,
530
+ onTicketClosed: closedTicketId => {
531
+ if (closedTicketId) {
532
+ setUnreadCounts(prev => {
533
+ const next = {
534
+ ...prev
535
+ };
536
+ delete next[closedTicketId];
537
+ return next;
538
+ });
539
+ }
540
+ clearSupport(ticketId);
541
+ },
542
+ onError: err => logger.error('AIAgent', '★ Socket error on select:', err)
543
+ });
544
+ socket.connect(ticket.wsUrl);
545
+ setSupportSocket(socket);
546
+ }, [tickets, supportSocket, selectedTicketId, analyticsKey, clearSupport]);
547
+ const handleBackToTickets = useCallback(() => {
548
+ // Cache socket in pendingSocketsRef instead of disconnecting —
549
+ // keeps the WS alive so new messages update unreadCounts in real time.
550
+ if (supportSocket && selectedTicketId) {
551
+ pendingSocketsRef.current.set(selectedTicketId, supportSocket);
552
+ logger.info('AIAgent', '★ Socket cached for ticket:', selectedTicketId, '— stays alive for badge updates');
553
+ }
554
+ setSupportSocket(null);
555
+ setSelectedTicketId(null);
556
+ setMessages([]);
557
+ setIsLiveAgentTyping(false);
558
+ }, [supportSocket, selectedTicketId]);
133
559
  const mergedCustomTools = useMemo(() => {
134
560
  if (!autoEscalateTool) return customTools;
135
561
  return {
@@ -152,14 +578,13 @@ export function AIAgent({
152
578
  const lastScreenContextRef = useRef('');
153
579
  const screenPollIntervalRef = useRef(null);
154
580
  const lastAgentErrorRef = useRef(null);
155
-
156
- // Compute available modes from props
157
581
  const availableModes = useMemo(() => {
158
582
  const modes = ['text'];
159
583
  if (enableVoice) modes.push('voice');
160
- logger.info('AIAgent', `Available modes: ${modes.join(', ')}`);
584
+ if (tickets.length > 0) modes.push('human');
585
+ logger.info('AIAgent', '★ availableModes recomputed:', modes, '| tickets:', tickets.length, '| ticketIds:', tickets.map(t => t.id));
161
586
  return modes;
162
- }, [enableVoice]);
587
+ }, [enableVoice, tickets]);
163
588
 
164
589
  // Ref-based resolver for ask_user — stays alive across renders
165
590
  const askUserResolverRef = useRef(null);
@@ -236,20 +661,19 @@ export function AIAgent({
236
661
  bindTelemetryService(null);
237
662
  return;
238
663
  }
239
- const telemetry = new TelemetryService({
240
- analyticsKey,
241
- analyticsProxyUrl,
242
- analyticsProxyHeaders,
243
- debug
244
- });
245
- telemetryRef.current = telemetry;
246
- bindTelemetryService(telemetry);
247
- telemetry.start();
248
- return () => {
249
- telemetry.stop();
250
- telemetryRef.current = null;
251
- bindTelemetryService(null);
252
- };
664
+
665
+ // Initialize persistent device ID before telemetry starts
666
+ initDeviceId().then(() => {
667
+ const telemetry = new TelemetryService({
668
+ analyticsKey,
669
+ analyticsProxyUrl,
670
+ analyticsProxyHeaders,
671
+ debug
672
+ });
673
+ telemetryRef.current = telemetry;
674
+ bindTelemetryService(telemetry);
675
+ telemetry.start();
676
+ }); // initDeviceId
253
677
  }, [analyticsKey, analyticsProxyUrl, analyticsProxyHeaders, debug]);
254
678
 
255
679
  // ─── Security warnings ──────────────────────────────────────
@@ -257,7 +681,7 @@ export function AIAgent({
257
681
  useEffect(() => {
258
682
  // @ts-ignore
259
683
  if (typeof __DEV__ !== 'undefined' && !__DEV__ && apiKey && !proxyUrl) {
260
- console.warn('[MobileAI] ⚠️ SECURITY WARNING: You are using `apiKey` directly in a production build. ' + 'This exposes your LLM provider key in the app binary. ' + 'Use `apiProxyUrl` to route requests through your backend instead. ' + 'See docs for details.');
684
+ logger.warn('[MobileAI] ⚠️ SECURITY WARNING: You are using `apiKey` directly in a production build. ' + 'This exposes your LLM provider key in the app binary. ' + 'Use `apiProxyUrl` to route requests through your backend instead. ' + 'See docs for details.');
261
685
  }
262
686
  }, [apiKey, proxyUrl]);
263
687
 
@@ -318,10 +742,10 @@ export function AIAgent({
318
742
 
319
743
  // ─── Voice/Live Service Initialization ──────────────────────
320
744
 
321
- // Initialize voice services when mode changes to voice or live
745
+ // Initialize voice services when mode changes to voice
322
746
  useEffect(() => {
323
- if (mode === 'text') {
324
- logger.info('AIAgent', 'Text mode — skipping voice service init');
747
+ if (mode !== 'voice') {
748
+ logger.info('AIAgent', `Mode ${mode} — skipping voice service init`);
325
749
  return;
326
750
  }
327
751
  logger.info('AIAgent', `Mode changed to "${mode}" — initializing voice services...`);
@@ -599,8 +1023,35 @@ export function AIAgent({
599
1023
  const handleSend = useCallback(async (message, options) => {
600
1024
  if (!message.trim() || isThinking) return;
601
1025
  logger.info('AIAgent', `User message: "${message}"`);
1026
+ setLastUserMessage(message.trim());
602
1027
 
603
- // Append user message
1028
+ // Intercom-style transparent intercept:
1029
+ // If we're connected to a human agent, all text input goes directly to them.
1030
+ if (selectedTicketId && supportSocket) {
1031
+ if (supportSocket.sendText(message)) {
1032
+ setMessages(prev => [...prev, {
1033
+ id: `user-${Date.now()}`,
1034
+ role: 'user',
1035
+ content: message.trim(),
1036
+ timestamp: Date.now()
1037
+ }]);
1038
+ setIsThinking(true);
1039
+ setStatusText('Sending to agent...');
1040
+ setTimeout(() => {
1041
+ setIsThinking(false);
1042
+ setStatusText('');
1043
+ }, 800);
1044
+ } else {
1045
+ setLastResult({
1046
+ success: false,
1047
+ message: 'Failed to send message to support agent. Connection lost.',
1048
+ steps: []
1049
+ });
1050
+ }
1051
+ return;
1052
+ }
1053
+
1054
+ // Append user message to AI thread
604
1055
  setMessages(prev => [...prev, {
605
1056
  id: Date.now().toString() + Math.random(),
606
1057
  role: 'user',
@@ -649,9 +1100,15 @@ export function AIAgent({
649
1100
  cost: result.tokenUsage?.estimatedCostUSD ?? 0
650
1101
  });
651
1102
  }
652
- setLastResult(result);
1103
+ logger.info('AIAgent', '★ handleSend — SETTING lastResult:', result.message.substring(0, 80), '| mode:', mode);
1104
+ logger.info('AIAgent', '★ handleSend — tickets:', tickets.length, 'selectedTicketId:', selectedTicketId);
653
1105
 
654
- // Append assistant message
1106
+ // Don't overwrite lastResult if escalation already switched us to human mode
1107
+ // (mode in this closure is stale — the actual mode may have changed during async execution)
1108
+ const stepsHadEscalation = result.steps?.some(s => s.action.name === 'escalate_to_human');
1109
+ if (!stepsHadEscalation) {
1110
+ setLastResult(result);
1111
+ }
655
1112
  setMessages(prev => [...prev, {
656
1113
  id: Date.now().toString() + Math.random(),
657
1114
  role: 'assistant',
@@ -765,8 +1222,12 @@ export function AIAgent({
765
1222
  onSend: handleSend,
766
1223
  isThinking: isThinking,
767
1224
  lastResult: lastResult,
1225
+ lastUserMessage: lastUserMessage,
768
1226
  language: 'en',
769
- onDismiss: () => setLastResult(null),
1227
+ onDismiss: () => {
1228
+ setLastResult(null);
1229
+ setLastUserMessage(null);
1230
+ },
770
1231
  theme: accentColor || theme ? {
771
1232
  ...(accentColor ? {
772
1233
  primaryColor: accentColor
@@ -776,12 +1237,13 @@ export function AIAgent({
776
1237
  availableModes: availableModes,
777
1238
  mode: mode,
778
1239
  onModeChange: newMode => {
779
- logger.info('AIAgent', `Mode change: ${mode}${newMode}`);
1240
+ logger.info('AIAgent', '★ onModeChange:', mode, '', newMode, '| tickets:', tickets.length, 'selectedTicketId:', selectedTicketId);
780
1241
  setMode(newMode);
781
1242
  },
782
1243
  isMicActive: isMicActive,
783
1244
  isSpeakerMuted: isSpeakerMuted,
784
1245
  isAISpeaking: isAISpeaking,
1246
+ isAgentTyping: isLiveAgentTyping,
785
1247
  onStopSession: stopVoiceSession,
786
1248
  isVoiceConnected: isVoiceConnected,
787
1249
  onMicToggle: active => {
@@ -809,8 +1271,23 @@ export function AIAgent({
809
1271
  } else {
810
1272
  audioOutputRef.current?.unmute();
811
1273
  }
812
- }
1274
+ },
1275
+ tickets: tickets,
1276
+ selectedTicketId: selectedTicketId,
1277
+ onTicketSelect: handleTicketSelect,
1278
+ onBackToTickets: handleBackToTickets,
1279
+ autoExpandTrigger: autoExpandTrigger,
1280
+ unreadCounts: unreadCounts,
1281
+ totalUnread: totalUnread
813
1282
  })
1283
+ }), /*#__PURE__*/_jsx(SupportChatModal, {
1284
+ visible: mode === 'human' && !!selectedTicketId,
1285
+ messages: messages,
1286
+ onSend: handleSend,
1287
+ onClose: handleBackToTickets,
1288
+ isAgentTyping: isLiveAgentTyping,
1289
+ isThinking: isThinking,
1290
+ scrollToEndTrigger: chatScrollTrigger
814
1291
  })]
815
1292
  })]
816
1293
  })