@mobileai/react-native 0.9.11 → 0.9.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/lib/module/components/AIAgent.js +141 -22
  2. package/lib/module/components/AIAgent.js.map +1 -1
  3. package/lib/module/components/AgentChatBar.js +3 -14
  4. package/lib/module/components/AgentChatBar.js.map +1 -1
  5. package/lib/module/services/telemetry/TelemetryService.js +5 -2
  6. package/lib/module/services/telemetry/TelemetryService.js.map +1 -1
  7. package/lib/module/services/telemetry/deviceMetadata.js +10 -0
  8. package/lib/module/services/telemetry/deviceMetadata.js.map +1 -0
  9. package/lib/module/support/EscalationEventSource.js +168 -0
  10. package/lib/module/support/EscalationEventSource.js.map +1 -0
  11. package/lib/module/support/SupportChatModal.js +32 -4
  12. package/lib/module/support/SupportChatModal.js.map +1 -1
  13. package/lib/module/support/escalateTool.js +8 -2
  14. package/lib/module/support/escalateTool.js.map +1 -1
  15. package/lib/module/support/index.js +2 -0
  16. package/lib/module/support/index.js.map +1 -1
  17. package/lib/typescript/src/components/AIAgent.d.ts.map +1 -1
  18. package/lib/typescript/src/components/AgentChatBar.d.ts.map +1 -1
  19. package/lib/typescript/src/services/telemetry/TelemetryService.d.ts +2 -1
  20. package/lib/typescript/src/services/telemetry/TelemetryService.d.ts.map +1 -1
  21. package/lib/typescript/src/services/telemetry/deviceMetadata.d.ts +6 -0
  22. package/lib/typescript/src/services/telemetry/deviceMetadata.d.ts.map +1 -0
  23. package/lib/typescript/src/support/EscalationEventSource.d.ts +38 -0
  24. package/lib/typescript/src/support/EscalationEventSource.d.ts.map +1 -0
  25. package/lib/typescript/src/support/SupportChatModal.d.ts +3 -1
  26. package/lib/typescript/src/support/SupportChatModal.d.ts.map +1 -1
  27. package/lib/typescript/src/support/escalateTool.d.ts +1 -0
  28. package/lib/typescript/src/support/escalateTool.d.ts.map +1 -1
  29. package/lib/typescript/src/support/index.d.ts +1 -0
  30. package/lib/typescript/src/support/index.d.ts.map +1 -1
  31. package/package.json +1 -1
  32. package/src/components/AIAgent.tsx +134 -21
  33. package/src/components/AgentChatBar.tsx +2 -9
  34. package/src/services/telemetry/TelemetryService.ts +6 -1
  35. package/src/services/telemetry/deviceMetadata.ts +13 -0
  36. package/src/support/EscalationEventSource.ts +190 -0
  37. package/src/support/SupportChatModal.tsx +58 -22
  38. package/src/support/escalateTool.ts +8 -2
  39. package/src/support/index.ts +1 -0
