@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,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
+ });