@mobileai/react-native 0.9.28 → 0.9.29

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 CHANGED
@@ -307,14 +307,13 @@ npm install react-native-agentic-ai
307
307
 
308
308
  No native modules required by default. Works with **Expo managed workflow** out of the box — no eject needed.
309
309
 
310
- ### Optional Dependencies
310
+ ### Screenshot Capture
311
311
 
312
312
  <details>
313
313
  <summary><b>📸 Screenshots</b> — for image/video content understanding</summary>
314
314
 
315
- ```bash
316
- npx expo install react-native-view-shot
317
- ```
315
+ `react-native-view-shot` is a required dependency for screenshot capture and is included with
316
+ `@mobileai/react-native`, so you do **not** need to add it separately.
318
317
 
319
318
  </details>
320
319
 
@@ -1275,7 +1274,7 @@ Tag any element with `aiPriority` to control AI visibility:
1275
1274
  | `date_picker(index, date)` | Set a date on a date picker |
1276
1275
  | `navigate(screen)` | Navigate to any screen |
1277
1276
  | `wait(seconds)` | Wait for loading states before acting |
1278
- | `capture_screenshot(reason)` | Capture the screen as an image (requires `react-native-view-shot`) |
1277
+ | `capture_screenshot(reason)` | Capture the SDK root component as an image (requires `react-native-view-shot`) |
1279
1278
  | `done(text)` | Finish the task with a response |
1280
1279
  | `ask_user(question)` | Ask the user for clarification |
1281
1280
  | `query_knowledge(question)` | Search the knowledge base |
@@ -0,0 +1,8 @@
1
+ #import <React/RCTViewComponentView.h>
2
+
3
+ NS_ASSUME_NONNULL_BEGIN
4
+
5
+ @interface MobileAIFloatingOverlayComponentView : RCTViewComponentView
6
+ @end
7
+
8
+ NS_ASSUME_NONNULL_END
@@ -0,0 +1,44 @@
1
+ #ifdef RCT_NEW_ARCH_ENABLED
2
+
3
+ #import "MobileAIFloatingOverlayComponentView.h"
4
+ #import <React/RCTFabricComponentsPlugins.h>
5
+ #import <react/renderer/components/RNMobileAIOverlaySpec/ComponentDescriptors.h>
6
+ #import <react/renderer/components/RNMobileAIOverlaySpec/Props.h>
7
+ #import <react/renderer/components/RNMobileAIOverlaySpec/RCTComponentViewHelpers.h>
8
+
9
+ using namespace facebook::react;
10
+
11
+ @implementation MobileAIFloatingOverlayComponentView
12
+
13
+ + (ComponentDescriptorProvider)componentDescriptorProvider
14
+ {
15
+ return concreteComponentDescriptorProvider<MobileAIFloatingOverlayComponentDescriptor>();
16
+ }
17
+
18
+ + (void)load
19
+ {
20
+ [super load];
21
+ }
22
+
23
+ - (instancetype)initWithFrame:(CGRect)frame
24
+ {
25
+ if (self = [super initWithFrame:frame]) {
26
+ static const auto defaultProps = std::make_shared<const MobileAIFloatingOverlayProps>();
27
+ _props = defaultProps;
28
+ }
29
+ return self;
30
+ }
31
+
32
+ - (void)updateProps:(Props::Shared const&)props oldProps:(Props::Shared const&)oldProps
33
+ {
34
+ [super updateProps:props oldProps:oldProps];
35
+ }
36
+
37
+ @end
38
+
39
+ Class<RCTComponentViewProtocol> MobileAIFloatingOverlayCls(void)
40
+ {
41
+ return MobileAIFloatingOverlayComponentView.class;
42
+ }
43
+
44
+ #endif
@@ -28,7 +28,7 @@ import { VoiceService } from "../services/VoiceService.js";
28
28
  import { AudioInputService } from "../services/AudioInputService.js";
29
29
  import { AudioOutputService } from "../services/AudioOutputService.js";
30
30
  import { TelemetryService, bindTelemetryService } from "../services/telemetry/index.js";
31
- import { extractTouchLabel, checkRageClick } from "../services/telemetry/TouchAutoCapture.js";
31
+ import { extractTouchTargetMetadata, checkRageClick } from "../services/telemetry/TouchAutoCapture.js";
32
32
  import { initDeviceId, getDeviceId } from "../services/telemetry/device.js";
33
33
  import { AgentErrorBoundary } from "./AgentErrorBoundary.js";
34
34
  import { HighlightOverlay } from "./HighlightOverlay.js";
@@ -69,6 +69,29 @@ function getTooltipStorage() {
69
69
  return null;
70
70
  }
71
71
  }
72
+ function sanitizeWireframeScreenshot(value) {
73
+ if (!value || typeof value !== 'string') return null;
74
+ const trimmed = value.trim();
75
+ if (!trimmed) return null;
76
+ const base64 = trimmed.startsWith('data:') ? trimmed.split(',')[1] ?? '' : trimmed;
77
+ return base64.length > 0 ? base64 : null;
78
+ }
79
+ async function captureHeatmapScreenshot(rootView) {
80
+ try {
81
+ if (!rootView) return null;
82
+ const viewShot = require('react-native-view-shot');
83
+ const captureRef = viewShot.captureRef || viewShot.default?.captureRef;
84
+ if (!captureRef || typeof captureRef !== 'function') return null;
85
+ const raw = await captureRef(rootView, {
86
+ format: 'jpg',
87
+ quality: 0.28,
88
+ result: 'base64'
89
+ });
90
+ return sanitizeWireframeScreenshot(raw);
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
72
95
 
73
96
  // ─── Props ─────────────────────────────────────────────────────
74
97
 
@@ -240,7 +263,9 @@ export function AIAgent({
240
263
  try {
241
264
  const AS = getTooltipStorage();
242
265
  await AS?.setItem('@mobileai_onboarding_completed', 'true');
243
- } catch {/* graceful */}
266
+ } catch {
267
+ /* graceful */
268
+ }
244
269
  })();
245
270
  } else {
246
271
  setCurrentOnboardingIndex(prev => prev + 1);
@@ -272,7 +297,9 @@ export function AIAgent({
272
297
  try {
273
298
  const AS = getTooltipStorage();
274
299
  await AS?.setItem('@mobileai_tooltip_seen', 'true');
275
- } catch {/* graceful */}
300
+ } catch {
301
+ /* graceful */
302
+ }
276
303
  })();
277
304
  }, []);
278
305
  useEffect(() => {
@@ -434,17 +461,50 @@ export function AIAgent({
434
461
  });
435
462
  });
436
463
  }, []);
