@pinagent/react-native 0.1.2 → 0.2.1

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.
@@ -16,7 +16,7 @@
16
16
  * analog. Renders `null` in production so it has zero cost in release
17
17
  * builds.
18
18
  *
19
- * Flow: tap the 💬 FAB to arm picking → tap a view → we resolve its source
19
+ * Flow: tap the pin FAB to arm picking → tap a view → we resolve its source
20
20
  * via the RN Inspector, hide our own overlay, and capture a screenshot →
21
21
  * type a comment → submit POSTs to the Metro middleware, which stores it
22
22
  * and (optionally) spawns an agent. When an agent is spawned, a live
@@ -33,8 +33,10 @@
33
33
  import type { ReactElement } from 'react';
34
34
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
35
35
  import {
36
+ Animated,
36
37
  Keyboard,
37
38
  Modal,
39
+ PanResponder,
38
40
  Platform,
39
41
  Pressable,
40
42
  StyleSheet,
@@ -43,8 +45,10 @@ import {
43
45
  useWindowDimensions,
44
46
  View,
45
47
  } from 'react-native';
48
+ import { BRAND_CREAM, BRAND_GOLD, BRAND_INK } from './brand';
46
49
  import { resolvePick } from './inspector';
47
50
  import { buildAdditionalAnchors, type ChipPick, removeChip } from './multi-pick';
51
+ import { PinIcon } from './pin-icon';
48
52
  import { restorePills } from './restore';
49
53
  import { StreamSheet } from './StreamSheet';
50
54
  import { captureScreenshot } from './screenshot';
@@ -102,6 +106,109 @@ function useKeyboardHeight(): number {
102
106
  return height;
103
107
  }
104
108
 
109
+ const FAB_SIZE = 52;
110
+ // Resting insets matching the old fixed layout (right/bottom), plus a uniform
111
+ // edge margin used to keep the button on-screen once it's free to roam.
112
+ const FAB_MARGIN = 20;
113
+ const FAB_BOTTOM = 40;
114
+ // Movement (px) under which a press counts as a tap, not a drag.
115
+ const FAB_TAP_SLOP = 6;
116
+
117
+ interface DraggableFab {
118
+ panHandlers: ReturnType<typeof PanResponder.create>['panHandlers'];
119
+ transform: ReturnType<Animated.ValueXY['getTranslateTransform']>;
120
+ }
121
+
122
+ /**
123
+ * Make the FAB draggable anywhere on screen.
124
+ *
125
+ * The button defaults to the bottom-right (matching the old fixed `right: 20,
126
+ * bottom: 40` layout) but can be dragged to any edge — handy when it sits over
127
+ * the very control the developer wants to comment on. A single PanResponder
128
+ * owns BOTH gestures: a stationary press (total movement under `FAB_TAP_SLOP`)
129
+ * fires `onTap` to arm picking, while any real movement relocates the button.
130
+ * Position lives in an `Animated.ValueXY` of the button's top-left in window
131
+ * coords; we keep a plain-object mirror (`committed`) because PanResponder
132
+ * callbacks can't read an Animated.Value synchronously.
133
+ *
134
+ * Position is session-local: RN keeps no device store (the dev-server DB is the
135
+ * source of truth and holds no ephemeral UI state), so it resets to the default
136
+ * corner on reload — same as the rest of the widget's transient UI.
137
+ */
138
+ function useDraggableFab(width: number, height: number, onTap: () => void): DraggableFab {
139
+ // Clamp a top-left position so the whole button stays on-screen.
140
+ const clamp = useCallback(
141
+ (x: number, y: number) => {
142
+ const maxX = Math.max(FAB_MARGIN, width - FAB_SIZE - FAB_MARGIN);
143
+ const maxY = Math.max(FAB_MARGIN, height - FAB_SIZE - FAB_MARGIN);
144
+ return {
145
+ x: Math.min(Math.max(FAB_MARGIN, x), maxX),
146
+ y: Math.min(Math.max(FAB_MARGIN, y), maxY),
147
+ };
148
+ },
149
+ [width, height],
150
+ );
151
+
152
+ // Default resting spot: bottom-right corner.
153
+ const home = useMemo(
154
+ () => clamp(width - FAB_SIZE - FAB_MARGIN, height - FAB_SIZE - FAB_BOTTOM),
155
+ [clamp, width, height],
156
+ );
157
+
158
+ const pos = useRef(new Animated.ValueXY(home)).current;
159
+ const committed = useRef(home);
160
+
161
+ // Keep the button on-screen across rotations / window-size changes.
162
+ useEffect(() => {
163
+ const next = clamp(committed.current.x, committed.current.y);
164
+ if (next.x !== committed.current.x || next.y !== committed.current.y) {
165
+ committed.current = next;
166
+ pos.setValue(next);
167
+ }
168
+ }, [clamp, pos]);
169
+
170
+ const responder = useMemo(
171
+ () =>
172
+ PanResponder.create({
173
+ // Claim the touch up front so a plain tap still reaches `onTap`; we
174
+ // discriminate tap vs drag by distance on release.
175
+ onStartShouldSetPanResponder: () => true,
176
+ onPanResponderGrant: () => {
177
+ // Drag relative to where the button currently rests.
178
+ pos.setOffset(committed.current);
179
+ pos.setValue({ x: 0, y: 0 });
180
+ },
181
+ onPanResponderMove: Animated.event([null, { dx: pos.x, dy: pos.y }], {
182
+ useNativeDriver: false,
183
+ }),
184
+ onPanResponderRelease: (_e, g) => {
185
+ pos.flattenOffset();
186
+ if (Math.abs(g.dx) <= FAB_TAP_SLOP && Math.abs(g.dy) <= FAB_TAP_SLOP) {
187
+ // No real movement → it was a tap; undo any sub-pixel drift.
188
+ pos.setValue(committed.current);
189
+ onTap();
190
+ return;
191
+ }
192
+ // Commit the dragged spot, clamped on-screen, with a small settle.
193
+ const next = clamp(committed.current.x + g.dx, committed.current.y + g.dy);
194
+ committed.current = next;
195
+ Animated.spring(pos, { toValue: next, useNativeDriver: false, bounciness: 0 }).start();
196
+ },
197
+ onPanResponderTerminate: (_e, g) => {
198
+ // Lost the responder mid-gesture (e.g. to a parent scroll view):
199
+ // keep wherever the drag had reached rather than snapping away.
200
+ pos.flattenOffset();
201
+ const next = clamp(committed.current.x + g.dx, committed.current.y + g.dy);
202
+ committed.current = next;
203
+ pos.setValue(next);
204
+ },
205
+ }),
206
+ [pos, clamp, onTap],
207
+ );
208
+
209
+ return { panHandlers: responder.panHandlers, transform: pos.getTranslateTransform() };
210
+ }
211
+
105
212
  /**
106
213
  * Hard dev-only gate. `__DEV__` is `false` in release bundles, so the
107
214
  * whole widget — and its require()s into RN internals — drops out. Kept
@@ -160,6 +267,18 @@ function PinagentDev({ projectRoot = '', screenName }: PinagentProps): ReactElem
160
267
  setExpandedId((cur) => (cur === id ? null : cur));
161
268
  }, []);
162
269
 
270
+ // Tapping the FAB toggles picking; cancelling a pick also drops a pending
271
+ // "+ Add element" intent. Extracted so the draggable-FAB gesture can fire it
272
+ // on a stationary press (a drag relocates the button instead — see below).
273
+ const toggleFab = useCallback(() => {
274
+ setPhase((p) => {
275
+ if (p === 'picking') addingExtra.current = false;
276
+ return p === 'picking' ? 'idle' : 'picking';
277
+ });
278
+ }, []);
279
+
280
+ const fab = useDraggableFab(width, height, toggleFab);
281
+
163
282
  // Restore minimized pills after an app reload (Fast Refresh, shake-reload,
164
283
  // restart). The dev server (.pinagent/db.sqlite) is the source of truth — RN
165
284
  // keeps no device-local store — so on mount we fetch the conversation list,
@@ -266,13 +385,15 @@ function PinagentDev({ projectRoot = '', screenName }: PinagentProps): ReactElem
266
385
 
267
386
  // The source location the comment is currently anchored to: the precise
268
387
  // tapped element while the innermost crumb is selected, otherwise the
269
- // chosen ancestor component's own location.
388
+ // chosen ancestor component's own (nearest-source-resolved) location. Each
389
+ // ancestor falls back to the precise tapped loc so an untaggable crumb still
390
+ // shows a real path rather than degrading to a bare component name.
270
391
  const activeLoc = useMemo(() => {
271
392
  if (!pick) return null;
272
393
  const last = pick.chain.length - 1;
273
394
  if (selectedIndex >= 0 && selectedIndex < pick.chain.length) {
274
395
  if (selectedIndex === last) return pick.loc ?? pick.chain[last]?.loc ?? null;
275
- return pick.chain[selectedIndex]?.loc ?? null;
396
+ return pick.chain[selectedIndex]?.loc ?? pick.loc ?? null;
276
397
  }
277
398
  return pick.loc ?? null;
278
399
  }, [pick, selectedIndex]);
@@ -537,22 +658,27 @@ function PinagentDev({ projectRoot = '', screenName }: PinagentProps): ReactElem
537
658
  </View>
538
659
  </Modal>
539
660
 
540
- {/* Floating action button. Toggles picking; shows status while
541
- sending. Hidden during `capturing` so it stays out of the
542
- screenshot. */}
661
+ {/* Floating action button. Drag it anywhere; tap to toggle picking.
662
+ Shows status while sending. Hidden during `capturing` so it stays
663
+ out of the screenshot. Positioned via an animated translate so the
664
+ PanResponder can move it (see useDraggableFab). */}
543
665
  {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]}
