@mobileai/react-native 0.9.18 → 0.9.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/LICENSE +28 -20
  2. package/MobileAIFloatingOverlay.podspec +25 -0
  3. package/android/build.gradle +61 -0
  4. package/android/src/main/AndroidManifest.xml +3 -0
  5. package/android/src/main/java/com/mobileai/overlay/FloatingOverlayView.kt +151 -0
  6. package/android/src/main/java/com/mobileai/overlay/MobileAIOverlayPackage.kt +23 -0
  7. package/android/src/newarch/com/mobileai/overlay/FloatingOverlayViewManager.kt +45 -0
  8. package/android/src/oldarch/com/mobileai/overlay/FloatingOverlayViewManager.kt +29 -0
  9. package/ios/MobileAIFloatingOverlayComponentView.mm +73 -0
  10. package/lib/module/components/AIAgent.js +902 -136
  11. package/lib/module/components/AIConsentDialog.js +439 -0
  12. package/lib/module/components/AgentChatBar.js +828 -134
  13. package/lib/module/components/AgentOverlay.js +2 -1
  14. package/lib/module/components/DiscoveryTooltip.js +21 -9
  15. package/lib/module/components/FloatingOverlayWrapper.js +108 -0
  16. package/lib/module/components/Icons.js +123 -0
  17. package/lib/module/config/endpoints.js +12 -2
  18. package/lib/module/core/AgentRuntime.js +373 -27
  19. package/lib/module/core/FiberAdapter.js +56 -0
  20. package/lib/module/core/FiberTreeWalker.js +186 -80
  21. package/lib/module/core/IdleDetector.js +19 -0
  22. package/lib/module/core/NativeAlertInterceptor.js +191 -0
  23. package/lib/module/core/systemPrompt.js +203 -45
  24. package/lib/module/index.js +3 -0
  25. package/lib/module/providers/GeminiProvider.js +72 -56
  26. package/lib/module/providers/ProviderFactory.js +6 -2
  27. package/lib/module/services/AudioInputService.js +3 -12
  28. package/lib/module/services/AudioOutputService.js +1 -13
  29. package/lib/module/services/ConversationService.js +166 -0
  30. package/lib/module/services/MobileAIKnowledgeRetriever.js +41 -0
  31. package/lib/module/services/VoiceService.js +29 -8
  32. package/lib/module/services/telemetry/MobileAI.js +44 -0
  33. package/lib/module/services/telemetry/TelemetryService.js +13 -1
  34. package/lib/module/services/telemetry/TouchAutoCapture.js +44 -18
  35. package/lib/module/specs/FloatingOverlayNativeComponent.ts +19 -0
  36. package/lib/module/support/CSATSurvey.js +95 -12
  37. package/lib/module/support/EscalationSocket.js +70 -1
  38. package/lib/module/support/ReportedIssueEventSource.js +148 -0
  39. package/lib/module/support/escalateTool.js +4 -2
  40. package/lib/module/support/index.js +1 -0
  41. package/lib/module/support/reportIssueTool.js +127 -0
  42. package/lib/module/support/supportPrompt.js +77 -9
  43. package/lib/module/tools/guideTool.js +2 -1
  44. package/lib/module/tools/longPressTool.js +4 -3
  45. package/lib/module/tools/pickerTool.js +6 -4
  46. package/lib/module/tools/tapTool.js +12 -3
  47. package/lib/module/tools/typeTool.js +19 -10
  48. package/lib/module/utils/logger.js +175 -6
  49. package/lib/typescript/react-native.config.d.ts +11 -0
  50. package/lib/typescript/src/components/AIAgent.d.ts +28 -2
  51. package/lib/typescript/src/components/AIConsentDialog.d.ts +153 -0
  52. package/lib/typescript/src/components/AgentChatBar.d.ts +15 -2
  53. package/lib/typescript/src/components/DiscoveryTooltip.d.ts +3 -1
  54. package/lib/typescript/src/components/FloatingOverlayWrapper.d.ts +51 -0
  55. package/lib/typescript/src/components/Icons.d.ts +8 -0
  56. package/lib/typescript/src/config/endpoints.d.ts +5 -3
  57. package/lib/typescript/src/core/AgentRuntime.d.ts +4 -0
  58. package/lib/typescript/src/core/FiberAdapter.d.ts +25 -0
  59. package/lib/typescript/src/core/FiberTreeWalker.d.ts +2 -0
  60. package/lib/typescript/src/core/IdleDetector.d.ts +11 -0
  61. package/lib/typescript/src/core/NativeAlertInterceptor.d.ts +55 -0
  62. package/lib/typescript/src/core/types.d.ts +106 -1
  63. package/lib/typescript/src/index.d.ts +9 -4
  64. package/lib/typescript/src/providers/GeminiProvider.d.ts +6 -5
  65. package/lib/typescript/src/services/ConversationService.d.ts +55 -0
  66. package/lib/typescript/src/services/MobileAIKnowledgeRetriever.d.ts +9 -0
  67. package/lib/typescript/src/services/telemetry/MobileAI.d.ts +7 -0
  68. package/lib/typescript/src/services/telemetry/TelemetryService.d.ts +1 -1
  69. package/lib/typescript/src/services/telemetry/TouchAutoCapture.d.ts +9 -6
  70. package/lib/typescript/src/services/telemetry/types.d.ts +3 -1
  71. package/lib/typescript/src/specs/FloatingOverlayNativeComponent.d.ts +17 -0
  72. package/lib/typescript/src/support/EscalationSocket.d.ts +17 -0
  73. package/lib/typescript/src/support/ReportedIssueEventSource.d.ts +24 -0
  74. package/lib/typescript/src/support/escalateTool.d.ts +5 -0
  75. package/lib/typescript/src/support/index.d.ts +2 -1
  76. package/lib/typescript/src/support/reportIssueTool.d.ts +20 -0
  77. package/lib/typescript/src/support/types.d.ts +56 -1
  78. package/lib/typescript/src/utils/logger.d.ts +15 -0
  79. package/package.json +19 -5
  80. package/react-native.config.js +12 -0
