@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.
package/dist/index.js CHANGED
@@ -256,7 +256,18 @@ var NativeUIBridgeRegistry = class {
256
256
  return Array.from(this.elements.values());
257
257
  }
258
258
  /**
259
- * Get all registered elements that are visible and have a layout
259
+ * Get all registered elements that are visible AND have a measured layout.
260
+ *
261
+ * This is the *strict* visibility filter: requires `state.visible === true`
262
+ * AND `state.layout !== null`. Use this when you need both signals
263
+ * (e.g. coord-based tap, where you need an actual rect to hit-test against).
264
+ *
265
+ * Callers driving an LLM agent typically want
266
+ * {@link getMountedVisibleElements} instead — that variant accepts
267
+ * elements that *should* be on screen (visible:true) but whose first
268
+ * `onLayout` has not yet fired. Without it, a snapshot taken in the gap
269
+ * between mount and the first layout pass returns 0 elements on a fully
270
+ * populated screen.
260
271
  */
261
272
  getVisibleElements() {
262
273
  return this.getAllElements().filter((e) => {
@@ -264,6 +275,26 @@ var NativeUIBridgeRegistry = class {
264
275
  return state.visible && state.layout !== null;
265
276
  });
266
277
  }
278
+ /**
279
+ * Get all registered elements marked as visible (regardless of layout).
280
+ *
281
+ * Looser than {@link getVisibleElements}: includes elements whose
282
+ * `onLayout` callback has not yet fired (so `layout === null`). React
283
+ * Native's `onLayout` is async — there's a one-tick window after mount
284
+ * where elements are in the tree, the user *sees* them, but the
285
+ * registry hasn't received their measurements yet. Excluding those
286
+ * elements makes `getSnapshot?visibleOnly=true` return 0 on a populated
287
+ * screen, which is a worse failure mode than including a few elements
288
+ * we can't yet locate spatially.
289
+ *
290
+ * Used by `createSnapshot`'s `visibleOnly` filter and by the
291
+ * `getSnapshot` / `getElements` handlers. Each emitted element carries a
292
+ * `visibility` discriminator (`"visible"` / `"likely-visible"`) so
293
+ * agents that *do* require a known rect can branch downstream.
294
+ */
295
+ getMountedVisibleElements() {
296
+ return this.getAllElements().filter((e) => e.getState().visible);
297
+ }
267
298
  /**
268
299
  * Get elements registered on a specific route (for page-scoped filtering)
269
300
  */
@@ -320,6 +351,51 @@ var NativeUIBridgeRegistry = class {
320
351
  this.emit("element:stateChanged", { id, state: newState });
321
352
  }
322
353
  }
354
+ /**
355
+ * Update an element's descriptive metadata (label / accessibilityLabel /
356
+ * testId). Sister to {@link updateElementState} (which carries layout +
357
+ * visibility) and {@link updateElementProps} (which carries event handlers).
358
+ *
359
+ * Idempotent — if every passed field equals the existing value, the call
360
+ * returns without mutating the entry or emitting an event. This keeps
361
+ * `useUIElement().updateLabel(...)` cheap when consumers re-publish from a
362
+ * render path that produces the same string on every tick.
363
+ *
364
+ * Returns `true` when the entry was mutated, `false` otherwise. Callers
365
+ * that want to gate a `console.warn` on no-op invocations can use the
366
+ * return value instead of recomputing equality.
367
+ */
368
+ updateElementMeta(id, meta) {
369
+ const element = this.elements.get(id);
370
+ if (!element) return false;
371
+ const currentIdentifier = element.getIdentifier();
372
+ const nextLabel = meta.label !== void 0 ? meta.label : element.label;
373
+ const nextAccessibilityLabel = meta.accessibilityLabel !== void 0 ? meta.accessibilityLabel : currentIdentifier.accessibilityLabel;
374
+ const nextTestId = meta.testId !== void 0 ? meta.testId : currentIdentifier.testId;
375
+ const labelChanged = nextLabel !== element.label;
376
+ const accessibilityLabelChanged = nextAccessibilityLabel !== currentIdentifier.accessibilityLabel;
377
+ const testIdChanged = nextTestId !== currentIdentifier.testId;
378
+ if (!labelChanged && !accessibilityLabelChanged && !testIdChanged) {
379
+ return false;
380
+ }
381
+ const updated = {
382
+ ...element,
383
+ label: nextLabel,
384
+ getIdentifier: () => ({
385
+ uiId: id,
386
+ testId: nextTestId,
387
+ accessibilityLabel: nextAccessibilityLabel,
388
+ treePath: currentIdentifier.treePath
389
+ })
390
+ };
391
+ this.elements.set(id, updated);
392
+ this.emit("element:registered", {
393
+ id,
394
+ type: element.type,
395
+ label: nextLabel
396
+ });
397
+ return true;
398
+ }
323
399
  /**
324
400
  * Update element props (for action execution)
325
401
  */
