@blario/react-native 0.1.5 → 0.1.7

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 (46) hide show
  1. package/README.md +8 -3
  2. package/dist/BlarProvider.d.ts +6 -0
  3. package/dist/actions/select.d.ts +1 -1
  4. package/dist/actions/text-input.d.ts +1 -0
  5. package/dist/agent/compose.d.ts +1 -0
  6. package/dist/agent-mode/keyboard-guard.d.ts +13 -0
  7. package/dist/snapshot/capture.d.ts +1 -0
  8. package/dist/snapshot/visibility.d.ts +1 -1
  9. package/dist/snapshot/wrappers.d.ts +0 -2
  10. package/dist/telemetry/contain.d.ts +5 -0
  11. package/dist/version.d.ts +1 -1
  12. package/package.json +1 -1
  13. package/src/BlarProvider.tsx +68 -28
  14. package/src/actions/executor.ts +6 -3
  15. package/src/actions/select.ts +16 -2
  16. package/src/actions/text-input.ts +30 -1
  17. package/src/agent/compose.ts +2 -0
  18. package/src/agent/controller.ts +3 -1
  19. package/src/agent-mode/keyboard-guard.ts +35 -0
  20. package/src/agent-mode/timers.ts +3 -1
  21. package/src/agent-mode/ui/AgentDock.tsx +8 -7
  22. package/src/agent-mode/ui/AgentLayer.tsx +2 -1
  23. package/src/agent-mode/ui/AgentLayerHost.tsx +2 -1
  24. package/src/agent-mode/ui/AgentPanel.tsx +4 -3
  25. package/src/agent-mode/ui/AgentTap.tsx +3 -2
  26. package/src/agent-mode/ui/SpotlightLayer.tsx +4 -3
  27. package/src/agent-mode/ui/StatusCard.tsx +3 -2
  28. package/src/agent-mode/ui/TouchShield.tsx +2 -1
  29. package/src/backend/foreground.ts +11 -6
  30. package/src/backend/health.ts +10 -6
  31. package/src/backend/presence.ts +12 -8
  32. package/src/backend/session-socket.ts +25 -18
  33. package/src/blar.ts +8 -7
  34. package/src/chat/ChatSheet.tsx +32 -11
  35. package/src/chat/ChatView.tsx +12 -10
  36. package/src/chat/Launcher.tsx +2 -1
  37. package/src/chat/widget-bundle.generated.ts +2 -2
  38. package/src/nudge/NudgeBubble.tsx +6 -5
  39. package/src/snapshot/capture.ts +8 -6
  40. package/src/snapshot/render.ts +1 -1
  41. package/src/snapshot/visibility.ts +11 -10
  42. package/src/snapshot/wrappers.ts +0 -13
  43. package/src/telemetry/contain.ts +28 -0
  44. package/src/telemetry/sdk-reporter.ts +3 -0
  45. package/src/transport/relay-transport.ts +13 -9
  46. package/src/version.ts +1 -1
@@ -7,6 +7,7 @@ import { chooseDock, freeBand, messageCap, type PanelDock, STACK_GAP } from '../
7
7
  import type { SpotlightSnapshot } from '../spotlight-controller.ts';
8
8
  import { type ActiveSpotlight, AgentPanel, panelChrome } from './AgentPanel.tsx';
9
9
  import { StatusCard, type StatusCorners } from './StatusCard.tsx';
10
+ import { contain } from '../../telemetry/contain.ts';
10
11
 