@@ -16,51 +16,77 @@
16
16
 
17
17
  // React Native imports not needed — we use Fiber internals directly
18
18
 
19
+ // ─── Rage Click Detection ──────────────────────────────────────────
20
+ //
21
+ // Industry-standard approach (FullStory, PostHog, LogRocket):
22
+ // - 3+ taps on the SAME element within a SHORT window
23
+ // - Must be on the SAME screen (screen changes = intentional navigation)
24
+ // - Navigation-style labels ("Next", "Continue") are excluded
25
+ // - 1-second window (PostHog standard) instead of 2s to reduce false positives
26
+
19
27
  const recentTaps = [];
20
- const RAGE_WINDOW_MS = 2000;
28
+ const RAGE_WINDOW_MS = 1000; // PostHog uses 1s — tighter = fewer false positives
21
29
  const RAGE_THRESHOLD = 3;
30
+ const MAX_TAP_BUFFER = 8;
31
+
32
+ // Labels that are naturally tapped multiple times in sequence (wizards, onboarding, etc.)
33
+ const NAVIGATION_LABELS = new Set(['next', 'continue', 'skip', 'back', 'done', 'ok', 'cancel', 'previous', 'dismiss', 'close', 'got it', 'confirm', 'proceed', 'التالي', 'متابعة', 'تخطي', 'رجوع', 'تم', 'إلغاء', 'إغلاق', 'حسناً']);
34
+ function isNavigationLabel(label) {
35
+ return NAVIGATION_LABELS.has(label.toLowerCase().trim());
36
+ }
22
37
 
23
38
  /**
24
- * Checks if the user is repeatedly tapping the same element in frustration.
25
- * If rage click detected, emits 'rage_click' event to telemetry.
39
+ * Checks if the user is rage-tapping an element.
40
+ *
41
+ * Industry best-practice criteria:
42
+ * 1. Same label tapped 3+ times within 1 second
43
+ * 2. Taps must be on the SAME screen (screen change = not rage, it's navigation)
44
+ * 3. Navigation labels ("Next", "Skip", etc.) are excluded
26
45
  */
