@qontinui/ui-bridge-native 0.5.1 → 0.6.2

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.
@@ -254,7 +254,18 @@ var NativeUIBridgeRegistry = class {
254
254
  return Array.from(this.elements.values());
255
255
  }
256
256
  /**
257
- * Get all registered elements that are visible and have a layout
257
+ * Get all registered elements that are visible AND have a measured layout.
258
+ *
259
+ * This is the *strict* visibility filter: requires `state.visible === true`
260
+ * AND `state.layout !== null`. Use this when you need both signals
261
+ * (e.g. coord-based tap, where you need an actual rect to hit-test against).
262
+ *
263
+ * Callers driving an LLM agent typically want
264
+ * {@link getMountedVisibleElements} instead — that variant accepts
265
+ * elements that *should* be on screen (visible:true) but whose first
266
+ * `onLayout` has not yet fired. Without it, a snapshot taken in the gap
267
+ * between mount and the first layout pass returns 0 elements on a fully
268
+ * populated screen.
258
269
  */
259
270
  getVisibleElements() {
260
271
  return this.getAllElements().filter((e) => {
@@ -262,6 +273,26 @@ var NativeUIBridgeRegistry = class {
262
273
  return state.visible && state.layout !== null;
263
274
  });
264
275
  }
276
+ /**
277
+ * Get all registered elements marked as visible (regardless of layout).
278
+ *
279
+ * Looser than {@link getVisibleElements}: includes elements whose
280
+ * `onLayout` callback has not yet fired (so `layout === null`). React
281
+ * Native's `onLayout` is async — there's a one-tick window after mount
282
+ * where elements are in the tree, the user *sees* them, but the
283
+ * registry hasn't received their measurements yet. Excluding those
284
+ * elements makes `getSnapshot?visibleOnly=true` return 0 on a populated
285
+ * screen, which is a worse failure mode than including a few elements
286
+ * we can't yet locate spatially.
287
+ *
288
+ * Used by `createSnapshot`'s `visibleOnly` filter and by the
289
+ * `getSnapshot` / `getElements` handlers. Each emitted element carries a
290
+ * `visibility` discriminator (`"visible"` / `"likely-visible"`) so
291
+ * agents that *do* require a known rect can branch downstream.
292
+ */
293
+ getMountedVisibleElements() {
294
+ return this.getAllElements().filter((e) => e.getState().visible);
295
+ }
265
296
  /**
266
297
  * Get elements registered on a specific route (for page-scoped filtering)
267
298
  */
@@ -318,6 +349,51 @@ var NativeUIBridgeRegistry = class {
318
349
  this.emit("element:stateChanged", { id, state: newState });
319
350
  }
320
351
  }
352
+ /**
353
+ * Update an element's descriptive metadata (label / accessibilityLabel /
354
+ * testId). Sister to {@link updateElementState} (which carries layout +
355
+ * visibility) and {@link updateElementProps} (which carries event handlers).
356
+ *
357
+ * Idempotent — if every passed field equals the existing value, the call
358
+ * returns without mutating the entry or emitting an event. This keeps
359
+ * `useUIElement().updateLabel(...)` cheap when consumers re-publish from a
360
+ * render path that produces the same string on every tick.
361
+ *
362
+ * Returns `true` when the entry was mutated, `false` otherwise. Callers
363
+ * that want to gate a `console.warn` on no-op invocations can use the
364
+ * return value instead of recomputing equality.
365
+ */
366
+ updateElementMeta(id, meta) {
367
+ const element = this.elements.get(id);
368
+ if (!element) return false;
369
+ const currentIdentifier = element.getIdentifier();
370
+ const nextLabel = meta.label !== void 0 ? meta.label : element.label;
371
+ const nextAccessibilityLabel = meta.accessibilityLabel !== void 0 ? meta.accessibilityLabel : currentIdentifier.accessibilityLabel;
372
+ const nextTestId = meta.testId !== void 0 ? meta.testId : currentIdentifier.testId;
373
+ const labelChanged = nextLabel !== element.label;
374
+ const accessibilityLabelChanged = nextAccessibilityLabel !== currentIdentifier.accessibilityLabel;
375
+ const testIdChanged = nextTestId !== currentIdentifier.testId;
376
+ if (!labelChanged && !accessibilityLabelChanged && !testIdChanged) {
377
+ return false;
378
+ }
379
+ const updated = {
380
+ ...element,
381
+ label: nextLabel,
382
+ getIdentifier: () => ({
383
+ uiId: id,
384
+ testId: nextTestId,
385
+ accessibilityLabel: nextAccessibilityLabel,
386
+ treePath: currentIdentifier.treePath
387
+ })
388
+ };
389
+ this.elements.set(id, updated);
390
+ this.emit("element:registered", {
391
+ id,
392
+ type: element.type,
393
+ label: nextLabel
394
+ });
395
+ return true;
396
+ }
321
397
  /**
322
398
  * Update element props (for action execution)
323
399
  */
