@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.
- package/README.md +11 -0
- package/lib/module/components/AIAgent.js +635 -39
- package/lib/module/components/AIAgent.js.map +1 -1
- package/lib/module/components/AgentChatBar.js +309 -13
- package/lib/module/components/AgentChatBar.js.map +1 -1
- package/lib/module/config/endpoints.js +22 -0
- package/lib/module/config/endpoints.js.map +1 -0
- package/lib/module/core/systemPrompt.js +126 -100
- package/lib/module/core/systemPrompt.js.map +1 -1
- package/lib/module/services/AudioInputService.js +9 -0
- package/lib/module/services/AudioInputService.js.map +1 -1
- package/lib/module/services/flags/FlagService.js +1 -1
- package/lib/module/services/flags/FlagService.js.map +1 -1
- package/lib/module/services/telemetry/TelemetryService.js +44 -15
- package/lib/module/services/telemetry/TelemetryService.js.map +1 -1
- package/lib/module/services/telemetry/device.js +80 -10
- package/lib/module/services/telemetry/device.js.map +1 -1
- package/lib/module/services/telemetry/deviceMetadata.js +10 -0
- package/lib/module/services/telemetry/deviceMetadata.js.map +1 -0
- package/lib/module/support/EscalationEventSource.js +168 -0
- package/lib/module/support/EscalationEventSource.js.map +1 -0
- package/lib/module/support/EscalationSocket.js +46 -7
- package/lib/module/support/EscalationSocket.js.map +1 -1
- package/lib/module/support/SupportChatModal.js +544 -0
- package/lib/module/support/SupportChatModal.js.map +1 -0
- package/lib/module/support/TicketStore.js +93 -0
- package/lib/module/support/TicketStore.js.map +1 -0
- package/lib/module/support/escalateTool.js +45 -13
- package/lib/module/support/escalateTool.js.map +1 -1
- package/lib/module/support/index.js +2 -0
- package/lib/module/support/index.js.map +1 -1
- package/lib/typescript/src/components/AIAgent.d.ts +24 -1
- package/lib/typescript/src/components/AIAgent.d.ts.map +1 -1
- package/lib/typescript/src/components/AgentChatBar.d.ts +24 -2
- package/lib/typescript/src/components/AgentChatBar.d.ts.map +1 -1
- package/lib/typescript/src/config/endpoints.d.ts +18 -0
- package/lib/typescript/src/config/endpoints.d.ts.map +1 -0
- package/lib/typescript/src/core/systemPrompt.d.ts +4 -13
- package/lib/typescript/src/core/systemPrompt.d.ts.map +1 -1
- package/lib/typescript/src/core/types.d.ts +1 -1
- package/lib/typescript/src/core/types.d.ts.map +1 -1
- package/lib/typescript/src/hooks/useAction.d.ts +2 -2
- package/lib/typescript/src/hooks/useAction.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +1 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/services/AudioInputService.d.ts.map +1 -1
- package/lib/typescript/src/services/telemetry/TelemetryService.d.ts +2 -1
- package/lib/typescript/src/services/telemetry/TelemetryService.d.ts.map +1 -1
- package/lib/typescript/src/services/telemetry/device.d.ts +15 -4
- package/lib/typescript/src/services/telemetry/device.d.ts.map +1 -1
- package/lib/typescript/src/services/telemetry/deviceMetadata.d.ts +6 -0
- package/lib/typescript/src/services/telemetry/deviceMetadata.d.ts.map +1 -0
- package/lib/typescript/src/support/EscalationEventSource.d.ts +38 -0
- package/lib/typescript/src/support/EscalationEventSource.d.ts.map +1 -0
- package/lib/typescript/src/support/EscalationSocket.d.ts +7 -1
- package/lib/typescript/src/support/EscalationSocket.d.ts.map +1 -1
- package/lib/typescript/src/support/SupportChatModal.d.ts +21 -0
- package/lib/typescript/src/support/SupportChatModal.d.ts.map +1 -0
- package/lib/typescript/src/support/TicketStore.d.ts +34 -0
- package/lib/typescript/src/support/TicketStore.d.ts.map +1 -0
- package/lib/typescript/src/support/escalateTool.d.ts +16 -1
- package/lib/typescript/src/support/escalateTool.d.ts.map +1 -1
- package/lib/typescript/src/support/index.d.ts +2 -1
- package/lib/typescript/src/support/index.d.ts.map +1 -1
- package/lib/typescript/src/support/types.d.ts +15 -0
- package/lib/typescript/src/support/types.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/components/AIAgent.tsx +622 -38
- package/src/components/AgentChatBar.tsx +348 -9
- package/src/config/endpoints.ts +22 -0
- package/src/core/systemPrompt.ts +126 -100
- package/src/core/types.ts +1 -1
- package/src/hooks/useAction.ts +2 -2
- package/src/index.ts +1 -0
- package/src/services/AudioInputService.ts +9 -0
- package/src/services/flags/FlagService.ts +1 -1
- package/src/services/telemetry/TelemetryService.ts +46 -14
- package/src/services/telemetry/device.ts +88 -11
- package/src/services/telemetry/deviceMetadata.ts +13 -0
- package/src/support/EscalationEventSource.ts +190 -0
- package/src/support/EscalationSocket.ts +47 -8
- package/src/support/SupportChatModal.tsx +563 -0
- package/src/support/TicketStore.ts +100 -0
- package/src/support/escalateTool.ts +53 -13
- package/src/support/index.ts +2 -0
- 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: (
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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'
|
|
285
|
-
content:
|
|
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
|
-
|
|
292
|
-
|
|
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
|
-
|
|
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
|
-
|
|
409
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
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
|
|
1023
|
+
// Initialize voice services when mode changes to voice
|
|
505
1024
|
useEffect(() => {
|
|
506
|
-
if (mode
|
|
507
|
-
logger.info('AIAgent',
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
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>
|