27
46
  export function checkRageClick(label, telemetry) {
47
+ // Skip navigation-style labels — sequential tapping is by design
48
+ if (isNavigationLabel(label)) return;
28
49
  const now = Date.now();
50
+ const currentScreen = telemetry.screen;
29
51
  recentTaps.push({
30
52
  label,
53
+ screen: currentScreen,
31
54
  ts: now
32
55
  });
33
56
 
34
- // Keep buffer unbounded size of 5
35
- if (recentTaps.length > 5) recentTaps.shift();
36
- const recent = recentTaps.filter(t => t.label === label && now - t.ts < RAGE_WINDOW_MS);
37
- if (recent.length >= RAGE_THRESHOLD) {
57
+ // Keep buffer bounded
58
+ if (recentTaps.length > MAX_TAP_BUFFER) recentTaps.shift();
59
+
60
+ // Count taps on the SAME label AND SAME screen within the time window
61
+ const matching = recentTaps.filter(t => t.label === label && t.screen === currentScreen && now - t.ts < RAGE_WINDOW_MS);
62
+ if (matching.length >= RAGE_THRESHOLD) {
38
63
  telemetry.track('rage_click', {
39
64
  label,
40
- count: recent.length,
41
- screen: telemetry.screen
65
+ count: matching.length,
66
+ screen: currentScreen
42
67
  });
43
- recentTaps.length = 0; // reset buffer after emitting to avoid spam
68
+ // Reset buffer after emitting to avoid duplicate rage events
69
+ recentTaps.length = 0;
44
70
  }
45
71
  }
46
72
 
47
73
  /**
48
- * Extract a label from a GestureResponderEvent's native target.
74
+ * Extract a label from a GestureResponderEvent.
49
75
  *
50
- * @param nativeEvent - The nativeEvent from onStartShouldSetResponderCapture
51
- * @param rootRef - The root View ref (to resolve relative positions)
76
+ * @param event - The GestureResponderEvent from onStartShouldSetResponderCapture
52
77
  * @returns A descriptive label string for the tapped element
53
78
  */
54
- export function extractTouchLabel(nativeEvent) {
79
+ export function extractTouchLabel(event) {
55
80
  // Try accessible properties first (most reliable)
56
- const target = nativeEvent?.target;
81
+ const target = event?.nativeEvent?.target;
57
82
  if (!target) return 'Unknown Element';
58
83
 
59
- // React Native internal: _internalFiberInstanceHandleDEV or _nativeTag
84
+ // React Native internal: _targetInst (synthetic event Fiber ref)
60
85
  // We can walk the Fiber tree from the target to find text
61
86
  try {
62
- // Strategy 1: Walk up the Fiber tree from the touched element
63
- let fiber = getFiberFromNativeTag(target);
87
+ // Strategy 1: Fiber from the SyntheticEvent (works in dev and production RN >= 0.60)
88
+ // Strategy 2: Walk up the Fiber tree from the touched element via DevTools hook
89
+ let fiber = event?._targetInst || getFiberFromNativeTag(target);
64
90
  if (fiber) {
65
91
  // Walk up looking for text content or accessibility labels
66
92
  let current = fiber;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Codegen spec for MobileAIFloatingOverlay native view.
3
+ *
4
+ * This file is required by React Native's Codegen (New Architecture / Fabric).
5
+ * It defines the TypeScript interface for the native view. During the build,
6
+ * Codegen uses this spec to generate C++ glue code that bridges JS and native.
7
+ *
8
+ * Consumers don't use this directly — use FloatingOverlayWrapper.tsx instead.
9
+ *
10
+ * Naming convention: file must end in NativeComponent.ts (Codegen convention).
11
+ */
12
+
13
+ import type { ViewProps } from 'react-native';
14
+ import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
15
+
16
+ export interface NativeProps extends ViewProps {}
17
+
18
+ // Codegen reads this export to generate the native component interfaces.
19
+ export default codegenNativeComponent<NativeProps>('MobileAIFloatingOverlay');
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { useState } from 'react';
11
11
  import { View, Text, TouchableOpacity, TextInput, StyleSheet } from 'react-native';
12
+ import { MobileAI } from "../services/telemetry/index.js";
12
13
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
13
14
  const EMOJI_OPTIONS = [{
14
15
  emoji: '😡',
@@ -31,6 +32,22 @@ const EMOJI_OPTIONS = [{
31
32
  label: 'Amazing',
32
33
  score: 5
33
34
  }];
35
+ const CES_OPTIONS = [{
36
+ label: 'Very Difficult',
37
+ score: 1
38
+ }, {
39
+ label: 'Difficult',
40
+ score: 2
41
+ }, {
42
+ label: 'Neutral',
43
+ score: 3
44
+ }, {
45
+ label: 'Easy',
46
+ score: 4
47
+ }, {
48
+ label: 'Very Easy',
49
+ score: 5
50
+ }];
34
51
  const STAR_COUNT = 5;
35
52
  export function CSATSurvey({
36
53
  config,
@@ -44,8 +61,10 @@ export function CSATSurvey({
44
61
  const primary = theme?.primaryColor ?? '#8b5cf6';
45
62
  const textColor = theme?.textColor ?? '#ffffff';
46
63
  const bgColor = theme?.backgroundColor ?? 'rgba(26, 26, 46, 0.98)';
64
+ const surveyType = config.surveyType ?? 'csat';
47
65
  const ratingType = config.ratingType ?? 'emoji';
48
- const question = config.question ?? 'How was your experience?';
66
+ const defaultQuestion = surveyType === 'ces' ? 'How easy was it to get the help you needed?' : 'How was your experience?';
67
+ const question = config.question ?? defaultQuestion;
49
68
  const handleSubmit = () => {
50
69
  if (selectedScore === null) return;
51
70
  const rating = {
@@ -56,6 +75,21 @@ export function CSATSurvey({
56
75
  config.onSubmit(rating);
57
76
  setSubmitted(true);
58
77
 
78
+ // Track CSAT/CES response
79
+ const fcrAchieved = selectedScore >= 4 || ratingType === 'thumbs' && selectedScore === 5;
80
+ const eventName = surveyType === 'ces' ? 'ces_response' : 'csat_response';
81
+ MobileAI.track(eventName, {
82
+ score: selectedScore,
83
+ fcrAchieved,
84
+ ticketId: metadata?.ticketId
85
+ });
86
+ if (fcrAchieved) {
87
+ MobileAI.track('fcr_achieved', {
88
+ score: selectedScore,
89
+ ticketId: metadata?.ticketId
90
+ });
91
+ }
92
+
59
93
  // Auto-dismiss after 1.5s
60
94
  setTimeout(onDismiss, 1500);
61
95
  };
@@ -83,7 +117,28 @@ export function CSATSurvey({
83
117
  children: question
84
118
  }), /*#__PURE__*/_jsxs(View, {
85
119
  style: styles.ratingContainer,
86
- children: [ratingType === 'emoji' && /*#__PURE__*/_jsx(View, {
120
+ children: [surveyType === 'ces' && /*#__PURE__*/_jsx(View, {
121
+ style: styles.cesRow,
122
+ children: CES_OPTIONS.map(opt => /*#__PURE__*/_jsxs(TouchableOpacity, {
123
+ onPress: () => setSelectedScore(opt.score),
124
+ style: [styles.cesButton, selectedScore === opt.score && {
125
+ backgroundColor: `${primary}30`,
126
+ borderColor: primary
127
+ }],
128
+ activeOpacity: 0.7,
129
+ children: [/*#__PURE__*/_jsx(Text, {
130
+ style: [styles.cesNumber, {
131
+ color: selectedScore === opt.score ? primary : textColor
132
+ }],
133
+ children: opt.score
134
+ }), /*#__PURE__*/_jsx(Text, {
135
+ style: [styles.cesLabel, {
136
+ color: selectedScore === opt.score ? primary : '#71717a'
137
+ }],
138
+ children: opt.label
139
+ })]
140
+ }, opt.score))
141
+ }), surveyType === 'csat' && ratingType === 'emoji' && /*#__PURE__*/_jsx(View, {
87
142
  style: styles.emojiRow,
88
143
  children: EMOJI_OPTIONS.map(opt => /*#__PURE__*/_jsxs(TouchableOpacity, {
89
144
  onPress: () => setSelectedScore(opt.score),
@@ -102,7 +157,7 @@ export function CSATSurvey({
102
157
  children: opt.label
103
158
  })]
104
159
  }, opt.score))
105
- }), ratingType === 'stars' && /*#__PURE__*/_jsx(View, {
160
+ }), surveyType === 'csat' && ratingType === 'stars' && /*#__PURE__*/_jsx(View, {
106
161
  style: styles.starsRow,
107
162
  children: Array.from({
108
163
  length: STAR_COUNT
@@ -116,28 +171,32 @@ export function CSATSurvey({
116
171
  children: "\u2605"
117
172
  })
118
173
  }, star))
119
- }), ratingType === 'thumbs' && /*#__PURE__*/_jsxs(View, {
174
+ }), surveyType === 'csat' && ratingType === 'thumbs' && /*#__PURE__*/_jsxs(View, {
120
175
  style: styles.thumbsRow,
121
176
  children: [/*#__PURE__*/_jsx(TouchableOpacity, {
122
- onPress: () => setSelectedScore(0),
123
- style: [styles.thumbButton, selectedScore === 0 && {
124
- backgroundColor: '#ef444430',
177
+ onPress: () => setSelectedScore(1),
178
+ style: [styles.thumbButton, selectedScore === 1 && {
179
+ backgroundColor: 'rgba(239, 68, 68, 0.2)',
125
180
  borderColor: '#ef4444'
126
181
  }],
127
182
  activeOpacity: 0.7,
128
183
  children: /*#__PURE__*/_jsx(Text, {
129
- style: styles.thumbEmoji,
184
+ style: [styles.thumbEmoji, selectedScore === 1 && {
185
+ opacity: 1
186
+ }],
130
187
  children: "\uD83D\uDC4E"
131
188
  })
132
189
  }), /*#__PURE__*/_jsx(TouchableOpacity, {
133
- onPress: () => setSelectedScore(1),
134
- style: [styles.thumbButton, selectedScore === 1 && {
135
- backgroundColor: '#22c55e30',
190
+ onPress: () => setSelectedScore(5),
191
+ style: [styles.thumbButton, selectedScore === 5 && {
192
+ backgroundColor: 'rgba(34, 197, 94, 0.2)',
136
193
  borderColor: '#22c55e'
137
194
  }],
138
195
  activeOpacity: 0.7,
139
196
  children: /*#__PURE__*/_jsx(Text, {
140
- style: styles.thumbEmoji,
197
+ style: [styles.thumbEmoji, selectedScore === 5 && {
198
+ opacity: 1
199
+ }],
141
200
  children: "\uD83D\uDC4D"
142
201
  })
143
202
  })]
@@ -219,6 +278,30 @@ const styles = StyleSheet.create({
219
278
  marginTop: 4,
220
279
  fontWeight: '500'
221
280
  },
281
+ cesRow: {
282
+ flexDirection: 'row',
283
+ justifyContent: 'center',
284
+ gap: 4
285
+ },
286
+ cesButton: {
287
+ alignItems: 'center',
288
+ paddingHorizontal: 8,
289
+ paddingVertical: 10,
290
+ borderRadius: 8,
291
+ borderWidth: 1,
292
+ borderColor: 'transparent',
293
+ minWidth: 60
294
+ },
295
+ cesNumber: {
296
+ fontSize: 22,
297
+ fontWeight: 'bold',
298
+ marginBottom: 4
299
+ },
300
+ cesLabel: {
301
+ fontSize: 9,
302
+ fontWeight: '600',
303
+ textAlign: 'center'
304
+ },
222
305
  starsRow: {
223
306
  flexDirection: 'row',
224
307
  justifyContent: 'center',
@@ -14,6 +14,7 @@
14
14
  * Handles:
15
15
  * - Server heartbeat pings (type: 'ping') — acknowledged silently
16
16
  * - Auto-reconnect on unexpected close (max 3 attempts, exponential backoff)
17
+ * - Message queue — buffers sendText calls while connecting, flushes on open
17
18
  */
18
19
 
19
20
  export class EscalationSocket {
@@ -22,6 +23,10 @@ export class EscalationSocket {
22
23
  reconnectAttempts = 0;
23
24
  reconnectTimer = null;
24
25
  intentionalClose = false;
26
+ _hasErrored = false;
27
+
28
+ /** Messages buffered while the socket is connecting / reconnecting. */
29
+ messageQueue = [];
25
30
  constructor(options) {
26
31
  this.onReply = options.onReply;
27
32
  this.onError = options.onError;
@@ -32,9 +37,33 @@ export class EscalationSocket {
32
37
  connect(wsUrl) {
33
38
  this.wsUrl = wsUrl;
34
39
  this.intentionalClose = false;
40
+ this._hasErrored = false;
35
41
  this.openConnection();
36
42
  }
43
+
44
+ /** True if the underlying WebSocket is open and ready to send. */
45
+ get isConnected() {
46
+ return this.ws?.readyState === 1; // WebSocket.OPEN
47
+ }
48
+
49
+ /** True if the socket encountered an error (and may not be reliable to reuse). */
50
+ get hasErrored() {
51
+ return this._hasErrored;
52
+ }
53
+
54
+ /**
55
+ * Send a text message to the live agent.
56
+ *
57
+ * If the socket is currently connecting or reconnecting, the message is
58
+ * buffered and sent automatically once the connection is established.
59
+ * Returns `true` in both cases (connected send + queued send).
60
+ * Returns `false` only if the socket has no URL (was never connected).
61
+ */
37
62
  sendText(text) {
63
+ if (!this.wsUrl) {
64
+ // No URL at all — nothing we can do.
65
+ return false;
66
+ }
38
67
  if (this.ws?.readyState === 1) {
39
68
  // WebSocket.OPEN
40
69
  this.ws.send(JSON.stringify({
@@ -43,7 +72,23 @@ export class EscalationSocket {
43
72
  }));
44
73
  return true;
45
74
  }
46
- return false;
75
+
76
+ // Socket is connecting (CONNECTING=0) or reconnecting (CLOSED=3 → scheduleReconnect).
77
+ // Queue the message so it is flushed as soon as onopen fires.
78
+ console.log('[EscalationSocket] ⏳ Socket not open — queuing message for when connected');
79
+ this.messageQueue.push(JSON.stringify({
80
+ type: 'user_message',
81
+ content: text
82
+ }));
83
+
84
+ // If the socket is fully closed (not just connecting), kick off a reconnect now
85
+ // rather than waiting for scheduleReconnect's timeout to fire.
86
+ const state = this.ws?.readyState;
87
+ if (state === undefined || state === 3 /* CLOSED */) {
88
+ console.log('[EscalationSocket] Socket CLOSED — initiating reconnect to flush queue');
89
+ this.openConnection();
90
+ }
91
+ return true; // optimistic — message is queued
47
92
  }
48
93
  sendTypingStatus(isTyping) {
49
94
  if (this.ws?.readyState === 1) {
@@ -56,6 +101,7 @@ export class EscalationSocket {
56
101
  }
57
102
  disconnect() {
58
103
  this.intentionalClose = true;
104
+ this.messageQueue = []; // drop queued messages on intentional close
59
105
  if (this.reconnectTimer) {
60
106
  clearTimeout(this.reconnectTimer);
61
107
  this.reconnectTimer = null;
@@ -65,8 +111,26 @@ export class EscalationSocket {
65
111
  this.ws = null;
66
112
  }
67
113
  }
114
+ flushQueue() {
115
+ if (this.messageQueue.length === 0) return;
116
+ console.log(`[EscalationSocket] 🚀 Flushing ${this.messageQueue.length} queued message(s)`);
117
+ const queue = this.messageQueue.splice(0); // drain atomically
118
+ for (const payload of queue) {
119
+ try {
120
+ this.ws?.send(payload);
121
+ } catch (err) {
122
+ console.error('[EscalationSocket] Failed to flush queued message:', err);
123
+ }
124
+ }
125
+ }
68
126
  openConnection() {
69
127
  if (!this.wsUrl) return;
128
+
129
+ // Don't open a second socket if one is already connecting
130
+ if (this.ws && this.ws.readyState === 0 /* CONNECTING */) {
131
+ console.log('[EscalationSocket] Already connecting — skipping duplicate openConnection');
132
+ return;
133
+ }
70
134
  try {
71
135
  this.ws = new WebSocket(this.wsUrl);
72
136
  } catch (err) {
@@ -76,6 +140,8 @@ export class EscalationSocket {
76
140
  this.ws.onopen = () => {
77
141
  console.log('[EscalationSocket] ✅ Connected to:', this.wsUrl);
78
142
  this.reconnectAttempts = 0;
143
+ this._hasErrored = false;
144
+ this.flushQueue(); // send any messages that arrived while connecting
79
145
  };
80
146
  this.ws.onmessage = event => {
81
147
  try {
@@ -107,6 +173,7 @@ export class EscalationSocket {
107
173
  };
108
174
  this.ws.onerror = event => {
109
175
  console.error('[EscalationSocket] ❌ WebSocket error. URL was:', this.wsUrl, event);
176
+ this._hasErrored = true;
110
177
  this.onError?.(event);
111
178
  };
112
179
  this.ws.onclose = event => {
@@ -118,12 +185,14 @@ export class EscalationSocket {
118
185
  scheduleReconnect() {
119
186
  if (this.reconnectAttempts >= this.maxReconnectAttempts) {
120
187
  console.warn('[EscalationSocket] Max reconnect attempts reached — giving up');
188
+ this.messageQueue = []; // drop queued messages — connection is permanently lost
121
189
  return;
122
190
  }
123
191
  const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 16_000);
124
192
  this.reconnectAttempts++;
125
193
  console.log(`[EscalationSocket] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
126
194
  this.reconnectTimer = setTimeout(() => {
195
+ this._hasErrored = false; // clear error flag before reconnect attempt
127
196
  this.openConnection();
128
197
  }, delay);
129
198
  }
@@ -0,0 +1,148 @@
1
+ "use strict";
2
+
3
+ import { logger } from "../utils/logger.js";
4
+ export class ReportedIssueEventSource {
5
+ abortController = null;
6
+ intentionalClose = false;
7
+ reconnectAttempts = 0;
8
+ reconnectTimer = null;
9
+ maxReconnectAttempts = 5;
10
+ constructor(options) {
11
+ this.options = options;
12
+ }
13
+ connect() {
14
+ this.intentionalClose = false;
15
+ this.reconnectAttempts = 0;
16
+ this.openConnection();
17
+ }
18
+ disconnect() {
19
+ this.intentionalClose = true;
20
+ if (this.reconnectTimer) {
21
+ clearTimeout(this.reconnectTimer);
22
+ this.reconnectTimer = null;
23
+ }
24
+ if (this.abortController) {
25
+ this.abortController.abort();
26
+ this.abortController = null;
27
+ }
28
+ }
29
+ async openConnection() {
30
+ if (this.intentionalClose) return;
31
+ this.abortController = new AbortController();
32
+ try {
33
+ const response = await fetch(this.options.url, {
34
+ signal: this.abortController.signal,
35
+ headers: {
36
+ Accept: 'text/event-stream'
37
+ }
38
+ });
39
+ if (!response.ok) {
40
+ this.scheduleReconnect();
41
+ return;
42
+ }
43
+ if (!response.body) {
44
+ await this.readFullResponse(response);
45
+ return;
46
+ }
47
+ this.reconnectAttempts = 0;
48
+ await this.readStream(response.body);
49
+ } catch (error) {
50
+ if (this.intentionalClose) return;
51
+ if (error.name === 'AbortError') return;
52
+ this.options.onError?.(error);
53
+ this.scheduleReconnect();
54
+ }
55
+ }
56
+ async readStream(body) {
57
+ const reader = body.getReader();
58
+ const decoder = new TextDecoder();
59
+ let buffer = '';
60
+ let currentEvent = '';
61
+ let currentData = '';
62
+ try {
63
+ while (true) {
64
+ const {
65
+ done,
66
+ value
67
+ } = await reader.read();
68
+ if (done) break;
69
+ buffer += decoder.decode(value, {
70
+ stream: true
71
+ });
72
+ const lines = buffer.split('\n');
73
+ buffer = lines.pop() ?? '';
74
+ for (const line of lines) {
75
+ if (line.startsWith('event: ')) {
76
+ currentEvent = line.slice(7).trim();
77
+ } else if (line.startsWith('data: ')) {
78
+ currentData = line.slice(6).trim();
79
+ } else if (line === '' && currentEvent) {
80
+ this.handleEvent(currentEvent, currentData);
81
+ currentEvent = '';
82
+ currentData = '';
83
+ }
84
+ }
85
+ }
86
+ } catch (error) {
87
+ if (this.intentionalClose) return;
88
+ if (error.name === 'AbortError') return;
89
+ logger.warn('ReportedIssueSSE', 'Stream read error:', error.message);
90
+ }
91
+ if (!this.intentionalClose) {
92
+ this.scheduleReconnect();
93
+ }
94
+ }
95
+ async readFullResponse(response) {
96
+ try {
97
+ const text = await response.text();
98
+ let currentEvent = '';
99
+ let currentData = '';
100
+ for (const line of text.split('\n')) {
101
+ if (line.startsWith('event: ')) {
102
+ currentEvent = line.slice(7).trim();
103
+ } else if (line.startsWith('data: ')) {
104
+ currentData = line.slice(6).trim();
105
+ } else if (line === '' && currentEvent) {
106
+ this.handleEvent(currentEvent, currentData);
107
+ currentEvent = '';
108
+ currentData = '';
109
+ }
110
+ }
111
+ } catch (error) {
112
+ if (this.intentionalClose) return;
113
+ logger.warn('ReportedIssueSSE', 'Full response read error:', error.message);
114
+ }
115
+ if (!this.intentionalClose) {
116
+ this.scheduleReconnect();
117
+ }
118
+ }
119
+ handleEvent(event, data) {
120
+ try {
121
+ const parsed = JSON.parse(data);
122
+ if (event === 'connected') {
123
+ this.options.onConnected?.();
124
+ return;
125
+ }
126
+ if (event === 'reported_issue_update' && parsed?.issue) {
127
+ this.options.onIssueUpdate?.(parsed.issue);
128
+ }
129
+ } catch {
130
+ // ignore bad payload
131
+ }
132
+ }
133
+ scheduleReconnect() {
134
+ if (this.intentionalClose) return;
135
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
136
+ logger.warn('ReportedIssueSSE', 'Max reconnect attempts reached — giving up');
137
+ return;
138
+ }
139
+ const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 16_000);
140
+ this.reconnectAttempts++;
141
+ this.reconnectTimer = setTimeout(() => {
142
+ this.openConnection().catch(() => {
143
+ // Connection errors are handled inside openConnection.
144
+ });
145
+ }, delay);
146
+ }
147
+ }
148
+ //# sourceMappingURL=ReportedIssueEventSource.js.map
@@ -36,6 +36,7 @@ export function createEscalateTool(depsOrConfig, legacyGetContext) {
36
36
  analyticsKey,
37
37
  getContext,
38
38
  getHistory,
39
+ getToolCalls,
39
40
  onHumanReply,
40
41
  onEscalationStarted,
41
42
  onTypingChange,
@@ -87,6 +88,7 @@ export function createEscalateTool(depsOrConfig, legacyGetContext) {
87
88
  device: getDeviceMetadata()
88
89
  },
89
90
  screenFlow: getScreenFlow?.() ?? [],
91
+ toolCalls: getToolCalls?.() ?? [],
90
92
  pushToken,
91
93
  pushTokenType,
92
94
  deviceId: getDeviceId()
@@ -131,7 +133,7 @@ export function createEscalateTool(depsOrConfig, legacyGetContext) {
131
133
  } catch (err) {
132
134
  logger.error('Escalation', 'Network error:', err.message);
133
135
  }
134
- const message = config.escalationMessage ?? 'Connecting you to a human agent...';
136
+ const message = config.escalationMessage ?? "Your request has been sent to our support team. A human agent will reply here as soon as possible.";
135
137
  return `ESCALATED: ${message}`;
136
138
  }
137
139
  }
@@ -144,7 +146,7 @@ export function createEscalateTool(depsOrConfig, legacyGetContext) {
144
146
  stepsBeforeEscalation: context.stepsBeforeEscalation
145
147
  };
146
148
  config.onEscalate?.(escalationContext);
147
- const message = config.escalationMessage ?? 'Connecting you to a human agent...';
149
+ const message = config.escalationMessage ?? "Your request has been sent to our support team. A human agent will reply here as soon as possible.";
148
150
  return `ESCALATED: ${message}`;
149
151
  }
150
152
  };
@@ -11,6 +11,7 @@ export { buildSupportPrompt } from "./supportPrompt.js";
11
11
 
12
12
  // Escalation tool + WebSocket manager
13
13
  export { createEscalateTool } from "./escalateTool.js";
14
+ export { createReportIssueTool } from "./reportIssueTool.js";
14
15
  export { EscalationSocket } from "./EscalationSocket.js";
15
16
  export { EscalationEventSource } from "./EscalationEventSource.js";
16
17