@@ -0,0 +1,190 @@
1
+ /**
2
+ * EscalationEventSource — SSE client using fetch + ReadableStream.
3
+ *
4
+ * Uses only the fetch API (available in all React Native runtimes)
5
+ * to consume Server-Sent Events — no EventSource polyfill needed.
6
+ * Provides a reliable, auto-reconnecting channel for server-push
7
+ * events like `ticket_closed` that complements the bidirectional
8
+ * WebSocket used for chat.
9
+ *
10
+ * Lifecycle:
11
+ * 1. SDK calls connect() → fetch with streaming response
12
+ * 2. Server holds connection open, pushes `ticket_closed` when agent resolves
13
+ * 3. On disconnect, auto-reconnects with exponential backoff (max 5 attempts)
14
+ * 4. If ticket is already closed, server responds immediately with the event
15
+ */
16
+
17
+ import { logger } from '../utils/logger';
18
+
19
+ export interface EscalationEventSourceOptions {
20
+ url: string;
21
+ onTicketClosed?: (ticketId: string) => void;
22
+ onConnected?: (ticketId: string) => void;
23
+ onError?: (error: Error) => void;
24
+ }
25
+
26
+ export class EscalationEventSource {
27
+ private abortController: AbortController | null = null;
28
+ private intentionalClose = false;
29
+ private reconnectAttempts = 0;
30
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
31
+ private readonly maxReconnectAttempts = 5;
32
+ private readonly options: EscalationEventSourceOptions;
33
+
34
+ constructor(options: EscalationEventSourceOptions) {
35
+ this.options = options;
36
+ }
37
+
38
+ connect(): void {
39
+ this.intentionalClose = false;
40
+ this.reconnectAttempts = 0;
41
+ this.openConnection();
42
+ }
43
+
44
+ disconnect(): void {
45
+ this.intentionalClose = true;
46
+ if (this.reconnectTimer) {
47
+ clearTimeout(this.reconnectTimer);
48
+ this.reconnectTimer = null;
49
+ }
50
+ if (this.abortController) {
51
+ this.abortController.abort();
52
+ this.abortController = null;
53
+ }
54
+ }
55
+
56
+ private async openConnection(): Promise<void> {
57
+ if (this.intentionalClose) return;
58
+
59
+ this.abortController = new AbortController();
60
+
61
+ try {
62
+ const response = await fetch(this.options.url, {
63
+ signal: this.abortController.signal,
64
+ headers: { Accept: 'text/event-stream' },
65
+ });
66
+
67
+ if (!response.ok) {
68
+ logger.warn('EscalationSSE', 'Non-OK response:', response.status);
69
+ this.scheduleReconnect();
70
+ return;
71
+ }
72
+
73
+ if (!response.body) {
74
+ logger.warn('EscalationSSE', 'No readable body — falling back to reading full response');
75
+ await this.readFullResponse(response);
76
+ return;
77
+ }
78
+
79
+ this.reconnectAttempts = 0;
80
+ await this.readStream(response.body);
81
+ } catch (err) {
82
+ if (this.intentionalClose) return;
83
+ if ((err as Error).name === 'AbortError') return;
84
+ logger.warn('EscalationSSE', 'Connection error:', (err as Error).message);
85
+ this.options.onError?.(err as Error);
86
+ this.scheduleReconnect();
87
+ }
88
+ }
89
+
90
+ private async readStream(body: ReadableStream<Uint8Array>): Promise<void> {
91
+ const reader = body.getReader();
92
+ const decoder = new TextDecoder();
93
+ let buffer = '';
94
+
95
+ try {
96
+ while (true) {
97
+ const { done, value } = await reader.read();
98
+ if (done) break;
99
+
100
+ buffer += decoder.decode(value, { stream: true });
101
+ const lines = buffer.split('\n');
102
+ buffer = lines.pop()!;
103
+
104
+ let currentEvent = '';
105
+ let currentData = '';
106
+
107
+ for (const line of lines) {
108
+ if (line.startsWith('event: ')) {
109
+ currentEvent = line.slice(7).trim();
110
+ } else if (line.startsWith('data: ')) {
111
+ currentData = line.slice(6).trim();
112
+ } else if (line === '' && currentEvent && currentData) {
113
+ this.handleEvent(currentEvent, currentData);
114
+ currentEvent = '';
115
+ currentData = '';
116
+ }
117
+ }
118
+ }
119
+ } catch (err) {
120
+ if (this.intentionalClose) return;
121
+ if ((err as Error).name === 'AbortError') return;
122
+ logger.warn('EscalationSSE', 'Stream read error:', (err as Error).message);
123
+ }
124
+
125
+ if (!this.intentionalClose) {
126
+ this.scheduleReconnect();
127
+ }
128
+ }
129
+
130
+ private async readFullResponse(response: Response): Promise<void> {
131
+ try {
132
+ const text = await response.text();
133
+ let currentEvent = '';
134
+ let currentData = '';
135
+
136
+ for (const line of text.split('\n')) {
137
+ if (line.startsWith('event: ')) {
138
+ currentEvent = line.slice(7).trim();
139
+ } else if (line.startsWith('data: ')) {
140
+ currentData = line.slice(6).trim();
141
+ } else if (line === '' && currentEvent && currentData) {
142
+ this.handleEvent(currentEvent, currentData);
143
+ currentEvent = '';
144
+ currentData = '';
145
+ }
146
+ }
147
+ } catch (err) {
148
+ if (this.intentionalClose) return;
149
+ logger.warn('EscalationSSE', 'Full response read error:', (err as Error).message);
150
+ }
151
+
152
+ if (!this.intentionalClose) {
153
+ this.scheduleReconnect();
154
+ }
155
+ }
156
+
157
+ private handleEvent(event: string, data: string): void {
158
+ try {
159
+ const parsed = JSON.parse(data);
160
+
161
+ if (event === 'connected') {
162
+ logger.info('EscalationSSE', 'Connected for ticket:', parsed.ticketId);
163
+ this.options.onConnected?.(parsed.ticketId);
164
+ } else if (event === 'ticket_closed') {
165
+ logger.info('EscalationSSE', 'Ticket closed event:', parsed.ticketId);
166
+ this.options.onTicketClosed?.(parsed.ticketId);
167
+ this.intentionalClose = true;
168
+ this.abortController?.abort();
169
+ }
170
+ } catch {
171
+ // ignore parse error
172
+ }
173
+ }
174
+
175
+ private scheduleReconnect(): void {
176
+ if (this.intentionalClose) return;
177
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
178
+ logger.warn('EscalationSSE', 'Max reconnect attempts reached — giving up');
179
+ return;
180
+ }
181
+
182
+ const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 16_000);
183
+ this.reconnectAttempts++;
184
+ logger.info('EscalationSSE', `Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
185
+
186
+ this.reconnectTimer = setTimeout(() => {
187
+ this.openConnection();
188
+ }, delay);
189
+ }
190
+ }
@@ -31,6 +31,8 @@ interface SupportChatModalProps {
31
31
  isThinking?: boolean;
32
32
  /** Optional: externally controlled scroll trigger. Pass when messages update externally. */
33
33
  scrollToEndTrigger?: number;
34
+ /** Ticket status — when 'closed' or 'resolved', input is hidden and a banner is shown. */
35
+ ticketStatus?: string;
34
36
  }
35
37
 
36
38
  // ─── Helpers ───────────────────────────────────────────────────
@@ -73,6 +75,8 @@ function AgentAvatar() {
73
75
 
74
76
  // ─── Main Component ────────────────────────────────────────────
75
77
 
78
+ const CLOSED_STATUSES = ['closed', 'resolved'];
79
+
76
80
  export function SupportChatModal({
77
81
  visible,
78
82
  messages,
@@ -81,7 +85,9 @@ export function SupportChatModal({
81
85
  isAgentTyping = false,
82
86
  isThinking = false,
83
87
  scrollToEndTrigger = 0,
88
+ ticketStatus,
84
89
  }: SupportChatModalProps) {
90
+ const isClosed = !!ticketStatus && CLOSED_STATUSES.includes(ticketStatus);
85
91
  const [text, setText] = useState('');
86
92
  const [keyboardHeight, setKeyboardHeight] = useState(0);
87
93
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -147,8 +153,10 @@ export function SupportChatModal({
147
153
  <View style={s.headerCenter}>
148
154
  <Text style={s.headerTitle}>Support Chat</Text>
149
155
  <View style={s.headerStatus}>
150
- <View style={s.statusDot} />
151
- <Text style={s.headerSubtitle}>Agent online</Text>
156
+ <View style={[s.statusDot, isClosed && s.statusDotClosed]} />
157
+ <Text style={s.headerSubtitle}>
158
+ {isClosed ? 'Conversation closed' : 'Agent online'}
159
+ </Text>
152
160
  </View>
153
161
  </View>
154
162
  <View style={s.headerBtn} />
@@ -227,26 +235,34 @@ export function SupportChatModal({
227
235
  </ScrollView>
228
236
  )}
229
237
 
230
- {/* ── Input Row ── */}
231
- <View style={s.inputRow}>
232
- <TextInput
233
- style={s.input}
234
- placeholder="Type a message..."
235
- placeholderTextColor="rgba(255,255,255,0.35)"
236
- value={text}
237
- onChangeText={setText}
238
- onSubmitEditing={handleSend}
239
- returnKeyType="send"
240
- editable={!isThinking}
241
- />
242
- <Pressable
243
- style={[s.sendBtn, text.trim() && !isThinking ? s.sendBtnActive : s.sendBtnInactive]}
244
- onPress={handleSend}
245
- disabled={!text.trim() || isThinking}
246
- >
247
- <SendArrowIcon size={18} color={text.trim() && !isThinking ? '#fff' : 'rgba(255,255,255,0.3)'} />
248
- </Pressable>
249
- </View>
238
+ {/* ── Input Row or Closed Banner ── */}
239
+ {isClosed ? (
240
+ <View style={s.closedBanner}>
241
+ <Text style={s.closedBannerText}>
242
+ This conversation has been closed. Start a new request to get help.
243
+ </Text>
244
+ </View>
245
+ ) : (
246
+ <View style={s.inputRow}>
247
+ <TextInput
248
+ style={s.input}
249
+ placeholder="Type a message..."
250
+ placeholderTextColor="rgba(255,255,255,0.35)"
251
+ value={text}
252
+ onChangeText={setText}
253
+ onSubmitEditing={handleSend}
254
+ returnKeyType="send"
255
+ editable={!isThinking}
256
+ />
257
+ <Pressable
258
+ style={[s.sendBtn, text.trim() && !isThinking ? s.sendBtnActive : s.sendBtnInactive]}
259
+ onPress={handleSend}
260
+ disabled={!text.trim() || isThinking}
261
+ >
262
+ <SendArrowIcon size={18} color={text.trim() && !isThinking ? '#fff' : 'rgba(255,255,255,0.3)'} />
263
+ </Pressable>
264
+ </View>
265
+ )}
250
266
  </View>
251
267
  </Modal>
252
268
  );
@@ -312,6 +328,9 @@ const s = StyleSheet.create({
312
328
  borderRadius: 4,
313
329
  backgroundColor: '#34C759',
314
330
  },
331
+ statusDotClosed: {
332
+ backgroundColor: '#8E8E93',
333
+ },
315
334
  headerSubtitle: {
316
335
  color: 'rgba(255,255,255,0.5)',
317
336
  fontSize: 12,
@@ -483,6 +502,23 @@ const s = StyleSheet.create({
483
502
  fontSize: 14,
484
503
  },
485
504
 
505
+ // ── Closed Banner ──
506
+ closedBanner: {
507
+ paddingHorizontal: 16,
508
+ paddingVertical: 16,
509
+ paddingBottom: Platform.OS === 'ios' ? 36 : 16,
510
+ backgroundColor: 'rgba(255,255,255,0.03)',
511
+ borderTopWidth: StyleSheet.hairlineWidth,
512
+ borderTopColor: 'rgba(255,255,255,0.06)',
513
+ alignItems: 'center',
514
+ },
515
+ closedBannerText: {
516
+ color: 'rgba(255,255,255,0.35)',
517
+ fontSize: 13,
518
+ textAlign: 'center',
519
+ lineHeight: 19,
520
+ },
521
+
486
522
  // ── Input Row ──
487
523
  inputRow: {
488
524
  flexDirection: 'row',
@@ -14,6 +14,7 @@ import { EscalationSocket } from './EscalationSocket';
14
14
 
15
15
  import { ENDPOINTS } from '../config/endpoints';
16
16
  import { getDeviceId } from '../services/telemetry/device';
17
+ import { getDeviceMetadata } from '../services/telemetry/deviceMetadata';
17
18
  import { logger } from '../utils/logger';
18
19
 
19
20
  const MOBILEAI_HOST = ENDPOINTS.escalation;
@@ -23,6 +24,7 @@ export interface EscalationToolDeps {
23
24
  analyticsKey?: string;
24
25
  getContext: () => Omit<EscalationContext, 'conversationSummary'>;
25
26
  getHistory: () => Array<{ role: string; content: string }>;
27
+ getScreenFlow?: () => string[];
26
28
  onHumanReply?: (reply: string, ticketId?: string) => void;
27
29
  onEscalationStarted?: (ticketId: string, socket: EscalationSocket) => void;
28
30
  onTypingChange?: (isTyping: boolean) => void;
@@ -61,7 +63,7 @@ export function createEscalateTool(
61
63
  deps = depsOrConfig as EscalationToolDeps;
62
64
  }
63
65
 
64
- const { config, analyticsKey, getContext, getHistory, onHumanReply, onEscalationStarted, onTypingChange, onTicketClosed, userContext, pushToken, pushTokenType } = deps;
66
+ const { config, analyticsKey, getContext, getHistory, onHumanReply, onEscalationStarted, onTypingChange, onTicketClosed, userContext, pushToken, pushTokenType, getScreenFlow } = deps;
65
67
 
66
68
  // Determine effective provider
67
69
  const provider = config.provider ?? (analyticsKey ? 'mobileai' : 'custom');
@@ -103,7 +105,11 @@ export function createEscalateTool(
103
105
  screen: context.currentScreen,
104
106
  history,
105
107
  stepsBeforeEscalation: context.stepsBeforeEscalation,
106
- userContext,
108
+ userContext: {
109
+ ...userContext,
110
+ device: getDeviceMetadata(),
111
+ },
112
+ screenFlow: getScreenFlow?.() ?? [],
107
113
  pushToken,
108
114
  pushTokenType,
109
115
  deviceId: getDeviceId(),
@@ -21,6 +21,7 @@ export { buildSupportPrompt } from './supportPrompt';
21
21
  export { createEscalateTool } from './escalateTool';
22
22
  export { EscalationSocket } from './EscalationSocket';
23
23
  export type { SocketReplyHandler } from './EscalationSocket';
24
+ export { EscalationEventSource } from './EscalationEventSource';
24
25
 
25
26
  // UI Components
26
27
  export { SupportGreeting } from './SupportGreeting';