@@ -536,7 +612,7 @@ var NativeUIBridgeRegistry = class {
536
612
  }
537
613
  return { currentRoute: null, segments: void 0 };
538
614
  })();
539
- let elements = options?.visibleOnly ? this.getVisibleElements() : this.getAllElements();
615
+ let elements = options?.visibleOnly ? this.getMountedVisibleElements() : this.getAllElements();
540
616
  if (options?.currentRouteOnly && resolvedRoute.currentRoute) {
541
617
  const currentRoute = resolvedRoute.currentRoute;
542
618
  elements = elements.filter((e) => e.registrationRoute === currentRoute);
@@ -545,16 +621,19 @@ var NativeUIBridgeRegistry = class {
545
621
  timestamp: Date.now(),
546
622
  elements: elements.map((e) => {
547
623
  const handlers = extractHandlerNames(e.props);
624
+ const state = e.getState();
625
+ const visibility = !state.visible ? "hidden" : state.layout !== null ? "visible" : "likely-visible";
548
626
  return {
549
627
  id: e.id,
550
628
  type: e.type,
551
629
  label: e.label,
552
630
  identifier: e.getIdentifier(),
553
- state: e.getState(),
631
+ state,
554
632
  actions: e.actions,
555
633
  customActions: e.customActions ? Object.keys(e.customActions) : void 0,
556
634
  registeredHandlers: handlers.length > 0 ? handlers : void 0,
557
- registrationRoute: e.registrationRoute
635
+ registrationRoute: e.registrationRoute,
636
+ visibility
558
637
  };
559
638
  }),
560
639
  components: this.getAllComponents().map((c) => ({
@@ -1896,22 +1975,25 @@ function createServerHandlers(registry, executor, config) {
1896
1975
  getElements: async (ctx) => {
1897
1976
  const visibleOnly = ctx.query?.visibleOnly === "true" || ctx.body?.visibleOnly === true;
1898
1977
  const forRoute = ctx.query?.route || ctx.body?.route;
1899
- let allElements = visibleOnly ? registry.getVisibleElements() : registry.getAllElements();
1978
+ let allElements = visibleOnly ? registry.getMountedVisibleElements() : registry.getAllElements();
1900
1979
  if (forRoute) {
1901
1980
  allElements = allElements.filter((e) => e.registrationRoute === forRoute);
1902
1981
  }
1903
1982
  const elements = allElements.map((e) => {
1904
1983
  const handlers = extractHandlerNames(e.props);
1984
+ const state = e.getState();
1985
+ const visibility = !state.visible ? "hidden" : state.layout !== null ? "visible" : "likely-visible";
1905
1986
  return {
1906
1987
  id: e.id,
1907
1988
  type: e.type,
1908
1989
  label: e.label,
1909
1990
  identifier: e.getIdentifier(),
1910
- state: e.getState(),
1991
+ state,
1911
1992
  actions: e.actions,
1912
1993
  customActions: e.customActions ? Object.keys(e.customActions) : void 0,
1913
1994
  registeredHandlers: handlers.length > 0 ? handlers : void 0,
1914
- registrationRoute: e.registrationRoute
1995
+ registrationRoute: e.registrationRoute,
1996
+ visibility
1915
1997
  };
1916
1998
  });
1917
1999
  return success2({ elements });
@@ -2174,6 +2256,37 @@ function createServerHandlers(registry, executor, config) {
2174
2256
  "NOT_SUPPORTED"
2175
2257
  );
2176
2258
  },
2259
+ // AI helpers — runner-only endpoints that have no React Native analog.
2260
+ //
2261
+ // The mobile bridge mounts explicit NOT_SUPPORTED stubs at the runner's
2262
+ // cheatsheet paths so callers (and the /manual-test skill in particular)
2263
+ // get a structured envelope explaining the gap rather than a confusing
2264
+ // HTTP 404. Each error message names an in-tree mobile replacement so
2265
+ // operators aren't left guessing what to use instead.
2266
+ aiForms: async () => {
2267
+ return error2(
2268
+ 'ai/forms (form discovery) is runner-only; mobile React Native has no DOM to walk. Use GET /ui-bridge/control/snapshot and filter for type === "input" instead.',
2269
+ "NOT_SUPPORTED"
2270
+ );
2271
+ },
2272
+ aiIdleStatus: async () => {
2273
+ return error2(
2274
+ 'ai/idle-status (page-idle signal) is runner-only; mobile React Native has no DOM-mutation / network-idle signals. Use the registry event stream (WS subscribe to "element:registered"/"element:stateChanged") or poll /control/snapshot for stability.',
2275
+ "NOT_SUPPORTED"
2276
+ );
2277
+ },
2278
+ aiChangeBufferEnable: async () => {
2279
+ return error2(
2280
+ 'ai/change-buffer (DOM mutation diff buffer) is runner-only; mobile React Native has no DOM. Use the WS event bridge (subscribe to "element:registered"/"element:unregistered"/"element:stateChanged") for change notifications.',
2281
+ "NOT_SUPPORTED"
2282
+ );
2283
+ },
2284
+ aiWaitForElement: async () => {
2285
+ return error2(
2286
+ 'POST /ai/wait-for-element is runner-only on mobile. Use the WS "waitForElement" JSON-RPC method or "waitForCondition" instead \u2014 same semantics, no HTTP polling overhead.',
2287
+ "NOT_SUPPORTED"
2288
+ );
2289
+ },
2177
2290
  // Meta / Introspection (stub — server constructor overrides with real route table)
2178
2291
  getMethods: async () => {
2179
2292
  return error2("Methods introspection not configured", "NOT_SUPPORTED");
@@ -2829,6 +2942,24 @@ var WS_ROUTES = [
2829
2942
  { pattern: "meta/methods", handler: "getMethods", httpMethods: ["GET"] },
2830
2943
  // AI helpers — mobile parity with the runner's `/ui-bridge/ai/*` family.
2831
2944
  { pattern: "ai/fill-form", handler: "fillForm", httpMethods: ["POST"] },
2945
+ // AI helpers — NOT_SUPPORTED stubs. These endpoints exist on the runner-side
2946
+ // UI Bridge surface (and the /manual-test skill's cheatsheet documents them
2947
+ // as if they're universally available). Mobile React Native has no DOM, so
2948
+ // the mobile bridge mounts explicit stubs so callers get a structured
2949
+ // NOT_SUPPORTED envelope (with a hint pointing at the mobile-native
2950
+ // replacement) instead of a confusing HTTP 404.
2951
+ { pattern: "ai/forms", handler: "aiForms", httpMethods: ["GET"] },
2952
+ { pattern: "ai/idle-status", handler: "aiIdleStatus", httpMethods: ["GET"] },
2953
+ {
2954
+ pattern: "ai/change-buffer/enable",
2955
+ handler: "aiChangeBufferEnable",
2956
+ httpMethods: ["POST"]
2957
+ },
2958
+ {
2959
+ pattern: "ai/wait-for-element",
2960
+ handler: "aiWaitForElement",
2961
+ httpMethods: ["POST"]
2962
+ },
2832
2963
  // Coord-based tap — synthesizes a press at the given screen coords by
2833
2964
  // matching against registered elements' layout rects.
2834
2965
  { pattern: "control/tap", handler: "tapAt", httpMethods: ["POST"] },
@@ -2901,6 +3032,10 @@ var HANDLER_DESCRIPTIONS = {
2901
3032
  diffBaseline: "Diff current UI against a saved baseline",
2902
3033
  getMethods: "List every handler name (legacy introspection)",
2903
3034
  fillForm: "Fill multiple inputs in one call: body { fields: [{elementId, value}] } \u2014 each field dispatches `type` + `clear:true`",
3035
+ aiForms: 'STUB \u2014 runner-only; mobile returns NOT_SUPPORTED. Use /control/snapshot + filter type==="input" instead.',
3036
+ aiIdleStatus: "STUB \u2014 runner-only; mobile returns NOT_SUPPORTED. Subscribe to registry events over WS for change signals.",
3037
+ aiChangeBufferEnable: "STUB \u2014 runner-only; mobile returns NOT_SUPPORTED. Use WS event subscriptions for change notifications.",
3038
+ aiWaitForElement: "STUB \u2014 runner-only; mobile returns NOT_SUPPORTED. Use the WS waitForElement / waitForCondition JSON-RPC methods.",
2904
3039
  tapAt: "Synthesize a press at given screen coords by matching registered element layout rects: body { x, y, action? }",
2905
3040
  getConsoleErrors: "TEST HOOK \u2014 last-N console.error/console.warn entries; ?since=<ms>&limit=<n>",
2906
3041
  getNetworkRequests: "TEST HOOK \u2014 last-N fetch/XHR entries with status + duration; ?since=<ms>&limit=<n>",
@@ -5021,6 +5156,25 @@ function flattenStateStyles(stateStyles) {
5021
5156
  if (stateStyles.disabled != null) result.disabled = flattenStyle(stateStyles.disabled);
5022
5157
  return Object.keys(result).length > 0 ? result : void 0;
5023
5158
  }
5159
+ var warnedDeadButtonIds = /* @__PURE__ */ new Set();
5160
+ var warnedRedundantUpdateLabelIds = /* @__PURE__ */ new Set();
5161
+ function scheduleDeadButtonWarning(bridge, id, type) {
5162
+ if (type !== "button" && type !== "pressable") return;
5163
+ if (warnedDeadButtonIds.has(id)) return;
5164
+ if (!bridge) return;
5165
+ setTimeout(() => {
5166
+ if (warnedDeadButtonIds.has(id)) return;
5167
+ const element = bridge.registry.getElement(id);
5168
+ if (!element) return;
5169
+ const props = element.props ?? {};
5170
+ const hasPress = typeof props.onPress === "function" || typeof props.onPressIn === "function" || typeof props.onResponderRelease === "function";
5171
+ if (hasPress) return;
5172
+ warnedDeadButtonIds.add(id);
5173
+ console.warn(
5174
+ `[UI Bridge] Registered \`${type}\` element "${id}" without an \`onPress\` handler \u2014 UI Bridge \`press\` actions on this element will fail with "No press handler found on element". Use \`useUIElementWithProps\` and \`captureProps({onPress})\` instead.`
5175
+ );
5176
+ }, 0);
5177
+ }
5024
5178
  function useUIElement(options) {
5025
5179
  const bridge = useUIBridgeNativeOptional();
5026
5180
  const ref = useRef(null);
@@ -5067,6 +5221,7 @@ function useUIElement(options) {
5067
5221
  registeredRef.current = true;
5068
5222
  registeredIdRef.current = id;
5069
5223
  setRegistered(true);
5224
+ scheduleDeadButtonWarning(bridge, id, type);
5070
5225
  }, [bridge, id, type, label, actions, customActions, treePath, style, stateStylesProp]);
5071
5226
  const unregister = useCallback(() => {
5072
5227
  if (!bridge || !registeredRef.current) return;
@@ -5126,30 +5281,42 @@ function useUIElement(options) {
5126
5281
  const node = ref.current;
5127
5282
  if (!node || typeof node.measureInWindow !== "function") return;
5128
5283
  let cancelled = false;
5129
- node.measureInWindow((pageX, pageY, w, h) => {
5130
- if (cancelled) return;
5131
- if (w <= 0 || h <= 0) return;
5132
- const newLayout = {
5133
- x: pageX,
5134
- y: pageY,
5135
- width: w,
5136
- height: h,
5137
- pageX,
5138
- pageY
5139
- };
5140
- setLayout(newLayout);
5141
- const newState = {
5142
- mounted: true,
5143
- visible: true,
5144
- enabled: true,
5145
- focused: false,
5146
- layout: newLayout
5147
- };
5148
- bridge.registry.updateElementState(id, newState);
5149
- onStateChangeRef.current?.(newState);
5150
- });
5284
+ let pendingTimer = null;
5285
+ const backoffMs = [50, 200, 500, 1e3, 2500];
5286
+ let attempt = 0;
5287
+ const tryMeasure = () => {
5288
+ if (cancelled || !node.measureInWindow) return;
5289
+ node.measureInWindow((pageX, pageY, w, h) => {
5290
+ if (cancelled) return;
5291
+ if (w > 0 && h > 0) {
5292
+ const newLayout = {
5293
+ x: pageX,
5294
+ y: pageY,
5295
+ width: w,
5296
+ height: h,
5297
+ pageX,
5298
+ pageY
5299
+ };
5300
+ setLayout(newLayout);
5301
+ const newState = {
5302
+ mounted: true,
5303
+ visible: true,
5304
+ enabled: true,
5305
+ focused: false,
5306
+ layout: newLayout
5307
+ };
5308
+ bridge.registry.updateElementState(id, newState);
5309
+ onStateChangeRef.current?.(newState);
5310
+ return;
5311
+ }
5312
+ if (attempt >= backoffMs.length) return;
5313
+ pendingTimer = setTimeout(tryMeasure, backoffMs[attempt++]);
5314
+ });
5315
+ };
5316
+ tryMeasure();
5151
5317
  return () => {
5152
5318
  cancelled = true;
5319
+ if (pendingTimer != null) clearTimeout(pendingTimer);
5153
5320
  };
5154
5321
  }, [bridge, registered, id]);
5155
5322
  const registeredHandlerKeysRef = useRef([]);
@@ -5219,6 +5386,23 @@ function useUIElement(options) {
5219
5386
  },
5220
5387
  [bridge, id]
5221
5388
  );
5389
+ const updateLabel = useCallback(
5390
+ (newLabel) => {
5391
+ if (!bridge || !registered) return;
5392
+ const changed = bridge.registry.updateElementMeta(id, {
5393
+ label: newLabel,
5394
+ accessibilityLabel: newLabel,
5395
+ testId: id
5396
+ });
5397
+ if (!changed && !warnedRedundantUpdateLabelIds.has(id)) {
5398
+ warnedRedundantUpdateLabelIds.add(id);
5399
+ console.warn(
5400
+ `[UI Bridge] updateLabel("${newLabel}") on element "${id}" was a no-op (label unchanged). Gate the call behind a useEffect with [label] deps to avoid redundant publishes.`
5401
+ );
5402
+ }
5403
+ },
5404
+ [bridge, registered, id]
5405
+ );
5222
5406
  const updateStyle = useCallback(
5223
5407
  (newStyle, newStateStyles) => {
5224
5408
  if (!bridge || !registered) return;
@@ -5244,7 +5428,8 @@ function useUIElement(options) {
5244
5428
  register,
5245
5429
  unregister,
5246
5430
  registeredElement,
5247
- updateStyle
5431
+ updateStyle,
5432
+ updateLabel
5248
5433
  };
5249
5434
  }
5250
5435
  function useUIElementWithProps(options) {