@@ -538,7 +614,7 @@ var NativeUIBridgeRegistry = class {
538
614
  }
539
615
  return { currentRoute: null, segments: void 0 };
540
616
  })();
541
- let elements = options?.visibleOnly ? this.getVisibleElements() : this.getAllElements();
617
+ let elements = options?.visibleOnly ? this.getMountedVisibleElements() : this.getAllElements();
542
618
  if (options?.currentRouteOnly && resolvedRoute.currentRoute) {
543
619
  const currentRoute = resolvedRoute.currentRoute;
544
620
  elements = elements.filter((e) => e.registrationRoute === currentRoute);
@@ -547,16 +623,19 @@ var NativeUIBridgeRegistry = class {
547
623
  timestamp: Date.now(),
548
624
  elements: elements.map((e) => {
549
625
  const handlers = extractHandlerNames(e.props);
626
+ const state = e.getState();
627
+ const visibility = !state.visible ? "hidden" : state.layout !== null ? "visible" : "likely-visible";
550
628
  return {
551
629
  id: e.id,
552
630
  type: e.type,
553
631
  label: e.label,
554
632
  identifier: e.getIdentifier(),
555
- state: e.getState(),
633
+ state,
556
634
  actions: e.actions,
557
635
  customActions: e.customActions ? Object.keys(e.customActions) : void 0,
558
636
  registeredHandlers: handlers.length > 0 ? handlers : void 0,
559
- registrationRoute: e.registrationRoute
637
+ registrationRoute: e.registrationRoute,
638
+ visibility
560
639
  };
561
640
  }),
562
641
  components: this.getAllComponents().map((c) => ({
@@ -1950,22 +2029,25 @@ function createServerHandlers(registry, executor, config) {
1950
2029
  getElements: async (ctx) => {
1951
2030
  const visibleOnly = ctx.query?.visibleOnly === "true" || ctx.body?.visibleOnly === true;
1952
2031
  const forRoute = ctx.query?.route || ctx.body?.route;
1953
- let allElements = visibleOnly ? registry.getVisibleElements() : registry.getAllElements();
2032
+ let allElements = visibleOnly ? registry.getMountedVisibleElements() : registry.getAllElements();
1954
2033
  if (forRoute) {
1955
2034
  allElements = allElements.filter((e) => e.registrationRoute === forRoute);
1956
2035
  }
1957
2036
  const elements = allElements.map((e) => {
1958
2037
  const handlers = extractHandlerNames(e.props);
2038
+ const state = e.getState();
2039
+ const visibility = !state.visible ? "hidden" : state.layout !== null ? "visible" : "likely-visible";
1959
2040
  return {
1960
2041
  id: e.id,
1961
2042
  type: e.type,
1962
2043
  label: e.label,
1963
2044
  identifier: e.getIdentifier(),
1964
- state: e.getState(),
2045
+ state,
1965
2046
  actions: e.actions,
1966
2047
  customActions: e.customActions ? Object.keys(e.customActions) : void 0,
1967
2048
  registeredHandlers: handlers.length > 0 ? handlers : void 0,
1968
- registrationRoute: e.registrationRoute
2049
+ registrationRoute: e.registrationRoute,
2050
+ visibility
1969
2051
  };
1970
2052
  });
1971
2053
  return success2({ elements });
@@ -2228,6 +2310,37 @@ function createServerHandlers(registry, executor, config) {
2228
2310
  "NOT_SUPPORTED"
2229
2311
  );
2230
2312
  },
2313
+ // AI helpers — runner-only endpoints that have no React Native analog.
2314
+ //
2315
+ // The mobile bridge mounts explicit NOT_SUPPORTED stubs at the runner's
2316
+ // cheatsheet paths so callers (and the /manual-test skill in particular)
2317
+ // get a structured envelope explaining the gap rather than a confusing
2318
+ // HTTP 404. Each error message names an in-tree mobile replacement so
2319
+ // operators aren't left guessing what to use instead.
2320
+ aiForms: async () => {
2321
+ return error2(
2322
+ '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.',
2323
+ "NOT_SUPPORTED"
2324
+ );
2325
+ },
2326
+ aiIdleStatus: async () => {
2327
+ return error2(
2328
+ '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.',
2329
+ "NOT_SUPPORTED"
2330
+ );
2331
+ },
2332
+ aiChangeBufferEnable: async () => {
2333
+ return error2(
2334
+ '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.',
2335
+ "NOT_SUPPORTED"
2336
+ );
2337
+ },
2338
+ aiWaitForElement: async () => {
2339
+ return error2(
2340
+ '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.',
2341
+ "NOT_SUPPORTED"
2342
+ );
2343
+ },
2231
2344
  // Meta / Introspection (stub — server constructor overrides with real route table)
2232
2345
  getMethods: async () => {
2233
2346
  return error2("Methods introspection not configured", "NOT_SUPPORTED");
@@ -2883,6 +2996,24 @@ var WS_ROUTES = [
2883
2996
  { pattern: "meta/methods", handler: "getMethods", httpMethods: ["GET"] },
2884
2997
  // AI helpers — mobile parity with the runner's `/ui-bridge/ai/*` family.
2885
2998
  { pattern: "ai/fill-form", handler: "fillForm", httpMethods: ["POST"] },
2999
+ // AI helpers — NOT_SUPPORTED stubs. These endpoints exist on the runner-side
3000
+ // UI Bridge surface (and the /manual-test skill's cheatsheet documents them
3001
+ // as if they're universally available). Mobile React Native has no DOM, so
3002
+ // the mobile bridge mounts explicit stubs so callers get a structured
3003
+ // NOT_SUPPORTED envelope (with a hint pointing at the mobile-native
3004
+ // replacement) instead of a confusing HTTP 404.
3005
+ { pattern: "ai/forms", handler: "aiForms", httpMethods: ["GET"] },
3006
+ { pattern: "ai/idle-status", handler: "aiIdleStatus", httpMethods: ["GET"] },
3007
+ {
3008
+ pattern: "ai/change-buffer/enable",
3009
+ handler: "aiChangeBufferEnable",
3010
+ httpMethods: ["POST"]
3011
+ },
3012
+ {
3013
+ pattern: "ai/wait-for-element",
3014
+ handler: "aiWaitForElement",
3015
+ httpMethods: ["POST"]
3016
+ },
2886
3017
  // Coord-based tap — synthesizes a press at the given screen coords by
2887
3018
  // matching against registered elements' layout rects.
2888
3019
  { pattern: "control/tap", handler: "tapAt", httpMethods: ["POST"] },
@@ -2955,6 +3086,10 @@ var HANDLER_DESCRIPTIONS = {
2955
3086
  diffBaseline: "Diff current UI against a saved baseline",
2956
3087
  getMethods: "List every handler name (legacy introspection)",
2957
3088
  fillForm: "Fill multiple inputs in one call: body { fields: [{elementId, value}] } \u2014 each field dispatches `type` + `clear:true`",
3089
+ aiForms: 'STUB \u2014 runner-only; mobile returns NOT_SUPPORTED. Use /control/snapshot + filter type==="input" instead.',
3090
+ aiIdleStatus: "STUB \u2014 runner-only; mobile returns NOT_SUPPORTED. Subscribe to registry events over WS for change signals.",
3091
+ aiChangeBufferEnable: "STUB \u2014 runner-only; mobile returns NOT_SUPPORTED. Use WS event subscriptions for change notifications.",
3092
+ aiWaitForElement: "STUB \u2014 runner-only; mobile returns NOT_SUPPORTED. Use the WS waitForElement / waitForCondition JSON-RPC methods.",
2958
3093
  tapAt: "Synthesize a press at given screen coords by matching registered element layout rects: body { x, y, action? }",
2959
3094
  getConsoleErrors: "TEST HOOK \u2014 last-N console.error/console.warn entries; ?since=<ms>&limit=<n>",
2960
3095
  getNetworkRequests: "TEST HOOK \u2014 last-N fetch/XHR entries with status + duration; ?since=<ms>&limit=<n>",
@@ -5075,6 +5210,25 @@ function flattenStateStyles(stateStyles) {
5075
5210
  if (stateStyles.disabled != null) result.disabled = flattenStyle(stateStyles.disabled);
5076
5211
  return Object.keys(result).length > 0 ? result : void 0;
5077
5212
  }
5213
+ var warnedDeadButtonIds = /* @__PURE__ */ new Set();
5214
+ var warnedRedundantUpdateLabelIds = /* @__PURE__ */ new Set();
5215
+ function scheduleDeadButtonWarning(bridge, id, type) {
5216
+ if (type !== "button" && type !== "pressable") return;
5217
+ if (warnedDeadButtonIds.has(id)) return;
5218
+ if (!bridge) return;
5219
+ setTimeout(() => {
5220
+ if (warnedDeadButtonIds.has(id)) return;
5221
+ const element = bridge.registry.getElement(id);
5222
+ if (!element) return;
5223
+ const props = element.props ?? {};
5224
+ const hasPress = typeof props.onPress === "function" || typeof props.onPressIn === "function" || typeof props.onResponderRelease === "function";
5225
+ if (hasPress) return;
5226
+ warnedDeadButtonIds.add(id);
5227
+ console.warn(
5228
+ `[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.`
5229
+ );
5230
+ }, 0);
5231
+ }
5078
5232
  function useUIElement(options) {
5079
5233
  const bridge = useUIBridgeNativeOptional();
5080
5234
  const ref = react.useRef(null);
@@ -5121,6 +5275,7 @@ function useUIElement(options) {
5121
5275
  registeredRef.current = true;
5122
5276
  registeredIdRef.current = id;
5123
5277
  setRegistered(true);
5278
+ scheduleDeadButtonWarning(bridge, id, type);
5124
5279
  }, [bridge, id, type, label, actions, customActions, treePath, style, stateStylesProp]);
5125
5280
  const unregister = react.useCallback(() => {
5126
5281
  if (!bridge || !registeredRef.current) return;
@@ -5180,30 +5335,42 @@ function useUIElement(options) {
5180
5335
  const node = ref.current;
5181
5336
  if (!node || typeof node.measureInWindow !== "function") return;
5182
5337
  let cancelled = false;
5183
- node.measureInWindow((pageX, pageY, w, h) => {
5184
- if (cancelled) return;
5185
- if (w <= 0 || h <= 0) return;
5186
- const newLayout = {
5187
- x: pageX,
5188
- y: pageY,
5189
- width: w,
5190
- height: h,
5191
- pageX,
5192
- pageY
5193
- };
5194
- setLayout(newLayout);
5195
- const newState = {
5196
- mounted: true,
5197
- visible: true,
5198
- enabled: true,
5199
- focused: false,
5200
- layout: newLayout
5201
- };
5202
- bridge.registry.updateElementState(id, newState);
5203
- onStateChangeRef.current?.(newState);
5204
- });
5338
+ let pendingTimer = null;
5339
+ const backoffMs = [50, 200, 500, 1e3, 2500];
5340
+ let attempt = 0;
5341
+ const tryMeasure = () => {
5342
+ if (cancelled || !node.measureInWindow) return;
5343
+ node.measureInWindow((pageX, pageY, w, h) => {
5344
+ if (cancelled) return;
5345
+ if (w > 0 && h > 0) {
5346
+ const newLayout = {
5347
+ x: pageX,
5348
+ y: pageY,
5349
+ width: w,
5350
+ height: h,
5351
+ pageX,
5352
+ pageY
5353
+ };
5354
+ setLayout(newLayout);
5355
+ const newState = {
5356
+ mounted: true,
5357
+ visible: true,
5358
+ enabled: true,
5359
+ focused: false,
5360
+ layout: newLayout
5361
+ };
5362
+ bridge.registry.updateElementState(id, newState);
5363
+ onStateChangeRef.current?.(newState);
5364
+ return;
5365
+ }
5366
+ if (attempt >= backoffMs.length) return;
5367
+ pendingTimer = setTimeout(tryMeasure, backoffMs[attempt++]);
5368
+ });
5369
+ };
5370
+ tryMeasure();
5205
5371
  return () => {
5206
5372
  cancelled = true;
5373
+ if (pendingTimer != null) clearTimeout(pendingTimer);
5207
5374
  };
5208
5375
  }, [bridge, registered, id]);
5209
5376
  const registeredHandlerKeysRef = react.useRef([]);
@@ -5273,6 +5440,23 @@ function useUIElement(options) {
5273
5440
  },
5274
5441
  [bridge, id]
5275
5442
  );
5443
+ const updateLabel = react.useCallback(
5444
+ (newLabel) => {
5445
+ if (!bridge || !registered) return;
5446
+ const changed = bridge.registry.updateElementMeta(id, {
5447
+ label: newLabel,
5448
+ accessibilityLabel: newLabel,
5449
+ testId: id
5450
+ });
5451
+ if (!changed && !warnedRedundantUpdateLabelIds.has(id)) {
5452
+ warnedRedundantUpdateLabelIds.add(id);
5453
+ console.warn(
5454
+ `[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.`
5455
+ );
5456
+ }
5457
+ },
5458
+ [bridge, registered, id]
5459
+ );
5276
5460
  const updateStyle = react.useCallback(
5277
5461
  (newStyle, newStateStyles) => {
5278
5462
  if (!bridge || !registered) return;
@@ -5298,7 +5482,8 @@ function useUIElement(options) {
5298
5482
  register,
5299
5483
  unregister,
5300
5484
  registeredElement,
5301
- updateStyle
5485
+ updateStyle,
5486
+ updateLabel
5302
5487
  };
5303
5488
  }
5304
5489
  function useUIElementWithProps(options) {
@@ -5696,6 +5881,31 @@ var UI_BRIDGE_NATIVE_ROUTES = {
5696
5881
  path: "/ui-bridge/ai/fill-form",
5697
5882
  description: "Fill multiple input elements in a single call"
5698
5883
  },
5884
+ // AI helpers (NOT_SUPPORTED stubs) — these endpoints exist on the runner-side
5885
+ // UI Bridge surface but have no analog on mobile React Native. The mobile
5886
+ // bridge registers explicit stubs so callers get a structured NOT_SUPPORTED
5887
+ // envelope instead of a confusing HTTP 404. The mobile bridge's snapshot +
5888
+ // `/control/find` already cover what an operator would reach for these for.
5889
+ AI_FORMS: {
5890
+ method: "GET",
5891
+ path: "/ui-bridge/ai/forms",
5892
+ description: "Discover forms (runner-only; mobile returns NOT_SUPPORTED)"
5893
+ },
5894
+ AI_IDLE_STATUS: {
5895
+ method: "GET",
5896
+ path: "/ui-bridge/ai/idle-status",
5897
+ description: "Get page-idle signal (runner-only; mobile returns NOT_SUPPORTED)"
5898
+ },
5899
+ AI_CHANGE_BUFFER_ENABLE: {
5900
+ method: "POST",
5901
+ path: "/ui-bridge/ai/change-buffer/enable",
5902
+ description: "Enable DOM change buffer (runner-only; mobile returns NOT_SUPPORTED)"
5903
+ },
5904
+ AI_WAIT_FOR_ELEMENT: {
5905
+ method: "POST",
5906
+ path: "/ui-bridge/ai/wait-for-element",
5907
+ description: "Wait for element predicate via HTTP (runner-only; mobile returns NOT_SUPPORTED \u2014 use WS waitForElement instead)"
5908
+ },
5699
5909
  // Test hooks — drive ModalDetector state from outside the React tree
5700
5910
  // (gated by `features.testHooks`). Mirrors `pushModal` / `dismissModal`
5701
5911
  // calls that components normally make in-process.