@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,703 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* <Pinagent/> — the React Native widget. Mount it once at your app root:
|
|
4
|
+
*
|
|
5
|
+
* export default function App() {
|
|
6
|
+
* return (
|
|
7
|
+
* <>
|
|
8
|
+
* <YourApp />
|
|
9
|
+
* <Pinagent />
|
|
10
|
+
* </>
|
|
11
|
+
* );
|
|
12
|
+
* }
|
|
13
|
+
*
|
|
14
|
+
* Modeled on pinagent's Next.js `<Pinagent/>` (a single root-mounted
|
|
15
|
+
* component) rather than the Vite `<script>` injection, which has no RN
|
|
16
|
+
* analog. Renders `null` in production so it has zero cost in release
|
|
17
|
+
* builds.
|
|
18
|
+
*
|
|
19
|
+
* Flow: tap the 💬 FAB to arm picking → tap a view → we resolve its source
|
|
20
|
+
* via the RN Inspector, hide our own overlay, and capture a screenshot →
|
|
21
|
+
* type a comment → submit POSTs to the Metro middleware, which stores it
|
|
22
|
+
* and (optionally) spawns an agent. When an agent is spawned, a live
|
|
23
|
+
* transcript sheet streams the run back over WebSocket (see StreamSheet /
|
|
24
|
+
* ws-client); otherwise a toast confirms the comment was filed for pull-mode
|
|
25
|
+
* (MCP) pickup. "+ Add element" multi-picks several targets into one comment
|
|
26
|
+
* (sent as `additionalAnchors`); a single pick leaves them null.
|
|
27
|
+
*
|
|
28
|
+
* The transcript sheet can be minimized to a pill, freeing the screen to pick
|
|
29
|
+
* another element and spawn a second agent. Each run keeps its own live sheet,
|
|
30
|
+
* so multiple agents can stream concurrently — the expanded one shows its full
|
|
31
|
+
* sheet; the rest sit as pills that keep streaming until tapped open.
|
|
32
|
+
*/
|
|
33
|
+
import type { ReactElement } from 'react';
|
|
34
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
35
|
+
import {
|
|
36
|
+
Keyboard,
|
|
37
|
+
Modal,
|
|
38
|
+
Platform,
|
|
39
|
+
Pressable,
|
|
40
|
+
StyleSheet,
|
|
41
|
+
Text,
|
|
42
|
+
TextInput,
|
|
43
|
+
useWindowDimensions,
|
|
44
|
+
View,
|
|
45
|
+
} from 'react-native';
|
|
46
|
+
import { resolvePick } from './inspector';
|
|
47
|
+
import { buildAdditionalAnchors, type ChipPick, removeChip } from './multi-pick';
|
|
48
|
+
import { restorePills } from './restore';
|
|
49
|
+
import { StreamSheet } from './StreamSheet';
|
|
50
|
+
import { captureScreenshot } from './screenshot';
|
|
51
|
+
import { submitOutcome } from './submit-outcome';
|
|
52
|
+
import { fetchFeedbackList, openInEditor, platformTag, submitFeedback } from './transport';
|
|
53
|
+
import type { PickResult } from './types';
|
|
54
|
+
|
|
55
|
+
export interface PinagentProps {
|
|
56
|
+
/**
|
|
57
|
+
* Absolute project root, used to make `_debugSource` file paths
|
|
58
|
+
* project-relative (matching the web babel plugin's output). Defaults
|
|
59
|
+
* to the value Metro injects, falling back to '' (paths stay absolute,
|
|
60
|
+
* still usable).
|
|
61
|
+
*/
|
|
62
|
+
projectRoot?: string;
|
|
63
|
+
/** Route/screen name to record with the comment. Defaults to OS name. */
|
|
64
|
+
screenName?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type Phase = 'idle' | 'picking' | 'capturing' | 'composing' | 'sending';
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Resolve after the next painted frame. We flip to the `capturing` phase to
|
|
71
|
+
* tear down our own overlay (picking tint, hint, FAB), then wait for that
|
|
72
|
+
* render to reach the screen before `react-native-view-shot` snaps it —
|
|
73
|
+
* otherwise pinagent's UI lands in the screenshot. Double-rAF is RN's
|
|
74
|
+
* idiom for "after the next paint".
|
|
75
|
+
*/
|
|
76
|
+
function nextPaint(): Promise<void> {
|
|
77
|
+
return new Promise((resolve) => {
|
|
78
|
+
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Track the soft keyboard's height so the composer can sit directly above it.
|
|
84
|
+
* `KeyboardAvoidingView` is unreliable inside a `Modal` — the modal presents
|
|
85
|
+
* in its own window, so the view's measured origin is wrong and the computed
|
|
86
|
+
* inset never lifts the sheet. Driving the inset off the keyboard frame is the
|
|
87
|
+
* robust cross-platform path. iOS fires the `*Will*` events (in sync with the
|
|
88
|
+
* slide animation); Android only fires `*Did*`.
|
|
89
|
+
*/
|
|
90
|
+
function useKeyboardHeight(): number {
|
|
91
|
+
const [height, setHeight] = useState(0);
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
const showEvt = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
|
94
|
+
const hideEvt = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
|
95
|
+
const show = Keyboard.addListener(showEvt, (e) => setHeight(e.endCoordinates.height));
|
|
96
|
+
const hide = Keyboard.addListener(hideEvt, () => setHeight(0));
|
|
97
|
+
return () => {
|
|
98
|
+
show.remove();
|
|
99
|
+
hide.remove();
|
|
100
|
+
};
|
|
101
|
+
}, []);
|
|
102
|
+
return height;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Hard dev-only gate. `__DEV__` is `false` in release bundles, so the
|
|
107
|
+
* whole widget — and its require()s into RN internals — drops out. Kept
|
|
108
|
+
* as a thin wrapper so the hooks live in `PinagentDev`, called
|
|
109
|
+
* unconditionally (rules-of-hooks).
|
|
110
|
+
*/
|
|
111
|
+
export function Pinagent(props: PinagentProps): ReactElement | null {
|
|
112
|
+
if (!__DEV__) return null;
|
|
113
|
+
return <PinagentDev {...props} />;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function PinagentDev({ projectRoot = '', screenName }: PinagentProps): ReactElement {
|
|
117
|
+
const { width, height } = useWindowDimensions();
|
|
118
|
+
const keyboardHeight = useKeyboardHeight();
|
|
119
|
+
const rootRef = useRef<View>(null);
|
|
120
|
+
const [phase, setPhase] = useState<Phase>('idle');
|
|
121
|
+
const [pick, setPick] = useState<PickResult | null>(null);
|
|
122
|
+
// Which breadcrumb segment the comment is anchored to (index into
|
|
123
|
+
// `pick.chain`). Defaults to the innermost — the tapped component — and
|
|
124
|
+
// moves outward when the user presses an ancestor crumb to re-focus.
|
|
125
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
126
|
+
const [shot, setShot] = useState<string | null>(null);
|
|
127
|
+
// Extra elements multi-picked into the SAME comment via "+ Add element"
|
|
128
|
+
// (ticket 008). The primary stays in `pick`; these are the 2nd…Nth taps,
|
|
129
|
+
// rendered as removable chips and sent as `additionalAnchors`. The screenshot
|
|
130
|
+
// (`shot`) is captured once at the first pick — extras don't re-capture.
|
|
131
|
+
const [extraPicks, setExtraPicks] = useState<ChipPick[]>([]);
|
|
132
|
+
// Counter for stable chip keys (pick order is preserved on the wire).
|
|
133
|
+
const pickSeq = useRef(0);
|
|
134
|
+
// True while picking was entered from the composer's "+ Add element" (so the
|
|
135
|
+
// next tap APPENDS an extra instead of starting a fresh primary pick).
|
|
136
|
+
const addingExtra = useRef(false);
|
|
137
|
+
const [comment, setComment] = useState('');
|
|
138
|
+
const [toast, setToast] = useState<string | null>(null);
|
|
139
|
+
// Transient note under the file:line link (e.g. "No editor found").
|
|
140
|
+
const [openNote, setOpenNote] = useState<string | null>(null);
|
|
141
|
+
// Inline submit error, shown in the composer when a POST fails. The draft
|
|
142
|
+
// (comment/pick/shot) is retained so the user can fix the cause and Retry,
|
|
143
|
+
// instead of losing everything to a vanishing toast (ticket 002).
|
|
144
|
+
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
145
|
+
// Live agent runs. Each spawned agent gets a StreamSheet; one can be expanded
|
|
146
|
+
// (full sheet) while the rest sit as minimized pills that keep streaming in
|
|
147
|
+
// the background — so you can minimize a run, interact with the app, and
|
|
148
|
+
// spawn another. `expandedId` is the one showing its full sheet (null = all
|
|
149
|
+
// minimized).
|
|
150
|
+
const [streams, setStreams] = useState<{ id: string; target: string }[]>([]);
|
|
151
|
+
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
152
|
+
|
|
153
|
+
// The surface this widget is mounted on — the same value we record as the
|
|
154
|
+
// comment `url` (web sends the page URL). Used to scope restored pills to
|
|
155
|
+
// this screen, mirroring the web widget's per-page restore.
|
|
156
|
+
const surfaceUrl = screenName ?? Platform.OS;
|
|
157
|
+
|
|
158
|
+
const closeStream = useCallback((id: string) => {
|
|
159
|
+
setStreams((prev) => prev.filter((s) => s.id !== id));
|
|
160
|
+
setExpandedId((cur) => (cur === id ? null : cur));
|
|
161
|
+
}, []);
|
|
162
|
+
|
|
163
|
+
// Restore minimized pills after an app reload (Fast Refresh, shake-reload,
|
|
164
|
+
// restart). The dev server (.pinagent/db.sqlite) is the source of truth — RN
|
|
165
|
+
// keeps no device-local store — so on mount we fetch the conversation list,
|
|
166
|
+
// filter it to this surface's still-pending runs (newest 5), and seed
|
|
167
|
+
// `streams` as MINIMIZED pills. Each restored StreamSheet then subscribes
|
|
168
|
+
// over WS, which replays the transcript (and fires `done` for finished runs,
|
|
169
|
+
// landing the sheet in its normal done state). Skips silently when the dev
|
|
170
|
+
// server is unreachable (fetchFeedbackList returns []). Runs once on mount.
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
let cancelled = false;
|
|
173
|
+
void (async () => {
|
|
174
|
+
const items = await fetchFeedbackList();
|
|
175
|
+
if (cancelled) return;
|
|
176
|
+
const pills = restorePills(items, surfaceUrl);
|
|
177
|
+
if (pills.length === 0) return;
|
|
178
|
+
// Don't clobber any pill spawned between mount and this async resolve;
|
|
179
|
+
// de-dupe by id and keep everything minimized (expandedId stays null).
|
|
180
|
+
setStreams((prev) => {
|
|
181
|
+
const have = new Set(prev.map((s) => s.id));
|
|
182
|
+
const added = pills.filter((p) => !have.has(p.id));
|
|
183
|
+
return added.length ? [...prev, ...added] : prev;
|
|
184
|
+
});
|
|
185
|
+
})();
|
|
186
|
+
return () => {
|
|
187
|
+
cancelled = true;
|
|
188
|
+
};
|
|
189
|
+
// Restore once per surface; we deliberately don't re-run on every render.
|
|
190
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
191
|
+
}, [surfaceUrl]);
|
|
192
|
+
|
|
193
|
+
const onPickTap = useCallback(
|
|
194
|
+
async (x: number, y: number) => {
|
|
195
|
+
// Tear our own overlay down BEFORE hit-testing. The inspector's
|
|
196
|
+
// `findNodeAtPoint` is geometric and paint-order based (it ignores
|
|
197
|
+
// `pointerEvents`), so a full-screen picking layer painted on top is
|
|
198
|
+
// the view "under" the tap — every pick would resolve to wherever
|
|
199
|
+
// pinagent is mounted (the app root) instead of the real component.
|
|
200
|
+
// Dropping to `capturing` unmounts the Pressable AND collapses our
|
|
201
|
+
// root to zero size (see the root View's style), so a frame later the
|
|
202
|
+
// tap resolves to the component beneath us — and, as a bonus, the
|
|
203
|
+
// screenshot already excludes pinagent's UI (the web widget excludes
|
|
204
|
+
// its host node from the html-to-image render for the same reason).
|
|
205
|
+
setPhase('capturing');
|
|
206
|
+
await nextPaint();
|
|
207
|
+
// Pass our overlay's host instance (not a findNodeHandle tag): the
|
|
208
|
+
// inspector climbs from it to the app root to hit-test there.
|
|
209
|
+
const picked = await resolvePick(rootRef.current, x, y, projectRoot);
|
|
210
|
+
|
|
211
|
+
// "+ Add element" re-pick: APPEND an extra target to the same comment —
|
|
212
|
+
// no re-capture (one screenshot per feedback, web parity), no touching
|
|
213
|
+
// the primary pick or its breadcrumb. The extra keeps the loc it was
|
|
214
|
+
// tapped with; only the primary re-anchors via the breadcrumb.
|
|
215
|
+
if (addingExtra.current) {
|
|
216
|
+
addingExtra.current = false;
|
|
217
|
+
const label = picked.chain.at(-1)?.name ?? picked.nameChain.at(-1) ?? 'component';
|
|
218
|
+
const chip: ChipPick = {
|
|
219
|
+
key: `x${pickSeq.current++}`,
|
|
220
|
+
loc: picked.loc,
|
|
221
|
+
selector: picked.nameChain.join(' > '),
|
|
222
|
+
clickX: x,
|
|
223
|
+
clickY: y,
|
|
224
|
+
label,
|
|
225
|
+
};
|
|
226
|
+
setExtraPicks((prev) => [...prev, chip]);
|
|
227
|
+
setPhase('composing');
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Fresh primary pick → fresh comment: capture the screenshot, reset the
|
|
232
|
+
// extras and any stale submit error.
|
|
233
|
+
setShot(await captureScreenshot());
|
|
234
|
+
setPick(picked);
|
|
235
|
+
// Anchor to the innermost (tapped) component by default.
|
|
236
|
+
setSelectedIndex(Math.max(0, picked.chain.length - 1));
|
|
237
|
+
setExtraPicks([]);
|
|
238
|
+
setOpenNote(null);
|
|
239
|
+
setSubmitError(null);
|
|
240
|
+
setPhase('composing');
|
|
241
|
+
},
|
|
242
|
+
[projectRoot],
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
// "+ Add element": re-enter picking from the composer, keeping the current
|
|
246
|
+
// comment + primary pick. The composer Modal hides while picking (phase !==
|
|
247
|
+
// 'composing'), so the user can tap another element; `addingExtra` routes the
|
|
248
|
+
// resulting tap to append a chip rather than start over.
|
|
249
|
+
const onAddElement = useCallback(() => {
|
|
250
|
+
addingExtra.current = true;
|
|
251
|
+
setOpenNote(null);
|
|
252
|
+
setPhase('picking');
|
|
253
|
+
}, []);
|
|
254
|
+
|
|
255
|
+
const onRemoveExtra = useCallback((key: string) => {
|
|
256
|
+
setExtraPicks((prev) => removeChip(prev, key));
|
|
257
|
+
}, []);
|
|
258
|
+
|
|
259
|
+
// Dismiss the composer and drop the whole draft (comment + extras + error).
|
|
260
|
+
// Used by Cancel and the modal's hardware-back close.
|
|
261
|
+
const onDismissComposer = useCallback(() => {
|
|
262
|
+
setExtraPicks([]);
|
|
263
|
+
setSubmitError(null);
|
|
264
|
+
setPhase('idle');
|
|
265
|
+
}, []);
|
|
266
|
+
|
|
267
|
+
// The source location the comment is currently anchored to: the precise
|
|
268
|
+
// tapped element while the innermost crumb is selected, otherwise the
|
|
269
|
+
// chosen ancestor component's own location.
|
|
270
|
+
const activeLoc = useMemo(() => {
|
|
271
|
+
if (!pick) return null;
|
|
272
|
+
const last = pick.chain.length - 1;
|
|
273
|
+
if (selectedIndex >= 0 && selectedIndex < pick.chain.length) {
|
|
274
|
+
if (selectedIndex === last) return pick.loc ?? pick.chain[last]?.loc ?? null;
|
|
275
|
+
return pick.chain[selectedIndex]?.loc ?? null;
|
|
276
|
+
}
|
|
277
|
+
return pick.loc ?? null;
|
|
278
|
+
}, [pick, selectedIndex]);
|
|
279
|
+
|
|
280
|
+
// The highlight outline tracks the selected crumb: the precise tapped frame
|
|
281
|
+
// while the innermost crumb is selected, otherwise the chosen ancestor's
|
|
282
|
+
// measured frame. So pressing a breadcrumb visibly moves the selection box.
|
|
283
|
+
const activeFrame = useMemo(() => {
|
|
284
|
+
if (!pick) return null;
|
|
285
|
+
const last = pick.chain.length - 1;
|
|
286
|
+
if (selectedIndex >= 0 && selectedIndex < pick.chain.length) {
|
|
287
|
+
if (selectedIndex === last) return pick.frame ?? pick.chain[last]?.frame ?? null;
|
|
288
|
+
return pick.chain[selectedIndex]?.frame ?? pick.frame ?? null;
|
|
289
|
+
}
|
|
290
|
+
return pick.frame ?? null;
|
|
291
|
+
}, [pick, selectedIndex]);
|
|
292
|
+
|
|
293
|
+
// Label for the primary target chip: the anchored file:line if resolved, else
|
|
294
|
+
// the selected component name (mirrors the composer title).
|
|
295
|
+
const primaryChipLabel = useMemo(() => {
|
|
296
|
+
if (activeLoc) return `${activeLoc.file}:${activeLoc.line}`;
|
|
297
|
+
return pick?.chain[selectedIndex]?.name ?? pick?.nameChain.at(-1) ?? 'component';
|
|
298
|
+
}, [activeLoc, pick, selectedIndex]);
|
|
299
|
+
|
|
300
|
+
const crumbs = pick?.chain ?? [];
|
|
301
|
+
|
|
302
|
+
const onCrumbPress = useCallback((index: number) => {
|
|
303
|
+
setSelectedIndex(index);
|
|
304
|
+
setOpenNote(null);
|
|
305
|
+
}, []);
|
|
306
|
+
|
|
307
|
+
const onOpenInEditor = useCallback(async () => {
|
|
308
|
+
if (!activeLoc) return;
|
|
309
|
+
setOpenNote('Opening…');
|
|
310
|
+
const ok = await openInEditor(activeLoc);
|
|
311
|
+
setOpenNote(ok ? null : 'No editor found (set PINAGENT_EDITOR)');
|
|
312
|
+
}, [activeLoc]);
|
|
313
|
+
|
|
314
|
+
const onSubmit = useCallback(async () => {
|
|
315
|
+
if (!comment.trim()) return;
|
|
316
|
+
// Human-readable target for the stream header, captured before we clear
|
|
317
|
+
// the pick: the anchored file:line if resolved, else the component name.
|
|
318
|
+
const target = activeLoc
|
|
319
|
+
? `${activeLoc.file}:${activeLoc.line}`
|
|
320
|
+
: (pick?.chain[selectedIndex]?.name ?? pick?.nameChain.at(-1) ?? 'component');
|
|
321
|
+
setSubmitError(null);
|
|
322
|
+
setPhase('sending');
|
|
323
|
+
const result = await submitFeedback({
|
|
324
|
+
comment: comment.trim(),
|
|
325
|
+
// The breadcrumb-selected anchor (defaults to the tapped element).
|
|
326
|
+
loc: activeLoc,
|
|
327
|
+
// v1 "selector" = the component name breadcrumb (RN has no CSS
|
|
328
|
+
// selectors). Gives the agent a readable hint and satisfies the
|
|
329
|
+
// schema's required `selector` field.
|
|
330
|
+
selector: pick?.nameChain.join(' > ') ?? '',
|
|
331
|
+
url: surfaceUrl,
|
|
332
|
+
viewport: { w: Math.round(width), h: Math.round(height) },
|
|
333
|
+
userAgent: platformTag(),
|
|
334
|
+
screenshot: shot ?? '',
|
|
335
|
+
createdAt: new Date().toISOString(),
|
|
336
|
+
// Multi-picked extras (ticket 008). Omitted entirely for a single pick,
|
|
337
|
+
// so the server keeps `additional_anchors` null — web parity.
|
|
338
|
+
additionalAnchors: buildAdditionalAnchors(extraPicks),
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const outcome = submitOutcome(result);
|
|
342
|
+
|
|
343
|
+
// Failed POST (Metro restart, network blip, release build): KEEP the draft
|
|
344
|
+
// — comment, picked anchor, and screenshot — reopen the composer, and show
|
|
345
|
+
// the reason inline with a Retry. We never destroy composer state on a
|
|
346
|
+
// failed submit (ticket 002).
|
|
347
|
+
if (outcome.composer === 'keep') {
|
|
348
|
+
setSubmitError(outcome.error);
|
|
349
|
+
setPhase('composing');
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Success: clear the composer (including any multi-picked extras).
|
|
354
|
+
setComment('');
|
|
355
|
+
setPick(null);
|
|
356
|
+
setShot(null);
|
|
357
|
+
setExtraPicks([]);
|
|
358
|
+
setPhase('idle');
|
|
359
|
+
|
|
360
|
+
// Agent spawned → stream the run live and expand its sheet (any previously
|
|
361
|
+
// expanded run drops to a minimized pill). Otherwise (spawn off) fall back
|
|
362
|
+
// to a transient toast; pull mode (MCP) picks it up.
|
|
363
|
+
if (outcome.streamId) {
|
|
364
|
+
const id = outcome.streamId;
|
|
365
|
+
setStreams((prev) => (prev.some((s) => s.id === id) ? prev : [...prev, { id, target }]));
|
|
366
|
+
setExpandedId(id);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (outcome.toast) {
|
|
370
|
+
setToast(outcome.toast);
|
|
371
|
+
setTimeout(() => setToast(null), 2500);
|
|
372
|
+
}
|
|
373
|
+
}, [comment, pick, selectedIndex, activeLoc, shot, extraPicks, surfaceUrl, width, height]);
|
|
374
|
+
|
|
375
|
+
return (
|
|
376
|
+
// collapsable={false} keeps this View in the native tree so its ref
|
|
377
|
+
// resolves to a real host instance for the Inspector call. During
|
|
378
|
+
// `capturing` we shrink it to zero size so it's not the topmost view at
|
|
379
|
+
// the tap point while the inspector hit-tests the app beneath us (see
|
|
380
|
+
// onPickTap); it has no visible children in that phase anyway.
|
|
381
|
+
<View
|
|
382
|
+
ref={rootRef}
|
|
383
|
+
collapsable={false}
|
|
384
|
+
style={phase === 'capturing' ? styles.collapsed : StyleSheet.absoluteFill}
|
|
385
|
+
pointerEvents="box-none"
|
|
386
|
+
>
|
|
387
|
+
{/* Picking overlay: a transparent full-screen catcher. onPress gives
|
|
388
|
+
us the tap coords; we forward them to the Inspector. */}
|
|
389
|
+
{phase === 'picking' && (
|
|
390
|
+
<Pressable
|
|
391
|
+
style={[StyleSheet.absoluteFill, styles.pickLayer]}
|
|
392
|
+
onPress={(e) => {
|
|
393
|
+
const { pageX, pageY } = e.nativeEvent;
|
|
394
|
+
void onPickTap(pageX, pageY);
|
|
395
|
+
}}
|
|
396
|
+
>
|
|
397
|
+
<View style={styles.pickHint} pointerEvents="none">
|
|
398
|
+
<Text style={styles.pickHintText}>Tap a component to comment on it</Text>
|
|
399
|
+
</View>
|
|
400
|
+
</Pressable>
|
|
401
|
+
)}
|
|
402
|
+
|
|
403
|
+
{/* Highlight rect for the current selection, drawn while composing. The
|
|
404
|
+
RN analog of the web widget's outline; coords come from the Inspector
|
|
405
|
+
frame (window space). Follows the selected breadcrumb via
|
|
406
|
+
`activeFrame`. */}
|
|
407
|
+
{phase === 'composing' && activeFrame && (
|
|
408
|
+
<View
|
|
409
|
+
pointerEvents="none"
|
|
410
|
+
style={[
|
|
411
|
+
styles.highlight,
|
|
412
|
+
{
|
|
413
|
+
left: activeFrame.x,
|
|
414
|
+
top: activeFrame.y,
|
|
415
|
+
width: activeFrame.width,
|
|
416
|
+
height: activeFrame.height,
|
|
417
|
+
},
|
|
418
|
+
]}
|
|
419
|
+
/>
|
|
420
|
+
)}
|
|
421
|
+
|
|
422
|
+
{/* Composer. A Modal (not an iframe — RN has no host focus-trap to
|
|
423
|
+
escape, so a plain modal is enough). */}
|
|
424
|
+
<Modal
|
|
425
|
+
visible={phase === 'composing'}
|
|
426
|
+
transparent
|
|
427
|
+
animationType="slide"
|
|
428
|
+
onRequestClose={onDismissComposer}
|
|
429
|
+
>
|
|
430
|
+
{/* Pad the docked composer up by the live keyboard height so the
|
|
431
|
+
input and actions clear the soft keyboard (see useKeyboardHeight
|
|
432
|
+
for why KeyboardAvoidingView can't do this inside a Modal). */}
|
|
433
|
+
<View style={[styles.composerBackdrop, { paddingBottom: keyboardHeight }]}>
|
|
434
|
+
<View style={styles.composer}>
|
|
435
|
+
{/* Title: the anchored file:line if resolved (pressable → opens
|
|
436
|
+
in the editor on the Metro host, the RN analog of web's
|
|
437
|
+
"navigate to file"), else the selected component name. */}
|
|
438
|
+
{activeLoc ? (
|
|
439
|
+
<Pressable onPress={onOpenInEditor}>
|
|
440
|
+
<Text style={[styles.composerTitle, styles.composerTitleLink]}>
|
|
441
|
+
{`${activeLoc.file}:${activeLoc.line}`}
|
|
442
|
+
</Text>
|
|
443
|
+
</Pressable>
|
|
444
|
+
) : (
|
|
445
|
+
<Text style={styles.composerTitle}>
|
|
446
|
+
{pick?.chain[selectedIndex]?.name ?? pick?.nameChain.at(-1) ?? 'Unknown component'}
|
|
447
|
+
</Text>
|
|
448
|
+
)}
|
|
449
|
+
{openNote ? <Text style={styles.openNote}>{openNote}</Text> : null}
|
|
450
|
+
{/* Breadcrumb: each component is pressable and re-anchors the
|
|
451
|
+
comment onto that ancestor (the selected one is highlighted).
|
|
452
|
+
Mirrors the web composer's ancestor-select. */}
|
|
453
|
+
{crumbs.length ? (
|
|
454
|
+
<View style={styles.breadcrumbRow}>
|
|
455
|
+
{crumbs.map((crumb, i) => (
|
|
456
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: positional ancestry path, rebuilt wholesale per pick — never reordered
|
|
457
|
+
<View key={`${crumb.name}-${i}`} style={styles.breadcrumbItem}>
|
|
458
|
+
{i > 0 ? <Text style={styles.breadcrumbSep}>›</Text> : null}
|
|
459
|
+
<Pressable
|
|
460
|
+
onPress={() => onCrumbPress(i)}
|
|
461
|
+
hitSlop={6}
|
|
462
|
+
accessibilityRole="button"
|
|
463
|
+
>
|
|
464
|
+
<Text
|
|
465
|
+
style={[
|
|
466
|
+
styles.breadcrumb,
|
|
467
|
+
i === selectedIndex && styles.breadcrumbSelected,
|
|
468
|
+
]}
|
|
469
|
+
>
|
|
470
|
+
{crumb.name}
|
|
471
|
+
</Text>
|
|
472
|
+
</Pressable>
|
|
473
|
+
</View>
|
|
474
|
+
))}
|
|
475
|
+
</View>
|
|
476
|
+
) : pick?.nameChain.length ? (
|
|
477
|
+
<Text style={styles.breadcrumb} numberOfLines={1}>
|
|
478
|
+
{pick.nameChain.join(' › ')}
|
|
479
|
+
</Text>
|
|
480
|
+
) : null}
|
|
481
|
+
{/* Target chips + "+ Add element" (ticket 008). The primary chip
|
|
482
|
+
(non-removable) reflects the breadcrumb-selected anchor; each
|
|
483
|
+
extra is a removable chip. Tapping "+ Add element" hides the
|
|
484
|
+
composer, re-enters picking, and appends the next tap as an
|
|
485
|
+
extra carried in `additionalAnchors`. */}
|
|
486
|
+
<View style={styles.chipRow}>
|
|
487
|
+
<View style={[styles.chip, styles.chipPrimary]}>
|
|
488
|
+
<Text style={styles.chipPrimaryText} numberOfLines={1}>
|
|
489
|
+
{primaryChipLabel}
|
|
490
|
+
</Text>
|
|
491
|
+
</View>
|
|
492
|
+
{extraPicks.map((ex) => (
|
|
493
|
+
<View key={ex.key} style={styles.chip}>
|
|
494
|
+
<Text style={styles.chipText} numberOfLines={1}>
|
|
495
|
+
{ex.label}
|
|
496
|
+
</Text>
|
|
497
|
+
<Pressable
|
|
498
|
+
onPress={() => onRemoveExtra(ex.key)}
|
|
499
|
+
hitSlop={8}
|
|
500
|
+
accessibilityRole="button"
|
|
501
|
+
accessibilityLabel={`Remove ${ex.label}`}
|
|
502
|
+
>
|
|
503
|
+
<Text style={styles.chipRemove}>×</Text>
|
|
504
|
+
</Pressable>
|
|
505
|
+
</View>
|
|
506
|
+
))}
|
|
507
|
+
<Pressable onPress={onAddElement} style={styles.addChip} accessibilityRole="button">
|
|
508
|
+
<Text style={styles.addChipText}>+ Add element</Text>
|
|
509
|
+
</Pressable>
|
|
510
|
+
</View>
|
|
511
|
+
<TextInput
|
|
512
|
+
autoFocus
|
|
513
|
+
multiline
|
|
514
|
+
value={comment}
|
|
515
|
+
onChangeText={setComment}
|
|
516
|
+
placeholder="What should change here?"
|
|
517
|
+
placeholderTextColor="#9aa0a6"
|
|
518
|
+
style={styles.input}
|
|
519
|
+
/>
|
|
520
|
+
{/* Inline submit error. The draft (comment/pick/shot) is retained
|
|
521
|
+
under it, so the primary button becomes Retry — no re-pick, no
|
|
522
|
+
re-capture, no lost typing (ticket 002). */}
|
|
523
|
+
{submitError ? <Text style={styles.submitError}>{submitError}</Text> : null}
|
|
524
|
+
<View style={styles.composerActions}>
|
|
525
|
+
<Pressable onPress={onDismissComposer} style={styles.btnGhost}>
|
|
526
|
+
<Text style={styles.btnGhostText}>Cancel</Text>
|
|
527
|
+
</Pressable>
|
|
528
|
+
<Pressable
|
|
529
|
+
onPress={onSubmit}
|
|
530
|
+
disabled={!comment.trim()}
|
|
531
|
+
style={[styles.btnPrimary, !comment.trim() && styles.btnDisabled]}
|
|
532
|
+
>
|
|
533
|
+
<Text style={styles.btnPrimaryText}>{submitError ? 'Retry' : 'Send'}</Text>
|
|
534
|
+
</Pressable>
|
|
535
|
+
</View>
|
|
536
|
+
</View>
|
|
537
|
+
</View>
|
|
538
|
+
</Modal>
|
|
539
|
+
|
|
540
|
+
{/* Floating action button. Toggles picking; shows status while
|
|
541
|
+
sending. Hidden during `capturing` so it stays out of the
|
|
542
|
+
screenshot. */}
|
|
543
|
+
{phase !== 'capturing' && (
|
|
544
|
+
<Pressable
|
|
545
|
+
onPress={() =>
|
|
546
|
+
setPhase((p) => {
|
|
547
|
+
// Cancelling a pick also drops a pending "+ Add element" intent.
|
|
548
|
+
if (p === 'picking') addingExtra.current = false;
|
|
549
|
+
return p === 'picking' ? 'idle' : 'picking';
|
|
550
|
+
})
|
|
551
|
+
}
|
|
552
|
+
style={[styles.fab, phase === 'picking' && styles.fabActive]}
|
|
553
|
+
>
|
|
554
|
+
<Text style={styles.fabText}>{phase === 'sending' ? '…' : '💬'}</Text>
|
|
555
|
+
</Pressable>
|
|
556
|
+
)}
|
|
557
|
+
|
|
558
|
+
{toast && (
|
|
559
|
+
<View pointerEvents="none" style={styles.toast}>
|
|
560
|
+
<Text style={styles.toastText}>{toast}</Text>
|
|
561
|
+
</View>
|
|
562
|
+
)}
|
|
563
|
+
|
|
564
|
+
{/* Live agent transcripts — one per spawned run. The expanded one shows
|
|
565
|
+
its full sheet; the rest render as minimized pills (stacked bottom-
|
|
566
|
+
left) that keep streaming. Each stays mounted across minimize/expand
|
|
567
|
+
so its WebSocket — and live transcript — survive. */}
|
|
568
|
+
{streams.map((s, i) => {
|
|
569
|
+
const minimized = s.id !== expandedId;
|
|
570
|
+
// Stack index among the minimized pills only, so they don't overlap.
|
|
571
|
+
const stackIndex = streams
|
|
572
|
+
.filter((o) => o.id !== expandedId)
|
|
573
|
+
.findIndex((o) => o.id === s.id);
|
|
574
|
+
return (
|
|
575
|
+
<StreamSheet
|
|
576
|
+
key={s.id}
|
|
577
|
+
feedbackId={s.id}
|
|
578
|
+
target={s.target}
|
|
579
|
+
minimized={minimized}
|
|
580
|
+
stackIndex={stackIndex < 0 ? i : stackIndex}
|
|
581
|
+
onMinimize={() => setExpandedId(null)}
|
|
582
|
+
onExpand={() => setExpandedId(s.id)}
|
|
583
|
+
onClose={() => closeStream(s.id)}
|
|
584
|
+
/>
|
|
585
|
+
);
|
|
586
|
+
})}
|
|
587
|
+
</View>
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const styles = StyleSheet.create({
|
|
592
|
+
// Zero-footprint root for the `capturing` phase — keeps the View (and its
|
|
593
|
+
// ref) mounted while removing it as a hit-test target over the app.
|
|
594
|
+
collapsed: { position: 'absolute', width: 0, height: 0 },
|
|
595
|
+
pickLayer: { backgroundColor: 'rgba(59,130,246,0.08)' },
|
|
596
|
+
pickHint: {
|
|
597
|
+
position: 'absolute',
|
|
598
|
+
top: 60,
|
|
599
|
+
alignSelf: 'center',
|
|
600
|
+
backgroundColor: 'rgba(17,24,39,0.9)',
|
|
601
|
+
paddingHorizontal: 14,
|
|
602
|
+
paddingVertical: 8,
|
|
603
|
+
borderRadius: 999,
|
|
604
|
+
},
|
|
605
|
+
pickHintText: { color: '#fff', fontSize: 13 },
|
|
606
|
+
highlight: {
|
|
607
|
+
position: 'absolute',
|
|
608
|
+
borderWidth: 2,
|
|
609
|
+
borderColor: '#3b82f6',
|
|
610
|
+
backgroundColor: 'rgba(59,130,246,0.15)',
|
|
611
|
+
borderRadius: 4,
|
|
612
|
+
},
|
|
613
|
+
composerBackdrop: { flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(0,0,0,0.35)' },
|
|
614
|
+
composer: {
|
|
615
|
+
backgroundColor: '#fff',
|
|
616
|
+
padding: 16,
|
|
617
|
+
borderTopLeftRadius: 16,
|
|
618
|
+
borderTopRightRadius: 16,
|
|
619
|
+
gap: 8,
|
|
620
|
+
},
|
|
621
|
+
composerTitle: { fontSize: 14, fontWeight: '600', color: '#111827' },
|
|
622
|
+
composerTitleLink: { color: '#2563eb', textDecorationLine: 'underline' },
|
|
623
|
+
openNote: { fontSize: 11, color: '#9aa0a6', marginTop: 2 },
|
|
624
|
+
submitError: { fontSize: 12, color: '#dc2626', marginTop: 2 },
|
|
625
|
+
breadcrumbRow: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', marginTop: 2 },
|
|
626
|
+
breadcrumbItem: { flexDirection: 'row', alignItems: 'center' },
|
|
627
|
+
breadcrumbSep: { fontSize: 12, color: '#c4c7cc', paddingHorizontal: 4 },
|
|
628
|
+
breadcrumb: { fontSize: 12, color: '#6b7280' },
|
|
629
|
+
breadcrumbSelected: { color: '#2563eb', fontWeight: '600' },
|
|
630
|
+
chipRow: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: 6, marginTop: 4 },
|
|
631
|
+
chip: {
|
|
632
|
+
flexDirection: 'row',
|
|
633
|
+
alignItems: 'center',
|
|
634
|
+
gap: 4,
|
|
635
|
+
backgroundColor: '#f3f4f6',
|
|
636
|
+
borderRadius: 999,
|
|
637
|
+
paddingHorizontal: 10,
|
|
638
|
+
paddingVertical: 4,
|
|
639
|
+
maxWidth: '100%',
|
|
640
|
+
},
|
|
641
|
+
chipPrimary: { backgroundColor: '#dbeafe' },
|
|
642
|
+
chipText: { fontSize: 12, color: '#374151', flexShrink: 1 },
|
|
643
|
+
chipPrimaryText: { fontSize: 12, color: '#1d4ed8', fontWeight: '600', flexShrink: 1 },
|
|
644
|
+
chipRemove: { fontSize: 15, color: '#6b7280', lineHeight: 15 },
|
|
645
|
+
addChip: {
|
|
646
|
+
borderRadius: 999,
|
|
647
|
+
borderWidth: 1,
|
|
648
|
+
borderColor: '#c7d2fe',
|
|
649
|
+
borderStyle: 'dashed',
|
|
650
|
+
paddingHorizontal: 10,
|
|
651
|
+
paddingVertical: 4,
|
|
652
|
+
},
|
|
653
|
+
addChipText: { fontSize: 12, color: '#2563eb', fontWeight: '600' },
|
|
654
|
+
input: {
|
|
655
|
+
minHeight: 80,
|
|
656
|
+
borderWidth: 1,
|
|
657
|
+
borderColor: '#e5e7eb',
|
|
658
|
+
borderRadius: 8,
|
|
659
|
+
padding: 10,
|
|
660
|
+
fontSize: 15,
|
|
661
|
+
color: '#111827',
|
|
662
|
+
textAlignVertical: 'top',
|
|
663
|
+
},
|
|
664
|
+
composerActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: 8 },
|
|
665
|
+
btnGhost: { paddingHorizontal: 16, paddingVertical: 10 },
|
|
666
|
+
btnGhostText: { color: '#6b7280', fontWeight: '600' },
|
|
667
|
+
btnPrimary: {
|
|
668
|
+
backgroundColor: '#3b82f6',
|
|
669
|
+
paddingHorizontal: 16,
|
|
670
|
+
paddingVertical: 10,
|
|
671
|
+
borderRadius: 8,
|
|
672
|
+
},
|
|
673
|
+
btnDisabled: { opacity: 0.4 },
|
|
674
|
+
btnPrimaryText: { color: '#fff', fontWeight: '600' },
|
|
675
|
+
fab: {
|
|
676
|
+
position: 'absolute',
|
|
677
|
+
right: 20,
|
|
678
|
+
bottom: 40,
|
|
679
|
+
width: 52,
|
|
680
|
+
height: 52,
|
|
681
|
+
borderRadius: 26,
|
|
682
|
+
backgroundColor: '#111827',
|
|
683
|
+
alignItems: 'center',
|
|
684
|
+
justifyContent: 'center',
|
|
685
|
+
shadowColor: '#000',
|
|
686
|
+
shadowOpacity: 0.25,
|
|
687
|
+
shadowRadius: 6,
|
|
688
|
+
shadowOffset: { width: 0, height: 2 },
|
|
689
|
+
elevation: 5,
|
|
690
|
+
},
|
|
691
|
+
fabActive: { backgroundColor: '#3b82f6' },
|
|
692
|
+
fabText: { fontSize: 22 },
|
|
693
|
+
toast: {
|
|
694
|
+
position: 'absolute',
|
|
695
|
+
bottom: 110,
|
|
696
|
+
alignSelf: 'center',
|
|
697
|
+
backgroundColor: 'rgba(17,24,39,0.95)',
|
|
698
|
+
paddingHorizontal: 16,
|
|
699
|
+
paddingVertical: 10,
|
|
700
|
+
borderRadius: 999,
|
|
701
|
+
},
|
|
702
|
+
toastText: { color: '#fff', fontSize: 13 },
|
|
703
|
+
});
|