437
- const getResolvedScreenName = useCallback(() => {
464
+ const getDeepestRouteName = useCallback(state => {
465
+ if (!state || !Array.isArray(state.routes) || state.index == null) {
466
+ return null;
467
+ }
468
+ const route = state.routes[state.index];
469
+ if (!route) return null;
470
+ const nested = route.state;
471
+ if (nested) {
472
+ const nestedName = getDeepestRouteName(nested);
473
+ if (nestedName) return nestedName;
474
+ }
475
+ return typeof route.name === 'string' ? route.name : null;
476
+ }, []);
477
+ const resolveScreenName = useCallback(() => {
478
+ if (pathname) {
479
+ const humanizedPath = humanizeScreenName(pathname === '/' ? 'index' : pathname);
480
+ if (humanizedPath) return humanizedPath;
481
+ }
482
+ const navState = navRef?.getRootState?.() ?? navRef?.getState?.();
483
+ const deepestFromState = getDeepestRouteName(navState);
484
+ if (deepestFromState) {
485
+ const humanized = humanizeScreenName(deepestFromState);
486
+ if (humanized) return humanized;
487
+ }
438
488
  const routeName = navRef?.getCurrentRoute?.()?.name;
439
489
  if (typeof routeName === 'string' && routeName.trim().length > 0) {
440
- return routeName;
490
+ const humanized = humanizeScreenName(routeName);
491
+ if (humanized) return humanized;
441
492
  }
442
493
  const telemetryScreen = telemetryRef.current?.screen;
443
- if (typeof telemetryScreen === 'string' && telemetryScreen !== 'Unknown') {
494
+ if (typeof telemetryScreen === 'string' && telemetryScreen !== 'Unknown' && telemetryScreen.trim().length > 0) {
444
495
  return telemetryScreen;
445
496
  }
446
497
  return 'unknown';
447
- }, [navRef]);
498
+ }, [pathname, navRef, getDeepestRouteName]);
499
+ const syncTelemetryScreen = useCallback(() => {
500
+ const screen = resolveScreenName();
501
+ if (screen === 'unknown') {
502
+ return screen;
503
+ }
504
+ telemetryRef.current?.setScreen(screen);
505
+ return screen;
506
+ }, [resolveScreenName]);
507
+ const getResolvedScreenName = useCallback(() => resolveScreenName(), [resolveScreenName]);
448
508
  const resolvedKnowledgeBase = useMemo(() => {
449
509
  if (knowledgeBase) return knowledgeBase;
450
510
  if (!analyticsKey) return undefined;
@@ -1289,18 +1349,14 @@ export function AIAgent({
1289
1349
  bindTelemetryService(telemetry);
1290
1350
  telemetry.start();
1291
1351
  // NavigationContainer is a child of AIAgent, so navRef may not be
1292
- // ready yet when this effect runs. Poll briefly until it's available.
1352
+ // ready yet when this effect runs. Poll briefly until it is.
1293
1353
  const resolveInitialScreen = () => {
1294
1354
  let attempts = 0;
1295
1355
  const maxAttempts = 15; // 15 × 200ms = 3s max wait
1296
1356
  const timer = setInterval(() => {
1297
1357
  attempts++;
1298
- const route = navRef?.getCurrentRoute?.();
1299
- if (route?.name) {
1300
- const cleanName = humanizeScreenName(route.name);
1301
- if (cleanName) {
1302
- telemetry.setScreen(cleanName);
1303
- }
1358
+ const cleanName = syncTelemetryScreen();
1359
+ if (cleanName !== 'unknown') {
1304
1360
  clearInterval(timer);
1305
1361
  } else if (attempts >= maxAttempts) {
1306
1362
  clearInterval(timer);
@@ -1309,7 +1365,7 @@ export function AIAgent({
1309
1365
  };
1310
1366
  resolveInitialScreen();
1311
1367
  }); // initDeviceId
1312
- }, [analyticsKey, analyticsProxyUrl, analyticsProxyHeaders, bindTelemetryService, debug, navRef]);
1368
+ }, [analyticsKey, analyticsProxyUrl, analyticsProxyHeaders, bindTelemetryService, debug, navRef, syncTelemetryScreen]);
1313
1369
 
1314
1370
  // ─── Security warnings ──────────────────────────────────────
1315
1371
 
@@ -1332,11 +1388,14 @@ export function AIAgent({
1332
1388
  if (rootViewRef.current) {
1333
1389
  const handle = InteractionManager.runAfterInteractions(() => {
1334
1390
  requestAnimationFrame(() => {
1335
- captureWireframe(rootViewRef, {
1391
+ Promise.all([captureWireframe(rootViewRef, {
1336
1392
  screenName
1337
- }).then(wireframe => {
1393
+ }), captureHeatmapScreenshot(rootViewRef.current)]).then(([wireframe, screenshot]) => {
1338
1394
  if (wireframe && telemetryRef.current) {
1339
- telemetryRef.current.trackWireframe(wireframe);
1395
+ telemetryRef.current.trackWireframe({
1396
+ ...wireframe,
1397
+ screenshot: screenshot || undefined
1398
+ });
1340
1399
  }
1341
1400
  }).catch(err => {
1342
1401
  if (debug) logger.debug('AIAgent', 'Wireframe capture failed:', err);
@@ -1378,16 +1437,13 @@ export function AIAgent({
1378
1437
  }
1379
1438
  };
1380
1439
  const unsubscribe = navRef.addListener('state', () => {
1381
- const currentRoute = navRef.getCurrentRoute?.();
1382
- if (currentRoute?.name) {
1383
- const cleanName = humanizeScreenName(currentRoute.name);
1384
- if (cleanName) {
1385
- checkScreenMilestone(cleanName);
1386
- }
1440
+ const cleanName = resolveScreenName();
1441
+ if (cleanName) {
1442
+ checkScreenMilestone(cleanName);
1387
1443
  }
1388
1444
  });
1389
1445
  return () => unsubscribe?.();
1390
- }, [navRef, customerSuccess, isOnboardingActive, onboarding, currentOnboardingIndex, advanceOnboarding]);
1446
+ }, [navRef, resolveScreenName, customerSuccess, isOnboardingActive, onboarding, currentOnboardingIndex, advanceOnboarding]);
1391
1447
 
1392
1448
  // ─── MCP Bridge ──────────────────────────────────────────────
1393
1449
 
@@ -1424,7 +1480,7 @@ export function AIAgent({
1424
1480
  setProactiveStage('badge');
1425
1481
  },
1426
1482
  onReset: () => setProactiveStage('hidden'),
1427
- generateSuggestion: () => proactiveHelp?.generateSuggestion?.(telemetryRef.current?.screen || 'Home') || proactiveHelp?.badgeText || "Need help with this screen?",
1483
+ generateSuggestion: () => proactiveHelp?.generateSuggestion?.(telemetryRef.current?.screen || 'Home') || proactiveHelp?.badgeText || 'Need help with this screen?',
1428
1484
  behaviorTriggers: proactiveHelp?.behaviorTriggers
1429
1485
  });
1430
1486
  return () => {
@@ -2187,29 +2243,46 @@ export function AIAgent({
2187
2243
  // Skip if the AI agent is currently executing a tool — those are
2188
2244
  // already tracked as `agent_step` events with full context.
2189
2245
  if (telemetryRef.current && !telemetryRef.current.isAgentActing) {
2190
- const label = extractTouchLabel(event);
2191
- if (label && label !== 'Unknown Element' && label !== '[pressable]') {
2246
+ syncTelemetryScreen();
2247
+ const target = extractTouchTargetMetadata(event);
2248
+ const pageX = Math.round(event.nativeEvent.pageX);
2249
+ const pageY = Math.round(event.nativeEvent.pageY);
2250
+ if (target.label) {
2192
2251
  telemetryRef.current.track('user_action', {
2193
2252
  canonical_type: 'button_tapped',
2194
2253
  type: 'tap',
2195
- action: label,
2196
- label,
2197
- element_label: label,
2254
+ action: target.label,
2255
+ label: target.label,
2256
+ element_label: target.label,
2257
+ element_kind: target.elementKind,
2258
+ label_confidence: target.labelConfidence,
2259
+ zone_id: target.zoneId,
2260
+ ancestor_path: target.ancestorPath,
2261
+ sibling_labels: target.siblingLabels,
2262
+ component_name: target.componentName,
2198
2263
  actor: 'user',
2199
- x: Math.round(event.nativeEvent.pageX),
2200
- y: Math.round(event.nativeEvent.pageY)
2264
+ x: pageX,
2265
+ y: pageY
2201
2266
  });
2202
2267
 
2203
2268
  // Track if user is rage-tapping this specific element
2204
- checkRageClick(label, telemetryRef.current);
2269
+ checkRageClick({
2270
+ ...target,
2271
+ x: pageX,
2272
+ y: pageY
2273
+ }, telemetryRef.current);
2205
2274
  } else {
2206
2275
  // Tapped an unlabelled/empty area
2207
2276
  telemetryRef.current.track('dead_click', {
2208
2277
  canonical_type: 'dead_click_detected',
2209
- x: Math.round(event.nativeEvent.pageX),
2210
- y: Math.round(event.nativeEvent.pageY),
2278
+ x: pageX,
2279
+ y: pageY,
2211
2280
  screen: telemetryRef.current.screen,
2212
- screen_area: 'unknown'
2281
+ screen_area: 'unknown',
2282
+ zone_id: target.zoneId,
2283
+ ancestor_path: target.ancestorPath,
2284
+ sibling_labels: target.siblingLabels,
2285
+ component_name: target.componentName
2213
2286
  });
2214
2287
  }
2215
2288
  }
@@ -442,14 +442,14 @@ export class AgentRuntime {
442
442
  // capture_screenshot — on-demand visual capture (for image/video content questions)
443
443
  this.tools.set('capture_screenshot', {
444
444
  name: 'capture_screenshot',
445
- description: 'Capture a screenshot of the current screen. Use when the user asks about visual content (images, videos, colors, layout appearance) that cannot be determined from the element tree alone.',
445
+ description: 'Capture the SDK root component as an image. Use when the user asks about visual content (images, videos, colors, layout appearance) that cannot be determined from the element tree alone.',
446
446
  parameters: {},
447
447
  execute: async () => {
448
448
  const screenshot = await this.captureScreenshot();
449
449
  if (screenshot) {
450
450
  return `✅ Screenshot captured (${Math.round(screenshot.length / 1024)}KB). Visual content is now available for analysis.`;
451
451
  }
452
- return '❌ Screenshot capture failed. react-native-view-shot may not be installed.';
452
+ return '❌ Screenshot capture failed. react-native-view-shot is required and must be installed in your app.';
453
453
  }
454
454
  });
455
455
 
@@ -676,12 +676,12 @@ export class AgentRuntime {
676
676
  }
677
677
  }
678
678
 
679
- // ─── Screenshot Capture (optional react-native-view-shot) ─────
679
+ // ─── Screenshot Capture (react-native-view-shot) ─────
680
680
 
681
681
  /**
682
- * Captures the current screen as a base64 JPEG for Gemini vision.
683
- * Uses react-native-view-shot as an optional peer dependency.
684
- * Returns null if the library is not installed (graceful fallback).
682
+ * Captures the root component as a base64 JPEG for vision tools.
683
+ * Uses react-native-view-shot as a required peer dependency.
684
+ * Returns null only when capture is temporarily unavailable.
685
685
  */
686
686
  async captureScreenshot() {
687
687
  try {
@@ -699,7 +699,7 @@ export class AgentRuntime {
699
699
  return uri || undefined;
700
700
  } catch (error) {
701
701
  if (error.message?.includes('Cannot find module') || error.code === 'MODULE_NOT_FOUND' || error.message?.includes('unknown module')) {
702
- logger.warn('AgentRuntime', 'Screenshot requires react-native-view-shot. Install with: npx expo install react-native-view-shot');
702
+ logger.warn('AgentRuntime', 'Screenshot requires react-native-view-shot. It is a peer dependency; install it in your host app with: npx expo install react-native-view-shot');
703
703
  } else {
704
704
  logger.debug('AgentRuntime', `Screenshot skipped: ${error.message}`);
705
705
  }
@@ -13,6 +13,7 @@ import { Dimensions } from 'react-native';
13
13
  import { logger } from "../utils/logger.js";
14
14
  import { getChild, getSibling, getParent, getProps, getStateNode, getType, getDisplayName } from "./FiberAdapter.js";
15
15
  import { getActiveAlert } from "./NativeAlertInterceptor.js";
16
+ import { chooseBestAnalyticsTarget, getAnalyticsElementKind } from "../services/telemetry/analyticsLabeling.js";
16
17
 
17
18
  // ─── Walk Configuration ─────────
18
19
 
@@ -130,6 +131,69 @@ function getComponentName(fiber) {
130
131
  if (type.render?.name) return type.render.name;
131
132
  return null;
132
133
  }
134
+ function getCustomAncestorPath(fiber, maxDepth = 6) {
135
+ const path = [];
136
+ const seen = new Set();
137
+ let current = getParent(fiber);
138
+ let depth = 0;
139
+ while (current && depth < maxDepth) {
140
+ const name = getComponentName(current);
141
+ const props = getProps(current);
142
+ const candidate = name === 'AIZone' && typeof props.id === 'string' && props.id.trim() ? props.id.trim() : name;
143
+ if (candidate && !RN_INTERNAL_NAMES.has(candidate) && !PRESSABLE_TYPES.has(candidate) && !seen.has(candidate)) {
144
+ path.push(candidate);
145
+ seen.add(candidate);
146
+ }
147
+ current = getParent(current);
148
+ depth++;
149
+ }
150
+ return path;
151
+ }
152
+ function getAnalyticsTargetForNode(fiber, resolvedType) {
153
+ const props = getProps(fiber);
154
+ const siblingTextLabel = resolvedType && EXTERNALLY_LABELED_TYPES.has(resolvedType) ? extractSiblingTextLabel(fiber) : null;
155
+ return chooseBestAnalyticsTarget([{
156
+ text: props.accessibilityLabel,
157
+ source: 'accessibility'
158
+ }, {
159
+ text: extractDeepTextContent(fiber),
160
+ source: 'deep-text'
161
+ }, {
162
+ text: siblingTextLabel,
163
+ source: 'sibling-text'
164
+ }, {
165
+ text: props.title,
166
+ source: 'title'
167
+ }, {
168
+ text: resolvedType === 'text-input' ? props.placeholder : null,
169
+ source: 'placeholder'
170
+ }, {
171
+ text: props.testID || props.nativeID,
172
+ source: 'test-id'
173
+ }], getAnalyticsElementKind(resolvedType));
174
+ }
175
+ function getSiblingAnalyticsLabels(fiber, maxLabels = 6) {
176
+ const parent = getParent(fiber);
177
+ if (!parent) return [];
178
+ const labels = [];
179
+ const seen = new Set();
180
+ let sibling = getChild(parent);
181
+ while (sibling) {
182
+ if (sibling !== fiber) {
183
+ const siblingType = getElementType(sibling);
184
+ if (siblingType && !isDisabled(sibling)) {
185
+ const label = getAnalyticsTargetForNode(sibling, siblingType).label;
186
+ if (label && !seen.has(label.toLowerCase())) {
187
+ labels.push(label);
188
+ seen.add(label.toLowerCase());
189
+ if (labels.length >= maxLabels) break;
190
+ }
191
+ }
192
+ }
193
+ sibling = getSibling(sibling);
194
+ }
195
+ return labels;
196
+ }
133
197
  function hasSliderLikeSemantics(props) {
134
198
  if (!props || typeof props !== 'object') return false;
135
199
  if (typeof props.onSlidingComplete === 'function') return true;
@@ -421,7 +485,7 @@ function isDisabled(fiber) {
421
485
  * Recursively extract ALL text content from a fiber's children.
422
486
  * Pierces through nested interactive elements — unlike typical tree walkers
423
487
  * that stop at inner Pressable/TouchableOpacity boundaries.
424
- *
488
+ *
425
489
  * This is critical for wrapper components (e.g. ZButton → internal
426
490
  * TouchableOpacity → Text) where stopping at nested interactives
427
491
  * would lose the text label entirely.
@@ -464,7 +528,7 @@ function isIconGlyph(text) {
464
528
  const trimmed = text.trim();
465
529
  if (trimmed.length === 0 || trimmed.length > 2) return false; // Glyphs are 1-2 chars (surrogate pairs)
466
530
  const code = trimmed.codePointAt(0) || 0;
467
- return code >= 0xE000 && code <= 0xF8FF || code >= 0xF0000 && code <= 0xFFFFF || code >= 0x100000 && code <= 0x10FFFF;
531
+ return code >= 0xe000 && code <= 0xf8ff || code >= 0xf0000 && code <= 0xfffff || code >= 0x100000 && code <= 0x10ffff;
468
532
  }
469
533
  function normalizeRuntimeLabel(text) {
470
534
  if (!text) return '';
@@ -541,11 +605,11 @@ function extractRawText(children) {
541
605
  /**
542
606
  * Recursively search a fiber subtree for icon/symbol components and
543
607
  * return their `name` prop as a semantic label.
544
- *
608
+ *
545
609
  * Works generically: any non-RN-internal child component with a string
546
610
  * `name` prop is treated as an icon (covers Ionicons, MaterialIcons,
547
611
  * FontAwesome, custom SVG wrappers, etc. — no hardcoded list needed).
548
- *
612
+ *
549
613
  * e.g. a TouchableOpacity wrapping <Ionicons name="add-circle" /> → "icon:add-circle"
550
614
  */
551
615
  function extractIconName(fiber, maxDepth = 5) {
@@ -870,6 +934,28 @@ export function walkFiberTree(rootRef, config) {
870
934
  text: parentContext && !new Set(['ScrollViewContext', 'VirtualizedListContext', 'ViewabilityHelper', 'ScrollResponder', 'AnimatedComponent', 'TouchableOpacity']).has(parentContext) ? parentContext : null,
871
935
  source: 'context'
872
936
  }]);
937
+ const analyticsTarget = chooseBestAnalyticsTarget([{
938
+ text: derivedProps.accessibilityLabel,
939
+ source: 'accessibility'
940
+ }, {
941
+ text: extractDeepTextContent(node),
942
+ source: 'deep-text'
943
+ }, {
944
+ text: siblingTextLabel,
945
+ source: 'sibling-text'
946
+ }, {
947
+ text: derivedProps.title,
948
+ source: 'title'
949
+ }, {
950
+ text: resolvedType === 'text-input' ? derivedProps.placeholder : null,
951
+ source: 'placeholder'
952
+ }, {
953
+ text: derivedProps.testID || derivedProps.nativeID,
954
+ source: 'test-id'
955
+ }], getAnalyticsElementKind(resolvedType));
956
+ const ancestorPath = getCustomAncestorPath(node);
957
+ const siblingLabels = getSiblingAnalyticsLabels(node);
958
+ const componentName = getComponentName(node);
873
959
  interactives.push({
874
960
  index: currentIndex,
875
961
  type: resolvedType,
@@ -878,7 +964,14 @@ export function walkFiberTree(rootRef, config) {
878
964
  zoneId: currentZoneId,
879
965
  fiberNode: node,
880
966
  props: derivedProps,
881
- requiresConfirmation: derivedProps.aiConfirm === true
967
+ requiresConfirmation: derivedProps.aiConfirm === true,
968
+ analyticsLabel: analyticsTarget.label,
969
+ analyticsElementKind: analyticsTarget.elementKind,
970
+ analyticsLabelConfidence: analyticsTarget.labelConfidence,
971
+ analyticsZoneId: currentZoneId,
972
+ analyticsAncestorPath: ancestorPath,
973
+ analyticsSiblingLabels: siblingLabels,
974
+ analyticsComponentName: componentName
882
975
  });
883
976
 
884
977
  // Build output tag with state attributes
@@ -1080,7 +1173,7 @@ export function findScrollableContainers(rootRef, screenName) {
1080
1173
  const contextLabel = getNearestCustomComponentName(node) || name || 'Unknown';
1081
1174
 
1082
1175
  // For scrollable containers, we need the native scroll ref.
1083
- // FlatList Fiber stateNode may be the component instance —
1176
+ // FlatList Fiber stateNode may be the component instance —
1084
1177
  // we need to find the underlying native ScrollView.
1085
1178
  let scrollRef = isPagerLike ? getStateNode(node) : resolveNativeScrollRef(node);
1086
1179
  if (scrollRef) {
@@ -1109,13 +1202,13 @@ export function findScrollableContainers(rootRef, screenName) {
1109
1202
 
1110
1203
  /**
1111
1204
  * Resolve the native scroll view reference from a Fiber node.
1112
- *
1205
+ *
1113
1206
  * Handles multiple React Native internals:
1114
1207
  * - RCTScrollView: stateNode IS the native scroll view
1115
1208
  * - FlatList/VirtualizedList: stateNode is a component instance,
1116
1209
  * need to find the inner ScrollView via getNativeScrollRef() or
1117
1210
  * by walking down the Fiber tree to find the RCTScrollView child
1118
- */
1211
+ */
1119
1212
  function resolveNativeScrollRef(fiberNode) {
1120
1213
  const stateNode = getStateNode(fiberNode);
1121
1214
 
@@ -1129,7 +1222,9 @@ function resolveNativeScrollRef(fiberNode) {
1129
1222
  try {
1130
1223
  const ref = stateNode.getNativeScrollRef();
1131
1224
  if (ref && typeof ref.scrollTo === 'function') return ref;
1132
- } catch {/* fall through */}
1225
+ } catch {
1226
+ /* fall through */
1227
+ }
1133
1228
  }
1134
1229
 
1135
1230
  // Case 3: stateNode has getScrollRef (another VirtualizedList pattern)
@@ -1142,7 +1237,9 @@ function resolveNativeScrollRef(fiberNode) {
1142
1237
  const nativeRef = ref.getNativeScrollRef();
1143
1238
  if (nativeRef && typeof nativeRef.scrollTo === 'function') return nativeRef;
1144
1239
  }
1145
- } catch {/* fall through */}
1240
+ } catch {
1241
+ /* fall through */
1242
+ }
1146
1243
  }
1147
1244
 
1148
1245
  // Case 4: stateNode has scrollToOffset directly (VirtualizedList instance)
@@ -1196,7 +1293,13 @@ function measureElement(el) {
1196
1293
  if (width > 0 && height > 0) {
1197
1294
  resolve({
1198
1295
  type: el.type,
1199
- label: el.label || el.type,
1296
+ label: el.analyticsLabel || '',
1297
+ elementKind: el.analyticsElementKind,
1298
+ labelConfidence: el.analyticsLabelConfidence,
1299
+ zoneId: el.analyticsZoneId,
1300
+ ancestorPath: el.analyticsAncestorPath,
1301
+ siblingLabels: el.analyticsSiblingLabels,
1302
+ componentName: el.analyticsComponentName,
1200
1303
  x: pageX,
1201
1304
  y: pageY,
1202
1305
  width,
@@ -16,6 +16,9 @@
16
16
 
17
17
  // React Native imports not needed — we use Fiber internals directly
18
18
 
19
+ import { chooseBestAnalyticsTarget, getAnalyticsElementKind } from "./analyticsLabeling.js";
20
+ import { getChild, getDisplayName, getParent, getProps, getSibling, getType } from "../../core/FiberAdapter.js";
21
+
19
22
  // ─── Rage Click Detection ──────────────────────────────────────────
20
23
  //
21
24
  // Industry-standard approach (FullStory, PostHog, LogRocket):
@@ -31,6 +34,158 @@ const MAX_TAP_BUFFER = 8;
31
34
 
32
35
  // Labels that are naturally tapped multiple times in sequence (wizards, onboarding, etc.)
33
36
  const NAVIGATION_LABELS = new Set(['next', 'continue', 'skip', 'back', 'done', 'ok', 'cancel', 'previous', 'dismiss', 'close', 'got it', 'confirm', 'proceed', 'التالي', 'متابعة', 'تخطي', 'رجوع', 'تم', 'إلغاء', 'إغلاق', 'حسناً']);
37
+ const INTERACTIVE_PROP_KEYS = new Set(['onPress', 'onPressIn', 'onPressOut', 'onLongPress', 'onValueChange', 'onChangeText', 'onChange', 'onBlur', 'onFocus', 'onSubmitEditing', 'onScrollToTop', 'onDateChange', 'onValueChangeComplete', 'onSlidingComplete', 'onRefresh', 'onEndEditing', 'onSelect', 'onCheckedChange']);
38
+ const INTERACTIVE_ROLES = new Set(['button', 'link', 'menuitem', 'tab', 'checkbox', 'switch', 'radio', 'slider', 'search', 'text', 'textbox']);
39
+ const RN_INTERNAL_NAMES = new Set(['View', 'RCTView', 'Pressable', 'TouchableOpacity', 'TouchableHighlight', 'ScrollView', 'RCTScrollView', 'FlatList', 'SectionList', 'SafeAreaView', 'RNCSafeAreaView', 'KeyboardAvoidingView', 'Modal', 'StatusBar', 'Text', 'RCTText', 'AnimatedComponent', 'AnimatedComponentWrapper', 'Animated']);
40
+ function isInteractiveNode(props, typeName) {
41
+ if (!props || typeof props !== 'object') return false;
42
+ for (const key of Object.keys(props)) {
43
+ if (INTERACTIVE_PROP_KEYS.has(key) && typeof props[key] === 'function') {
44
+ return true;
45
+ }
46
+ }
47
+ const role = props.accessibilityRole;
48
+ if (typeof role === 'string' && INTERACTIVE_ROLES.has(role.toLowerCase())) {
49
+ return true;
50
+ }
51
+ if (!typeName) return false;
52
+ const normalizedType = typeName.toLowerCase();
53
+ return normalizedType.includes('pressable') || normalizedType.includes('touchable') || normalizedType.includes('button') || normalizedType.includes('textfield') || normalizedType.includes('textinput') || normalizedType.includes('switch') || normalizedType.includes('checkbox') || normalizedType.includes('slider') || normalizedType.includes('picker') || normalizedType.includes('datepicker');
54
+ }
55
+ function getComponentName(fiber) {
56
+ const type = getType(fiber);
57
+ if (!type) return null;
58
+ if (typeof type === 'string') return type;
59
+ const displayName = getDisplayName(fiber);
60
+ if (displayName) return displayName;
61
+ if (type.name) return type.name;
62
+ if (type.render?.displayName) return type.render.displayName;
63
+ if (type.render?.name) return type.render.name;
64
+ return null;
65
+ }
66
+ function getZoneId(fiber, maxDepth = 8) {
67
+ let current = fiber;
68
+ let depth = 0;
69
+ while (current && depth < maxDepth) {
70
+ const name = getComponentName(current);
71
+ const props = getProps(current);
72
+ if (name === 'AIZone' && typeof props.id === 'string' && props.id.trim().length > 0) {
73
+ return props.id.trim();
74
+ }
75
+ current = getParent(current);
76
+ depth++;
77
+ }
78
+ return null;
79
+ }
80
+ function getAncestorPath(fiber, maxDepth = 6) {
81
+ const labels = [];
82
+ const seen = new Set();
83
+ let current = getParent(fiber);
84
+ let depth = 0;
85
+ while (current && depth < maxDepth) {
86
+ const name = getComponentName(current);
87
+ const props = getProps(current);
88
+ const candidate = name === 'AIZone' && typeof props.id === 'string' && props.id.trim() ? props.id.trim() : name;
89
+ if (candidate && !RN_INTERNAL_NAMES.has(candidate) && !seen.has(candidate)) {
90
+ labels.push(candidate);
91
+ seen.add(candidate);
92
+ }
93
+ current = getParent(current);
94
+ depth++;
95
+ }
96
+ return labels;
97
+ }
98
+ function getLabelForFiberNode(fiber) {
99
+ const props = getProps(fiber);
100
+ return chooseBestAnalyticsTarget([{
101
+ text: props.accessibilityLabel,
102
+ source: 'accessibility'
103
+ }, {
104
+ text: props.title,
105
+ source: 'title'
106
+ }, {
107
+ text: props.placeholder,
108
+ source: 'placeholder'
109
+ }, {
110
+ text: props.testID,
111
+ source: 'test-id'
112
+ }, {
113
+ text: typeof props.children === 'string' ? props.children : Array.isArray(props.children) ? findTextInChildren(props.children) : props.children && typeof props.children === 'object' ? findTextInChildren([props.children]) : null,
114
+ source: 'deep-text'
115
+ }], getAnalyticsElementKind(props.accessibilityRole || getComponentName(fiber))).label;
116
+ }
117
+ function getSiblingLabels(fiber, maxLabels = 6) {
118
+ const parent = getParent(fiber);
119
+ if (!parent) return [];
120
+ const labels = [];
121
+ const seen = new Set();
122
+ let sibling = getChild(parent);
123
+ while (sibling) {
124
+ if (sibling !== fiber) {
125
+ const siblingProps = getProps(sibling);
126
+ const siblingName = getComponentName(sibling) || undefined;
127
+ if (isInteractiveNode(siblingProps, siblingName)) {
128
+ const label = getLabelForFiberNode(sibling);
129
+ if (label && !seen.has(label.toLowerCase())) {
130
+ labels.push(label);
131
+ seen.add(label.toLowerCase());
132
+ if (labels.length >= maxLabels) break;
133
+ }
134
+ }
135
+ }
136
+ sibling = getSibling(sibling);
137
+ }
138
+ return labels;
139
+ }
140
+ function addCandidatesFromProps(candidates, props, isInteractiveContext = false) {
141
+ if (!props) return;
142
+ const candidateSources = [{
143
+ source: 'accessibility',
144
+ text: props.accessibilityLabel
145
+ }, {
146
+ source: 'title',
147
+ text: props.title
148
+ }, {
149
+ source: 'placeholder',
150
+ text: props.placeholder
151
+ }, {
152
+ source: 'test-id',
153
+ text: props.testID
154
+ }];
155
+ for (const item of candidateSources) {
156
+ if (!item.text) continue;
157
+ candidates.push({
158
+ text: item.text,
159
+ source: item.source,
160
+ isInteractiveContext
161
+ });
162
+ }
163
+ if (typeof props.children === 'string' && props.children.trim()) {
164
+ candidates.push({
165
+ text: props.children.trim(),
166
+ source: 'deep-text',
167
+ isInteractiveContext
168
+ });
169
+ } else if (Array.isArray(props.children)) {
170
+ const text = findTextInChildren(props.children);
171
+ if (text) {
172
+ candidates.push({
173
+ text,
174
+ source: 'deep-text',
175
+ isInteractiveContext
176
+ });
177
+ }
178
+ } else if (props.children && typeof props.children === 'object') {
179
+ const text = findTextInChildren([props.children]);
180
+ if (text) {
181
+ candidates.push({
182
+ text,
183
+ source: 'deep-text',
184
+ isInteractiveContext
185
+ });
186
+ }
187
+ }
188
+ }
34
189
  function isNavigationLabel(label) {
35
190
  return NAVIGATION_LABELS.has(label.toLowerCase().trim());
36
191
  }
@@ -43,7 +198,10 @@ function isNavigationLabel(label) {
43
198
  * 2. Taps must be on the SAME screen (screen change = not rage, it's navigation)
44
199
  * 3. Navigation labels ("Next", "Skip", etc.) are excluded
45
200
  */
46
- export function checkRageClick(label, telemetry) {
201
+ export function checkRageClick(target, telemetry) {
202
+ const label = target.label;
203
+ if (!label) return;
204
+
47
205
  // Skip navigation-style labels — sequential tapping is by design
48
206
  if (isNavigationLabel(label)) return;
49
207
  const now = Date.now();
@@ -64,8 +222,16 @@ export function checkRageClick(label, telemetry) {
64
222
  canonical_type: 'rage_click_detected',
65
223
  label,
66
224
  element_label: label,
225
+ element_kind: target.elementKind,
226
+ label_confidence: target.labelConfidence,
227
+ zone_id: target.zoneId,
228
+ ancestor_path: target.ancestorPath,
229
+ sibling_labels: target.siblingLabels,
230
+ component_name: target.componentName,
67
231
  count: matching.length,
68
- screen: currentScreen
232
+ screen: currentScreen,
233
+ x: target.x,
234
+ y: target.y
69
235
  });
70
236
  // Reset buffer after emitting to avoid duplicate rage events
71
237
  recentTaps.length = 0;
@@ -78,69 +244,90 @@ export function checkRageClick(label, telemetry) {
78
244
  * @param event - The GestureResponderEvent from onStartShouldSetResponderCapture
79
245
  * @returns A descriptive label string for the tapped element
80
246
  */
81
- export function extractTouchLabel(event) {
247
+ export function extractTouchTargetMetadata(event) {
82
248
  const target = event?.nativeEvent?.target;
83
- if (!target) return 'Unknown Element';
249
+ if (!target) {
250
+ return {
251
+ label: null,
252
+ elementKind: 'unknown',
253
+ labelConfidence: 'low'
254
+ };
255
+ }
84
256
  try {
85
257
  let fiber = event?._targetInst || getFiberFromNativeTag(target);
86
258
  if (fiber) {
87
259
  let current = fiber;
88
260
  let depth = 0;
89
261
  const MAX_DEPTH = 12;
90
- let bestLabel = null;
91
- let detectedRole = null;
262
+ let foundInteractive = false;
263
+ const candidates = [];
264
+ let detectedKind = getAnalyticsElementKind(null);
265
+ let interactiveFiber = null;
92
266
  while (current && depth < MAX_DEPTH) {
267
+ const props = current.memoizedProps;
268
+ const typeName = current.type?.name || current.type?.displayName;
269
+ const nodeInteractive = isInteractiveNode(props, typeName);
270
+ if (nodeInteractive) {
271
+ foundInteractive = true;
272
+ interactiveFiber = current;
273
+ }
93
274
  // 1. Detect Component Type Context
94
- if (!detectedRole) {
95
- if (current.memoizedProps?.accessibilityRole) {
96
- detectedRole = current.memoizedProps.accessibilityRole;
97
- } else if (current.memoizedProps?.onValueChange && typeof current.memoizedProps?.value === 'boolean') {
98
- detectedRole = 'Toggle/Switch';
99
- } else if (current.memoizedProps?.onChangeText) {
100
- detectedRole = 'TextInput';
101
- } else if (current.memoizedProps?.onPress) {
102
- detectedRole = 'Button';
103
- } else if (current.type?.name || current.type?.displayName) {
104
- const name = current.type.name || current.type.displayName;
105
- if (typeof name === 'string' && name.length > 2 && !name.toLowerCase().includes('wrapper') && !name.startsWith('RCT')) {
106
- detectedRole = name;
107
- }
275
+ if (detectedKind === 'unknown') {
276
+ if (props?.accessibilityRole) {
277
+ detectedKind = getAnalyticsElementKind(props.accessibilityRole);
278
+ } else if (props?.onValueChange && typeof props?.value === 'boolean') {
279
+ detectedKind = 'toggle';
280
+ } else if (props?.onChangeText) {
281
+ detectedKind = 'text_input';
282
+ } else if (props?.onPress) {
283
+ detectedKind = 'button';
284
+ } else if (typeName) {
285
+ detectedKind = getAnalyticsElementKind(typeName);
108
286
  }
109
287
  }
288
+ if (!props) {
289
+ break;
290
+ }
291
+ addCandidatesFromProps(candidates, props, nodeInteractive);
110
292
 
111
- // 2. Detect String Label Output
112
- if (!bestLabel) {
113
- if (current.memoizedProps?.accessibilityLabel) {
114
- bestLabel = current.memoizedProps.accessibilityLabel;
115
- } else if (current.memoizedProps?.testID) {
116
- bestLabel = current.memoizedProps.testID;
117
- } else if (current.memoizedProps?.title) {
118
- bestLabel = current.memoizedProps.title;
119
- } else if (current.memoizedProps?.placeholder) {
120
- bestLabel = current.memoizedProps.placeholder;
121
- } else if (typeof current.memoizedProps?.children === 'string' && current.memoizedProps.children.trim()) {
122
- bestLabel = current.memoizedProps.children.trim();
123
- } else if (Array.isArray(current.memoizedProps?.children)) {
124
- bestLabel = findTextInChildren(current.memoizedProps.children);
125
- } else if (current.memoizedProps?.children && typeof current.memoizedProps.children === 'object') {
126
- bestLabel = findTextInChildren([current.memoizedProps.children]);
127
- }
293
+ // Stop at the nearest interactive node. If this node does not provide a
294
+ // usable label, still allow a child text fallback from descendants.
295
+ if (foundInteractive) {
296
+ break;
128
297
  }
129
298
  current = current.return;
130
299
  depth++;
131
300
  }
132
- if (bestLabel) {
133
- if (detectedRole && detectedRole.toLowerCase() !== 'text' && detectedRole.toLowerCase() !== 'view') {
134
- const formattedRole = detectedRole.charAt(0).toUpperCase() + detectedRole.slice(1);
135
- return `[${formattedRole}] ${bestLabel}`;
136
- }
137
- return bestLabel;
301
+ if (!foundInteractive) {
302
+ return {
303
+ label: null,
304
+ elementKind: detectedKind,
305
+ labelConfidence: 'low'
306
+ };
138
307
  }
308
+
309
+ // Prioritize nearest interactive context when available.
310
+ const resolved = chooseBestAnalyticsTarget(candidates, detectedKind);
311
+ const sourceFiber = interactiveFiber || fiber;
312
+ return {
313
+ ...resolved,
314
+ zoneId: getZoneId(sourceFiber),
315
+ ancestorPath: getAncestorPath(sourceFiber),
316
+ siblingLabels: getSiblingLabels(sourceFiber),
317
+ componentName: getComponentName(sourceFiber)
318
+ };
139
319
  }
140
320
  } catch {
141
321
  // Fiber access failed — fall back gracefully
142
322
  }
143
- return 'Unknown Element';
323
+ return {
324
+ label: null,
325
+ elementKind: 'unknown',
326
+ labelConfidence: 'low'
327
+ };
328
+ }
329
+ export function extractTouchLabel(event) {
330
+ return extractTouchTargetMetadata(event).label ?? 'Unknown Element';
144
331
  }
145
332
 
146
333
  /**
@@ -0,0 +1,187 @@
1
+ "use strict";
2
+
3
+ const GENERIC_LABELS = new Set(['button', 'buttons', 'component', 'components', 'container', 'containers', 'content', 'cta', 'item', 'items', 'label', 'labels', 'root', 'row', 'rows', 'screen', 'screens', 'text', 'texts', 'title', 'titles', 'unknown', 'value', 'values', 'view', 'views', 'wrapper', 'wrappers']);
4
+ const GENERIC_IDENTIFIER_TOKENS = new Set(['btn', 'button', 'card', 'cell', 'component', 'container', 'content', 'cta', 'icon', 'input', 'item', 'label', 'node', 'pressable', 'root', 'row', 'screen', 'target', 'text', 'tile', 'toggle', 'view', 'wrapper']);
5
+ const INTERNAL_NAME_PATTERNS = [/^RCT[A-Z]/, /^React/, /^TextImpl/i, /^Android/i, /^UI[A-Z]/, /^RN[A-Z]/, /^Virtualized/i, /^ScrollResponder$/i, /^Animated(Component|.*Wrapper)?$/i, /^Touchable[A-Z]/, /^Pressable$/i, /^View$/i, /^Text$/i, /^Modal$/i, /Legacy/i, /Wrapper/i, /Context$/i, /Provider$/i];
6
+ function normalizeWhitespace(value) {
7
+ if (!value) return '';
8
+ return String(value).replace(/\s+/g, ' ').trim();
9
+ }
10
+ function toTitleCase(value) {
11
+ return value.split(/\s+/).filter(Boolean).map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(' ');
12
+ }
13
+ function humanizeIdentifier(value) {
14
+ const humanized = value.replace(/^icon:/i, '').replace(/[_./-]+/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2').replace(/\b\d+\b/g, ' ').replace(/\s+/g, ' ').trim();
15
+ return toTitleCase(humanized);
16
+ }
17
+ function stripDecorators(value) {
18
+ return value.replace(/^\[[^\]]+\]\s*/g, '').replace(/^["'`]+|["'`]+$/g, '').trim();
19
+ }
20
+ function looksInternal(value) {
21
+ return INTERNAL_NAME_PATTERNS.some(pattern => pattern.test(value));
22
+ }
23
+ function isLowSignalValue(value) {
24
+ const lowered = value.toLowerCase();
25
+ return GENERIC_LABELS.has(lowered);
26
+ }
27
+ function sanitizeLabelValue(rawValue, source) {
28
+ let normalized = normalizeWhitespace(rawValue);
29
+ if (!normalized) return null;
30
+ normalized = stripDecorators(normalized);
31
+ if (!normalized) return null;
32
+ if (source === 'test-id' || source === 'icon' || source === 'context') {
33
+ normalized = humanizeIdentifier(normalized);
34
+ }
35
+ if (!normalized) return null;
36
+ if (normalized.length > 80) return null;
37
+ if (looksInternal(normalized)) return null;
38
+ const words = normalized.split(/\s+/).filter(Boolean);
39
+ if (words.length === 0) return null;
40
+ if (words.length === 1) {
41
+ const token = words[0].toLowerCase();
42
+ if (GENERIC_IDENTIFIER_TOKENS.has(token) || GENERIC_LABELS.has(token)) {
43
+ return null;
44
+ }
45
+ }
46
+ if (isLowSignalValue(normalized)) return null;
47
+ return normalized;
48
+ }
49
+ function scoreAnalyticsLabel(label, source, isInteractiveContext = false) {
50
+ let score = 0;
51
+ const words = label.split(/\s+/).filter(Boolean);
52
+ if (source === 'accessibility') score += 95;
53
+ if (source === 'deep-text') score += 78;
54
+ if (source === 'sibling-text') score += 64;
55
+ if (source === 'title') score += 56;
56
+ if (source === 'placeholder') score += 42;
57
+ if (source === 'context') score += 12;
58
+ if (source === 'icon') score -= 8;
59
+ if (source === 'test-id') score -= 14;
60
+ if (isInteractiveContext) score += 18;
61
+ if (words.length >= 2 && words.length <= 6) score += 20;else if (words.length === 1) score += 4;
62
+ if (label.length >= 4 && label.length <= 36) score += 18;else if (label.length > 56) score -= 20;
63
+ if (/^[A-Z]/.test(label)) score += 8;
64
+ if (/[A-Za-z]/.test(label) && !/[_./]/.test(label)) score += 10;
65
+ return score;
66
+ }
67
+ export function getFallbackAnalyticsLabel(elementKind) {
68
+ switch (elementKind) {
69
+ case 'button':
70
+ return 'Primary action';
71
+ case 'text_input':
72
+ return 'Text input';
73
+ case 'toggle':
74
+ return 'Toggle';
75
+ case 'picker':
76
+ return 'Picker';
77
+ case 'slider':
78
+ return 'Slider';
79
+ case 'link':
80
+ return 'Link';
81
+ case 'tab':
82
+ return 'Tab';
83
+ case 'list_item':
84
+ return 'List item';
85
+ case 'image':
86
+ return 'Image';
87
+ case 'icon':
88
+ return 'Icon';
89
+ case 'text':
90
+ return 'Text';
91
+ case 'card':
92
+ return 'Card';
93
+ case 'modal':
94
+ return 'Modal';
95
+ case 'sheet':
96
+ return 'Bottom sheet';
97
+ case 'scroll_area':
98
+ return 'Scrollable area';
99
+ default:
100
+ return null;
101
+ }
102
+ }
103
+ export function getAnalyticsElementKind(elementType) {
104
+ switch (elementType) {
105
+ case 'pressable':
106
+ case 'radio':
107
+ case 'button':
108
+ case 'checkbox':
109
+ return 'button';
110
+ case 'link':
111
+ return 'link';
112
+ case 'tab':
113
+ case 'tabbar':
114
+ return 'tab';
115
+ case 'listitem':
116
+ case 'list-item':
117
+ case 'menuitem':
118
+ return 'list_item';
119
+ case 'text-input':
120
+ case 'text_input':
121
+ case 'textinput':
122
+ return 'text_input';
123
+ case 'switch':
124
+ case 'toggle':
125
+ return 'toggle';
126
+ case 'slider':
127
+ return 'slider';
128
+ case 'picker':
129
+ return 'picker';
130
+ case 'date-picker':
131
+ case 'select':
132
+ case 'dropdown':
133
+ return 'picker';
134
+ case 'image':
135
+ case 'imagebutton':
136
+ return 'image';
137
+ case 'icon':
138
+ return 'icon';
139
+ case 'text':
140
+ case 'label':
141
+ case 'header':
142
+ return 'text';
143
+ case 'card':
144
+ return 'card';
145
+ case 'modal':
146
+ return 'modal';
147
+ case 'sheet':
148
+ case 'bottomsheet':
149
+ return 'sheet';
150
+ case 'scrollable':
151
+ case 'scrollview':
152
+ case 'flatlist':
153
+ case 'sectionlist':
154
+ case 'adjustable':
155
+ return 'scroll_area';
156
+ default:
157
+ return 'unknown';
158
+ }
159
+ }
160
+ export function chooseBestAnalyticsTarget(candidates, elementKind) {
161
+ let best;
162
+ for (const candidate of candidates) {
163
+ const label = sanitizeLabelValue(candidate.text, candidate.source);
164
+ if (!label) continue;
165
+ const score = scoreAnalyticsLabel(label, candidate.source, candidate.isInteractiveContext === true);
166
+ if (!best || score > best.score) {
167
+ best = {
168
+ label,
169
+ score
170
+ };
171
+ }
172
+ }
173
+ if (best) {
174
+ const labelConfidence = best.score >= 100 ? 'high' : 'low';
175
+ return {
176
+ label: best.label,
177
+ elementKind,
178
+ labelConfidence
179
+ };
180
+ }
181
+ return {
182
+ label: getFallbackAnalyticsLabel(elementKind),
183
+ elementKind,
184
+ labelConfidence: 'low'
185
+ };
186
+ }
187
+ //# sourceMappingURL=analyticsLabeling.js.map
@@ -82,9 +82,9 @@ export declare class AgentRuntime {
82
82
  /** Maps a tool call to a user-friendly status label for the loading overlay. */
83
83
  private getToolStatusLabel;
84
84
  /**
85
- * Captures the current screen as a base64 JPEG for Gemini vision.
86
- * Uses react-native-view-shot as an optional peer dependency.
87
- * Returns null if the library is not installed (graceful fallback).
85
+ * Captures the root component as a base64 JPEG for vision tools.
86
+ * Uses react-native-view-shot as a required peer dependency.
87
+ * Returns null only when capture is temporarily unavailable.
88
88
  */
89
89
  private captureScreenshot;
90
90
  /**
@@ -22,6 +22,8 @@ export interface VerifierConfig {
22
22
  maxFollowupSteps?: number;
23
23
  }
24
24
  export type ElementType = 'pressable' | 'text-input' | 'switch' | 'radio' | 'scrollable' | 'slider' | 'picker' | 'date-picker';
25
+ export type AnalyticsElementKind = 'button' | 'text_input' | 'toggle' | 'slider' | 'picker' | 'link' | 'tab' | 'list_item' | 'image' | 'icon' | 'text' | 'card' | 'modal' | 'sheet' | 'scroll_area' | 'unknown';
26
+ export type AnalyticsLabelConfidence = 'high' | 'low';
25
27
  export interface InteractiveElement {
26
28
  /** Unique index assigned during tree walk */
27
29
  index: number;
@@ -60,10 +62,30 @@ export interface InteractiveElement {
60
62
  /** 0-based index of the button in the active alert */
61
63
  alertButtonIndex: number;
62
64
  };
65
+ /** Sanitized analytics label used for telemetry and wireframes. */
66
+ analyticsLabel?: string | null;
67
+ /** Generic analytics-facing element kind. */
68
+ analyticsElementKind?: AnalyticsElementKind;
69
+ /** Confidence that the analytics label is user-facing. */
70
+ analyticsLabelConfidence?: AnalyticsLabelConfidence;
71
+ /** Nearest enclosing AI zone id, preserved for analytics/debugging. */
72
+ analyticsZoneId?: string | null;
73
+ /** Nearest custom component ancestry above the interactive node. */
74
+ analyticsAncestorPath?: string[];
75
+ /** Nearby sibling interactive labels from the same parent group. */
76
+ analyticsSiblingLabels?: string[];
77
+ /** Concrete component name for the matched interactive host. */
78
+ analyticsComponentName?: string | null;
63
79
  }
64
80
  export interface WireframeComponent {
65
81
  type: ElementType;
66
82
  label: string;
83
+ elementKind?: AnalyticsElementKind;
84
+ labelConfidence?: AnalyticsLabelConfidence;
85
+ zoneId?: string | null;
86
+ ancestorPath?: string[];
87
+ siblingLabels?: string[];
88
+ componentName?: string | null;
67
89
  x: number;
68
90
  y: number;
69
91
  width: number;
@@ -75,6 +97,12 @@ export interface WireframeSnapshot {
75
97
  deviceWidth: number;
76
98
  deviceHeight: number;
77
99
  capturedAt: string;
100
+ /**
101
+ * Optional JPEG screenshot captured at the same moment as this wireframe.
102
+ * Base64 payload (without data URI prefix) to avoid embedding UI-dependent
103
+ * image schemes in analytics payloads.
104
+ */
105
+ screenshot?: string | null;
78
106
  }
79
107
  export interface DehydratedScreen {
80
108
  /** Current screen name (from navigation state) */
@@ -12,6 +12,7 @@
12
12
  * 4. Last resort: "Unknown Element".
13
13
  */
14
14
  import type { TelemetryService } from './TelemetryService';
15
+ import type { AnalyticsTargetMetadata } from './analyticsLabeling';
15
16
  /**
16
17
  * Checks if the user is rage-tapping an element.
17
18
  *
@@ -20,12 +21,16 @@ import type { TelemetryService } from './TelemetryService';
20
21
  * 2. Taps must be on the SAME screen (screen change = not rage, it's navigation)
21
22
  * 3. Navigation labels ("Next", "Skip", etc.) are excluded
22
23
  */
23
- export declare function checkRageClick(label: string, telemetry: TelemetryService): void;
24
+ export declare function checkRageClick(target: AnalyticsTargetMetadata & {
25
+ x: number;
26
+ y: number;
27
+ }, telemetry: TelemetryService): void;
24
28
  /**
25
29
  * Extract a label from a GestureResponderEvent.
26
30
  *
27
31
  * @param event - The GestureResponderEvent from onStartShouldSetResponderCapture
28
32
  * @returns A descriptive label string for the tapped element
29
33
  */
34
+ export declare function extractTouchTargetMetadata(event: any): AnalyticsTargetMetadata;
30
35
  export declare function extractTouchLabel(event: any): string;
31
36
  //# sourceMappingURL=TouchAutoCapture.d.ts.map
@@ -0,0 +1,20 @@
1
+ import type { AnalyticsElementKind, AnalyticsLabelConfidence, ElementType } from '../../core/types';
2
+ export type AnalyticsLabelSource = 'accessibility' | 'deep-text' | 'sibling-text' | 'placeholder' | 'title' | 'test-id' | 'icon' | 'context';
3
+ export interface AnalyticsLabelCandidate {
4
+ text?: string | null;
5
+ source: AnalyticsLabelSource;
6
+ isInteractiveContext?: boolean;
7
+ }
8
+ export interface AnalyticsTargetMetadata {
9
+ label: string | null;
10
+ elementKind: AnalyticsElementKind;
11
+ labelConfidence: AnalyticsLabelConfidence;
12
+ zoneId?: string | null;
13
+ ancestorPath?: string[];
14
+ siblingLabels?: string[];
15
+ componentName?: string | null;
16
+ }
17
+ export declare function getFallbackAnalyticsLabel(elementKind: AnalyticsElementKind): string | null;
18
+ export declare function getAnalyticsElementKind(elementType?: ElementType | string | null): AnalyticsElementKind;
19
+ export declare function chooseBestAnalyticsTarget(candidates: AnalyticsLabelCandidate[], elementKind: AnalyticsElementKind): AnalyticsTargetMetadata;
20
+ //# sourceMappingURL=analyticsLabeling.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mobileai/react-native",
3
- "version": "0.9.28",
3
+ "version": "0.9.29",
4
4
  "description": "Build autonomous AI agents for React Native and Expo apps. Provides AI-native UI traversal, tool calling, and structured reasoning.",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",
@@ -82,14 +82,14 @@
82
82
  ],
83
83
  "repository": {
84
84
  "type": "git",
85
- "url": "git+https://github.com/MobileAIAgent/react-native.git"
85
+ "url": "git+https://github.com/mohamed2m2018/react-native-agentic-ai.git"
86
86
  },
87
87
  "author": "Mohamed Salah <mohamed2m2018@gmail.com> (https://example.com)",
88
88
  "license": "SEE LICENSE IN LICENSE",
89
89
  "bugs": {
90
- "url": "https://github.com/MobileAIAgent/react-native/issues"
90
+ "url": "https://github.com/mohamed2m2018/react-native-agentic-ai/issues"
91
91
  },
92
- "homepage": "https://github.com/MobileAIAgent/react-native#readme",
92
+ "homepage": "https://github.com/mohamed2m2018/react-native-agentic-ai#readme",
93
93
  "publishConfig": {
94
94
  "registry": "https://registry.npmjs.org/"
95
95
  },
@@ -98,6 +98,7 @@
98
98
  "@babel/traverse": "^7.29.0",
99
99
  "@babel/types": "^7.29.0",
100
100
  "expo": "~55.0.8",
101
+ "react-native-view-shot": "4.0.3",
101
102
  "react": "19.2.0",
102
103
  "react-native": "0.83.2"
103
104
  },
@@ -134,8 +135,7 @@
134
135
  "react": "*",
135
136
  "react-native": "*",
136
137
  "react-native-audio-api": "*",
137
- "react-native-screens": "*",
138
- "react-native-view-shot": "*"
138
+ "react-native-screens": "*"
139
139
  },
140
140
  "peerDependenciesMeta": {
141
141
  "@react-native-async-storage/async-storage": {
@@ -147,9 +147,6 @@
147
147
  "react-native-screens": {
148
148
  "optional": true
149
149
  },
150
- "react-native-view-shot": {
151
- "optional": true
152
- },
153
150
  "expo-speech-recognition": {
154
151
  "optional": true
155
152
  }