@pinagent/react-native 0.1.0
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 +201 -0
- package/dist/babel.cjs +130 -0
- package/dist/babel.cjs.map +1 -0
- package/dist/babel.d.cts +16 -0
- package/dist/babel.d.cts.map +1 -0
- package/dist/babel.d.ts +16 -0
- package/dist/babel.d.ts.map +1 -0
- package/dist/babel.js +130 -0
- package/dist/babel.js.map +1 -0
- package/dist/server.cjs +4684 -0
- package/dist/server.cjs.map +1 -0
- package/dist/server.d.cts +109 -0
- package/dist/server.d.cts.map +1 -0
- package/dist/server.d.ts +110 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +4681 -0
- package/dist/server.js.map +1 -0
- package/package.json +83 -0
- package/src/native/Pinagent.tsx +703 -0
- package/src/native/StreamSheet.tsx +426 -0
- package/src/native/index.ts +9 -0
- package/src/native/inspector.ts +407 -0
- package/src/native/multi-pick.ts +74 -0
- package/src/native/restore.ts +91 -0
- package/src/native/screenshot.ts +34 -0
- package/src/native/submit-outcome.ts +70 -0
- package/src/native/transcript.ts +143 -0
- package/src/native/transport.ts +162 -0
- package/src/native/types.ts +95 -0
- package/src/native/ws-client.ts +173 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Tap point → source location, the React Native way.
|
|
4
|
+
*
|
|
5
|
+
* This is the RN analog of the web widget's DOM walk for `data-pa-loc`.
|
|
6
|
+
* RN's own dev Inspector (Dev Menu → "Show Inspector") resolves a touch
|
|
7
|
+
* to a component + source file using exactly this internal API; we lean
|
|
8
|
+
* on the same machinery rather than reinventing it.
|
|
9
|
+
*
|
|
10
|
+
* Source data comes from the `data-pa-loc="file:line:col"` prop the
|
|
11
|
+
* `@pinagent/react-native/babel` plugin splices onto every authored JSX
|
|
12
|
+
* element at build time — the exact RN analog of the web babel plugin's
|
|
13
|
+
* DOM attribute. The plugin's prop rides along on the host fiber's
|
|
14
|
+
* `memoizedProps`, which `getInspectorDataForViewAtPoint` hands back to us
|
|
15
|
+
* as `data.props`, so the tapped view resolves to its source directly.
|
|
16
|
+
*
|
|
17
|
+
* Why a build-time prop instead of RN's old `_debugSource`: React 19
|
|
18
|
+
* deleted `_debugSource`, and RN 0.81+ dropped the `source` field from the
|
|
19
|
+
* inspector payload — neither carries a source location anymore. We still
|
|
20
|
+
* read both as a fallback for older RN/React, then degrade to `loc: null`.
|
|
21
|
+
*
|
|
22
|
+
* Both the module path AND the payload shape returned by
|
|
23
|
+
* `getInspectorDataForViewAtPoint` have shifted across RN versions, so
|
|
24
|
+
* every read here is defensive: we extract what we can and degrade to
|
|
25
|
+
* `loc: null` rather than throw inside a tap handler.
|
|
26
|
+
*/
|
|
27
|
+
import type { PickResult } from './types';
|
|
28
|
+
|
|
29
|
+
// Internal RN module — not a public export, but the path has been stable
|
|
30
|
+
// and is what the built-in Inspector imports. Typed loosely on purpose.
|
|
31
|
+
//
|
|
32
|
+
// `inspectedView` must be a **host component public instance** (a view ref's
|
|
33
|
+
// `.current`), NOT a `findNodeHandle` tag: on Fabric the renderer calls
|
|
34
|
+
// `getNodeFromPublicInstance(inspectedView)` and then hit-tests *within that
|
|
35
|
+
// view's shadow subtree*. A number fails the guard ("expects to receive a
|
|
36
|
+
// host component"); the instance must also be an ancestor of the tapped view,
|
|
37
|
+
// so we pass the app root (see `rootHostInstance`), not pinagent's overlay.
|
|
38
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
39
|
+
type InspectorFn = (
|
|
40
|
+
inspectedView: unknown,
|
|
41
|
+
locationX: number,
|
|
42
|
+
locationY: number,
|
|
43
|
+
callback: (data: RawInspectorData) => void,
|
|
44
|
+
) => void;
|
|
45
|
+
|
|
46
|
+
interface RawFiberLike {
|
|
47
|
+
fileName?: string;
|
|
48
|
+
lineNumber?: number;
|
|
49
|
+
columnNumber?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type RawProps = Record<string, unknown> | null | undefined;
|
|
53
|
+
|
|
54
|
+
/** RN measure callback: `(x, y, width, height, pageX, pageY)`. */
|
|
55
|
+
type RawMeasureCb = (
|
|
56
|
+
x: number,
|
|
57
|
+
y: number,
|
|
58
|
+
width: number,
|
|
59
|
+
height: number,
|
|
60
|
+
pageX: number,
|
|
61
|
+
pageY: number,
|
|
62
|
+
) => void;
|
|
63
|
+
|
|
64
|
+
interface RawHierarchyItem {
|
|
65
|
+
name?: string;
|
|
66
|
+
getInspectorData?: (toFiber: unknown) => {
|
|
67
|
+
/** Removed in RN 0.81+, kept for back-compat. */
|
|
68
|
+
source?: RawFiberLike | null;
|
|
69
|
+
/** The host fiber's `memoizedProps` — carries our `data-pa-loc`. */
|
|
70
|
+
props?: RawProps;
|
|
71
|
+
/** Measures this owner's host fiber (window/screen coords). */
|
|
72
|
+
measure?: (cb: RawMeasureCb) => void;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface RawInspectorData {
|
|
77
|
+
frame?: { left: number; top: number; width: number; height: number } | null;
|
|
78
|
+
hierarchy?: RawHierarchyItem[];
|
|
79
|
+
/** Newer RN returns the closest fiber directly. */
|
|
80
|
+
closestInstance?: { _debugSource?: RawFiberLike } | null;
|
|
81
|
+
/** Some versions surface the resolved source straight on the payload. */
|
|
82
|
+
source?: RawFiberLike | null;
|
|
83
|
+
/** The tapped host view's props — where `data-pa-loc` lands. */
|
|
84
|
+
props?: RawProps;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let cachedFn: InspectorFn | null | undefined;
|
|
88
|
+
|
|
89
|
+
function asFn(mod: unknown): InspectorFn | null {
|
|
90
|
+
const fn = (mod as { default?: unknown })?.default ?? mod;
|
|
91
|
+
return typeof fn === 'function' ? (fn as InspectorFn) : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function loadInspector(): InspectorFn | null {
|
|
95
|
+
if (cachedFn !== undefined) return cachedFn;
|
|
96
|
+
// The inspector module moved in RN 0.81:
|
|
97
|
+
// Libraries/Inspector/… → src/private/devsupport/devmenu/elementinspector/…
|
|
98
|
+
// We require the RN 0.81+ path only and deliberately do NOT fall back to the
|
|
99
|
+
// pre-0.81 `Libraries/Inspector/…` path: that file no longer exists on modern
|
|
100
|
+
// RN, so a static `require` of it makes Metro's resolver log an "invalid
|
|
101
|
+
// package.json / file does not exist" warning on every bundle (RN's `./*`
|
|
102
|
+
// exports entry maps it to a missing `.js`). The legacy inspector also
|
|
103
|
+
// predates the build-time `data-pa-loc` prop this package relies on, so it
|
|
104
|
+
// couldn't carry a location anyway — pre-0.81 RN degrades to `loc: null`.
|
|
105
|
+
// Lazy so a production build (widget tree-shaken / `__DEV__`-gated away)
|
|
106
|
+
// never reaches into RN internals.
|
|
107
|
+
try {
|
|
108
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
|
|
109
|
+
const fn = asFn(
|
|
110
|
+
require('react-native/src/private/devsupport/devmenu/elementinspector/getInspectorDataForViewAtPoint'),
|
|
111
|
+
);
|
|
112
|
+
if (fn) {
|
|
113
|
+
cachedFn = fn;
|
|
114
|
+
return cachedFn;
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
// Module absent (release build, Node/CI, or an RN version that moved it).
|
|
118
|
+
}
|
|
119
|
+
cachedFn = null;
|
|
120
|
+
return cachedFn;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// React fiber tag for a host component (`<View>`, `<Text>`, …). Stable across
|
|
124
|
+
// React versions.
|
|
125
|
+
const HOST_COMPONENT = 5;
|
|
126
|
+
|
|
127
|
+
interface FiberLike {
|
|
128
|
+
tag?: number;
|
|
129
|
+
return?: FiberLike | null;
|
|
130
|
+
stateNode?: { canonical?: { publicInstance?: unknown } } | null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let cachedGetHandle: ((instance: unknown) => FiberLike | null) | null | undefined;
|
|
134
|
+
|
|
135
|
+
function getHandleFromPublicInstance(instance: unknown): FiberLike | null {
|
|
136
|
+
if (cachedGetHandle === undefined) {
|
|
137
|
+
try {
|
|
138
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
|
|
139
|
+
const RNPrivate = require('react-native/Libraries/ReactPrivate/ReactNativePrivateInterface');
|
|
140
|
+
const fn = RNPrivate?.getInternalInstanceHandleFromPublicInstance;
|
|
141
|
+
cachedGetHandle = typeof fn === 'function' ? fn : null;
|
|
142
|
+
} catch {
|
|
143
|
+
cachedGetHandle = null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
return cachedGetHandle ? cachedGetHandle(instance) : null;
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Climb from pinagent's own overlay view to the app's **root** host view and
|
|
155
|
+
* return its public instance.
|
|
156
|
+
*
|
|
157
|
+
* Why: `getInspectorDataForViewAtPoint` hit-tests *within* the shadow subtree
|
|
158
|
+
* of the instance we pass. pinagent mounts as a sibling/descendant of the
|
|
159
|
+
* app, so its own view's subtree doesn't contain the tapped component — we
|
|
160
|
+
* must hand the inspector an ancestor that does. RN's built-in Inspector uses
|
|
161
|
+
* `AppContainer`'s inner root view for exactly this; we reach the same node by
|
|
162
|
+
* walking the fiber `return` chain to the topmost host component.
|
|
163
|
+
*
|
|
164
|
+
* Defensive: any failure (Paper, an RN internals shuffle, a null handle)
|
|
165
|
+
* falls back to the instance we were given — the picker degrades, never
|
|
166
|
+
* throws.
|
|
167
|
+
*/
|
|
168
|
+
function rootHostInstance(publicInstance: unknown): unknown {
|
|
169
|
+
let fiber = getHandleFromPublicInstance(publicInstance);
|
|
170
|
+
if (!fiber) return publicInstance;
|
|
171
|
+
let topHost: FiberLike | null = null;
|
|
172
|
+
// Cap the walk — a malformed `return` cycle must not spin forever.
|
|
173
|
+
for (let i = 0; fiber && i < 10_000; i++) {
|
|
174
|
+
if (fiber.tag === HOST_COMPONENT) topHost = fiber;
|
|
175
|
+
fiber = fiber.return ?? null;
|
|
176
|
+
}
|
|
177
|
+
const rootInstance = topHost?.stateNode?.canonical?.publicInstance;
|
|
178
|
+
return rootInstance ?? publicInstance;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Turn an absolute `_debugSource.fileName` into the project-relative,
|
|
183
|
+
* POSIX path the rest of pinagent expects (matching what the web babel
|
|
184
|
+
* plugin emits). Falls back to the raw filename if it isn't under root.
|
|
185
|
+
*/
|
|
186
|
+
function toProjectRelative(fileName: string, projectRoot: string): string {
|
|
187
|
+
const norm = fileName.replace(/\\/g, '/');
|
|
188
|
+
const root = projectRoot.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
189
|
+
if (root && norm.startsWith(`${root}/`)) return norm.slice(root.length + 1);
|
|
190
|
+
return norm;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
type Loc = NonNullable<PickResult['loc']>;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Parse a `data-pa-loc` value (`"src/Foo.tsx:42:7"`) into a {@link Loc}.
|
|
197
|
+
* The path may itself contain colons on exotic platforms, so we split off
|
|
198
|
+
* the trailing `:line:col` rather than splitting greedily.
|
|
199
|
+
*/
|
|
200
|
+
function parsePaLoc(value: unknown): Loc | null {
|
|
201
|
+
if (typeof value !== 'string') return null;
|
|
202
|
+
const m = /^(.*):(\d+):(\d+)$/.exec(value);
|
|
203
|
+
if (!m) return null;
|
|
204
|
+
return { file: m[1]!, line: Number(m[2]), col: Number(m[3]) };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The first `data-pa-loc` found on the tapped view or its nearest owner. */
|
|
208
|
+
function paLocOf(props: RawProps): Loc | null {
|
|
209
|
+
return parsePaLoc(props?.['data-pa-loc']);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Resolve the source location. Preferred path: the build-time `data-pa-loc`
|
|
214
|
+
* prop our babel plugin splices on (read from the tapped host view's props,
|
|
215
|
+
* then from each owner outward). Fallback: RN's legacy `_debugSource` /
|
|
216
|
+
* inspector `source` field, for older RN/React where they still exist.
|
|
217
|
+
*/
|
|
218
|
+
function pickLoc(data: RawInspectorData, projectRoot: string): Loc | null {
|
|
219
|
+
// 1. `data-pa-loc` on the directly tapped host view.
|
|
220
|
+
const direct = paLocOf(data.props);
|
|
221
|
+
if (direct) return direct;
|
|
222
|
+
|
|
223
|
+
// 2. `data-pa-loc` walking the owner hierarchy from the tapped element out.
|
|
224
|
+
for (let i = (data.hierarchy?.length ?? 0) - 1; i >= 0; i--) {
|
|
225
|
+
const item = data.hierarchy?.[i];
|
|
226
|
+
const loc = paLocOf(item?.getInspectorData?.(() => null)?.props);
|
|
227
|
+
if (loc) return loc;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// 3. Legacy `_debugSource` / inspector `source` (pre-React-19 / pre-0.81).
|
|
231
|
+
const src = legacySource(data);
|
|
232
|
+
if (src?.fileName && typeof src.lineNumber === 'number') {
|
|
233
|
+
return {
|
|
234
|
+
file: toProjectRelative(src.fileName, projectRoot),
|
|
235
|
+
line: src.lineNumber,
|
|
236
|
+
col: src.columnNumber ?? 0,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function legacySource(data: RawInspectorData): RawFiberLike | null {
|
|
243
|
+
if (data.source?.fileName) return data.source;
|
|
244
|
+
if (data.closestInstance?._debugSource?.fileName) {
|
|
245
|
+
return data.closestInstance._debugSource;
|
|
246
|
+
}
|
|
247
|
+
for (let i = (data.hierarchy?.length ?? 0) - 1; i >= 0; i--) {
|
|
248
|
+
const item = data.hierarchy?.[i];
|
|
249
|
+
const src = item?.getInspectorData?.(() => null)?.source;
|
|
250
|
+
if (src?.fileName) return src;
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Keep only authored React components in the breadcrumb. Two kinds of noise
|
|
257
|
+
* are hidden because clicking them is meaningless — they map to no source the
|
|
258
|
+
* developer can act on:
|
|
259
|
+
*
|
|
260
|
+
* - **Native host components** — RN's view classes, named `RCT…`
|
|
261
|
+
* (`RCTText`, `RCTView`, `RCTScrollView`, …).
|
|
262
|
+
* - **HOC / wrapper display names** — parenthesized by convention
|
|
263
|
+
* (`withDevTools(App)`, `ForwardRef(X)`, `Memo(X)`, `Connect(X)`).
|
|
264
|
+
*
|
|
265
|
+
* Identifiers can't contain `(`, so a parenthesized name is always a wrapper.
|
|
266
|
+
*/
|
|
267
|
+
export function isAuthoredComponentName(name: unknown): name is string {
|
|
268
|
+
return (
|
|
269
|
+
typeof name === 'string' && name.length > 0 && !name.startsWith('RCT') && !name.includes('(')
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function nameChainOf(data: RawInspectorData): string[] {
|
|
274
|
+
return (data.hierarchy ?? []).map((h) => h.name).filter(isAuthoredComponentName);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
type Frame = NonNullable<PickResult['frame']>;
|
|
278
|
+
|
|
279
|
+
interface RawCrumb {
|
|
280
|
+
name: string;
|
|
281
|
+
loc: Loc | null;
|
|
282
|
+
measure?: (cb: RawMeasureCb) => void;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Build the per-segment breadcrumb: each authored component in the hierarchy
|
|
287
|
+
* paired with its own `data-pa-loc` (so a press can re-anchor onto that
|
|
288
|
+
* ancestor) and a `measure` fn (so the highlight can follow the selection).
|
|
289
|
+
* Same order as {@link nameChainOf} — root first, tapped last.
|
|
290
|
+
*/
|
|
291
|
+
function crumbsOf(data: RawInspectorData): RawCrumb[] {
|
|
292
|
+
return (data.hierarchy ?? [])
|
|
293
|
+
.filter((h): h is RawHierarchyItem & { name: string } => isAuthoredComponentName(h.name))
|
|
294
|
+
.map((h) => {
|
|
295
|
+
const inspector = h.getInspectorData?.(() => null);
|
|
296
|
+
return { name: h.name, loc: paLocOf(inspector?.props), measure: inspector?.measure };
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Measure a hierarchy item's host fiber to a window-coordinate {@link Frame}.
|
|
302
|
+
* Resolves null if there's no measure fn or it never calls back (guarded so a
|
|
303
|
+
* stuck measure can't hang the pick).
|
|
304
|
+
*/
|
|
305
|
+
export function measureFrame(measure?: (cb: RawMeasureCb) => void): Promise<Frame | null> {
|
|
306
|
+
if (!measure) return Promise.resolve(null);
|
|
307
|
+
return new Promise((resolve) => {
|
|
308
|
+
let settled = false;
|
|
309
|
+
const finish = (f: Frame | null) => {
|
|
310
|
+
if (!settled) {
|
|
311
|
+
settled = true;
|
|
312
|
+
resolve(f);
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
const timer = setTimeout(() => finish(null), 150);
|
|
316
|
+
try {
|
|
317
|
+
measure((_x, _y, width, height, pageX, pageY) => {
|
|
318
|
+
clearTimeout(timer);
|
|
319
|
+
finish(width > 0 || height > 0 ? { x: pageX, y: pageY, width, height } : null);
|
|
320
|
+
});
|
|
321
|
+
} catch {
|
|
322
|
+
clearTimeout(timer);
|
|
323
|
+
finish(null);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Resolve a tap (in window coordinates) to a {@link PickResult}.
|
|
330
|
+
*
|
|
331
|
+
* @param rootView A host view instance from which to reach the app root —
|
|
332
|
+
* pass a ref's `.current` (the widget passes its own overlay `<View>`).
|
|
333
|
+
* We climb to the app-root host instance before hit-testing, since the
|
|
334
|
+
* inspector searches within the passed view's subtree. NOT a
|
|
335
|
+
* `findNodeHandle` number — the Fabric inspector rejects a bare tag.
|
|
336
|
+
*/
|
|
337
|
+
export function resolvePick(
|
|
338
|
+
rootView: unknown,
|
|
339
|
+
x: number,
|
|
340
|
+
y: number,
|
|
341
|
+
projectRoot: string,
|
|
342
|
+
): Promise<PickResult> {
|
|
343
|
+
const fn = loadInspector();
|
|
344
|
+
if (!fn) {
|
|
345
|
+
// Inspector unavailable (release build, or an RN version that moved
|
|
346
|
+
// the module). Degrade gracefully — the comment can still be filed
|
|
347
|
+
// with `loc: null`, which the server accepts.
|
|
348
|
+
return Promise.resolve({ loc: null, nameChain: [], chain: [], frame: null });
|
|
349
|
+
}
|
|
350
|
+
const inspectedView = rootHostInstance(rootView);
|
|
351
|
+
return new Promise((resolve) => {
|
|
352
|
+
let settled = false;
|
|
353
|
+
const done = (r: PickResult) => {
|
|
354
|
+
if (!settled) {
|
|
355
|
+
settled = true;
|
|
356
|
+
resolve(r);
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
// The callback is sometimes never invoked if the point misses every
|
|
360
|
+
// view; guard with a microtask-ish fallback so the picker can't hang.
|
|
361
|
+
const timer = setTimeout(() => done({ loc: null, nameChain: [], chain: [], frame: null }), 250);
|
|
362
|
+
try {
|
|
363
|
+
fn(inspectedView, x, y, (data) => {
|
|
364
|
+
clearTimeout(timer);
|
|
365
|
+
const frame = data.frame
|
|
366
|
+
? {
|
|
367
|
+
x: data.frame.left,
|
|
368
|
+
y: data.frame.top,
|
|
369
|
+
width: data.frame.width,
|
|
370
|
+
height: data.frame.height,
|
|
371
|
+
}
|
|
372
|
+
: null;
|
|
373
|
+
const rawCrumbs = crumbsOf(data);
|
|
374
|
+
const loc = pickLoc(data, projectRoot);
|
|
375
|
+
const nameChain = nameChainOf(data);
|
|
376
|
+
// Measure each crumb so pressing one can move the on-screen highlight
|
|
377
|
+
// to that ancestor. Concurrent, each guarded — adds ~one measure pass.
|
|
378
|
+
Promise.all(rawCrumbs.map((c) => measureFrame(c.measure)))
|
|
379
|
+
.then((frames) => {
|
|
380
|
+
done({
|
|
381
|
+
loc,
|
|
382
|
+
nameChain,
|
|
383
|
+
chain: rawCrumbs.map((c, i) => ({
|
|
384
|
+
name: c.name,
|
|
385
|
+
loc: c.loc,
|
|
386
|
+
frame: frames[i] ?? null,
|
|
387
|
+
})),
|
|
388
|
+
frame,
|
|
389
|
+
});
|
|
390
|
+
})
|
|
391
|
+
.catch(() => {
|
|
392
|
+
// Measuring failed wholesale — still return the pick without
|
|
393
|
+
// per-crumb frames rather than hang.
|
|
394
|
+
done({
|
|
395
|
+
loc,
|
|
396
|
+
nameChain,
|
|
397
|
+
chain: rawCrumbs.map((c) => ({ name: c.name, loc: c.loc, frame: null })),
|
|
398
|
+
frame,
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
} catch {
|
|
403
|
+
clearTimeout(timer);
|
|
404
|
+
done({ loc: null, nameChain: [], chain: [], frame: null });
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Multi-select payload builder (ticket 008).
|
|
4
|
+
*
|
|
5
|
+
* On web, Cmd/Ctrl-click accumulates several elements into one comment and the
|
|
6
|
+
* extras are submitted as `additionalAnchors` (landing in the
|
|
7
|
+
* `widget_anchors.additional_anchors` JSON column). RN is touch-only, so the
|
|
8
|
+
* composer offers an explicit "+ Add element" affordance: re-enter pick mode,
|
|
9
|
+
* tap another element, and it appends a removable chip. This module turns the
|
|
10
|
+
* primary anchor + the collected extras into the `additionalAnchors` array,
|
|
11
|
+
* matching the web `AdditionalAnchorSchema` shape so the server accepts it
|
|
12
|
+
* unchanged.
|
|
13
|
+
*
|
|
14
|
+
* Pure (no RN runtime imports) → unit-testable here. The RN UI state + capture
|
|
15
|
+
* lives in Pinagent.tsx; this just shapes the wire payload.
|
|
16
|
+
*
|
|
17
|
+
* Web semantics preserved:
|
|
18
|
+
* - A single pick (no extras) → `additionalAnchors` OMITTED (the server stores
|
|
19
|
+
* `additional_anchors` as null), not an empty array.
|
|
20
|
+
* - The primary anchor is NOT duplicated into the extras.
|
|
21
|
+
* - Each extra keeps the loc/selector it was tapped with (no breadcrumb
|
|
22
|
+
* re-anchoring for extras — that applies to the primary only, web parity).
|
|
23
|
+
*/
|
|
24
|
+
import type { AdditionalAnchor } from './types';
|
|
25
|
+
|
|
26
|
+
/** A primary or extra pick as captured by the RN composer. */
|
|
27
|
+
export interface ChipPick {
|
|
28
|
+
/** Stable key for the chip + removal (e.g. a per-pick counter). */
|
|
29
|
+
key: string;
|
|
30
|
+
/** Resolved source location, or null for an unresolvable native view. */
|
|
31
|
+
loc: { file: string; line: number; col: number } | null;
|
|
32
|
+
/** Component name-chain ("App > Home > Button") — RN's selector stand-in. */
|
|
33
|
+
selector: string;
|
|
34
|
+
/** Tap point in window coordinates (the `clickX`/`clickY` the schema wants). */
|
|
35
|
+
clickX: number;
|
|
36
|
+
clickY: number;
|
|
37
|
+
/** Innermost component name, for the chip label. */
|
|
38
|
+
label: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Map one extra pick to the wire `AdditionalAnchor` shape. */
|
|
42
|
+
function toAnchor(pick: ChipPick): AdditionalAnchor {
|
|
43
|
+
return {
|
|
44
|
+
file: pick.loc?.file ?? null,
|
|
45
|
+
line: pick.loc?.line ?? null,
|
|
46
|
+
col: pick.loc?.col ?? null,
|
|
47
|
+
selector: pick.selector,
|
|
48
|
+
clickX: Math.round(pick.clickX),
|
|
49
|
+
clickY: Math.round(pick.clickY),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Build the `additionalAnchors` field from the extra picks (everything after
|
|
55
|
+
* the primary). Returns `undefined` when there are no extras so the caller can
|
|
56
|
+
* spread it and leave the field off entirely for single-pick submits.
|
|
57
|
+
*
|
|
58
|
+
* @param extras the non-primary picks, in pick order (preserved on the wire).
|
|
59
|
+
*/
|
|
60
|
+
export function buildAdditionalAnchors(
|
|
61
|
+
extras: readonly ChipPick[],
|
|
62
|
+
): AdditionalAnchor[] | undefined {
|
|
63
|
+
if (extras.length === 0) return undefined;
|
|
64
|
+
return extras.map(toAnchor);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Remove a chip by key from a pick list, preserving order. Used for both the
|
|
69
|
+
* primary+extras chip row removal and the extras-only list; the caller decides
|
|
70
|
+
* which list to pass (the primary is never removable in the UI).
|
|
71
|
+
*/
|
|
72
|
+
export function removeChip(picks: readonly ChipPick[], key: string): ChipPick[] {
|
|
73
|
+
return picks.filter((p) => p.key !== key);
|
|
74
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Restore-filter: turn the server's feedback list into the minimized pills the
|
|
4
|
+
* RN widget seeds on mount, so an app reload mid-run brings the running streams
|
|
5
|
+
* back instead of losing them.
|
|
6
|
+
*
|
|
7
|
+
* The dev server (`.pinagent/db.sqlite`) is the source of truth — RN keeps no
|
|
8
|
+
* device-local mirror (see ticket 001). On `<Pinagent/>` mount we
|
|
9
|
+
* `GET /__pinagent/feedback`, run the list through {@link restorePills}, and
|
|
10
|
+
* seed `streams` with the result; each restored id then subscribes over the
|
|
11
|
+
* existing WS client, which replays the transcript (and fires `done` for
|
|
12
|
+
* already-finished runs).
|
|
13
|
+
*
|
|
14
|
+
* This module is pure (no RN runtime imports) so it's unit-testable here. The
|
|
15
|
+
* filter mirrors the web widget's `listPendingForCurrentPage`
|
|
16
|
+
* (`packages/widget/src/db/reads.ts`): pending-only, scoped to the current
|
|
17
|
+
* surface URL, newest first.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Default cap so a stale backlog doesn't flood the screen with pills. */
|
|
21
|
+
export const RESTORE_LIMIT = 5;
|
|
22
|
+
|
|
23
|
+
/** A minimized run pill: the same shape `<Pinagent/>` keeps in `streams`. */
|
|
24
|
+
export interface RestoredPill {
|
|
25
|
+
id: string;
|
|
26
|
+
/** Header label — `file:line` if anchored, else the selector/component. */
|
|
27
|
+
target: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The subset of a feedback list item (`storage.list()` projection,
|
|
32
|
+
* `FeedbackRecord`) this filter reads. Loosely typed so it tolerates the wire
|
|
33
|
+
* JSON without importing the agent-runner type into RN source.
|
|
34
|
+
*/
|
|
35
|
+
export interface RestoreCandidate {
|
|
36
|
+
id?: unknown;
|
|
37
|
+
status?: unknown;
|
|
38
|
+
url?: unknown;
|
|
39
|
+
file?: unknown;
|
|
40
|
+
line?: unknown;
|
|
41
|
+
selector?: unknown;
|
|
42
|
+
updatedAt?: unknown;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isNonEmptyString(v: unknown): v is string {
|
|
46
|
+
return typeof v === 'string' && v.length > 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Human-readable target for the pill header (mirrors `onSubmit`'s logic). */
|
|
50
|
+
function targetFor(item: RestoreCandidate): string {
|
|
51
|
+
if (isNonEmptyString(item.file) && typeof item.line === 'number') {
|
|
52
|
+
return `${item.file}:${item.line}`;
|
|
53
|
+
}
|
|
54
|
+
if (isNonEmptyString(item.selector)) {
|
|
55
|
+
// The selector is the component name-chain ("App > Home > Button"); the
|
|
56
|
+
// innermost (tapped) component is the most useful label.
|
|
57
|
+
const last = item.selector.split('>').pop()?.trim();
|
|
58
|
+
if (last) return last;
|
|
59
|
+
}
|
|
60
|
+
return 'component';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Filter a feedback list to the pills worth restoring on this surface:
|
|
65
|
+
*
|
|
66
|
+
* - `status === 'pending'` — resolved/dismissed runs don't come back (web parity).
|
|
67
|
+
* - `url === surfaceUrl` — RN submits `url: screenName ?? Platform.OS`; a run
|
|
68
|
+
* started on a different screen would restore at meaningless coordinates.
|
|
69
|
+
* - newest first by `updatedAt`, capped at `limit` (default {@link RESTORE_LIMIT}).
|
|
70
|
+
*
|
|
71
|
+
* Items missing an `id` are dropped (can't subscribe to them).
|
|
72
|
+
*/
|
|
73
|
+
export function restorePills(
|
|
74
|
+
items: readonly RestoreCandidate[] | null | undefined,
|
|
75
|
+
surfaceUrl: string,
|
|
76
|
+
limit: number = RESTORE_LIMIT,
|
|
77
|
+
): RestoredPill[] {
|
|
78
|
+
if (!Array.isArray(items)) return [];
|
|
79
|
+
return items
|
|
80
|
+
.filter((it) => isNonEmptyString(it.id) && it.status === 'pending' && it.url === surfaceUrl)
|
|
81
|
+
.sort((a, b) => updatedAtMs(b.updatedAt) - updatedAtMs(a.updatedAt))
|
|
82
|
+
.slice(0, Math.max(0, limit))
|
|
83
|
+
.map((it) => ({ id: it.id as string, target: targetFor(it) }));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Parse an ISO `updatedAt` to epoch ms; unparseable values sort oldest (0). */
|
|
87
|
+
function updatedAtMs(v: unknown): number {
|
|
88
|
+
if (typeof v !== 'string') return 0;
|
|
89
|
+
const ms = Date.parse(v);
|
|
90
|
+
return Number.isNaN(ms) ? 0 : ms;
|
|
91
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Screenshot capture — the RN analog of the web widget's `html-to-image`.
|
|
4
|
+
*
|
|
5
|
+
* Uses `react-native-view-shot`'s `captureScreen`, which snapshots the
|
|
6
|
+
* whole window (so the picked component plus its surroundings land in the
|
|
7
|
+
* image, same as the web capture). Returns base64 PNG with no `data:`
|
|
8
|
+
* prefix, matching the `screenshot` field the middleware decodes.
|
|
9
|
+
*
|
|
10
|
+
* `react-native-view-shot` is an optional peer: if it isn't installed we
|
|
11
|
+
* return a 1x1 transparent PNG so the comment can still be filed (the
|
|
12
|
+
* server requires a non-empty screenshot string). The same transparent
|
|
13
|
+
* placeholder the web widget uses on capture failure.
|
|
14
|
+
*/
|
|
15
|
+
const TRANSPARENT_PNG_BASE64 =
|
|
16
|
+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
|
|
17
|
+
|
|
18
|
+
export async function captureScreenshot(): Promise<string> {
|
|
19
|
+
try {
|
|
20
|
+
// Lazy require so a release build never pulls the native module in.
|
|
21
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
|
|
22
|
+
const { captureScreen } = require('react-native-view-shot');
|
|
23
|
+
// result: 'base64' returns the raw base64 string (no data: prefix),
|
|
24
|
+
// which is exactly what FeedbackInput.screenshot wants.
|
|
25
|
+
const b64: string = await captureScreen({
|
|
26
|
+
format: 'png',
|
|
27
|
+
quality: 0.9,
|
|
28
|
+
result: 'base64',
|
|
29
|
+
});
|
|
30
|
+
return b64;
|
|
31
|
+
} catch {
|
|
32
|
+
return TRANSPARENT_PNG_BASE64;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Pure submit-outcome reducer (ticket 002).
|
|
4
|
+
*
|
|
5
|
+
* `onSubmit` in Pinagent.tsx used to clear the composer (comment, pick,
|
|
6
|
+
* screenshot) UNCONDITIONALLY after `submitFeedback()` returned — so a Metro
|
|
7
|
+
* restart, a network blip, or a release build at the moment of submit threw
|
|
8
|
+
* away the typed comment, the picked anchor, and the screenshot, leaving the
|
|
9
|
+
* user with a 2.5s toast and a blank composer.
|
|
10
|
+
*
|
|
11
|
+
* This module computes the next composer state from a `SubmitResult` so the
|
|
12
|
+
* "keep the draft on failure, clear only on success" rule is data, not buried
|
|
13
|
+
* UI flow — and is unit-testable here (the RN UI itself is not). Mapping the
|
|
14
|
+
* outcome to React state lives in `onSubmit`; this just decides the shape.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** The relevant submit result fields (a subset of transport's `SubmitResult`). */
|
|
18
|
+
export interface SubmitOutcomeInput {
|
|
19
|
+
ok: boolean;
|
|
20
|
+
id?: string;
|
|
21
|
+
agentSpawned?: boolean;
|
|
22
|
+
error?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What the composer should do next, given a submit result. */
|
|
26
|
+
export interface SubmitOutcome {
|
|
27
|
+
/**
|
|
28
|
+
* `'clear'` — wipe the composer (comment/pick/shot) and go idle (success).
|
|
29
|
+
* `'keep'` — retain the composer and surface the error inline + offer Retry.
|
|
30
|
+
*/
|
|
31
|
+
composer: 'clear' | 'keep';
|
|
32
|
+
/** Inline error to show under the composer when `composer === 'keep'`. */
|
|
33
|
+
error: string | null;
|
|
34
|
+
/**
|
|
35
|
+
* When a run was spawned, the id to open as a live stream. Null when nothing
|
|
36
|
+
* to stream (spawn off, or a failed submit).
|
|
37
|
+
*/
|
|
38
|
+
streamId: string | null;
|
|
39
|
+
/**
|
|
40
|
+
* Transient toast text for the non-streaming success/failure paths, or null
|
|
41
|
+
* when the outcome opens a stream instead (which has its own UI). Kept for a
|
|
42
|
+
* filed-for-pull-mode confirmation; failures show inline, not a toast.
|
|
43
|
+
*/
|
|
44
|
+
toast: string | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Decide the next composer state from a submit result.
|
|
49
|
+
*
|
|
50
|
+
* - Failure (`ok === false`): keep the composer + its draft, surface the error
|
|
51
|
+
* inline (never a vanishing toast), no stream, no clear. Retry re-submits the
|
|
52
|
+
* retained payload.
|
|
53
|
+
* - Success with a spawned agent: clear the composer and open the stream.
|
|
54
|
+
* - Success without a spawned agent (spawn off / pull mode): clear the composer
|
|
55
|
+
* and show a transient "Sent" toast.
|
|
56
|
+
*/
|
|
57
|
+
export function submitOutcome(result: SubmitOutcomeInput): SubmitOutcome {
|
|
58
|
+
if (!result.ok) {
|
|
59
|
+
return {
|
|
60
|
+
composer: 'keep',
|
|
61
|
+
error: `Failed: ${result.error ?? 'unknown'}`,
|
|
62
|
+
streamId: null,
|
|
63
|
+
toast: null,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (result.agentSpawned && result.id) {
|
|
67
|
+
return { composer: 'clear', error: null, streamId: result.id, toast: null };
|
|
68
|
+
}
|
|
69
|
+
return { composer: 'clear', error: null, streamId: null, toast: 'Sent' };
|
|
70
|
+
}
|