@blario/react-native 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +18 -3
  2. package/dist/BlarProvider.d.ts +2 -0
  3. package/dist/actions/scrolling.d.ts +3 -1
  4. package/dist/actions/select.d.ts +16 -0
  5. package/dist/actions/text-input.d.ts +1 -0
  6. package/dist/actions/types.d.ts +3 -0
  7. package/dist/agent/compose.d.ts +1 -0
  8. package/dist/agent-mode/keyboard-guard.d.ts +13 -0
  9. package/dist/backend/app-info.d.ts +1 -0
  10. package/dist/fiber/text.d.ts +1 -0
  11. package/dist/snapshot/capture.d.ts +1 -0
  12. package/dist/snapshot/view.d.ts +3 -0
  13. package/dist/snapshot/visibility.d.ts +1 -1
  14. package/dist/snapshot/wrappers.d.ts +0 -2
  15. package/dist/version.d.ts +1 -1
  16. package/package.json +1 -1
  17. package/src/BlarProvider.tsx +13 -0
  18. package/src/actions/click.ts +2 -1
  19. package/src/actions/executor.ts +8 -2
  20. package/src/actions/landing.ts +2 -2
  21. package/src/actions/parse.ts +2 -0
  22. package/src/actions/scrolling.ts +3 -20
  23. package/src/actions/select.ts +176 -0
  24. package/src/actions/text-input.ts +30 -1
  25. package/src/actions/types.ts +1 -0
  26. package/src/agent/compose.ts +2 -0
  27. package/src/agent/controller.ts +3 -1
  28. package/src/agent-mode/keyboard-guard.ts +33 -0
  29. package/src/backend/app-info.ts +3 -0
  30. package/src/chat/ChatSheet.tsx +29 -9
  31. package/src/chat/ChatView.tsx +1 -1
  32. package/src/chat/widget-bundle.generated.ts +2 -2
  33. package/src/fiber/text.ts +2 -0
  34. package/src/snapshot/capture.ts +9 -6
  35. package/src/snapshot/collect.ts +1 -2
  36. package/src/snapshot/render.ts +4 -2
  37. package/src/snapshot/view.ts +34 -0
  38. package/src/snapshot/visibility.ts +11 -10
  39. package/src/snapshot/wrappers.ts +0 -13
  40. package/src/version.ts +1 -1
