@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.
@@ -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
+ }