11
12
  export interface DockActions {
12
13
  place: (id: number, band: Band) => void | Promise<void>;
@@ -88,7 +89,7 @@ export function AgentDock({ spotlight, actions, card, strings, color, reduceMoti
88
89
  const pending = useRef<Array<ReturnType<typeof setTimeout>>>([]);
89
90
 
90
91
  const later = (callback: () => void, milliseconds: number): void => {
91
- pending.current.push(setTimeout(callback, milliseconds));
92
+ pending.current.push(setTimeout(contain('AgentDock.later', callback), milliseconds));
92
93
  };
93
94
 
94
95
  const rest = (): void => {
@@ -118,15 +119,15 @@ export function AgentDock({ spotlight, actions, card, strings, color, reduceMoti
118
119
  const duration = reduceMotion ? REDUCED_MS : CLOSE_MS;
119
120
  const easing = reduceMotion ? Easing.linear : CLOSE;
120
121
  if (dockRef.current === 'bottom') {
121
- Animated.parallel([animate(panelOpacity, 0, duration, easing), animate(panelShift, reduceMotion ? 0 : ENTER_SHIFT, duration, easing)]).start(() => {
122
+ Animated.parallel([animate(panelOpacity, 0, duration, easing), animate(panelShift, reduceMotion ? 0 : ENTER_SHIFT, duration, easing)]).start(contain('AgentDock.close', () => {
122
123
  if (presentedId.current === null) {
123
124
  rest();
124
125
  setPresented(null);
125
126
  }
126
- });
127
+ }));
127
128
  return;
128
129
  }
129
- Animated.parallel([animate(stackOpacity, 0, duration, easing), animate(stackShift, reduceMotion ? 0 : -ENTER_SHIFT, duration, easing)]).start(() => {
130
+ Animated.parallel([animate(stackOpacity, 0, duration, easing), animate(stackShift, reduceMotion ? 0 : -ENTER_SHIFT, duration, easing)]).start(contain('AgentDock.closeTop', () => {
130
131
  if (presentedId.current !== null) {
131
132
  return;
132
133
  }
@@ -134,7 +135,7 @@ export function AgentDock({ spotlight, actions, card, strings, color, reduceMoti
134
135
  stackOpacity.setValue(0);
135
136
  setPresented(null);
136
137
  later(() => animate(stackOpacity, 1, STACK_RETURN_MS, Easing.out(Easing.ease)).start(), STACK_RETURN_PAUSE_MS);
137
- });
138
+ }));
138
139
  };
139
140
 
140
141
  useEffect(() => {
@@ -193,7 +194,7 @@ export function AgentDock({ spotlight, actions, card, strings, color, reduceMoti
193
194
  Animated.parallel([animate(panelOpacity, 1, duration, easing), animate(panelShift, 0, duration, easing)]).start();
194
195
  return;
195
196
  }
196
- animate(stackOpacity, 0, STACK_FADE_MS, Easing.out(Easing.ease)).start(() => {
197
+ animate(stackOpacity, 0, STACK_FADE_MS, Easing.out(Easing.ease)).start(contain('AgentDock.moveTop', () => {
197
198
  later(() => {
198
199
  if (presentedId.current !== presented.id) {
199
200
  return;
@@ -205,7 +206,7 @@ export function AgentDock({ spotlight, actions, card, strings, color, reduceMoti
205
206
  stackShift.setValue(reduceMotion ? 0 : -ENTER_SHIFT);
206
207
  Animated.parallel([animate(stackOpacity, 1, duration, easing), animate(stackShift, 0, duration, easing)]).start();
207
208
  }, STACK_SWAP_PAUSE_MS);
208
- });
209
+ }));
209
210
  }, [presented]);
210
211
 
211
212
  useEffect(() => {
@@ -14,6 +14,7 @@ import { MessageBubbles } from './MessageBubbles.tsx';
14
14
  import { PendingOverlay } from './PendingOverlay.tsx';
15
15
  import { SpotlightLayer } from './SpotlightLayer.tsx';
16
16
  import { TouchShield } from './TouchShield.tsx';
17
+ import { contain } from '../../telemetry/contain.ts';
17
18
 
18
19
  export interface AgentLayerProps {
19
20
  snapshot: AgentModeSnapshot;
@@ -64,7 +65,7 @@ function useBlockedTap(): { toast: boolean; pulse: number; block: () => void } {
64
65
  if (timer.current !== null) {
65
66
  clearTimeout(timer.current);
66
67
  }
67
- timer.current = setTimeout(() => setToast(false), TOAST_MS);
68
+ timer.current = setTimeout(contain('AgentLayer.toast', () => setToast(false)), TOAST_MS);
68
69
  };
69
70
  return { toast, pulse, block };
70
71
  }
@@ -3,6 +3,7 @@ import { Modal, Platform, StyleSheet, View } from 'react-native';
3
3
 
4
4
  import { loadFullWindowOverlay } from './full-window-overlay.ts';
5
5
  import { chooseLayerHost } from './layer-host.ts';
6
+ import { contain } from '../../telemetry/contain.ts';
6
7
 
7
8
  export interface AgentLayerHostProps {
8
9
  visible: boolean;
@@ -20,7 +21,7 @@ export function AgentLayerHost({ visible, generation, onRequestClose, children }
20
21
  }
21
22
  if (HOST === 'modal') {
22
23
  return (
23
- <Modal key={generation} visible transparent animationType="none" hardwareAccelerated onRequestClose={onRequestClose}>
24
+ <Modal key={generation} visible transparent animationType="none" hardwareAccelerated onRequestClose={contain('AgentLayerHost.close', onRequestClose)}>
24
25
  {children}
25
26
  </Modal>
26
27
  );
@@ -9,6 +9,7 @@ import type { PanelDock } from '../panel-geometry.ts';
9
9
  import type { SpotlightSnapshot } from '../spotlight-controller.ts';
10
10
  import { MarkdownText } from './MarkdownText.tsx';
11
11
  import { WarningIcon } from './WarningIcon.tsx';
12
+ import { contain } from '../../telemetry/contain.ts';
12
13
 
13
14
  export type ActiveSpotlight = Exclude<SpotlightSnapshot, { kind: 'none' }>;
14
15
 
@@ -104,7 +105,7 @@ export function AgentPanel({ spotlight, dock, strings, color, messageMaxHeight,
104
105
  {spotlight.kind === 'reading' ? (
105
106
  <View style={styles.progressRow}>
106
107
  <Progress durationMs={spotlight.durationMs} running={spotlight.shown} color={color} />
107
- <Pressable accessibilityRole="button" onPress={onSkip} style={styles.skip}>
108
+ <Pressable accessibilityRole="button" onPress={contain('AgentPanel.skip', onSkip)} style={styles.skip}>
108
109
  <Text style={styles.skipText}>{strings.readingSkip}</Text>
109
110
  </Pressable>
110
111
  </View>
@@ -117,12 +118,12 @@ export function AgentPanel({ spotlight, dock, strings, color, messageMaxHeight,
117
118
  </View>
118
119
  ) : null}
119
120
  <View style={[styles.buttons, destructive ? styles.destructiveButtons : null]}>
120
- <Pressable accessibilityRole="button" onPress={onStop} style={[styles.button, styles.stop]}>
121
+ <Pressable accessibilityRole="button" onPress={contain('AgentPanel.stop', onStop)} style={[styles.button, styles.stop]}>
121
122
  <Text style={[styles.buttonText, { color: INK }]}>{strings.confirmStop}</Text>
122
123
  </Pressable>
123
124
  <Pressable
124
125
  accessibilityRole="button"
125
- onPress={onConfirm}
126
+ onPress={contain('AgentPanel.confirm', onConfirm)}
126
127
  style={[styles.button, destructive ? { backgroundColor: DESTRUCTIVE } : [styles.confirmButton, { backgroundColor: color }]]}
127
128
  >
128
129
  <Text style={[styles.buttonText, { color: destructive ? '#ffffff' : onColor(color) }]}>
@@ -3,6 +3,7 @@ import { Animated, Easing, StyleSheet, View } from 'react-native';
3
3
 
4
4
  import { centerOf, type Point } from '../../host/rect.ts';
5
5
  import type { CursorSnapshot } from '../cursor.ts';
6
+ import { contain } from '../../telemetry/contain.ts';
6
7
 
7
8
  export interface AgentTapProps {
8
9
  snapshot: CursorSnapshot;
@@ -90,7 +91,7 @@ function TapRing({ point, landed, ending, color, reduceMotion, onEnded }: TapRin
90
91
 
91
92
  useEffect(() => {
92
93
  if (ending) {
93
- timing(opacity, 0, END_FADE_MS, EASE_OUT).start(() => ended.current());
94
+ timing(opacity, 0, END_FADE_MS, EASE_OUT).start(contain('AgentTap.ended', () => ended.current()));
94
95
  }
95
96
  }, [ending, opacity, ended]);
96
97
 
@@ -107,7 +108,7 @@ function TapRipple({ point, onDone }: { point: Point; onDone: () => void }): Rea
107
108
  const progress = useRef(new Animated.Value(0)).current;
108
109
  const done = useLatest(onDone);
109
110
  useEffect(() => {
110
- timing(progress, 1, RIPPLE_MS, RIPPLE, PRESS_MS).start(() => done.current());
111
+ timing(progress, 1, RIPPLE_MS, RIPPLE, PRESS_MS).start(contain('AgentTap.ripple', () => done.current()));
111
112
  }, [progress, done]);
112
113
  return (
113
114
  <Animated.View
@@ -5,6 +5,7 @@ import type { SpotlightSnapshot } from '../spotlight-controller.ts';
5
5
  import type { ActiveSpotlight } from './AgentPanel.tsx';
6
6
  import { SPOTLIGHT_DIM, SpotlightOverlay } from './SpotlightOverlay.tsx';
7
7
  import { TargetPulse } from './TargetPulse.tsx';
8
+ import { contain } from '../../telemetry/contain.ts';
8
9
 
9
10
  export interface SpotlightLayerProps {
10
11
  spotlight: SpotlightSnapshot;
@@ -33,11 +34,11 @@ export function SpotlightLayer({ spotlight, color, reduceMotion, onBackdropPress
33
34
  Animated.timing(opacity, { toValue: 1, duration, easing: Easing.out(Easing.ease), useNativeDriver: true }).start();
34
35
  return;
35
36
  }
36
- Animated.timing(opacity, { toValue: 0, duration, easing: Easing.out(Easing.ease), useNativeDriver: true }).start(({ finished }) => {
37
+ Animated.timing(opacity, { toValue: 0, duration, easing: Easing.out(Easing.ease), useNativeDriver: true }).start(contain('SpotlightLayer.fade', ({ finished }: { finished: boolean }) => {
37
38
  if (finished) {
38
39
  setGone(true);
39
40
  }
40
- });
41
+ }));
41
42
  }, [visible, reduceMotion, opacity]);
42
43
 
43
44
  const current = last.current;
@@ -46,7 +47,7 @@ export function SpotlightLayer({ spotlight, color, reduceMotion, onBackdropPress
46
47
  }
47
48
  return (
48
49
  <Animated.View pointerEvents={visible ? 'box-none' : 'none'} style={[StyleSheet.absoluteFill, { opacity }]}>
49
- {current.kind === 'confirming' ? <Pressable style={StyleSheet.absoluteFill} onPress={onBackdropPress} /> : null}
50
+ {current.kind === 'confirming' ? <Pressable style={StyleSheet.absoluteFill} onPress={contain('SpotlightLayer.backdrop', onBackdropPress)} /> : null}
50
51
  {current.hole === null ? <View pointerEvents="none" style={styles.dim} /> : <SpotlightOverlay hole={current.hole} />}
51
52
  {current.kind === 'confirming' && current.target !== null ? (
52
53
  <TargetPulse target={current.target} radius={current.request.target?.radius ?? 0} color={color} reduceMotion={reduceMotion} />
@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
2
2
  import { Animated, Pressable, StyleSheet, Text, View } from 'react-native';
3
3
 
4
4
  import { onColor, withAlpha } from '../colors.ts';
5
+ import { contain } from '../../telemetry/contain.ts';
5
6
 
6
7
  export interface StatusCardProps {
7
8
  status: string;
@@ -54,7 +55,7 @@ function useStopPulse(pulse: number): { scale: Animated.Value; bordered: boolean
54
55
  ]);
55
56
  const animation = Animated.loop(beat, { iterations: PULSE_TIMES });
56
57
  animation.start();
57
- const timer = setTimeout(() => setBordered(false), BORDER_MS);
58
+ const timer = setTimeout(contain('StatusCard.border', () => setBordered(false)), BORDER_MS);
58
59
  return () => {
59
60
  animation.stop();
60
61
  clearTimeout(timer);
@@ -83,7 +84,7 @@ export function StatusCard({ status, queued, stopLabel, color, stopHidden, corne
83
84
  accessibilityLabel={stopLabel}
84
85
  hitSlop={STOP_HIT_SLOP}
85
86
  style={[styles.stop, { backgroundColor: withAlpha(ink, 0.2) }, bordered ? { borderWidth: 2, borderColor: withAlpha(ink, 0.8) } : null]}
86
- onPress={onStop}
87
+ onPress={contain('StatusCard.stop', onStop)}
87
88
  >
88
89
  <View style={[styles.stopIcon, { backgroundColor: ink }]} />
89
90
  </Pressable>
@@ -1,5 +1,6 @@
1
1
  import React from 'react';
2
2
  import { StyleSheet, View } from 'react-native';
3
+ import { contain } from '../../telemetry/contain.ts';
3
4
 
4
5
  export interface TouchShieldProps {
5
6
  onBlockedTap: () => void;
@@ -10,7 +11,7 @@ export function TouchShield({ onBlockedTap }: TouchShieldProps): React.JSX.Eleme
10
11
  <View
11
12
  style={styles.shield}
12
13
  onStartShouldSetResponder={() => true}
13
- onResponderRelease={onBlockedTap}
14
+ onResponderRelease={contain('TouchShield.tap', onBlockedTap)}
14
15
  accessibilityElementsHidden
15
16
  importantForAccessibility="no-hide-descendants"
16
17
  />
@@ -1,10 +1,15 @@
1
- import { AppState } from 'react-native';
1
+ import { AppState, type AppStateStatus } from 'react-native';
2
+
3
+ import { contain } from '../telemetry/contain.ts';
2
4
 
3
5
  export function watchForeground(onActive: () => void): () => void {
4
- const subscription = AppState.addEventListener('change', (state) => {
5
- if (state === 'active') {
6
- onActive();
7
- }
8
- });
6
+ const subscription = AppState.addEventListener(
7
+ 'change',
8
+ contain('watchForeground', (state: AppStateStatus) => {
9
+ if (state === 'active') {
10
+ onActive();
11
+ }
12
+ }),
13
+ );
9
14
  return () => subscription.remove();
10
15
  }
@@ -1,6 +1,7 @@
1
1
  import { AppState, type AppStateStatus, type NativeEventSubscription } from 'react-native';
2
2
 
3
3
  import { SDK_VERSION } from '../version.ts';
4
+ import { contain } from '../telemetry/contain.ts';
4
5
 
5
6
  export interface ClientHealth {
6
7
  visibility: 'visible' | 'hidden';
@@ -27,13 +28,16 @@ export class ClientHealthProbe {
27
28
  return;
28
29
  }
29
30
  this.hiddenSince = AppState.currentState === 'active' ? null : Date.now();
30
- this.subscription = AppState.addEventListener('change', (state) => this.onStateChange(state));
31
+ this.subscription = AppState.addEventListener('change', contain('ClientHealth.state', (state: AppStateStatus) => this.onStateChange(state)));
31
32
  this.expectedTickAt = Date.now() + PROBE_INTERVAL_MS;
32
- this.timer = setInterval(() => {
33
- const now = Date.now();
34
- this.worstLagMs = Math.max(this.worstLagMs, now - this.expectedTickAt);
35
- this.expectedTickAt = now + PROBE_INTERVAL_MS;
36
- }, PROBE_INTERVAL_MS);
33
+ this.timer = setInterval(
34
+ contain('ClientHealth.probe', () => {
35
+ const now = Date.now();
36
+ this.worstLagMs = Math.max(this.worstLagMs, now - this.expectedTickAt);
37
+ this.expectedTickAt = now + PROBE_INTERVAL_MS;
38
+ }),
39
+ PROBE_INTERVAL_MS,
40
+ );
37
41
  }
38
42
 
39
43
  stop(): void {
@@ -1,6 +1,7 @@
1
- import { AppState, type NativeEventSubscription } from 'react-native';
1
+ import { AppState, type AppStateStatus, type NativeEventSubscription } from 'react-native';
2
2
 
3
3
  import type { ApiClient } from './api-client.ts';
4
+ import { contain } from '../telemetry/contain.ts';
4
5
 
5
6
  const HEARTBEAT_INTERVAL_MS = 30_000;
6
7
  const IDLE_TIMEOUT_MS = 3 * 60_000;
@@ -29,12 +30,15 @@ export class PresenceManager {
29
30
  }
30
31
  this.started = true;
31
32
  this.lastInteraction = this.now();
32
- this.subscription = AppState.addEventListener('change', (state) => {
33
- if (state === 'active') {
34
- this.lastInteraction = this.now();
35
- this.fireNow();
36
- }
37
- });
33
+ this.subscription = AppState.addEventListener(
34
+ 'change',
35
+ contain('Presence.state', (state: AppStateStatus) => {
36
+ if (state === 'active') {
37
+ this.lastInteraction = this.now();
38
+ this.fireNow();
39
+ }
40
+ }),
41
+ );
38
42
  this.fireNow();
39
43
  }
40
44
 
@@ -71,7 +75,7 @@ export class PresenceManager {
71
75
  if (this.timer !== null) {
72
76
  clearTimeout(this.timer);
73
77
  }
74
- this.timer = setTimeout(() => this.fireNow(), HEARTBEAT_INTERVAL_MS);
78
+ this.timer = setTimeout(contain('Presence.beat', () => this.fireNow()), HEARTBEAT_INTERVAL_MS);
75
79
  }
76
80
 
77
81
  private async send(): Promise<void> {
@@ -3,6 +3,7 @@ import { backoffDelay, isExhausted, SOCKET_BACKOFF } from './backoff.ts';
3
3
  import type { IdentityProof } from './identity.ts';
4
4
  import { ResponseCache } from './response-cache.ts';
5
5
  import { buildSocketUrl } from './socket-url.ts';
6
+ import { contain } from '../telemetry/contain.ts';
6
7
 
7
8
  export type SocketState = 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'failed';
8
9
 
@@ -149,7 +150,7 @@ export class SessionSocket {
149
150
  return;
150
151
  }
151
152
  this.socket = socket;
152
- socket.onopen = () => {
153
+ socket.onopen = contain('SessionSocket.open', () => {
153
154
  if (this.epoch !== epoch) {
154
155
  return;
155
156
  }
@@ -159,8 +160,8 @@ export class SessionSocket {
159
160
  this.options.onState('connected');
160
161
  socket.send(JSON.stringify({ type: 'joined' }));
161
162
  this.cache.drain().forEach((payload) => socket.send(payload));
162
- };
163
- socket.onmessage = (event) => {
163
+ });
164
+ socket.onmessage = contain('SessionSocket.message', (event: { data?: unknown }) => {
164
165
  if (this.epoch !== epoch || typeof event.data !== 'string') {
165
166
  return;
166
167
  }
@@ -170,13 +171,13 @@ export class SessionSocket {
170
171
  this.identityRefreshUsed = false;
171
172
  }
172
173
  this.options.onMessage(event.data, frame);
173
- };
174
+ });
174
175
  socket.onerror = () => undefined;
175
- socket.onclose = (event) => {
176
+ socket.onclose = contain('SessionSocket.close', (event: { code?: number }) => {
176
177
  if (this.epoch === epoch) {
177
178
  this.handleClose(epoch, event.code ?? STALE_CLOSE_CODE);
178
179
  }
179
- };
180
+ });
180
181
  }
181
182
 
182
183
  private handleClose(epoch: number, code: number): void {
@@ -210,17 +211,20 @@ export class SessionSocket {
210
211
  if (isExhausted(SOCKET_BACKOFF, this.attempts)) {
211
212
  this.options.onState('failed');
212
213
  this.exhausted = true;
213
- this.reconnectTimer = setTimeout(() => this.recover(), EXHAUSTED_RETRY_MS);
214
+ this.reconnectTimer = setTimeout(contain('SessionSocket.recover', () => this.recover()), EXHAUSTED_RETRY_MS);
214
215
  return;
215
216
  }
216
217
  const delay = backoffDelay(SOCKET_BACKOFF, this.attempts);
217
218
  this.options.onState('reconnecting');
218
- this.reconnectTimer = setTimeout(() => {
219
- this.attempts += 1;
220
- if (this.sessionId !== null) {
221
- this.connect(this.sessionId);
222
- }
223
- }, delay);
219
+ this.reconnectTimer = setTimeout(
220
+ contain('SessionSocket.reconnect', () => {
221
+ this.attempts += 1;
222
+ if (this.sessionId !== null) {
223
+ this.connect(this.sessionId);
224
+ }
225
+ }),
226
+ delay,
227
+ );
224
228
  }
225
229
 
226
230
  private recover(): void {
@@ -240,11 +244,14 @@ export class SessionSocket {
240
244
  private startLiveness(): void {
241
245
  this.stopLiveness();
242
246
  this.lastFrameAt = Date.now();
243
- this.livenessTimer = setInterval(() => {
244
- if (Date.now() - this.lastFrameAt >= STALE_AFTER_MS) {
245
- this.dropStaleSocket();
246
- }
247
- }, LIVENESS_CHECK_MS);
247
+ this.livenessTimer = setInterval(
248
+ contain('SessionSocket.liveness', () => {
249
+ if (Date.now() - this.lastFrameAt >= STALE_AFTER_MS) {
250
+ this.dropStaleSocket();
251
+ }
252
+ }),
253
+ LIVENESS_CHECK_MS,
254
+ );
248
255
  }
249
256
 
250
257
  private stopLiveness(): void {
package/src/blar.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { BackendTransport } from './backend/backend-transport.ts';
2
2
  import type { BlarIdentity } from './backend/identity.ts';
3
3
  import type { MessageMetadata } from './backend/message-metadata.ts';
4
+ import { attempt } from './telemetry/contain.ts';
4
5
 
5
6
  export interface MessageMetadataOptions {
6
7
  includeTextDom: boolean;
@@ -49,19 +50,19 @@ class BlarClient {
49
50
  }
50
51
 
51
52
  open(): void {
52
- this.chat?.open();
53
+ attempt('Blar.open', () => this.chat?.open());
53
54
  }
54
55
 
55
56
  close(): void {
56
- this.chat?.close();
57
+ attempt('Blar.close', () => this.chat?.close());
57
58
  }
58
59
 
59
60
  getUnreadCount(): number {
60
- return this.chat?.getUnread() ?? 0;
61
+ return attempt('Blar.getUnreadCount', () => this.chat?.getUnread()) ?? 0;
61
62
  }
62
63
 
63
64
  isChatOpen(): boolean {
64
- return this.chat?.isOpen() ?? false;
65
+ return attempt('Blar.isChatOpen', () => this.chat?.isOpen()) ?? false;
65
66
  }
66
67
 
67
68
  async setUser(identity: BlarIdentity): Promise<void> {
@@ -81,15 +82,15 @@ class BlarClient {
81
82
  }
82
83
 
83
84
  isConnected(): boolean {
84
- return this.transport?.isConnected() ?? false;
85
+ return attempt('Blar.isConnected', () => this.transport?.isConnected()) ?? false;
85
86
  }
86
87
 
87
88
  getTabId(): string | null {
88
- return this.transport?.getTabId() ?? null;
89
+ return attempt('Blar.getTabId', () => this.transport?.getTabId()) ?? null;
89
90
  }
90
91
 
91
92
  getSessionState(): ReturnType<BackendTransport['getState']> | null {
92
- return this.transport?.getState() ?? null;
93
+ return attempt('Blar.getSessionState', () => this.transport?.getState()) ?? null;
93
94
  }
94
95
 
95
96
  getMessageMetadata(options: MessageMetadataOptions = { includeTextDom: false }): Promise<MessageMetadata | null> {
@@ -1,9 +1,10 @@
1
1
  import React, { useEffect, useRef } from 'react';
2
- import { Animated, BackHandler, Dimensions, Easing, KeyboardAvoidingView, Platform, Pressable, StyleSheet, View } from 'react-native';
2
+ import { Animated, BackHandler, Dimensions, Easing, Keyboard, type KeyboardEvent, Platform, Pressable, StyleSheet, View } from 'react-native';
3
3
 
4
4
  import { useInsets } from '../ui/useInsets.ts';
5
5
  import type { UiStrings } from '../ui/strings.ts';
6
6
  import type { SheetMode } from './chat-state.ts';
7
+ import { attempt, contain } from '../telemetry/contain.ts';
7
8
 
8
9
  export interface ChatSheetProps {
9
10
  mode: SheetMode;
@@ -16,12 +17,38 @@ export interface ChatSheetProps {
16
17
  const TOP_GAP = 12;
17
18
  const ANIMATION_MS = 240;
18
19
 
20
+ // iOS lifts the sheet over the keyboard while the chat is open. Not KeyboardAvoidingView: it reacts to every keyboard in
21
+ // the app even with the chat closed, and its LayoutAnimation applies to the host's whole next commit, which aborted a
22
+ // debug build as soon as a keyboard opened while the host mounted views. On Android the window resizes by itself.
23
+ function useKeyboardInset(active: boolean, homeIndicator: number): Animated.Value {
24
+ const inset = useRef(new Animated.Value(0)).current;
25
+ useEffect(() => {
26
+ if (!active || Platform.OS !== 'ios') {
27
+ inset.setValue(0);
28
+ return undefined;
29
+ }
30
+ const subscription = Keyboard.addListener('keyboardWillChangeFrame', contain('ChatSheet.keyboard', (event: KeyboardEvent) => {
31
+ const covered = Dimensions.get('window').height - event.endCoordinates.screenY;
32
+ Animated.timing(inset, {
33
+ // The keyboard covers the home-indicator inset, so the sheet's bottom padding sits under it, not above it.
34
+ toValue: Math.max(0, covered - homeIndicator),
35
+ duration: event.duration,
36
+ easing: Easing.out(Easing.cubic),
37
+ useNativeDriver: false,
38
+ }).start();
39
+ }));
40
+ return () => subscription.remove();
41
+ }, [active, homeIndicator, inset]);
42
+ return inset;
43
+ }
44
+
19
45
  export function ChatSheet({ mode, mounted, strings, onClose, children }: ChatSheetProps): React.JSX.Element | null {
20
46
  const insets = useInsets();
21
47
  const windowHeight = Dimensions.get('window').height;
22
48
  const sheetHeight = windowHeight - insets.top - TOP_GAP;
23
49
  const progress = useRef(new Animated.Value(mode === 'open' ? 1 : 0)).current;
24
50
  const isOpen = mode === 'open';
51
+ const keyboardInset = useKeyboardInset(isOpen, insets.bottom);
25
52
 
26
53
  useEffect(() => {
27
54
  Animated.timing(progress, { toValue: isOpen ? 1 : 0, duration: ANIMATION_MS, easing: Easing.out(Easing.cubic), useNativeDriver: true }).start();
@@ -32,7 +59,7 @@ export function ChatSheet({ mode, mounted, strings, onClose, children }: ChatShe
32
59
  return undefined;
33
60
  }
34
61
  const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
35
- onClose();
62
+ attempt('ChatSheet.back', onClose);
36
63
  return true;
37
64
  });
38
65
  return () => subscription.remove();
@@ -46,15 +73,9 @@ export function ChatSheet({ mode, mounted, strings, onClose, children }: ChatShe
46
73
  return (
47
74
  <View style={StyleSheet.absoluteFill} pointerEvents={isOpen ? 'auto' : 'none'}>
48
75
  <Animated.View style={[styles.backdrop, { opacity: backdropOpacity }]}>
49
- <Pressable accessibilityLabel={strings.chatClose} style={StyleSheet.absoluteFill} onPress={onClose} />
76
+ <Pressable accessibilityLabel={strings.chatClose} style={StyleSheet.absoluteFill} onPress={contain('ChatSheet.backdrop', onClose)} />
50
77
  </Animated.View>
51
- <KeyboardAvoidingView
52
- style={[styles.container, { paddingTop: insets.top + TOP_GAP }]}
53
- behavior={Platform.OS === 'ios' ? 'padding' : undefined}
54
- // The keyboard covers the home-indicator inset, so the sheet's bottom padding sits under it, not above it.
55
- keyboardVerticalOffset={-insets.bottom}
56
- pointerEvents="box-none"
57
- >
78
+ <Animated.View style={[styles.container, { paddingTop: insets.top + TOP_GAP, paddingBottom: keyboardInset }]} pointerEvents="box-none">
58
79
  <Animated.View
59
80
  accessibilityElementsHidden={!isOpen}
60
81
  importantForAccessibility={isOpen ? 'yes' : 'no-hide-descendants'}
@@ -62,7 +83,7 @@ export function ChatSheet({ mode, mounted, strings, onClose, children }: ChatShe
62
83
  >
63
84
  {children}
64
85
  </Animated.View>
65
- </KeyboardAvoidingView>
86
+ </Animated.View>
66
87
  </View>
67
88
  );
68
89
  }
@@ -10,6 +10,7 @@ import type { SessionRequest } from './chat-coordinator.ts';
10
10
  import { type ChatNavigation, leavesChat } from './navigation.ts';
11
11
  import { buildWidgetShell, type WidgetShellOptions } from './widget-shell.ts';
12
12
  import { loadWebView, type WebViewHandle } from './webview.ts';
13
+ import { attempt, contain } from '../telemetry/contain.ts';
13
14
 
14
15
  export interface ChatViewProps {
15
16
  shell: WidgetShellOptions;
@@ -58,7 +59,7 @@ export function ChatView({ shell, unavailableText, getMetadata, getProof, onClos
58
59
  );
59
60
 
60
61
  const onMessage = useCallback(
61
- (event: WebViewMessageEvent) => {
62
+ contain('ChatView.message', (event: WebViewMessageEvent) => {
62
63
  const message = parsePageMessage(event.nativeEvent.data);
63
64
  if (message === null) {
64
65
  return;
@@ -74,18 +75,19 @@ export function ChatView({ shell, unavailableText, getMetadata, getProof, onClos
74
75
  } else {
75
76
  log?.('webview ready');
76
77
  }
77
- },
78
+ }),
78
79
  [handleCall, log, onClosed, onSessionChanged],
79
80
  );
80
81
 
81
82
  const onNavigation = useCallback(
82
- (navigation: ChatNavigation) => {
83
- if (!leavesChat(navigation, shell.apiBaseUrl)) {
84
- return true;
85
- }
86
- Linking.openURL(navigation.url).catch(() => log?.('chat link could not be opened'));
87
- return false;
88
- },
83
+ (navigation: ChatNavigation) =>
84
+ attempt('ChatView.navigation', () => {
85
+ if (!leavesChat(navigation, shell.apiBaseUrl)) {
86
+ return true;
87
+ }
88
+ Linking.openURL(navigation.url).catch(() => log?.('chat link could not be opened'));
89
+ return false;
90
+ }) ?? false,
89
91
  [log, shell.apiBaseUrl],
90
92
  );
91
93
 
@@ -107,7 +109,7 @@ export function ChatView({ shell, unavailableText, getMetadata, getProof, onClos
107
109
  mixedContentMode="always"
108
110
  setSupportMultipleWindows={false}
109
111
  keyboardDisplayRequiresUserAction={false}
110
- // The sheet's KeyboardAvoidingView already lifts the chat above the keyboard. WKWebView would also scroll its
112
+ // The sheet already lifts the chat above the keyboard. WKWebView would also scroll its
111
113
  // own page up to reveal the composer, pushing the widget out of the sheet; with scrolling off it resets that
112
114
  // offset (the message list is an overflow container and keeps scrolling). The form bar (^ v Done) is for
113
115
  // multi-field forms, not a one-field composer.
@@ -2,6 +2,7 @@ import React from 'react';
2
2
  import { Pressable, StyleSheet, Text, View } from 'react-native';
3
3
 
4
4
  import { useInsets } from '../ui/useInsets.ts';
5
+ import { contain } from '../telemetry/contain.ts';
5
6
 
6
7
  export interface LauncherPlacement {
7
8
  side: 'left' | 'right';
@@ -26,7 +27,7 @@ export function Launcher({ unread, label, color, placement, onPress }: LauncherP
26
27
  <Pressable
27
28
  accessibilityRole="button"
28
29
  accessibilityLabel={label}
29
- onPress={onPress}
30
+ onPress={contain('Launcher.press', onPress)}
30
31
  style={[styles.button, horizontal, { bottom: insets.bottom + placement.offsetY, backgroundColor: color }]}
31
32
  >
32
33
  <View style={styles.bubble}>