package/README.md CHANGED
@@ -63,6 +63,7 @@ export default function App() {
63
63
  Screenshots use `react-native-view-shot` when it is installed, otherwise `@shopify/react-native-skia`
64
64
  (`makeImageFromView`) on iOS. Android requires view-shot: Skia's Android capture can raise a fatal native error
65
65
  when a view unmounts during capture. With no supported capture module, a screenshot answers `sdk_error`.
66
+ Android on the New Architecture needs view-shot 4 or later: 3.x fails every capture there, while actions keep working.
66
67
  Each module is resolved once when the SDK loads:
67
68
 
68
69
  - view-shot leaves a host `Modal` out (it is a separate window), so the response carries `partial: true` and
@@ -230,7 +231,7 @@ A run looks and behaves like the web widget's agent mode (`web-sdk/src/managers/
230
231
  installed (both optional peers); without one this is a no-op, reported once with `debug`.
231
232
 
232
233
  Known differences from the web: the pending dim covers the whole screen (the web dims only the chat window); the
233
- layer takes window focus, so an open keyboard closes when a run starts; `session_closed` ends the run (the web only
234
+ keyboard stays closed during a run, unless the chat is open; `session_closed` ends the run (the web only
234
235
  tears it down). Visual effects (dot grid, glow, slime) come in a later milestone.
235
236
 
236
237
  ### Test nudges without a real delivery
@@ -284,6 +285,16 @@ npx expo start --clear # Metro; the simulator reaches 127.0.
284
285
  xcrun simctl launch "iPhone 17 iOS 26" io.blar.example
285
286
  ```
286
287
 
288
+ ### On an Android emulator
289
+
290
+ ```bash
291
+ npx expo prebuild -p android --no-install # it rewrites package.json's scripts: revert that
292
+ # newArchEnabled=true in android/gradle.properties, and sdk.dir in android/local.properties
293
+ (cd android && ./gradlew app:assembleDebug)
294
+ adb install -r android/app/build/outputs/apk/debug/app-debug.apk
295
+ adb reverse tcp:8081 tcp:8081 && adb reverse tcp:8790 tcp:8790
296
+ ```
297
+
287
298
  `.maestro/` holds [Maestro](https://maestro.dev) flows that tap through the app, chat WebView included
288
299
  (`brew install mobile-dev-inc/tap/maestro`, needs Java 17): `maestro test .maestro/chat-keyboard.yaml`.
289
300
 
@@ -315,6 +326,7 @@ Wire envelope (mirrors the backend): `agent_command` / `request_dom_state` / `re
315
326
  | `click` | | `btn-N` (a switch toggles, an input is focused, a wheel picker's row scrolls to the center) |
316
327
  | `type` | `value`, `clear_first` (default true) | `inp-N` text input |
317
328
  | `check` | `checked` (omit to toggle) | `inp-N` switch |
329
+ | `select` | `value` | `scr-N` or one of its rows: centers the row with that text, in one list at a time |
318
330
  | `press_key` | `key` (`Enter` only) | `inp-N`, or the last input used |
319
331
  | `scroll` | `direction` up/down/left/right, `amount` px | `scr-N` |
320
332
  | `scroll_to` | | any element, uses its nearest scroll container |
@@ -322,8 +334,11 @@ Wire envelope (mirrors the backend): `agent_command` / `request_dom_state` / `re
322
334
  | `highlight` | `duration_ms` (default 3000) | any element |
323
335
 
324
336
  `click`, `type`, `check` and `highlight` scroll the target into view first. Elements that sit beyond their scroll
325
- viewport are listed with `offscreen=above|below|left|right` (at most 30, nearest first). A wheel picker is a snapping
326
- list (`snapToInterval` or `snapToOffsets`) of plain text rows: each row is a `btn-N`, and all of them stay listed.
337
+ viewport are listed with `offscreen=above|below|left|right`, however many there are. The page text also includes what a
338
+ list holds off screen in any direction; only blocks parked beyond the window with no list behind them are left out. A wheel picker is a snapping
339
+ list (`snapToInterval` or `snapToOffsets`) of plain text rows: each row is a `btn-N`, and all of them stay listed. Lists
340
+ carry `list=<name>` from their own or their wrapper's `accessibilityLabel` or `testID`, and so do those rows. `select`
341
+ needs no snap props: it reaches any list, so a wheel that snaps in JS works through it.
327
342
 
328
343
  ## Develop
329
344
 
@@ -42,6 +42,7 @@ export declare class BlarProvider extends Component<BlarProviderProps, BlarProvi
42
42
  private chatControls;
43
43
  private shownNudge;
44
44
  private readonly releases;
45
+ private readonly keyboardGuard;
45
46
  private stackingCheckQueued;
46
47
  private loggedPhase;
47
48
  private readonly stacking;
@@ -62,6 +63,7 @@ export declare class BlarProvider extends Component<BlarProviderProps, BlarProvi
62
63
  private createTransport;
63
64
  private createChat;
64
65
  private attachBackend;
66
+ private syncKeyboardGuard;
65
67
  private queueStackingCheck;
66
68
  private keepLayerOnTop;
67
69
  private countHostModals;
@@ -1,12 +1,14 @@
1
1
  import { type Band } from '../host/rect.ts';
2
+ import { type ScrollGeometry } from '../snapshot/scroll.ts';
2
3
  import type { ResolvedTarget } from './target.ts';
3
4
  import type { AgentAction, HandlerResult } from './types.ts';
4
5
  type ScrollAction = Extract<AgentAction, {
5
6
  type: 'scroll';
6
7
  }>;
8
+ export declare function applyOffset(target: ResolvedTarget, geometry: ScrollGeometry, offset: number, animated: boolean): HandlerResult;
7
9
  export declare function scrollContainer(target: ResolvedTarget, action: ScrollAction): Promise<HandlerResult>;
10
+ export declare function nearestScrollAncestor(target: ResolvedTarget): ResolvedTarget['fiber'] | null;
8
11
  export declare function isInWheel(target: ResolvedTarget): boolean;
9
- export declare function selectWheelOption(target: ResolvedTarget): Promise<HandlerResult>;
10
12
  export declare function scrollIntoView(target: ResolvedTarget, options?: {
11
13
  animated: boolean;
12
14
  }): Promise<HandlerResult>;
@@ -0,0 +1,16 @@
1
+ import type { Fiber } from '../fiber/types.ts';
2
+ import type { ActionServices } from './services.ts';
3
+ import type { ResolvedTarget } from './target.ts';
4
+ import type { HandlerResult } from './types.ts';
5
+ interface ListRow {
6
+ host: Fiber;
7
+ text: string;
8
+ }
9
+ export declare function centerInList(target: ResolvedTarget, list: Fiber, row: Fiber): Promise<HandlerResult>;
10
+ export declare function scrollListTo(target: ResolvedTarget, list: Fiber, offset: number): Promise<HandlerResult>;
11
+ export declare function selectWheelOption(target: ResolvedTarget): Promise<HandlerResult>;
12
+ export declare function rowsOf(list: Fiber): ListRow[];
13
+ export declare function matchesRow(text: string, value: string): boolean;
14
+ export declare function selectValue(target: ResolvedTarget, value: string, services: ActionServices, isCancelled?: () => boolean): Promise<HandlerResult>;
15
+ export declare function walk(from: number, to: number, step: number): number[];
16
+ export {};
@@ -4,6 +4,7 @@ import type { AgentAction, HandlerResult } from './types.ts';
4
4
  type TypeAction = Extract<AgentAction, {
5
5
  type: 'type';
6
6
  }>;
7
+ export declare function restoreKeyboards(): void;
7
8
  export declare function typeText(target: ResolvedTarget, action: TypeAction, services: ActionServices): HandlerResult;
8
9
  export declare function submitInput(target: ResolvedTarget): HandlerResult;
9
10
  export {};
@@ -9,6 +9,9 @@ export type AgentAction = {
9
9
  } | {
10
10
  type: 'check';
11
11
  checked: boolean | null;
12
+ } | {
13
+ type: 'select';
14
+ value: string;
12
15
  } | {
13
16
  type: 'press_key';
14
17
  key: string;
@@ -11,6 +11,7 @@ export interface AgentEnvironment {
11
11
  transport: AgentTransport;
12
12
  getAnchorFiber: () => Fiber | null;
13
13
  getViewport: () => Size;
14
+ getWindow: () => Size;
14
15
  getNavigationRef: () => NavigationRefLike | null;
15
16
  getCaptureTarget: () => unknown;
16
17
  reading: ReadingPort;
@@ -0,0 +1,13 @@
1
+ export interface KeyboardPort {
2
+ addListener: (event: 'keyboardWillShow' | 'keyboardDidShow', listener: () => void) => {
3
+ remove: () => void;
4
+ };
5
+ dismiss: () => void;
6
+ }
7
+ export declare class KeyboardGuard {
8
+ private readonly keyboard;
9
+ private readonly onRelease;
10
+ private subscriptions;
11
+ constructor(keyboard: KeyboardPort, onRelease?: () => void);
12
+ setActive(active: boolean): void;
13
+ }
@@ -1,5 +1,6 @@
1
1
  export interface AppInfo {
2
2
  platform: 'react-native';
3
+ sdk_version: string;
3
4
  os: string;
4
5
  os_version: string;
5
6
  rn_version: string;
@@ -1,4 +1,5 @@
1
1
  import type { Fiber } from './types.ts';
2
+ export declare const TEXT_HOST_TYPE = "RCTText";
2
3
  export declare function normalizeText(value: string): string;
3
4
  export declare function collectText(fiber: Fiber, limit: number): string;
4
5
  export declare function precedingText(ancestors: Fiber[], fiber: Fiber, limit: number): string;
@@ -13,6 +13,7 @@ export interface SnapshotDependencies {
13
13
  getAnchorFiber: () => Fiber | null;
14
14
  registry: ElementRegistry;
15
15
  getViewport: () => Size;
16
+ getWindow: () => Size;
16
17
  getExtraRoutes: () => string[];
17
18
  }
18
19
  export type CaptureMode = 'register' | 'peek';
@@ -17,8 +17,10 @@ export interface ElementView {
17
17
  selected: boolean | null;
18
18
  scroll: string | null;
19
19
  offscreen: Offscreen | null;
20
+ list: string | null;
20
21
  rect: Rect;
21
22
  }
23
+ export declare function listNameOf(list: Fiber, ancestors: Fiber[]): string | null;
22
24
  export interface ViewSource {
23
25
  kind: ElementKind;
24
26
  fiber: Fiber;
@@ -26,5 +28,6 @@ export interface ViewSource {
26
28
  rect: Rect;
27
29
  scroll: string | null;
28
30
  offscreen?: Offscreen | null;
31
+ wheelOption?: boolean;
29
32
  }
30
33
  export declare function buildView(source: ViewSource): ElementView;
@@ -9,9 +9,9 @@ export type Visibility = {
9
9
  } | {
10
10
  kind: 'offscreen';
11
11
  direction: Offscreen;
12
- distance: number;
13
12
  } | {
14
13
  kind: 'hidden';
15
14
  };
16
15
  export declare function classifyVisibility(rect: Rect, window: Size, containers: Fiber[], geometries: ScrollGeometries): Visibility;
16
+ export declare function markOffscreenText(visibility: Visibility, text: string): string;
17
17
  export declare function keepInTextTree(visibility: Visibility, rect: Rect, window: Size, viewportOnly: boolean): boolean;
@@ -5,7 +5,5 @@ export interface MeasuredCandidate {
5
5
  candidate: Candidate;
6
6
  rect: Rect;
7
7
  offscreen: Offscreen | null;
8
- distance: number;
9
8
  }
10
9
  export declare function dropWrapperDuplicates(measured: MeasuredCandidate[]): MeasuredCandidate[];
11
- export declare function limitOffscreen(measured: MeasuredCandidate[]): MeasuredCandidate[];
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const SDK_VERSION = "0.1.4";
1
+ export declare const SDK_VERSION = "0.1.6";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blario/react-native",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Blar agent SDK for React Native",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -13,6 +13,8 @@ import type { AgentModePhase } from './agent-mode/state.ts';
13
13
  import { KEEP_AWAKE_ADAPTER, KeepAwake } from './agent-mode/keep-awake.ts';
14
14
  import { LayerStacking } from './agent-mode/layer-stacking.ts';
15
15
  import { AgentLayer } from './agent-mode/ui/AgentLayer.tsx';
16
+ import { restoreKeyboards } from './actions/text-input.ts';
17
+ import { KeyboardGuard } from './agent-mode/keyboard-guard.ts';
16
18
  import { AgentLayerHost } from './agent-mode/ui/AgentLayerHost.tsx';
17
19
  import { BackendTransport, type WidgetProof } from './backend/backend-transport.ts';
18
20
  import type { BlarIdentity } from './backend/identity.ts';
@@ -104,6 +106,8 @@ export class BlarProvider extends Component<BlarProviderProps, BlarProviderState
104
106
 
105
107
  private readonly releases: Array<() => void> = [];
106
108
 
109
+ private readonly keyboardGuard = new KeyboardGuard(Keyboard, restoreKeyboards);
110
+
107
111
  private stackingCheckQueued = false;
108
112
 
109
113
  private loggedPhase: AgentModePhase = 'idle';
@@ -163,6 +167,7 @@ export class BlarProvider extends Component<BlarProviderProps, BlarProviderState
163
167
  transport,
164
168
  getAnchorFiber: () => (this as WithReactInternals)._reactInternals ?? null,
165
169
  getViewport: () => visibleViewport(Dimensions.get('window'), Keyboard.metrics()?.height ?? 0),
170
+ getWindow: () => Dimensions.get('window'),
166
171
  getNavigationRef: () => this.props.navigationRef ?? null,
167
172
  getCaptureTarget: () => this.contentRef.current,
168
173
  reading: this.spotlight,
@@ -316,8 +321,10 @@ export class BlarProvider extends Component<BlarProviderProps, BlarProviderState
316
321
  this.debug(`agent mode ${snapshot.phase}`);
317
322
  }
318
323
  this.setState({ agentMode: snapshot });
324
+ this.syncKeyboardGuard();
319
325
  }),
320
326
  );
327
+ this.releases.push(() => this.keyboardGuard.setActive(false));
321
328
  return agentMode;
322
329
  }
323
330
 
@@ -373,6 +380,7 @@ export class BlarProvider extends Component<BlarProviderProps, BlarProviderState
373
380
  coordinator.subscribe((chat) => {
374
381
  this.noteNudge(chat);
375
382
  this.setState({ chat });
383
+ this.syncKeyboardGuard();
376
384
  }),
377
385
  );
378
386
  this.releases.push(backend.onFrame((frame) => coordinator.handleFrame(frame)));
@@ -395,6 +403,11 @@ export class BlarProvider extends Component<BlarProviderProps, BlarProviderState
395
403
  Blar.attach(backend, (options) => controller.readMessageMetadata({ ...options, tabId: backend.getTabId() }));
396
404
  }
397
405
 
406
+ private syncKeyboardGuard(): void {
407
+ const running = this.agentMode?.getSnapshot().phase === 'active';
408
+ this.keyboardGuard.setActive(running && this.chatControls?.isOpen() !== true);
409
+ }
410
+
398
411
  private queueStackingCheck(): void {
399
412
  if (this.stackingCheckQueued || (this.agentMode?.getSnapshot().phase ?? 'idle') === 'idle') {
400
413
  return;
@@ -6,7 +6,8 @@ import { buildPressEvent } from './events.ts';
6
6
  import { checkInteractable } from './interaction-guards.ts';
7
7
  import { failed, failedFromException, succeeded } from './result.ts';
8
8
  import type { ActionServices } from './services.ts';
9
- import { isInWheel, selectWheelOption } from './scrolling.ts';
9
+ import { isInWheel } from './scrolling.ts';
10
+ import { selectWheelOption } from './select.ts';
10
11
  import type { ResolvedTarget } from './target.ts';
11
12
  import type { HandlerResult } from './types.ts';
12
13
  import { toggleSwitch } from './switch.ts';
@@ -5,6 +5,7 @@ import { highlight } from './highlight.ts';
5
5
  import { tapsTarget, waitForLanding, waitsForLanding } from './landing.ts';
6
6
  import { failed, failedFromException } from './result.ts';
7
7
  import { scrollContainer, scrollIntoView } from './scrolling.ts';
8
+ import { selectValue } from './select.ts';
8
9
  import { SYSTEM_CLOCK } from './settle.ts';
9
10
  import type { ActionServices } from './services.ts';
10
11
  import { waitForSettle } from './settle.ts';
@@ -40,6 +41,9 @@ export class ActionExecutor {
40
41
  if (command.action.type === 'navigate') {
41
42
  return { ok: true, target: null };
42
43
  }
44
+ if (command.action.type === 'press_key' && command.action.key !== 'Enter') {
45
+ return { ok: false, outcome: outcomeOf(failed('unsupported_action', `key ${command.action.key} is not supported: only Enter is. To move a list use scroll or select on its scr-N`)) };
46
+ }
43
47
  const target = this.resolveTarget(command);
44
48
  if (target === null) {
45
49
  return { ok: false, outcome: outcomeOf(this.missingTarget(command)) };
@@ -78,7 +82,7 @@ export class ActionExecutor {
78
82
  if (options.isCancelled()) {
79
83
  return failed('user_cancelled', CANCELLED_MESSAGE);
80
84
  }
81
- return await this.dispatch(command, target);
85
+ return await this.dispatch(command, target, options);
82
86
  } catch (error) {
83
87
  return failedFromException(error);
84
88
  }
@@ -123,7 +127,7 @@ export class ActionExecutor {
123
127
  return failed('element_not_found', reason);
124
128
  }
125
129
 
126
- private dispatch(command: AgentCommandData, target: ResolvedTarget): Promise<HandlerResult> | HandlerResult {
130
+ private dispatch(command: AgentCommandData, target: ResolvedTarget, options: ExecutionOptions): Promise<HandlerResult> | HandlerResult {
127
131
  const { action } = command;
128
132
  switch (action.type) {
129
133
  case 'click':
@@ -132,6 +136,8 @@ export class ActionExecutor {
132
136
  return typeText(target, action, this.services);
133
137
  case 'check':
134
138
  return toggleSwitch(target, action.checked);
139
+ case 'select':
140
+ return selectValue(target, action.value, this.services, options.isCancelled);
135
141
  case 'press_key':
136
142
  return action.key === 'Enter' ? submitInput(target) : failed('unsupported_action', `key ${action.key} is not supported`);
137
143
  case 'scroll':
@@ -9,9 +9,9 @@ export interface CursorPort {
9
9
 
10
10
  export const LANDING_MAX_MS = 1800;
11
11
 
12
- const TAP_ACTIONS: readonly AgentAction['type'][] = ['click', 'check', 'type'];
12
+ const TAP_ACTIONS: readonly AgentAction['type'][] = ['click', 'check', 'select', 'type'];
13
13
 
14
- const LANDING_ACTIONS: readonly AgentAction['type'][] = ['click', 'check'];
14
+ const LANDING_ACTIONS: readonly AgentAction['type'][] = ['click', 'check', 'select'];
15
15
 
16
16
  export function tapsTarget(action: AgentAction): boolean {
17
17
  return TAP_ACTIONS.includes(action.type);
@@ -32,6 +32,8 @@ function parseAction(raw: unknown): AgentAction | null {
32
32
  return typeof raw.value === 'string' ? { type: 'type', value: raw.value, clear_first: raw.clear_first !== false } : null;
33
33
  case 'check':
34
34
  return { type: 'check', checked: typeof raw.checked === 'boolean' ? raw.checked : null };
35
+ case 'select':
36
+ return typeof raw.value === 'string' ? { type: 'select', value: raw.value } : null;
35
37
  case 'press_key':
36
38
  return typeof raw.key === 'string' ? { type: 'press_key', key: raw.key } : null;
37
39
  case 'scroll':
@@ -1,7 +1,7 @@
1
1
  import { measureHost } from '../host/measure.ts';
2
2
  import { type Band, scrollDeltaIntoBand } from '../host/rect.ts';
3
3
  import { scrollInstanceOf } from '../snapshot/classify.ts';
4
- import { isWheel, maxScrollOffsetOf, measureScrollGeometry, offsetCentering, scrollOffsetOf, type ScrollGeometry, viewportExtentOf } from '../snapshot/scroll.ts';
4
+ import { isWheel, maxScrollOffsetOf, measureScrollGeometry, scrollOffsetOf, type ScrollGeometry, viewportExtentOf } from '../snapshot/scroll.ts';
5
5
  import { failed, failedFromException, succeeded } from './result.ts';
6
6
  import type { ResolvedTarget } from './target.ts';
7
7
  import type { AgentAction, HandlerResult } from './types.ts';
@@ -16,7 +16,7 @@ function clamp(value: number, minimum: number, maximum: number): number {
16
16
  return Math.min(Math.max(value, minimum), maximum);
17
17
  }
18
18
 
19
- function applyOffset(target: ResolvedTarget, geometry: ScrollGeometry, offset: number, animated: boolean): HandlerResult {
19
+ export function applyOffset(target: ResolvedTarget, geometry: ScrollGeometry, offset: number, animated: boolean): HandlerResult {
20
20
  const instance = scrollInstanceOf(target.fiber);
21
21
  if (instance === null) {
22
22
  return failed('element_not_interactable', 'the scroll container has no scrollTo');
@@ -51,7 +51,7 @@ export async function scrollContainer(target: ResolvedTarget, action: ScrollActi
51
51
  return applyOffset(target, geometry, next, true);
52
52
  }
53
53
 
54
- function nearestScrollAncestor(target: ResolvedTarget): ResolvedTarget['fiber'] | null {
54
+ export function nearestScrollAncestor(target: ResolvedTarget): ResolvedTarget['fiber'] | null {
55
55
  for (let index = target.ancestors.length - 1; index >= 0; index -= 1) {
56
56
  const ancestor = target.ancestors[index];
57
57
  if (ancestor !== undefined && scrollInstanceOf(ancestor) !== null) {
@@ -66,23 +66,6 @@ export function isInWheel(target: ResolvedTarget): boolean {
66
66
  return container !== null && isWheel(container);
67
67
  }
68
68
 
69
- // The animated scroll ends in onMomentumScrollEnd, the event a wheel commits its value on, as a user's swipe does.
70
- export async function selectWheelOption(target: ResolvedTarget): Promise<HandlerResult> {
71
- const container = nearestScrollAncestor(target);
72
- if (container === null || target.host === null) {
73
- return failed('element_not_interactable', 'the option is not inside a wheel');
74
- }
75
- const [geometry, rect] = await Promise.all([measureScrollGeometry(container), measureHost(target.host)]);
76
- if (geometry === null || rect === null) {
77
- return failed('element_not_found', 'the option or its wheel could not be measured');
78
- }
79
- const next = offsetCentering(rect, geometry);
80
- if (Math.abs(next - scrollOffsetOf(geometry)) < MOVEMENT_TOLERANCE) {
81
- return succeeded('the option is already selected');
82
- }
83
- return applyOffset({ ...target, fiber: container }, geometry, next, true);
84
- }
85
-
86
69
  function offsetDeltaToReveal(start: number, end: number, viewportStart: number, viewportEnd: number): number {
87
70
  const startShortfall = start - (viewportStart + VISIBLE_MARGIN);
88
71
  if (startShortfall < 0) {
@@ -0,0 +1,176 @@
1
+ import { Platform } from 'react-native';
2
+
3
+ import { isFunction } from '../fiber/narrow.ts';
4
+ import { childrenOf, isHostComponent, propsOf } from '../fiber/reader.ts';
5
+ import { collectText, TEXT_HOST_TYPE } from '../fiber/text.ts';
6
+ import type { Fiber } from '../fiber/types.ts';
7
+ import { measureHost } from '../host/measure.ts';
8
+ import { scrollInstanceOf } from '../snapshot/classify.ts';
9
+ import { maxScrollOffsetOf, measureScrollGeometry, offsetCentering, type ScrollGeometry, scrollOffsetOf, viewportExtentOf } from '../snapshot/scroll.ts';
10
+ import { runBatched } from './batched.ts';
11
+ import { failed, succeeded } from './result.ts';
12
+ import { applyOffset, isInWheel, nearestScrollAncestor } from './scrolling.ts';
13
+ import { SYSTEM_CLOCK } from './settle.ts';
14
+ import type { ActionServices } from './services.ts';
15
+ import type { ResolvedTarget } from './target.ts';
16
+ import type { HandlerResult } from './types.ts';
17
+
18
+ const ROW_TEXT_LIMIT = 80;
19
+ const MOUNT_WAIT_MS = 600;
20
+ const WALK_STEP_FRACTION = 3;
21
+ const WALK_SETTLE_MS = 120;
22
+ const MOUNT_POLL_MS = 150;
23
+ const LISTED_OPTIONS = 30;
24
+ const CANCELLED_MESSAGE = 'Cancelled by the user before the action ran';
25
+ const MOVEMENT_TOLERANCE = 1;
26
+ const SCROLL_ANIMATION_MS = 400;
27
+
28
+ interface ListRow {
29
+ host: Fiber;
30
+ text: string;
31
+ }
32
+
33
+ // iOS ends an animated scrollTo in onMomentumScrollEnd, the event a wheel commits its value on, as a user's swipe does.
34
+ // Android only fires it after a fling, so the wheel is told here.
35
+ function commitScrollEnd(list: Fiber, geometry: ScrollGeometry, offset: number): void {
36
+ const onMomentumScrollEnd = propsOf(list).onMomentumScrollEnd;
37
+ if (Platform.OS !== 'android' || !isFunction(onMomentumScrollEnd)) {
38
+ return;
39
+ }
40
+ const { viewport, content, horizontal } = geometry;
41
+ runBatched(() =>
42
+ onMomentumScrollEnd({
43
+ persist: () => undefined,
44
+ nativeEvent: {
45
+ contentInset: { top: 0, left: 0, bottom: 0, right: 0 },
46
+ contentOffset: horizontal ? { x: offset, y: 0 } : { x: 0, y: offset },
47
+ contentSize: { width: content.width, height: content.height },
48
+ layoutMeasurement: { width: viewport.width, height: viewport.height },
49
+ },
50
+ }),
51
+ );
52
+ }
53
+
54
+ export async function centerInList(target: ResolvedTarget, list: Fiber, row: Fiber): Promise<HandlerResult> {
55
+ const [geometry, rect] = await Promise.all([measureScrollGeometry(list), measureHost(row)]);
56
+ if (geometry === null || rect === null) {
57
+ return failed('element_not_found', 'the option or its list could not be measured');
58
+ }
59
+ const next = offsetCentering(rect, geometry);
60
+ if (Math.abs(next - scrollOffsetOf(geometry)) < MOVEMENT_TOLERANCE) {
61
+ commitScrollEnd(list, geometry, next);
62
+ return succeeded('the option is already selected');
63
+ }
64
+ const moved = applyOffset({ ...target, fiber: list }, geometry, next, true);
65
+ if (moved.ok) {
66
+ await SYSTEM_CLOCK.sleep(SCROLL_ANIMATION_MS);
67
+ commitScrollEnd(list, geometry, next);
68
+ }
69
+ return moved;
70
+ }
71
+
72
+ export async function scrollListTo(target: ResolvedTarget, list: Fiber, offset: number): Promise<HandlerResult> {
73
+ const geometry = await measureScrollGeometry(list);
74
+ return geometry === null ? failed('element_not_found', 'the list could not be measured') : applyOffset({ ...target, fiber: list }, geometry, offset, false);
75
+ }
76
+
77
+ export async function selectWheelOption(target: ResolvedTarget): Promise<HandlerResult> {
78
+ const container = nearestScrollAncestor(target);
79
+ if (container === null || target.host === null) {
80
+ return failed('element_not_interactable', 'the option is not inside a wheel');
81
+ }
82
+ return centerInList(target, container, target.host);
83
+ }
84
+
85
+ export function rowsOf(list: Fiber): ListRow[] {
86
+ const rows: ListRow[] = [];
87
+ const visit = (fiber: Fiber): void => {
88
+ if (fiber !== list && scrollInstanceOf(fiber) !== null) {
89
+ return;
90
+ }
91
+ if (isHostComponent(fiber) && fiber.type === TEXT_HOST_TYPE) {
92
+ const text = collectText(fiber, ROW_TEXT_LIMIT);
93
+ if (text.length > 0) {
94
+ rows.push({ host: fiber, text });
95
+ }
96
+ return;
97
+ }
98
+ childrenOf(fiber).forEach(visit);
99
+ };
100
+ visit(list);
101
+ return rows;
102
+ }
103
+
104
+ export function matchesRow(text: string, value: string): boolean {
105
+ const [left, right] = [text.trim().toLowerCase(), value.trim().toLowerCase()];
106
+ return left === right || (/^\d+$/.test(left) && /^\d+$/.test(right) && Number(left) === Number(right));
107
+ }
108
+
109
+ // Any list counts when the agent names it, not only a snapping one: this is what reaches a wheel that snaps in JS.
110
+ function listOf(target: ResolvedTarget): Fiber | null {
111
+ if (target.element.kind === 'scroll') {
112
+ return target.fiber;
113
+ }
114
+ return isInWheel(target) ? nearestScrollAncestor(target) : null;
115
+ }
116
+
117
+ // A virtualized list mounts its rows shortly after it opens, and a distant row only once scrolling gets near it: wait
118
+ // a moment, then walk the list. A value that isn't there leaves the list where it was.
119
+ export async function selectValue(target: ResolvedTarget, value: string, services: ActionServices, isCancelled: () => boolean = () => false): Promise<HandlerResult> {
120
+ const first = listOf(target);
121
+ if (first === null) {
122
+ return failed('element_not_interactable', 'select works on one open list: click this field to open its picker, then select each value on its list (scr-N)');
123
+ }
124
+ let current = target;
125
+ let list = first;
126
+ const seen = new Set<string>();
127
+ const findRow = (): ListRow | undefined => {
128
+ current = services.resolver.resolveElement(target.element) ?? current;
129
+ list = listOf(current) ?? list;
130
+ const rows = rowsOf(list);
131
+ rows.forEach((row) => seen.add(row.text));
132
+ return rows.find((row) => matchesRow(row.text, value));
133
+ };
134
+ let row = findRow();
135
+ for (let waited = 0; row === undefined && waited < MOUNT_WAIT_MS && !isCancelled(); waited += MOUNT_POLL_MS) {
136
+ await SYSTEM_CLOCK.sleep(MOUNT_POLL_MS);
137
+ row = findRow();
138
+ }
139
+ if (isCancelled()) {
140
+ return failed('user_cancelled', CANCELLED_MESSAGE);
141
+ }
142
+ if (row !== undefined) {
143
+ return centerInList(current, list, row.host);
144
+ }
145
+ const geometry = await measureScrollGeometry(list);
146
+ if (geometry !== null) {
147
+ const start = scrollOffsetOf(geometry);
148
+ const step = Math.max(1, viewportExtentOf(geometry) * WALK_STEP_FRACTION);
149
+ const end = maxScrollOffsetOf(geometry);
150
+ const stops = [...walk(start, end, step), ...walk(start, 0, step)];
151
+ for (const stop of stops) {
152
+ if (isCancelled()) {
153
+ await scrollListTo(current, list, start);
154
+ return failed('user_cancelled', CANCELLED_MESSAGE);
155
+ }
156
+ await scrollListTo(current, list, stop);
157
+ await SYSTEM_CLOCK.sleep(WALK_SETTLE_MS);
158
+ row = findRow();
159
+ if (row !== undefined) {
160
+ return centerInList(current, list, row.host);
161
+ }
162
+ }
163
+ await scrollListTo(current, list, start);
164
+ }
165
+ const options = [...seen].slice(0, LISTED_OPTIONS).join(', ');
166
+ return failed('element_not_found', `"${value}" is not in this list. Options: ${options}`);
167
+ }
168
+
169
+ export function walk(from: number, to: number, step: number): number[] {
170
+ const stops: number[] = [];
171
+ const direction = to >= from ? 1 : -1;
172
+ for (let offset = from + direction * step; direction > 0 ? offset < to : offset > to; offset += direction * step) {
173
+ stops.push(offset);
174
+ }
175
+ return from === to ? stops : [...stops, to];
176
+ }
@@ -25,6 +25,33 @@ function isControlled(target: ResolvedTarget): boolean {
25
25
  return typeof target.props.value === 'string';
26
26
  }
27
27
 
28
+ const noKeyboard = new Map<NativeInput, boolean>();
29
+
30
+ interface NativeInput {
31
+ setNativeProps?: (props: { showSoftInputOnFocus: boolean }) => void;
32
+ }
33
+
34
+ // Focus without a keyboard: blurring a field to close it would dismiss what depends on its focus, like the suggestions
35
+ // of a search box. The field gets its keyboard back when the run ends.
36
+ function focusWithoutKeyboard(instance: NativeInput & { focus?: () => void }, target: ResolvedTarget): void {
37
+ if (!noKeyboard.has(instance)) {
38
+ noKeyboard.set(instance, target.props.showSoftInputOnFocus !== false);
39
+ instance.setNativeProps?.({ showSoftInputOnFocus: false });
40
+ }
41
+ instance.focus?.();
42
+ }
43
+
44
+ export function restoreKeyboards(): void {
45
+ noKeyboard.forEach((enabled, instance) => {
46
+ try {
47
+ instance.setNativeProps?.({ showSoftInputOnFocus: enabled });
48
+ } catch {
49
+ // The field is gone: nothing left to restore.
50
+ }
51
+ });
52
+ noKeyboard.clear();
53
+ }
54
+
28
55
  export function typeText(target: ResolvedTarget, action: TypeAction, services: ActionServices): HandlerResult {
29
56
  if (target.element.kind !== 'text_input') {
30
57
  return failed('element_not_interactable', 'type needs a text input');
@@ -36,7 +63,9 @@ export function typeText(target: ResolvedTarget, action: TypeAction, services: A
36
63
  const next = action.clear_first ? action.value : currentTextOf(target) + action.value;
37
64
  const instance = target.host === null ? null : hostInstanceOf(target.host);
38
65
  try {
39
- instance?.focus?.();
66
+ if (instance !== null) {
67
+ focusWithoutKeyboard(instance as NativeInput & { focus?: () => void }, target);
68
+ }
40
69
  if (!isControlled(target)) {
41
70
  if (Platform.OS === 'android') {
42
71
  const tag = findNodeHandle(instance as Parameters<typeof findNodeHandle>[0]);
@@ -6,6 +6,7 @@ export type AgentAction =
6
6
  | { type: 'click' }
7
7
  | { type: 'type'; value: string; clear_first: boolean }
8
8
  | { type: 'check'; checked: boolean | null }
9
+ | { type: 'select'; value: string }
9
10
  | { type: 'press_key'; key: string }
10
11
  | { type: 'scroll'; direction: ScrollDirectionName; amount: number | null }
11
12
  | { type: 'scroll_to' }