@blario/react-native 0.1.5 → 0.1.7

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 (46) hide show
  1. package/README.md +8 -3
  2. package/dist/BlarProvider.d.ts +6 -0
  3. package/dist/actions/select.d.ts +1 -1
  4. package/dist/actions/text-input.d.ts +1 -0
  5. package/dist/agent/compose.d.ts +1 -0
  6. package/dist/agent-mode/keyboard-guard.d.ts +13 -0
  7. package/dist/snapshot/capture.d.ts +1 -0
  8. package/dist/snapshot/visibility.d.ts +1 -1
  9. package/dist/snapshot/wrappers.d.ts +0 -2
  10. package/dist/telemetry/contain.d.ts +5 -0
  11. package/dist/version.d.ts +1 -1
  12. package/package.json +1 -1
  13. package/src/BlarProvider.tsx +68 -28
  14. package/src/actions/executor.ts +6 -3
  15. package/src/actions/select.ts +16 -2
  16. package/src/actions/text-input.ts +30 -1
  17. package/src/agent/compose.ts +2 -0
  18. package/src/agent/controller.ts +3 -1
  19. package/src/agent-mode/keyboard-guard.ts +35 -0
  20. package/src/agent-mode/timers.ts +3 -1
  21. package/src/agent-mode/ui/AgentDock.tsx +8 -7
  22. package/src/agent-mode/ui/AgentLayer.tsx +2 -1
  23. package/src/agent-mode/ui/AgentLayerHost.tsx +2 -1
  24. package/src/agent-mode/ui/AgentPanel.tsx +4 -3
  25. package/src/agent-mode/ui/AgentTap.tsx +3 -2
  26. package/src/agent-mode/ui/SpotlightLayer.tsx +4 -3
  27. package/src/agent-mode/ui/StatusCard.tsx +3 -2
  28. package/src/agent-mode/ui/TouchShield.tsx +2 -1
  29. package/src/backend/foreground.ts +11 -6
  30. package/src/backend/health.ts +10 -6
  31. package/src/backend/presence.ts +12 -8
  32. package/src/backend/session-socket.ts +25 -18
  33. package/src/blar.ts +8 -7
  34. package/src/chat/ChatSheet.tsx +32 -11
  35. package/src/chat/ChatView.tsx +12 -10
  36. package/src/chat/Launcher.tsx +2 -1
  37. package/src/chat/widget-bundle.generated.ts +2 -2
  38. package/src/nudge/NudgeBubble.tsx +6 -5
  39. package/src/snapshot/capture.ts +8 -6
  40. package/src/snapshot/render.ts +1 -1
  41. package/src/snapshot/visibility.ts +11 -10
  42. package/src/snapshot/wrappers.ts +0 -13
  43. package/src/telemetry/contain.ts +28 -0
  44. package/src/telemetry/sdk-reporter.ts +3 -0
  45. package/src/transport/relay-transport.ts +13 -9
  46. 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