666
+ <Animated.View
667
+ {...fab.panHandlers}
668
+ accessibilityRole="button"
669
+ accessibilityLabel="Pinagent tap to comment, drag to move"
670
+ style={[
671
+ styles.fab,
672
+ phase === 'picking' && styles.fabActive,
673
+ { transform: fab.transform },
674
+ ]}
553
675
  >
554
- <Text style={styles.fabText}>{phase === 'sending' ? '…' : '💬'}</Text>
555
- </Pressable>
676
+ {phase === 'sending' ? (
677
+ <Text style={styles.fabText}>…</Text>
678
+ ) : (
679
+ <PinIcon size={26} color={BRAND_CREAM} />
680
+ )}
681
+ </Animated.View>
556
682
  )}
557
683
 
558
684
  {toast && (
@@ -673,13 +799,20 @@ const styles = StyleSheet.create({
673
799
  btnDisabled: { opacity: 0.4 },
674
800
  btnPrimaryText: { color: '#fff', fontWeight: '600' },
675
801
  fab: {
802
+ // Anchored top-left; the live position is applied via an animated
803
+ // translate so the FAB can be dragged (see useDraggableFab). FAB_SIZE
804
+ // must stay in sync with width/height below.
676
805
  position: 'absolute',
677
- right: 20,
678
- bottom: 40,
806
+ left: 0,
807
+ top: 0,
679
808
  width: 52,
680
809
  height: 52,
681
810
  borderRadius: 26,
682
- backgroundColor: '#111827',
811
+ backgroundColor: BRAND_INK,
812
+ // Constant-width rim so toggling the active gold ring never shifts layout;
813
+ // cream @ 14% is the same subtle idle rim the web widget FAB uses.
814
+ borderWidth: 2,
815
+ borderColor: 'rgba(252, 249, 232, 0.14)',
683
816
  alignItems: 'center',
684
817
  justifyContent: 'center',
685
818
  shadowColor: '#000',
@@ -688,8 +821,9 @@ const styles = StyleSheet.create({
688
821
  shadowOffset: { width: 0, height: 2 },
689
822
  elevation: 5,
690
823
  },
691
- fabActive: { backgroundColor: '#3b82f6' },
692
- fabText: { fontSize: 22 },
824
+ // Gold ring while picking (web widget parity) — replaces the old blue fill.
825
+ fabActive: { borderColor: BRAND_GOLD },
826
+ fabText: { fontSize: 22, color: BRAND_CREAM },
693
827
  toast: {
694
828
  position: 'absolute',
695
829
  bottom: 110,
@@ -0,0 +1,15 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Pinagent brand palette for the React Native widget.
4
+ *
5
+ * Mirrors BRAND_INK / BRAND_CREAM / BRAND_GOLD in `packages/ui/src/tokens.ts`. That
6
+ * package is web-only (React-DOM + Tailwind) so the native widget can't import
7
+ * it — keep these in sync if the brand palette changes there.
8
+ */
9
+
10
+ /** Dark charcoal — the FAB surface / ink-mode surfaces and primary text. */
11
+ export const BRAND_INK = '#201B21';
12
+ /** Warm cream — the pin mark on dark surfaces, light backgrounds, brand identity. */
13
+ export const BRAND_CREAM = '#FCF9E8';
14
+ /** Gold accent — focus rings; the active picking state. */
15
+ export const BRAND_GOLD = '#FFD700';
@@ -282,19 +282,63 @@ interface RawCrumb {
282
282
  measure?: (cb: RawMeasureCb) => void;
283
283
  }
284
284
 
285
+ /**
286
+ * The nearest resolvable source to hierarchy index `i`, searching descendants
287
+ * first (deeper into the component, toward the tapped leaf) then ancestors
288
+ * (outward toward the root). Returns the first non-null `loc`, or null.
289
+ *
290
+ * Why descendants first: a crumb's own props come from RN's `getInspectorData`,
291
+ * which surfaces the first HOST fiber *inside* that component's render
292
+ * (`getHostProps`/`findCurrentHostFiber`). When that host carries no
293
+ * `data-pa-loc` (e.g. it's an untagged 3rd-party view), the most relevant real
294
+ * source is still the next tagged host *within* this component's subtree — a
295
+ * descendant — before we fall back to an enclosing ancestor.
296
+ */
297
+ function nearestLoc(locs: (Loc | null)[], i: number): Loc | null {
298
+ for (let j = i + 1; j < locs.length; j++) {
299
+ if (locs[j]) return locs[j];
300
+ }
301
+ for (let j = i - 1; j >= 0; j--) {
302
+ if (locs[j]) return locs[j];
303
+ }
304
+ return null;
305
+ }
306
+
285
307
  /**
286
308
  * Build the per-segment breadcrumb: each authored component in the hierarchy
287
309
  * paired with its own `data-pa-loc` (so a press can re-anchor onto that
288
310
  * ancestor) and a `measure` fn (so the highlight can follow the selection).
289
311
  * Same order as {@link nameChainOf} — root first, tapped last.
312
+ *
313
+ * Each crumb's `loc` falls back to the nearest resolvable source in the
314
+ * hierarchy when its own host props carry none, so pressing ANY breadcrumb
315
+ * re-anchors the comment onto a real `file:line` — not just the tapped
316
+ * (innermost) one. Without the fallback, only the tapped element (which
317
+ * `pickLoc` resolves with its own hierarchy walk) got a path; ancestor crumbs
318
+ * whose first host child is untagged collapsed to `loc: null`, so re-focusing
319
+ * onto them showed a bare component name instead of a source snippet.
320
+ *
321
+ * Exported for unit testing — a pure function over the (duck-typed) inspector
322
+ * payload, so it needs no RN runtime (unlike `resolvePick`).
290
323
  */
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
- });
324
+ export function crumbsOf(data: RawInspectorData): RawCrumb[] {
325
+ // Resolve each hierarchy item's inspector data ONCE. We compute locs over the
326
+ // FULL hierarchy (not just the authored crumbs) so the nearest-source
327
+ // fallback can borrow a location from an untagged host sitting between two
328
+ // authored components.
329
+ const items = (data.hierarchy ?? []).map((h) => {
330
+ const inspector = h.getInspectorData?.(() => null);
331
+ return { name: h.name, loc: paLocOf(inspector?.props), measure: inspector?.measure };
332
+ });
333
+ const locs = items.map((it) => it.loc);
334
+ return items
335
+ .map((it, i) => ({ it, i }))
336
+ .filter(({ it }) => isAuthoredComponentName(it.name))
337
+ .map(({ it, i }) => ({
338
+ name: it.name as string,
339
+ loc: it.loc ?? nearestLoc(locs, i),
340
+ measure: it.measure,
341
+ }));
298
342
  }
299
343
 
300
344
  /**
@@ -0,0 +1,86 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * PinIcon — pinagent's teardrop pin mark, for the React Native FAB.
4
+ *
5
+ * The mark is the canonical PIN_PATH / BRAND_VIEWBOX from `packages/ui/src/tokens.ts`
6
+ * (web-only, hence mirrored here). We draw it with `react-native-svg` when it's
7
+ * available — an OPTIONAL peer, lazily required exactly like
8
+ * `react-native-view-shot` in screenshot.ts, so a release build never pulls the
9
+ * native module in: the require only runs once <Pinagent/> actually renders,
10
+ * which it never does when `!__DEV__`.
11
+ *
12
+ * When the peer isn't installed we fall back to a View-drawn teardrop in the
13
+ * same colour, so the FAB always shows a brand pin — never a generic glyph.
14
+ */
15
+ import type { ReactElement } from 'react';
16
+ import { View } from 'react-native';
17
+
18
+ // Canonical pinagent pin — mirror of PIN_PATH / BRAND_VIEWBOX in
19
+ // `packages/ui/src/tokens.ts`. Keep in sync if the mark changes there.
20
+ const PIN_PATH =
21
+ 'M38.0761 27C24.2046 27 16.7486 43.8193 26.2852 53.7027L26.4587 53.8761L47.2659 74.6834L68.0732 53.8761L68.2466 53.7027C77.9567 43.8193 70.3273 27 56.4558 27L38.0761 27Z';
22
+ const VIEWBOX = '0 0 93 93';
23
+
24
+ // The two react-native-svg components we use, or `null` when the peer isn't
25
+ // installed. Typed loosely (the peer's types aren't a dep) — native/ isn't
26
+ // tsc-typechecked, and these are only ever rendered as JSX elements.
27
+ type SvgModule = { Svg: unknown; Path: unknown } | null;
28
+
29
+ // Resolved once, on first render (dev only — see header). `undefined` = not yet
30
+ // resolved, `null` = peer not installed.
31
+ let svgModule: SvgModule | undefined;
32
+ function resolveSvg(): SvgModule {
33
+ if (svgModule !== undefined) return svgModule;
34
+ try {
35
+ // Lazy require so a release build never pulls the native module in.
36
+ // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
37
+ const mod = require('react-native-svg');
38
+ svgModule = { Svg: mod.Svg ?? mod.default, Path: mod.Path };
39
+ } catch {
40
+ svgModule = null;
41
+ }
42
+ return svgModule;
43
+ }
44
+
45
+ export interface PinIconProps {
46
+ /** Square edge length, in px. */
47
+ size?: number;
48
+ /** Fill colour of the pin (pass a brand token). */
49
+ color: string;
50
+ }
51
+
52
+ export function PinIcon({ size = 26, color }: PinIconProps): ReactElement {
53
+ const svg = resolveSvg();
54
+ if (svg?.Svg && svg.Path) {
55
+ const { Svg, Path } = svg;
56
+ return (
57
+ <Svg width={size} height={size} viewBox={VIEWBOX}>
58
+ <Path d={PIN_PATH} fill={color} />
59
+ </Svg>
60
+ );
61
+ }
62
+ return <FallbackPin size={size} color={color} />;
63
+ }
64
+
65
+ /**
66
+ * react-native-svg-free fallback: a map-pin teardrop drawn from a single View —
67
+ * a rounded square with one squared corner, rotated so the point faces down.
68
+ * The classic CSS `border-radius: 50% 50% 50% 0` recipe, in the brand colour.
69
+ */
70
+ function FallbackPin({ size, color }: { size: number; color: string }): ReactElement {
71
+ const d = Math.round(size * 0.8);
72
+ return (
73
+ <View
74
+ style={{
75
+ width: d,
76
+ height: d,
77
+ backgroundColor: color,
78
+ borderTopLeftRadius: d / 2,
79
+ borderTopRightRadius: d / 2,
80
+ borderBottomRightRadius: d / 2,
81
+ borderBottomLeftRadius: 0,
82
+ transform: [{ rotate: '-45deg' }],
83
+ }}
84
+ />
85
+ );
86
+ }