@qontinui/ui-bridge-native 0.5.1 → 0.6.1

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.
@@ -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
  */
@@ -538,7 +569,7 @@ var NativeUIBridgeRegistry = class {
538
569
  }
539
570
  return { currentRoute: null, segments: void 0 };
540
571
  })();
541
- let elements = options?.visibleOnly ? this.getVisibleElements() : this.getAllElements();
572
+ let elements = options?.visibleOnly ? this.getMountedVisibleElements() : this.getAllElements();
542
573
  if (options?.currentRouteOnly && resolvedRoute.currentRoute) {
543
574
  const currentRoute = resolvedRoute.currentRoute;
544
575
  elements = elements.filter((e) => e.registrationRoute === currentRoute);
@@ -547,16 +578,19 @@ var NativeUIBridgeRegistry = class {
547
578
  timestamp: Date.now(),
548
579
  elements: elements.map((e) => {
549
580
  const handlers = extractHandlerNames(e.props);
581
+ const state = e.getState();
582
+ const visibility = !state.visible ? "hidden" : state.layout !== null ? "visible" : "likely-visible";
550
583
  return {
551
584
  id: e.id,
552
585
  type: e.type,
553
586
  label: e.label,
554
587
  identifier: e.getIdentifier(),
555
- state: e.getState(),
588
+ state,
556
589
  actions: e.actions,
557
590
  customActions: e.customActions ? Object.keys(e.customActions) : void 0,
558
591
  registeredHandlers: handlers.length > 0 ? handlers : void 0,
559
- registrationRoute: e.registrationRoute
592
+ registrationRoute: e.registrationRoute,
593
+ visibility
560
594
  };
561
595
  }),
562
596
  components: this.getAllComponents().map((c) => ({
@@ -1898,22 +1932,25 @@ function createServerHandlers(registry, executor, config) {
1898
1932
  getElements: async (ctx) => {
1899
1933
  const visibleOnly = ctx.query?.visibleOnly === "true" || ctx.body?.visibleOnly === true;
1900
1934
  const forRoute = ctx.query?.route || ctx.body?.route;
1901
- let allElements = visibleOnly ? registry.getVisibleElements() : registry.getAllElements();
1935
+ let allElements = visibleOnly ? registry.getMountedVisibleElements() : registry.getAllElements();
1902
1936
  if (forRoute) {
1903
1937
  allElements = allElements.filter((e) => e.registrationRoute === forRoute);
1904
1938
  }
1905
1939
  const elements = allElements.map((e) => {
1906
1940
  const handlers = extractHandlerNames(e.props);
1941
+ const state = e.getState();
1942
+ const visibility = !state.visible ? "hidden" : state.layout !== null ? "visible" : "likely-visible";
1907
1943
  return {
1908
1944
  id: e.id,
1909
1945
  type: e.type,
1910
1946
  label: e.label,
1911
1947
  identifier: e.getIdentifier(),
1912
- state: e.getState(),
1948
+ state,
1913
1949
  actions: e.actions,
1914
1950
  customActions: e.customActions ? Object.keys(e.customActions) : void 0,
1915
1951
  registeredHandlers: handlers.length > 0 ? handlers : void 0,
1916
- registrationRoute: e.registrationRoute
1952
+ registrationRoute: e.registrationRoute,
1953
+ visibility
1917
1954
  };
1918
1955
  });
1919
1956
  return success2({ elements });
@@ -2176,6 +2213,37 @@ function createServerHandlers(registry, executor, config) {
2176
2213
  "NOT_SUPPORTED"
2177
2214
  );
2178
2215
  },
2216
+ // AI helpers — runner-only endpoints that have no React Native analog.
2217
+ //
2218
+ // The mobile bridge mounts explicit NOT_SUPPORTED stubs at the runner's
2219
+ // cheatsheet paths so callers (and the /manual-test skill in particular)
2220
+ // get a structured envelope explaining the gap rather than a confusing
2221
+ // HTTP 404. Each error message names an in-tree mobile replacement so
2222
+ // operators aren't left guessing what to use instead.
2223
+ aiForms: async () => {
2224
+ return error2(
2225
+ '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.',
2226
+ "NOT_SUPPORTED"
2227
+ );
2228
+ },
2229
+ aiIdleStatus: async () => {
2230
+ return error2(
2231
+ '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.',
2232
+ "NOT_SUPPORTED"
2233
+ );
2234
+ },
2235
+ aiChangeBufferEnable: async () => {
2236
+ return error2(
2237
+ '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.',
2238
+ "NOT_SUPPORTED"
2239
+ );
2240
+ },
2241
+ aiWaitForElement: async () => {
2242
+ return error2(
2243
+ '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.',
2244
+ "NOT_SUPPORTED"
2245
+ );
2246
+ },
2179
2247
  // Meta / Introspection (stub — server constructor overrides with real route table)
2180
2248
  getMethods: async () => {
2181
2249
  return error2("Methods introspection not configured", "NOT_SUPPORTED");
@@ -2831,6 +2899,24 @@ var WS_ROUTES = [
2831
2899
  { pattern: "meta/methods", handler: "getMethods", httpMethods: ["GET"] },
2832
2900
  // AI helpers — mobile parity with the runner's `/ui-bridge/ai/*` family.
2833
2901
  { pattern: "ai/fill-form", handler: "fillForm", httpMethods: ["POST"] },
2902
+ // AI helpers — NOT_SUPPORTED stubs. These endpoints exist on the runner-side
2903
+ // UI Bridge surface (and the /manual-test skill's cheatsheet documents them
2904
+ // as if they're universally available). Mobile React Native has no DOM, so
2905
+ // the mobile bridge mounts explicit stubs so callers get a structured
2906
+ // NOT_SUPPORTED envelope (with a hint pointing at the mobile-native
2907
+ // replacement) instead of a confusing HTTP 404.
2908
+ { pattern: "ai/forms", handler: "aiForms", httpMethods: ["GET"] },
2909
+ { pattern: "ai/idle-status", handler: "aiIdleStatus", httpMethods: ["GET"] },
2910
+ {
2911
+ pattern: "ai/change-buffer/enable",
2912
+ handler: "aiChangeBufferEnable",
2913
+ httpMethods: ["POST"]
2914
+ },
2915
+ {
2916
+ pattern: "ai/wait-for-element",
2917
+ handler: "aiWaitForElement",
2918
+ httpMethods: ["POST"]
2919
+ },
2834
2920
  // Coord-based tap — synthesizes a press at the given screen coords by
2835
2921
  // matching against registered elements' layout rects.
2836
2922
  { pattern: "control/tap", handler: "tapAt", httpMethods: ["POST"] },
@@ -2903,6 +2989,10 @@ var HANDLER_DESCRIPTIONS = {
2903
2989
  diffBaseline: "Diff current UI against a saved baseline",
2904
2990
  getMethods: "List every handler name (legacy introspection)",
2905
2991
  fillForm: "Fill multiple inputs in one call: body { fields: [{elementId, value}] } \u2014 each field dispatches `type` + `clear:true`",
2992
+ aiForms: 'STUB \u2014 runner-only; mobile returns NOT_SUPPORTED. Use /control/snapshot + filter type==="input" instead.',
2993
+ aiIdleStatus: "STUB \u2014 runner-only; mobile returns NOT_SUPPORTED. Subscribe to registry events over WS for change signals.",
2994
+ aiChangeBufferEnable: "STUB \u2014 runner-only; mobile returns NOT_SUPPORTED. Use WS event subscriptions for change notifications.",
2995
+ aiWaitForElement: "STUB \u2014 runner-only; mobile returns NOT_SUPPORTED. Use the WS waitForElement / waitForCondition JSON-RPC methods.",
2906
2996
  tapAt: "Synthesize a press at given screen coords by matching registered element layout rects: body { x, y, action? }",
2907
2997
  getConsoleErrors: "TEST HOOK \u2014 last-N console.error/console.warn entries; ?since=<ms>&limit=<n>",
2908
2998
  getNetworkRequests: "TEST HOOK \u2014 last-N fetch/XHR entries with status + duration; ?since=<ms>&limit=<n>",
@@ -5023,6 +5113,24 @@ function flattenStateStyles(stateStyles) {
5023
5113
  if (stateStyles.disabled != null) result.disabled = flattenStyle(stateStyles.disabled);
5024
5114
  return Object.keys(result).length > 0 ? result : void 0;
5025
5115
  }
5116
+ var warnedDeadButtonIds = /* @__PURE__ */ new Set();
5117
+ function scheduleDeadButtonWarning(bridge, id, type) {
5118
+ if (type !== "button" && type !== "pressable") return;
5119
+ if (warnedDeadButtonIds.has(id)) return;
5120
+ if (!bridge) return;
5121
+ setTimeout(() => {
5122
+ if (warnedDeadButtonIds.has(id)) return;
5123
+ const element = bridge.registry.getElement(id);
5124
+ if (!element) return;
5125
+ const props = element.props ?? {};
5126
+ const hasPress = typeof props.onPress === "function" || typeof props.onPressIn === "function" || typeof props.onResponderRelease === "function";
5127
+ if (hasPress) return;
5128
+ warnedDeadButtonIds.add(id);
5129
+ console.warn(
5130
+ `[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.`
5131
+ );
5132
+ }, 0);
5133
+ }
5026
5134
  function useUIElement(options) {
5027
5135
  const bridge = useUIBridgeNativeOptional();
5028
5136
  const ref = react.useRef(null);
@@ -5069,6 +5177,7 @@ function useUIElement(options) {
5069
5177
  registeredRef.current = true;
5070
5178
  registeredIdRef.current = id;
5071
5179
  setRegistered(true);
5180
+ scheduleDeadButtonWarning(bridge, id, type);
5072
5181
  }, [bridge, id, type, label, actions, customActions, treePath, style, stateStylesProp]);
5073
5182
  const unregister = react.useCallback(() => {
5074
5183
  if (!bridge || !registeredRef.current) return;
@@ -5128,30 +5237,42 @@ function useUIElement(options) {
5128
5237
  const node = ref.current;
5129
5238
  if (!node || typeof node.measureInWindow !== "function") return;
5130
5239
  let cancelled = false;
5131
- node.measureInWindow((pageX, pageY, w, h) => {
5132
- if (cancelled) return;
5133
- if (w <= 0 || h <= 0) return;
5134
- const newLayout = {
5135
- x: pageX,
5136
- y: pageY,
5137
- width: w,
5138
- height: h,
5139
- pageX,
5140
- pageY
5141
- };
5142
- setLayout(newLayout);
5143
- const newState = {
5144
- mounted: true,
5145
- visible: true,
5146
- enabled: true,
5147
- focused: false,
5148
- layout: newLayout
5149
- };
5150
- bridge.registry.updateElementState(id, newState);
5151
- onStateChangeRef.current?.(newState);
5152
- });
5240
+ let pendingTimer = null;
5241
+ const backoffMs = [50, 200, 500, 1e3, 2500];
5242
+ let attempt = 0;
5243
+ const tryMeasure = () => {
5244
+ if (cancelled || !node.measureInWindow) return;
5245
+ node.measureInWindow((pageX, pageY, w, h) => {
5246
+ if (cancelled) return;
5247
+ if (w > 0 && h > 0) {
5248
+ const newLayout = {
5249
+ x: pageX,
5250
+ y: pageY,
5251
+ width: w,
5252
+ height: h,
5253
+ pageX,
5254
+ pageY
5255
+ };
5256
+ setLayout(newLayout);
5257
+ const newState = {
5258
+ mounted: true,
5259
+ visible: true,
5260
+ enabled: true,
5261
+ focused: false,
5262
+ layout: newLayout
5263
+ };
5264
+ bridge.registry.updateElementState(id, newState);
5265
+ onStateChangeRef.current?.(newState);
5266
+ return;
5267
+ }
5268
+ if (attempt >= backoffMs.length) return;
5269
+ pendingTimer = setTimeout(tryMeasure, backoffMs[attempt++]);
5270
+ });
5271
+ };
5272
+ tryMeasure();
5153
5273
  return () => {
5154
5274
  cancelled = true;
5275
+ if (pendingTimer != null) clearTimeout(pendingTimer);
5155
5276
  };
5156
5277
  }, [bridge, registered, id]);
5157
5278
  const registeredHandlerKeysRef = react.useRef([]);