@@ -333,7 +334,8 @@ Wire envelope (mirrors the backend): `agent_command` / `request_dom_state` / `re
333
334
  | `highlight` | `duration_ms` (default 3000) | any element |
334
335
 
335
336
  `click`, `type`, `check` and `highlight` scroll the target into view first. Elements that sit beyond their scroll
336
- viewport are listed with `offscreen=above|below|left|right` (at most 30, nearest first). A wheel picker is a snapping
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
337
339
  list (`snapToInterval` or `snapToOffsets`) of plain text rows: each row is a `btn-N`, and all of them stay listed. Lists
338
340
  carry `list=<name>` from their own or their wrapper's `accessibilityLabel` or `testID`, and so do those rows. `select`
339
341
  needs no snap props: it reaches any list, so a wheel that snaps in JS works through it.
@@ -405,7 +407,10 @@ If trusted publishing is not wanted, a granular npm token (Read and write, scope
405
407
  Like the web widget, the SDK reports its own contained failures to Blar's Sentry, through a client that is isolated from
406
408
  the host app: it installs no global error handler and no native crash hook, never touches the app's own Sentry, and
407
409
  only sees errors the SDK catches itself (the agent runtime, the backend transport and a boundary around the SDK's own
408
- UI, never the app's components). Events carry the SDK version, platform, publishable key, `source_id`, session id and
410
+ UI, never the app's components). An error in a socket callback, timer, native listener, animation callback or press
411
+ handler would otherwise end the app in a release build, or on Android's bridgeless runtime leave it on a white screen
412
+ with the process alive, so each goes through `contain()` (`src/telemetry/contain.ts`) and a test fails on one that
413
+ doesn't. A native crash can't be contained from JS and isn't reported here: it shows up in the app's own crash reporting. Events carry the SDK version, platform, publishable key, `source_id`, session id and
409
414
  the user's external id; emails, bearer tokens and identity tokens are scrubbed from messages. Network failures and
410
415
  refused credentials only become breadcrumbs, and at most 50 events are sent per launch. Without a DSN (the repo
411
416
  default, set only by the release job) nothing is sent. `enabled={false}` stops the SDK, and its reporting with it.
@@ -42,17 +42,22 @@ 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;
48
49
  private readonly attachCursor;
49
50
  private readonly detachCursor;
51
+ private readonly noteInteraction;
50
52
  componentDidMount(): void;
51
53
  componentDidUpdate(previous: BlarProviderProps): void;
52
54
  componentWillUnmount(): void;
55
+ private startContained;
56
+ private syncProps;
53
57
  private startSdk;
54
58
  private stopSdk;
55
59
  render(): React.JSX.Element;
60
+ private renderOverlay;
56
61
  private renderAgentLayer;
57
62
  private renderChat;
58
63
  private chatOptions;
@@ -62,6 +67,7 @@ export declare class BlarProvider extends Component<BlarProviderProps, BlarProvi
62
67
  private createTransport;
63
68
  private createChat;
64
69
  private attachBackend;
70
+ private syncKeyboardGuard;
65
71
  private queueStackingCheck;
66
72
  private keepLayerOnTop;
67
73
  private countHostModals;
@@ -11,6 +11,6 @@ export declare function scrollListTo(target: ResolvedTarget, list: Fiber, offset
11
11
  export declare function selectWheelOption(target: ResolvedTarget): Promise<HandlerResult>;
12
12
  export declare function rowsOf(list: Fiber): ListRow[];
13
13
  export declare function matchesRow(text: string, value: string): boolean;
14
- export declare function selectValue(target: ResolvedTarget, value: string, services: ActionServices): Promise<HandlerResult>;
14
+ export declare function selectValue(target: ResolvedTarget, value: string, services: ActionServices, isCancelled?: () => boolean): Promise<HandlerResult>;
15
15
  export declare function walk(from: number, to: number, step: number): number[];
16
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 {};
@@ -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
+ }
@@ -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';
@@ -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[];
@@ -0,0 +1,5 @@
1
+ type Report = (context: string, error: unknown) => void;
2
+ export declare function setContainmentReporter(reporter: Report): void;
3
+ export declare function attempt<T>(context: string, run: () => T): T | undefined;
4
+ export declare function contain<Args extends unknown[]>(context: string, callback: (...args: Args) => unknown): (...args: Args) => void;
5
+ export {};
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const SDK_VERSION = "0.1.5";
1
+ export declare const SDK_VERSION = "0.1.7";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blario/react-native",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Blar agent SDK for React Native",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,5 +1,5 @@
1
1
  import React, { Component, createRef, type ReactNode } from 'react';
2
- import { AccessibilityInfo, AppState, Dimensions, Keyboard, StyleSheet, View } from 'react-native';
2
+ import { AccessibilityInfo, AppState, type AppStateStatus, Dimensions, Keyboard, StyleSheet, View } from 'react-native';
3
3
 
4
4
  import { createAgentController } from './agent/compose.ts';
5
5
  import { SYSTEM_CLOCK } from './actions/settle.ts';
@@ -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';
@@ -29,7 +31,9 @@ import { visibleViewport } from './host/visible-area.ts';
29
31
  import { AgentOverlay } from './overlay/AgentOverlay.tsx';
30
32
  import { CAPTURE_BACKEND } from './screenshot/screenshotter.ts';
31
33
  import { countOpenModals } from './snapshot/modals.ts';
34
+ import { attempt, contain } from './telemetry/contain.ts';
32
35
  import { SdkErrorBoundary } from './telemetry/SdkErrorBoundary.tsx';
36
+ import { reportSdkError } from './telemetry/sdk-reporter.ts';
33
37
  import { resolveConnectionOptions, type ConnectionConfig } from './transport/connection-options.ts';
34
38
  import { RelayTransport } from './transport/relay-transport.ts';
35
39
  import type { AgentTransport } from './transport/transport.ts';
@@ -104,6 +108,8 @@ export class BlarProvider extends Component<BlarProviderProps, BlarProviderState
104
108
 
105
109
  private readonly releases: Array<() => void> = [];
106
110
 
111
+ private readonly keyboardGuard = new KeyboardGuard(Keyboard, restoreKeyboards);
112
+
107
113
  private stackingCheckQueued = false;
108
114
 
109
115
  private loggedPhase: AgentModePhase = 'idle';
@@ -114,17 +120,37 @@ export class BlarProvider extends Component<BlarProviderProps, BlarProviderState
114
120
 
115
121
  private readonly detachCursor = (): void => this.cursor.detach();
116
122
 
123
+ private readonly noteInteraction = contain('BlarProvider.touch', () => this.backend?.noteInteraction());
124
+
117
125
  override componentDidMount(): void {
118
126
  if (this.props.enabled !== false) {
119
- this.startSdk();
127
+ this.startContained();
120
128
  }
121
129
  }
122
130
 
123
131
  override componentDidUpdate(previous: BlarProviderProps): void {
132
+ attempt('BlarProvider.update', () => this.syncProps(previous));
133
+ }
134
+
135
+ override componentWillUnmount(): void {
136
+ attempt('BlarProvider.unmount', () => this.stopSdk());
137
+ }
138
+
139
+ // A failed start leaves the host app running, without the SDK.
140
+ private startContained(): void {
141
+ try {
142
+ this.startSdk();
143
+ } catch (error) {
144
+ reportSdkError('BlarProvider.start', error);
145
+ attempt('BlarProvider.stop', () => this.stopSdk());
146
+ }
147
+ }
148
+
149
+ private syncProps(previous: BlarProviderProps): void {
124
150
  const enabled = this.props.enabled !== false;
125
151
  if (enabled !== (previous.enabled !== false)) {
126
152
  if (enabled) {
127
- this.startSdk();
153
+ this.startContained();
128
154
  } else {
129
155
  this.stopSdk();
130
156
  this.setState({ chat: null, agentMode: null, spotlight: this.spotlight.getSnapshot(), cursor: this.cursor.getSnapshot() });
@@ -141,10 +167,6 @@ export class BlarProvider extends Component<BlarProviderProps, BlarProviderState
141
167
  void Blar.setUser(this.props.user);
142
168
  }
143
169
 
144
- override componentWillUnmount(): void {
145
- this.stopSdk();
146
- }
147
-
148
170
  private startSdk(): void {
149
171
  this.connection = resolveConnectionOptions(this.props, typeof __DEV__ !== 'undefined' && __DEV__);
150
172
  const transport = this.createTransport();
@@ -163,6 +185,7 @@ export class BlarProvider extends Component<BlarProviderProps, BlarProviderState
163
185
  transport,
164
186
  getAnchorFiber: () => (this as WithReactInternals)._reactInternals ?? null,
165
187
  getViewport: () => visibleViewport(Dimensions.get('window'), Keyboard.metrics()?.height ?? 0),
188
+ getWindow: () => Dimensions.get('window'),
166
189
  getNavigationRef: () => this.props.navigationRef ?? null,
167
190
  getCaptureTarget: () => this.contentRef.current,
168
191
  reading: this.spotlight,
@@ -199,25 +222,29 @@ export class BlarProvider extends Component<BlarProviderProps, BlarProviderState
199
222
  }
200
223
 
201
224
  override render(): React.JSX.Element {
202
- const strings = resolveStrings(this.props.language ?? 'es', this.props.strings);
203
- const phase = this.state.agentMode?.phase ?? 'idle';
204
225
  return (
205
- <View style={styles.root} collapsable={false} onTouchStart={() => this.backend?.noteInteraction()}>
226
+ <View style={styles.root} collapsable={false} onTouchStart={this.noteInteraction}>
206
227
  <View ref={this.contentRef} style={styles.root} collapsable={false}>
207
228
  {this.props.children}
208
229
  </View>
209
- <SdkErrorBoundary context="BlarProvider.overlay">
210
- <AgentOverlay>
211
- {this.renderChat(strings)}
212
- <AgentLayerHost visible={phase !== 'idle'} generation={this.state.layerGeneration} onRequestClose={() => this.agentMode?.stop()}>
213
- {this.renderAgentLayer(strings)}
214
- </AgentLayerHost>
215
- </AgentOverlay>
216
- </SdkErrorBoundary>
230
+ <SdkErrorBoundary context="BlarProvider.overlay">{attempt('BlarProvider.render', () => this.renderOverlay())}</SdkErrorBoundary>
217
231
  </View>
218
232
  );
219
233
  }
220
234
 
235
+ private renderOverlay(): React.JSX.Element {
236
+ const strings = resolveStrings(this.props.language ?? 'es', this.props.strings);
237
+ const phase = this.state.agentMode?.phase ?? 'idle';
238
+ return (
239
+ <AgentOverlay>
240
+ {this.renderChat(strings)}
241
+ <AgentLayerHost visible={phase !== 'idle'} generation={this.state.layerGeneration} onRequestClose={() => this.agentMode?.stop()}>
242
+ {this.renderAgentLayer(strings)}
243
+ </AgentLayerHost>
244
+ </AgentOverlay>
245
+ );
246
+ }
247
+
221
248
  private renderAgentLayer(strings: UiStrings): React.JSX.Element | null {
222
249
  const { agentMode } = this.state;
223
250
  if (agentMode === null || agentMode.phase === 'idle') {
@@ -275,17 +302,20 @@ export class BlarProvider extends Component<BlarProviderProps, BlarProviderState
275
302
  }
276
303
 
277
304
  private watchForeground(): void {
278
- const subscription = AppState.addEventListener('change', (state) => {
279
- if (state !== 'active') {
280
- this.spotlight.backgrounded();
281
- }
282
- });
305
+ const subscription = AppState.addEventListener(
306
+ 'change',
307
+ contain('BlarProvider.foreground', (state: AppStateStatus) => {
308
+ if (state !== 'active') {
309
+ this.spotlight.backgrounded();
310
+ }
311
+ }),
312
+ );
283
313
  this.releases.push(() => subscription.remove());
284
314
  }
285
315
 
286
316
  private watchMotionPreference(): void {
287
317
  void AccessibilityInfo.isReduceMotionEnabled().then((reduceMotion) => this.setState({ reduceMotion }));
288
- const subscription = AccessibilityInfo.addEventListener('reduceMotionChanged', (reduceMotion) => this.setState({ reduceMotion }));
318
+ const subscription = AccessibilityInfo.addEventListener('reduceMotionChanged', contain('BlarProvider.motion', (reduceMotion: boolean) => this.setState({ reduceMotion })));
289
319
  this.releases.push(() => subscription.remove());
290
320
  }
291
321
 
@@ -316,8 +346,10 @@ export class BlarProvider extends Component<BlarProviderProps, BlarProviderState
316
346
  this.debug(`agent mode ${snapshot.phase}`);
317
347
  }
318
348
  this.setState({ agentMode: snapshot });
349
+ this.syncKeyboardGuard();
319
350
  }),
320
351
  );
352
+ this.releases.push(() => this.keyboardGuard.setActive(false));
321
353
  return agentMode;
322
354
  }
323
355
 
@@ -373,6 +405,7 @@ export class BlarProvider extends Component<BlarProviderProps, BlarProviderState
373
405
  coordinator.subscribe((chat) => {
374
406
  this.noteNudge(chat);
375
407
  this.setState({ chat });
408
+ this.syncKeyboardGuard();
376
409
  }),
377
410
  );
378
411
  this.releases.push(backend.onFrame((frame) => coordinator.handleFrame(frame)));
@@ -395,15 +428,22 @@ export class BlarProvider extends Component<BlarProviderProps, BlarProviderState
395
428
  Blar.attach(backend, (options) => controller.readMessageMetadata({ ...options, tabId: backend.getTabId() }));
396
429
  }
397
430
 
431
+ private syncKeyboardGuard(): void {
432
+ const running = this.agentMode?.getSnapshot().phase === 'active';
433
+ this.keyboardGuard.setActive(running && this.chatControls?.isOpen() !== true);
434
+ }
435
+
398
436
  private queueStackingCheck(): void {
399
437
  if (this.stackingCheckQueued || (this.agentMode?.getSnapshot().phase ?? 'idle') === 'idle') {
400
438
  return;
401
439
  }
402
440
  this.stackingCheckQueued = true;
403
- requestAnimationFrame(() => {
404
- this.stackingCheckQueued = false;
405
- this.keepLayerOnTop();
406
- });
441
+ requestAnimationFrame(
442
+ contain('BlarProvider.stacking', () => {
443
+ this.stackingCheckQueued = false;
444
+ this.keepLayerOnTop();
445
+ }),
446
+ );
407
447
  }
408
448
 
409
449
  private keepLayerOnTop(): void {
@@ -41,6 +41,9 @@ export class ActionExecutor {
41
41
  if (command.action.type === 'navigate') {
42
42
  return { ok: true, target: null };
43
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
+ }
44
47
  const target = this.resolveTarget(command);
45
48
  if (target === null) {
46
49
  return { ok: false, outcome: outcomeOf(this.missingTarget(command)) };
@@ -79,7 +82,7 @@ export class ActionExecutor {
79
82
  if (options.isCancelled()) {
80
83
  return failed('user_cancelled', CANCELLED_MESSAGE);
81
84
  }
82
- return await this.dispatch(command, target);
85
+ return await this.dispatch(command, target, options);
83
86
  } catch (error) {
84
87
  return failedFromException(error);
85
88
  }
@@ -124,7 +127,7 @@ export class ActionExecutor {
124
127
  return failed('element_not_found', reason);
125
128
  }
126
129
 
127
- private dispatch(command: AgentCommandData, target: ResolvedTarget): Promise<HandlerResult> | HandlerResult {
130
+ private dispatch(command: AgentCommandData, target: ResolvedTarget, options: ExecutionOptions): Promise<HandlerResult> | HandlerResult {
128
131
  const { action } = command;
129
132
  switch (action.type) {
130
133
  case 'click':
@@ -134,7 +137,7 @@ export class ActionExecutor {
134
137
  case 'check':
135
138
  return toggleSwitch(target, action.checked);
136
139
  case 'select':
137
- return selectValue(target, action.value, this.services);
140
+ return selectValue(target, action.value, this.services, options.isCancelled);
138
141
  case 'press_key':
139
142
  return action.key === 'Enter' ? submitInput(target) : failed('unsupported_action', `key ${action.key} is not supported`);
140
143
  case 'scroll':
@@ -5,6 +5,7 @@ import { childrenOf, isHostComponent, propsOf } from '../fiber/reader.ts';
5
5
  import { collectText, TEXT_HOST_TYPE } from '../fiber/text.ts';
6
6
  import type { Fiber } from '../fiber/types.ts';
7
7
  import { measureHost } from '../host/measure.ts';
8
+ import { scrollInstanceOf } from '../snapshot/classify.ts';
8
9
  import { maxScrollOffsetOf, measureScrollGeometry, offsetCentering, type ScrollGeometry, scrollOffsetOf, viewportExtentOf } from '../snapshot/scroll.ts';
9
10
  import { runBatched } from './batched.ts';
10
11
  import { failed, succeeded } from './result.ts';
@@ -20,6 +21,7 @@ const WALK_STEP_FRACTION = 3;
20
21
  const WALK_SETTLE_MS = 120;
21
22
  const MOUNT_POLL_MS = 150;
22
23
  const LISTED_OPTIONS = 30;
24
+ const CANCELLED_MESSAGE = 'Cancelled by the user before the action ran';
23
25
  const MOVEMENT_TOLERANCE = 1;
24
26
  const SCROLL_ANIMATION_MS = 400;
25
27
 
@@ -38,7 +40,9 @@ function commitScrollEnd(list: Fiber, geometry: ScrollGeometry, offset: number):
38
40
  const { viewport, content, horizontal } = geometry;
39
41
  runBatched(() =>
40
42
  onMomentumScrollEnd({
43
+ persist: () => undefined,
41
44
  nativeEvent: {
45
+ contentInset: { top: 0, left: 0, bottom: 0, right: 0 },
42
46
  contentOffset: horizontal ? { x: offset, y: 0 } : { x: 0, y: offset },
43
47
  contentSize: { width: content.width, height: content.height },
44
48
  layoutMeasurement: { width: viewport.width, height: viewport.height },
@@ -81,6 +85,9 @@ export async function selectWheelOption(target: ResolvedTarget): Promise<Handler
81
85
  export function rowsOf(list: Fiber): ListRow[] {
82
86
  const rows: ListRow[] = [];
83
87
  const visit = (fiber: Fiber): void => {
88
+ if (fiber !== list && scrollInstanceOf(fiber) !== null) {
89
+ return;
90
+ }
84
91
  if (isHostComponent(fiber) && fiber.type === TEXT_HOST_TYPE) {
85
92
  const text = collectText(fiber, ROW_TEXT_LIMIT);
86
93
  if (text.length > 0) {
@@ -109,7 +116,7 @@ function listOf(target: ResolvedTarget): Fiber | null {
109
116
 
110
117
  // A virtualized list mounts its rows shortly after it opens, and a distant row only once scrolling gets near it: wait
111
118
  // a moment, then walk the list. A value that isn't there leaves the list where it was.
112
- export async function selectValue(target: ResolvedTarget, value: string, services: ActionServices): Promise<HandlerResult> {
119
+ export async function selectValue(target: ResolvedTarget, value: string, services: ActionServices, isCancelled: () => boolean = () => false): Promise<HandlerResult> {
113
120
  const first = listOf(target);
114
121
  if (first === null) {
115
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)');
@@ -125,10 +132,13 @@ export async function selectValue(target: ResolvedTarget, value: string, service
125
132
  return rows.find((row) => matchesRow(row.text, value));
126
133
  };
127
134
  let row = findRow();
128
- for (let waited = 0; row === undefined && waited < MOUNT_WAIT_MS; waited += MOUNT_POLL_MS) {
135
+ for (let waited = 0; row === undefined && waited < MOUNT_WAIT_MS && !isCancelled(); waited += MOUNT_POLL_MS) {
129
136
  await SYSTEM_CLOCK.sleep(MOUNT_POLL_MS);
130
137
  row = findRow();
131
138
  }
139
+ if (isCancelled()) {
140
+ return failed('user_cancelled', CANCELLED_MESSAGE);
141
+ }
132
142
  if (row !== undefined) {
133
143
  return centerInList(current, list, row.host);
134
144
  }
@@ -139,6 +149,10 @@ export async function selectValue(target: ResolvedTarget, value: string, service
139
149
  const end = maxScrollOffsetOf(geometry);
140
150
  const stops = [...walk(start, end, step), ...walk(start, 0, step)];
141
151
  for (const stop of stops) {
152
+ if (isCancelled()) {
153
+ await scrollListTo(current, list, start);
154
+ return failed('user_cancelled', CANCELLED_MESSAGE);
155
+ }
142
156
  await scrollListTo(current, list, stop);
143
157
  await SYSTEM_CLOCK.sleep(WALK_SETTLE_MS);
144
158
  row = findRow();
@@ -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]);
@@ -20,6 +20,7 @@ export interface AgentEnvironment {
20
20
  transport: AgentTransport;
21
21
  getAnchorFiber: () => Fiber | null;
22
22
  getViewport: () => Size;
23
+ getWindow: () => Size;
23
24
  getNavigationRef: () => NavigationRefLike | null;
24
25
  getCaptureTarget: () => unknown;
25
26
  reading: ReadingPort;
@@ -40,6 +41,7 @@ export function createAgentController(environment: AgentEnvironment): AgentContr
40
41
  getAnchorFiber: environment.getAnchorFiber,
41
42
  registry,
42
43
  getViewport: environment.getViewport,
44
+ getWindow: environment.getWindow,
43
45
  getExtraRoutes: (): string[] => navigation.listRefRoutePaths(),
44
46
  });
45
47
  const commitWatch = new CommitWatch(environment.getAnchorFiber, environment.onCommit);
@@ -14,6 +14,8 @@ import { handleElementContext } from './handlers/element-context.ts';
14
14
  import { handleScreenshot } from './handlers/screenshot.ts';
15
15
  import { handleTextDom, readTextDom } from './handlers/text-dom.ts';
16
16
 
17
+ const MESSAGE_TEXT_DOM_MAX_CHARS = 100_000;
18
+
17
19
  export interface ControllerDependencies {
18
20
  transport: AgentTransport;
19
21
  commitWatch: CommitWatch;
@@ -44,7 +46,7 @@ export class AgentController {
44
46
 
45
47
  async readMessageMetadata(options: { includeTextDom: boolean; tabId: string }): Promise<MessageMetadata> {
46
48
  const snapshot = await this.context.capturer.capture('peek');
47
- const textDom = options.includeTextDom ? await readTextDom(this.context, { viewportOnly: false, maxChars: 0 }) : null;
49
+ const textDom = options.includeTextDom ? await readTextDom(this.context, { viewportOnly: false, maxChars: MESSAGE_TEXT_DOM_MAX_CHARS }) : null;
48
50
  return buildMessageMetadata({
49
51
  screen: snapshot.screen,
50
52
  tabId: options.tabId,
@@ -0,0 +1,35 @@
1
+ import { contain } from '../telemetry/contain.ts';
2
+
3
+ export interface KeyboardPort {
4
+ addListener: (event: 'keyboardWillShow' | 'keyboardDidShow', listener: () => void) => { remove: () => void };
5
+ dismiss: () => void;
6
+ }
7
+
8
+ // Nobody types during a run, and an open keyboard only covers what the agent is doing: an input that focuses itself would
9
+ // otherwise leave it up. Android has no keyboardWillShow, so it hides a beat later. The agent's own typing never opens it
10
+ // (focusWithoutKeyboard); ending the run closes the field it left focused, so the next tap opens a normal keyboard.
11
+ export class KeyboardGuard {
12
+ private subscriptions: Array<{ remove: () => void }> = [];
13
+
14
+ constructor(
15
+ private readonly keyboard: KeyboardPort,
16
+ private readonly onRelease: () => void = () => undefined,
17
+ ) {}
18
+
19
+ setActive(active: boolean): void {
20
+ if (!active) {
21
+ if (this.subscriptions.length > 0) {
22
+ this.subscriptions.splice(0).forEach((subscription) => subscription.remove());
23
+ this.keyboard.dismiss();
24
+ }
25
+ this.onRelease();
26
+ return;
27
+ }
28
+ if (this.subscriptions.length > 0) {
29
+ return;
30
+ }
31
+ const dismiss = contain('KeyboardGuard.dismiss', () => this.keyboard.dismiss());
32
+ this.subscriptions = [this.keyboard.addListener('keyboardWillShow', dismiss), this.keyboard.addListener('keyboardDidShow', dismiss)];
33
+ dismiss();
34
+ }
35
+ }
@@ -1,3 +1,5 @@
1
+ import { contain } from '../telemetry/contain.ts';
2
+
1
3
  export interface Timers {
2
4
  set: (callback: () => void, milliseconds: number) => unknown;
3
5
  clear: (handle: unknown) => void;
@@ -5,7 +7,7 @@ export interface Timers {
5
7
  }
6
8
 
7
9
  export const SYSTEM_TIMERS: Timers = {
8
- set: (callback, milliseconds) => setTimeout(callback, milliseconds),
10
+ set: (callback, milliseconds) => setTimeout(contain('Timers', callback), milliseconds),
9
11
  clear: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
10
12
  now: () => Date.now(),
11
13
  };