@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.
- package/README.md +8 -3
- package/dist/BlarProvider.d.ts +6 -0
- package/dist/actions/select.d.ts +1 -1
- package/dist/actions/text-input.d.ts +1 -0
- package/dist/agent/compose.d.ts +1 -0
- package/dist/agent-mode/keyboard-guard.d.ts +13 -0
- package/dist/snapshot/capture.d.ts +1 -0
- package/dist/snapshot/visibility.d.ts +1 -1
- package/dist/snapshot/wrappers.d.ts +0 -2
- package/dist/telemetry/contain.d.ts +5 -0
- package/dist/version.d.ts +1 -1
- package/package.json +1 -1
- package/src/BlarProvider.tsx +68 -28
- package/src/actions/executor.ts +6 -3
- package/src/actions/select.ts +16 -2
- package/src/actions/text-input.ts +30 -1
- package/src/agent/compose.ts +2 -0
- package/src/agent/controller.ts +3 -1
- package/src/agent-mode/keyboard-guard.ts +35 -0
- package/src/agent-mode/timers.ts +3 -1
- package/src/agent-mode/ui/AgentDock.tsx +8 -7
- package/src/agent-mode/ui/AgentLayer.tsx +2 -1
- package/src/agent-mode/ui/AgentLayerHost.tsx +2 -1
- package/src/agent-mode/ui/AgentPanel.tsx +4 -3
- package/src/agent-mode/ui/AgentTap.tsx +3 -2
- package/src/agent-mode/ui/SpotlightLayer.tsx +4 -3
- package/src/agent-mode/ui/StatusCard.tsx +3 -2
- package/src/agent-mode/ui/TouchShield.tsx +2 -1
- package/src/backend/foreground.ts +11 -6
- package/src/backend/health.ts +10 -6
- package/src/backend/presence.ts +12 -8
- package/src/backend/session-socket.ts +25 -18
- package/src/blar.ts +8 -7
- package/src/chat/ChatSheet.tsx +32 -11
- package/src/chat/ChatView.tsx +12 -10
- package/src/chat/Launcher.tsx +2 -1
- package/src/chat/widget-bundle.generated.ts +2 -2
- package/src/nudge/NudgeBubble.tsx +6 -5
- package/src/snapshot/capture.ts +8 -6
- package/src/snapshot/render.ts +1 -1
- package/src/snapshot/visibility.ts +11 -10
- package/src/snapshot/wrappers.ts +0 -13
- package/src/telemetry/contain.ts +28 -0
- package/src/telemetry/sdk-reporter.ts +3 -0
- package/src/transport/relay-transport.ts +13 -9
- 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
|
-
|
|
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
|
|
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).
|
|
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.
|
package/dist/BlarProvider.d.ts
CHANGED
|
@@ -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;
|
package/dist/actions/select.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/agent/compose.d.ts
CHANGED
|
@@ -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
|
+
}
|
|
@@ -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.
|
|
1
|
+
export declare const SDK_VERSION = "0.1.7";
|
package/package.json
CHANGED
package/src/BlarProvider.tsx
CHANGED
|
@@ -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.
|
|
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.
|
|
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={
|
|
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(
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
405
|
-
|
|
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 {
|
package/src/actions/executor.ts
CHANGED
|
@@ -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':
|
package/src/actions/select.ts
CHANGED
|
@@ -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
|
|
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]);
|
package/src/agent/compose.ts
CHANGED
|
@@ -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);
|
package/src/agent/controller.ts
CHANGED
|
@@ -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:
|
|
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
|
+
}
|
package/src/agent-mode/timers.ts
CHANGED
|
@@ -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
|
};
|