@pinagent/react-native 0.1.4 → 0.2.2

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,50 @@
1
+ /**
2
+ * <Pinagent/> — the React Native widget. Mount it once at your app root:
3
+ *
4
+ * export default function App() {
5
+ * return (
6
+ * <>
7
+ * <YourApp />
8
+ * <Pinagent />
9
+ * </>
10
+ * );
11
+ * }
12
+ *
13
+ * Modeled on pinagent's Next.js `<Pinagent/>` (a single root-mounted
14
+ * component) rather than the Vite `<script>` injection, which has no RN
15
+ * analog. Renders `null` in production so it has zero cost in release
16
+ * builds.
17
+ *
18
+ * Flow: tap the pin FAB to arm picking → tap a view → we resolve its source
19
+ * via the RN Inspector, hide our own overlay, and capture a screenshot →
20
+ * type a comment → submit POSTs to the Metro middleware, which stores it
21
+ * and (optionally) spawns an agent. When an agent is spawned, a live
22
+ * transcript sheet streams the run back over WebSocket (see StreamSheet /
23
+ * ws-client); otherwise a toast confirms the comment was filed for pull-mode
24
+ * (MCP) pickup. "+ Add element" multi-picks several targets into one comment
25
+ * (sent as `additionalAnchors`); a single pick leaves them null.
26
+ *
27
+ * The transcript sheet can be minimized to a pill, freeing the screen to pick
28
+ * another element and spawn a second agent. Each run keeps its own live sheet,
29
+ * so multiple agents can stream concurrently — the expanded one shows its full
30
+ * sheet; the rest sit as pills that keep streaming until tapped open.
31
+ */
32
+ import type { ReactElement } from 'react';
33
+ export interface PinagentProps {
34
+ /**
35
+ * Absolute project root, used to make `_debugSource` file paths
36
+ * project-relative (matching the web babel plugin's output). Defaults
37
+ * to the value Metro injects, falling back to '' (paths stay absolute,
38
+ * still usable).
39
+ */
40
+ projectRoot?: string;
41
+ /** Route/screen name to record with the comment. Defaults to OS name. */
42
+ screenName?: string;
43
+ }
44
+ /**
45
+ * Hard dev-only gate. `__DEV__` is `false` in release bundles, so the
46
+ * whole widget — and its require()s into RN internals — drops out. Kept
47
+ * as a thin wrapper so the hooks live in `PinagentDev`, called
48
+ * unconditionally (rules-of-hooks).
49
+ */
50
+ export declare function Pinagent(props: PinagentProps): ReactElement | null;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Live agent transcript sheet for React Native.
3
+ *
4
+ * After a comment is submitted and an agent is spawned, the widget opens this
5
+ * bottom sheet and streams the run over WebSocket (see `ws-client.ts`). It's
6
+ * the RN analog of the web widget's agent tray: a scrolling transcript, an
7
+ * answer form when the agent calls `ask_user`, a follow-up box, and Stop /
8
+ * Dismiss controls.
9
+ *
10
+ * State is intentionally simple and reducer-driven: agent events accumulate in
11
+ * one array folded by `renderTranscript`; user follow-ups are tracked locally
12
+ * (the bus only streams agent events, not the developer's messages). A
13
+ * reconnect replays the agent transcript, so we clear `events` on `onReset`
14
+ * but keep local follow-ups.
15
+ *
16
+ * The sheet can be **minimized** to a compact status pill so the developer can
17
+ * keep interacting with the app — e.g. to pick another element and spawn a
18
+ * second agent. Minimizing doesn't tear the run down: the component stays
19
+ * mounted (only its rendering changes), so the WebSocket keeps streaming in the
20
+ * background and the live transcript is intact the moment it's re-expanded.
21
+ * `<Pinagent/>` mounts one of these per concurrent run.
22
+ */
23
+ import type { ReactElement } from 'react';
24
+ export interface StreamSheetProps {
25
+ feedbackId: string;
26
+ /** Source label shown in the header (e.g. `file:line` or component name). */
27
+ target: string;
28
+ /** Render as a compact pill (WS stays live) instead of the full sheet. */
29
+ minimized: boolean;
30
+ /** Stack position among minimized pills (0 = bottom-most), for layout. */
31
+ stackIndex: number;
32
+ /** Collapse the full sheet to its pill. */
33
+ onMinimize: () => void;
34
+ /** Expand the pill back to the full sheet. */
35
+ onExpand: () => void;
36
+ /** Dismiss for good — tears down the WS and removes this run's view. */
37
+ onClose: () => void;
38
+ }
39
+ export declare function StreamSheet({ feedbackId, target, minimized, stackIndex, onMinimize, onExpand, onClose, }: StreamSheetProps): ReactElement;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Pinagent brand palette for the React Native widget.
3
+ *
4
+ * Mirrors BRAND_INK / BRAND_CREAM / BRAND_GOLD in `packages/ui/src/tokens.ts`. That
5
+ * package is web-only (React-DOM + Tailwind) so the native widget can't import
6
+ * it — keep these in sync if the brand palette changes there.
7
+ */
8
+ /** Dark charcoal — the FAB surface / ink-mode surfaces and primary text. */
9
+ export declare const BRAND_INK = "#201B21";
10
+ /** Warm cream — the pin mark on dark surfaces, light backgrounds, brand identity. */
11
+ export declare const BRAND_CREAM = "#FCF9E8";
12
+ /** Gold accent — focus rings; the active picking state. */
13
+ export declare const BRAND_GOLD = "#FFD700";
@@ -0,0 +1,7 @@
1
+ export type { PinagentProps } from './Pinagent';
2
+ export { Pinagent } from './Pinagent';
3
+ export type { AgentEvent, ServerMessage, TranscriptRow } from './transcript';
4
+ export { pendingAsk, renderTranscript } from './transcript';
5
+ export { devServerBaseUrl, submitFeedback } from './transport';
6
+ export type { FeedbackInput, PickResult } from './types';
7
+ export { devServerWsUrl, StreamClient } from './ws-client';
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Tap point → source location, the React Native way.
3
+ *
4
+ * This is the RN analog of the web widget's DOM walk for `data-pa-loc`.
5
+ * RN's own dev Inspector (Dev Menu → "Show Inspector") resolves a touch
6
+ * to a component + source file using exactly this internal API; we lean
7
+ * on the same machinery rather than reinventing it.
8
+ *
9
+ * Source data comes from the `data-pa-loc="file:line:col"` prop the
10
+ * `@pinagent/react-native/babel` plugin splices onto every authored JSX
11
+ * element at build time — the exact RN analog of the web babel plugin's
12
+ * DOM attribute. The plugin's prop rides along on the host fiber's
13
+ * `memoizedProps`, which `getInspectorDataForViewAtPoint` hands back to us
14
+ * as `data.props`, so the tapped view resolves to its source directly.
15
+ *
16
+ * Why a build-time prop instead of RN's old `_debugSource`: React 19
17
+ * deleted `_debugSource`, and RN 0.81+ dropped the `source` field from the
18
+ * inspector payload — neither carries a source location anymore. We still
19
+ * read both as a fallback for older RN/React, then degrade to `loc: null`.
20
+ *
21
+ * Both the module path AND the payload shape returned by
22
+ * `getInspectorDataForViewAtPoint` have shifted across RN versions, so
23
+ * every read here is defensive: we extract what we can and degrade to
24
+ * `loc: null` rather than throw inside a tap handler.
25
+ */
26
+ import type { PickResult } from './types';
27
+ interface RawFiberLike {
28
+ fileName?: string;
29
+ lineNumber?: number;
30
+ columnNumber?: number;
31
+ }
32
+ type RawProps = Record<string, unknown> | null | undefined;
33
+ /** RN measure callback: `(x, y, width, height, pageX, pageY)`. */
34
+ type RawMeasureCb = (x: number, y: number, width: number, height: number, pageX: number, pageY: number) => void;
35
+ interface RawHierarchyItem {
36
+ name?: string;
37
+ getInspectorData?: (toFiber: unknown) => {
38
+ /** Removed in RN 0.81+, kept for back-compat. */
39
+ source?: RawFiberLike | null;
40
+ /** The host fiber's `memoizedProps` — carries our `data-pa-loc`. */
41
+ props?: RawProps;
42
+ /** Measures this owner's host fiber (window/screen coords). */
43
+ measure?: (cb: RawMeasureCb) => void;
44
+ };
45
+ }
46
+ interface RawInspectorData {
47
+ frame?: {
48
+ left: number;
49
+ top: number;
50
+ width: number;
51
+ height: number;
52
+ } | null;
53
+ hierarchy?: RawHierarchyItem[];
54
+ /** Newer RN returns the closest fiber directly. */
55
+ closestInstance?: {
56
+ _debugSource?: RawFiberLike;
57
+ } | null;
58
+ /** Some versions surface the resolved source straight on the payload. */
59
+ source?: RawFiberLike | null;
60
+ /** The tapped host view's props — where `data-pa-loc` lands. */
61
+ props?: RawProps;
62
+ }
63
+ type Loc = NonNullable<PickResult['loc']>;
64
+ /**
65
+ * Keep only authored React components in the breadcrumb. Two kinds of noise
66
+ * are hidden because clicking them is meaningless — they map to no source the
67
+ * developer can act on:
68
+ *
69
+ * - **Native host components** — RN's view classes, named `RCT…`
70
+ * (`RCTText`, `RCTView`, `RCTScrollView`, …).
71
+ * - **HOC / wrapper display names** — parenthesized by convention
72
+ * (`withDevTools(App)`, `ForwardRef(X)`, `Memo(X)`, `Connect(X)`).
73
+ *
74
+ * Identifiers can't contain `(`, so a parenthesized name is always a wrapper.
75
+ */
76
+ export declare function isAuthoredComponentName(name: unknown): name is string;
77
+ type Frame = NonNullable<PickResult['frame']>;
78
+ interface RawCrumb {
79
+ name: string;
80
+ loc: Loc | null;
81
+ measure?: (cb: RawMeasureCb) => void;
82
+ }
83
+ /**
84
+ * Build the per-segment breadcrumb: each authored component in the hierarchy
85
+ * paired with its own `data-pa-loc` (so a press can re-anchor onto that
86
+ * ancestor) and a `measure` fn (so the highlight can follow the selection).
87
+ * Same order as {@link nameChainOf} — root first, tapped last.
88
+ *
89
+ * Each crumb's `loc` falls back to the nearest resolvable source in the
90
+ * hierarchy when its own host props carry none, so pressing ANY breadcrumb
91
+ * re-anchors the comment onto a real `file:line` — not just the tapped
92
+ * (innermost) one. Without the fallback, only the tapped element (which
93
+ * `pickLoc` resolves with its own hierarchy walk) got a path; ancestor crumbs
94
+ * whose first host child is untagged collapsed to `loc: null`, so re-focusing
95
+ * onto them showed a bare component name instead of a source snippet.
96
+ *
97
+ * Exported for unit testing — a pure function over the (duck-typed) inspector
98
+ * payload, so it needs no RN runtime (unlike `resolvePick`).
99
+ */
100
+ export declare function crumbsOf(data: RawInspectorData): RawCrumb[];
101
+ /**
102
+ * Measure a hierarchy item's host fiber to a window-coordinate {@link Frame}.
103
+ * Resolves null if there's no measure fn or it never calls back (guarded so a
104
+ * stuck measure can't hang the pick).
105
+ */
106
+ export declare function measureFrame(measure?: (cb: RawMeasureCb) => void): Promise<Frame | null>;
107
+ /**
108
+ * Resolve a tap (in window coordinates) to a {@link PickResult}.
109
+ *
110
+ * @param rootView A host view instance from which to reach the app root —
111
+ * pass a ref's `.current` (the widget passes its own overlay `<View>`).
112
+ * We climb to the app-root host instance before hit-testing, since the
113
+ * inspector searches within the passed view's subtree. NOT a
114
+ * `findNodeHandle` number — the Fabric inspector rejects a bare tag.
115
+ */
116
+ export declare function resolvePick(rootView: unknown, x: number, y: number, projectRoot: string): Promise<PickResult>;
117
+ export {};
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Multi-select payload builder (ticket 008).
3
+ *
4
+ * On web, Cmd/Ctrl-click accumulates several elements into one comment and the
5
+ * extras are submitted as `additionalAnchors` (landing in the
6
+ * `widget_anchors.additional_anchors` JSON column). RN is touch-only, so the
7
+ * composer offers an explicit "+ Add element" affordance: re-enter pick mode,
8
+ * tap another element, and it appends a removable chip. This module turns the
9
+ * primary anchor + the collected extras into the `additionalAnchors` array,
10
+ * matching the web `AdditionalAnchorSchema` shape so the server accepts it
11
+ * unchanged.
12
+ *
13
+ * Pure (no RN runtime imports) → unit-testable here. The RN UI state + capture
14
+ * lives in Pinagent.tsx; this just shapes the wire payload.
15
+ *
16
+ * Web semantics preserved:
17
+ * - A single pick (no extras) → `additionalAnchors` OMITTED (the server stores
18
+ * `additional_anchors` as null), not an empty array.
19
+ * - The primary anchor is NOT duplicated into the extras.
20
+ * - Each extra keeps the loc/selector it was tapped with (no breadcrumb
21
+ * re-anchoring for extras — that applies to the primary only, web parity).
22
+ */
23
+ import type { AdditionalAnchor } from './types';
24
+ /** A primary or extra pick as captured by the RN composer. */
25
+ export interface ChipPick {
26
+ /** Stable key for the chip + removal (e.g. a per-pick counter). */
27
+ key: string;
28
+ /** Resolved source location, or null for an unresolvable native view. */
29
+ loc: {
30
+ file: string;
31
+ line: number;
32
+ col: number;
33
+ } | null;
34
+ /** Component name-chain ("App > Home > Button") — RN's selector stand-in. */
35
+ selector: string;
36
+ /** Tap point in window coordinates (the `clickX`/`clickY` the schema wants). */
37
+ clickX: number;
38
+ clickY: number;
39
+ /** Innermost component name, for the chip label. */
40
+ label: string;
41
+ }
42
+ /**
43
+ * Build the `additionalAnchors` field from the extra picks (everything after
44
+ * the primary). Returns `undefined` when there are no extras so the caller can
45
+ * spread it and leave the field off entirely for single-pick submits.
46
+ *
47
+ * @param extras the non-primary picks, in pick order (preserved on the wire).
48
+ */
49
+ export declare function buildAdditionalAnchors(extras: readonly ChipPick[]): AdditionalAnchor[] | undefined;
50
+ /**
51
+ * Remove a chip by key from a pick list, preserving order. Used for both the
52
+ * primary+extras chip row removal and the extras-only list; the caller decides
53
+ * which list to pass (the primary is never removable in the UI).
54
+ */
55
+ export declare function removeChip(picks: readonly ChipPick[], key: string): ChipPick[];
@@ -0,0 +1,21 @@
1
+ /**
2
+ * PinIcon — pinagent's teardrop pin mark, for the React Native FAB.
3
+ *
4
+ * The mark is the canonical PIN_PATH / BRAND_VIEWBOX from `packages/ui/src/tokens.ts`
5
+ * (web-only, hence mirrored here). We draw it with `react-native-svg` when it's
6
+ * available — an OPTIONAL peer, lazily required exactly like
7
+ * `react-native-view-shot` in screenshot.ts, so a release build never pulls the
8
+ * native module in: the require only runs once <Pinagent/> actually renders,
9
+ * which it never does when `!__DEV__`.
10
+ *
11
+ * When the peer isn't installed we fall back to a View-drawn teardrop in the
12
+ * same colour, so the FAB always shows a brand pin — never a generic glyph.
13
+ */
14
+ import type { ReactElement } from 'react';
15
+ export interface PinIconProps {
16
+ /** Square edge length, in px. */
17
+ size?: number;
18
+ /** Fill colour of the pin (pass a brand token). */
19
+ color: string;
20
+ }
21
+ export declare function PinIcon({ size, color }: PinIconProps): ReactElement;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Restore-filter: turn the server's feedback list into the minimized pills the
3
+ * RN widget seeds on mount, so an app reload mid-run brings the running streams
4
+ * back instead of losing them.
5
+ *
6
+ * The dev server (`.pinagent/db.sqlite`) is the source of truth — RN keeps no
7
+ * device-local mirror (see ticket 001). On `<Pinagent/>` mount we
8
+ * `GET /__pinagent/feedback`, run the list through {@link restorePills}, and
9
+ * seed `streams` with the result; each restored id then subscribes over the
10
+ * existing WS client, which replays the transcript (and fires `done` for
11
+ * already-finished runs).
12
+ *
13
+ * This module is pure (no RN runtime imports) so it's unit-testable here. The
14
+ * filter mirrors the web widget's `listPendingForCurrentPage`
15
+ * (`packages/widget/src/db/reads.ts`): pending-only, scoped to the current
16
+ * surface URL, newest first.
17
+ */
18
+ /** Default cap so a stale backlog doesn't flood the screen with pills. */
19
+ export declare const RESTORE_LIMIT = 5;
20
+ /** A minimized run pill: the same shape `<Pinagent/>` keeps in `streams`. */
21
+ export interface RestoredPill {
22
+ id: string;
23
+ /** Header label — `file:line` if anchored, else the selector/component. */
24
+ target: string;
25
+ }
26
+ /**
27
+ * The subset of a feedback list item (`storage.list()` projection,
28
+ * `FeedbackRecord`) this filter reads. Loosely typed so it tolerates the wire
29
+ * JSON without importing the agent-runner type into RN source.
30
+ */
31
+ export interface RestoreCandidate {
32
+ id?: unknown;
33
+ status?: unknown;
34
+ url?: unknown;
35
+ file?: unknown;
36
+ line?: unknown;
37
+ selector?: unknown;
38
+ updatedAt?: unknown;
39
+ }
40
+ /**
41
+ * Filter a feedback list to the pills worth restoring on this surface:
42
+ *
43
+ * - `status === 'pending'` — resolved/dismissed runs don't come back (web parity).
44
+ * - `url === surfaceUrl` — RN submits `url: screenName ?? Platform.OS`; a run
45
+ * started on a different screen would restore at meaningless coordinates.
46
+ * - newest first by `updatedAt`, capped at `limit` (default {@link RESTORE_LIMIT}).
47
+ *
48
+ * Items missing an `id` are dropped (can't subscribe to them).
49
+ */
50
+ export declare function restorePills(items: readonly RestoreCandidate[] | null | undefined, surfaceUrl: string, limit?: number): RestoredPill[];
@@ -0,0 +1 @@
1
+ export declare function captureScreenshot(): Promise<string>;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Pure submit-outcome reducer (ticket 002).
3
+ *
4
+ * `onSubmit` in Pinagent.tsx used to clear the composer (comment, pick,
5
+ * screenshot) UNCONDITIONALLY after `submitFeedback()` returned — so a Metro
6
+ * restart, a network blip, or a release build at the moment of submit threw
7
+ * away the typed comment, the picked anchor, and the screenshot, leaving the
8
+ * user with a 2.5s toast and a blank composer.
9
+ *
10
+ * This module computes the next composer state from a `SubmitResult` so the
11
+ * "keep the draft on failure, clear only on success" rule is data, not buried
12
+ * UI flow — and is unit-testable here (the RN UI itself is not). Mapping the
13
+ * outcome to React state lives in `onSubmit`; this just decides the shape.
14
+ */
15
+ /** The relevant submit result fields (a subset of transport's `SubmitResult`). */
16
+ export interface SubmitOutcomeInput {
17
+ ok: boolean;
18
+ id?: string;
19
+ agentSpawned?: boolean;
20
+ error?: string;
21
+ }
22
+ /** What the composer should do next, given a submit result. */
23
+ export interface SubmitOutcome {
24
+ /**
25
+ * `'clear'` — wipe the composer (comment/pick/shot) and go idle (success).
26
+ * `'keep'` — retain the composer and surface the error inline + offer Retry.
27
+ */
28
+ composer: 'clear' | 'keep';
29
+ /** Inline error to show under the composer when `composer === 'keep'`. */
30
+ error: string | null;
31
+ /**
32
+ * When a run was spawned, the id to open as a live stream. Null when nothing
33
+ * to stream (spawn off, or a failed submit).
34
+ */
35
+ streamId: string | null;
36
+ /**
37
+ * Transient toast text for the non-streaming success/failure paths, or null
38
+ * when the outcome opens a stream instead (which has its own UI). Kept for a
39
+ * filed-for-pull-mode confirmation; failures show inline, not a toast.
40
+ */
41
+ toast: string | null;
42
+ }
43
+ /**
44
+ * Decide the next composer state from a submit result.
45
+ *
46
+ * - Failure (`ok === false`): keep the composer + its draft, surface the error
47
+ * inline (never a vanishing toast), no stream, no clear. Retry re-submits the
48
+ * retained payload.
49
+ * - Success with a spawned agent: clear the composer and open the stream.
50
+ * - Success without a spawned agent (spawn off / pull mode): clear the composer
51
+ * and show a transient "Sent" toast.
52
+ */
53
+ export declare function submitOutcome(result: SubmitOutcomeInput): SubmitOutcome;
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Transcript reducer + wire types for the RN widget.
3
+ *
4
+ * Deliberate, dependency-free mirror of `@pinagent/shared`'s `AgentEvent`
5
+ * union (event-bus.ts), `ServerMessage` (ws-protocol.ts), and the canonical
6
+ * `renderTranscript` (render-transcript.ts). We DON'T import the package: the
7
+ * native client ships as **source** and is bundled onto the device by the
8
+ * consumer's Metro, but `@pinagent/shared` is `private` (unpublished) and
9
+ * built for Node — importing it into device code would break a real
10
+ * `npm install @pinagent/react-native`, the same way bundling any unpublished
11
+ * `@pinagent/*` dep does. The package's `./server` entry CAN depend on shared
12
+ * (tsdown bundles it into dist); device source cannot.
13
+ *
14
+ * Keep in sync with those three files. The shapes are stable and the reducer
15
+ * mirrors the shared one, extended with the streaming-only kinds (ask/status)
16
+ * the interactive RN sheet renders.
17
+ */
18
+ /** Flat AgentEvent union — mirror of `@pinagent/shared`'s `event-bus.ts`. */
19
+ export type AgentEvent = {
20
+ type: 'init';
21
+ sessionId: string;
22
+ model: string;
23
+ permissionMode: string;
24
+ apiKeySource: string;
25
+ } | {
26
+ type: 'text';
27
+ text: string;
28
+ } | {
29
+ type: 'tool_use';
30
+ name: string;
31
+ summary: string;
32
+ } | {
33
+ type: 'tool_result';
34
+ ok: boolean;
35
+ } | {
36
+ type: 'progress';
37
+ turn: number;
38
+ } | {
39
+ type: 'ask_user';
40
+ askId: string;
41
+ question: string;
42
+ context?: string;
43
+ options?: string[];
44
+ } | {
45
+ type: 'error';
46
+ message: string;
47
+ } | {
48
+ type: 'result';
49
+ subtype: string;
50
+ numTurns: number;
51
+ totalCostUsd: number;
52
+ durationMs: number;
53
+ } | {
54
+ type: 'status_changed';
55
+ status: 'pending' | 'fixed' | 'wontfix' | 'deferred';
56
+ note: string | null;
57
+ commitSha: string | null;
58
+ resolvedAt: string | null;
59
+ };
60
+ /**
61
+ * Server → client frames the RN client acts on (subset of the web protocol).
62
+ *
63
+ * Only the frames the widget handles are modeled. Other frames (project /
64
+ * extension fan-out, pong) still arrive on the wire — `StreamClient.onMessage`
65
+ * casts the parsed JSON to this type and its `switch` ignores any `type` it
66
+ * doesn't handle. We deliberately avoid an open
67
+ * `{ type: string; [k: string]: unknown }` member: it overlaps every
68
+ * discriminant, collapsing `event`/`message` to `{}` at the use sites and
69
+ * defeating narrowing.
70
+ */
71
+ export type ServerMessage = {
72
+ type: 'event';
73
+ feedbackId: string;
74
+ event: AgentEvent;
75
+ } | {
76
+ type: 'done';
77
+ feedbackId: string;
78
+ } | {
79
+ type: 'error';
80
+ feedbackId?: string;
81
+ message: string;
82
+ } | {
83
+ type: 'worktree_state';
84
+ feedbackId: string;
85
+ state: string;
86
+ commitSha?: string;
87
+ };
88
+ export type TranscriptKind = 'text' | 'tool' | 'error' | 'result' | 'ask' | 'status';
89
+ export interface TranscriptRow {
90
+ /** Stable key for React lists; derived from event index. */
91
+ id: string;
92
+ kind: TranscriptKind;
93
+ /** Primary text. For tools, the tool name. */
94
+ text: string;
95
+ /** Tool argument summary / ask options, when present. */
96
+ detail?: string;
97
+ /** tool_result success flag → ✓/✗ marker. */
98
+ ok?: boolean;
99
+ }
100
+ /**
101
+ * Fold AgentEvents into render-ready rows. Pure and deterministic — mirrors
102
+ * the shared `renderTranscript`, plus `ask_user` / `status_changed` rows the
103
+ * interactive RN sheet shows. `init`, `progress`, `tool_result` produce no row
104
+ * of their own (`tool_result` annotates the preceding tool row with ✓/✗).
105
+ */
106
+ export declare function renderTranscript(events: AgentEvent[]): TranscriptRow[];
107
+ /** The latest unanswered ask_user, if the run is currently blocked on one. */
108
+ export declare function pendingAsk(events: AgentEvent[]): {
109
+ askId: string;
110
+ question: string;
111
+ options: string[];
112
+ } | null;
@@ -0,0 +1,49 @@
1
+ import type { RestoreCandidate } from './restore';
2
+ import type { FeedbackInput } from './types';
3
+ /**
4
+ * Parse `http://192.168.1.5:8081/index.bundle?...` (or the RN packager's
5
+ * variants) down to `http://192.168.1.5:8081`. Returns null in release builds,
6
+ * where no Metro server is reachable.
7
+ *
8
+ * Prefers RN's `getDevServer()` (TurboModule-backed, works under the New
9
+ * Architecture) and falls back to the legacy `NativeModules.SourceCode.scriptURL`
10
+ * for pre-bridgeless RN. The legacy proxy is empty under bridgeless, which is
11
+ * what made every submit fail with "No dev server" on RN 0.82+.
12
+ */
13
+ export declare function devServerBaseUrl(): string | null;
14
+ export interface SubmitResult {
15
+ ok: boolean;
16
+ id?: string;
17
+ agentSpawned?: boolean;
18
+ error?: string;
19
+ }
20
+ /**
21
+ * Send one comment. Mirrors the web widget's POST to
22
+ * `/__pinagent/feedback`; the response (`{ id, agentSpawned }`) is the
23
+ * same one the Vite/Next middleware returns.
24
+ */
25
+ export declare function submitFeedback(input: FeedbackInput): Promise<SubmitResult>;
26
+ /**
27
+ * Fetch the conversation list from the dev server (`GET /__pinagent/feedback`).
28
+ * Used on `<Pinagent/>` mount to restore minimized pills after an app reload —
29
+ * the server's `.pinagent/db.sqlite` is the source of truth, so RN keeps no
30
+ * device-local mirror. Returns `[]` (degrade silently) when the dev server is
31
+ * unreachable or the request fails — exactly as today when there's no server.
32
+ *
33
+ * The items are the `storage.list()` projection (`FeedbackRecord[]`); the
34
+ * caller filters them with `restorePills`. Typed loosely here so the wire JSON
35
+ * doesn't drag the agent-runner type into RN source.
36
+ */
37
+ export declare function fetchFeedbackList(): Promise<RestoreCandidate[]>;
38
+ /**
39
+ * Ask the dev server to open a source location in the editor on the machine
40
+ * running Metro — the RN analog of the web composer's "navigate to file".
41
+ * Fire-and-forget: the device gets no useful signal beyond "request sent".
42
+ */
43
+ export declare function openInEditor(loc: {
44
+ file: string;
45
+ line: number;
46
+ col: number;
47
+ }): Promise<boolean>;
48
+ /** `${Platform.OS} ${Platform.Version}` — RN's stand-in for a UA string. */
49
+ export declare function platformTag(): string;
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The wire shape the RN widget produces. It mirrors `FeedbackInputSchema`
3
+ * in `packages/agent-runner/src/storage.ts` so the existing server
4
+ * accepts a phone-filed comment with zero backend changes. Keep this in
5
+ * lockstep with that zod schema — the middleware re-validates against it.
6
+ */
7
+ export interface FeedbackInput {
8
+ /** Comment text the developer typed. 1..8000 chars (server-enforced). */
9
+ comment: string;
10
+ /**
11
+ * Source location of the tapped component, from the fiber's
12
+ * `_debugSource` (the RN analog of web's `data-pa-loc`). Null when the
13
+ * tapped view has no resolvable source (a deep native view with no
14
+ * composite owner in dev).
15
+ */
16
+ loc: {
17
+ file: string;
18
+ line: number;
19
+ col: number;
20
+ } | null;
21
+ /**
22
+ * Web sends a CSS selector here for HMR re-anchoring. RN has no
23
+ * selectors, so v1 sends the component display-name chain (e.g.
24
+ * "App > HomeScreen > PrimaryButton") purely to satisfy the schema and
25
+ * give the agent a human-readable hint. See the design doc's
26
+ * "Deliberate cuts for v1".
27
+ */
28
+ selector: string;
29
+ /** The current route/screen name (web sends the page URL). */
30
+ url: string;
31
+ /** Window dimensions at pick time. */
32
+ viewport: {
33
+ w: number;
34
+ h: number;
35
+ };
36
+ /** `${Platform.OS} ${Platform.Version}` — the RN analog of a UA string. */
37
+ userAgent: string;
38
+ /** base64 PNG (no data: prefix). Capped at 5MB by the middleware. */
39
+ screenshot: string;
40
+ /** ISO timestamp. */
41
+ createdAt: string;
42
+ /**
43
+ * Extra elements the developer multi-picked into the SAME comment (the
44
+ * 2nd…Nth taps). Optional — omitted entirely for a single pick, exactly
45
+ * like the web widget (the server stores `additional_anchors` as null
46
+ * unless this is a non-empty array). The agent receives them as
47
+ * `additionalTargets` and addresses every location. Same per-anchor shape
48
+ * the web sends in `FeedbackInputSchema.additionalAnchors`
49
+ * (`packages/agent-runner/src/storage.ts`).
50
+ */
51
+ additionalAnchors?: AdditionalAnchor[];
52
+ }
53
+ /**
54
+ * One extra (non-primary) pick. Matches the web `AdditionalAnchorSchema`
55
+ * exactly so the server accepts it unchanged: `clickX`/`clickY` are required
56
+ * integers (the tap point in window coordinates); `component` is optional.
57
+ */
58
+ export interface AdditionalAnchor {
59
+ file: string | null;
60
+ line: number | null;
61
+ col: number | null;
62
+ selector: string;
63
+ clickX: number;
64
+ clickY: number;
65
+ component?: string | null;
66
+ }
67
+ /** One segment of the component breadcrumb. */
68
+ export interface PickCrumb {
69
+ /** Component display name (e.g. `FeatureCard`). */
70
+ name: string;
71
+ /**
72
+ * Source location of that component, from its own `data-pa-loc`. Null when
73
+ * the component carries no resolvable source (e.g. an untagged 3rd-party
74
+ * wrapper). Lets the breadcrumb re-anchor the comment onto an ancestor.
75
+ */
76
+ loc: FeedbackInput['loc'];
77
+ /**
78
+ * Highlight rectangle for this component (window coordinates), so pressing
79
+ * the crumb moves the on-screen selection outline onto it. Null when it
80
+ * couldn't be measured.
81
+ */
82
+ frame: {
83
+ x: number;
84
+ y: number;
85
+ width: number;
86
+ height: number;
87
+ } | null;
88
+ }
89
+ /** Result of resolving a tap point to a source location. */
90
+ export interface PickResult {
91
+ /** The precise tapped element's source location (the default target). */
92
+ loc: FeedbackInput['loc'];
93
+ /** Component display-name breadcrumb, newest (tapped) last. */
94
+ nameChain: string[];
95
+ /**
96
+ * Per-segment breadcrumb (same order as {@link nameChain}: root first,
97
+ * tapped component last), each carrying its own `loc` so pressing a
98
+ * breadcrumb can re-anchor the comment onto that ancestor.
99
+ */
100
+ chain: PickCrumb[];
101
+ /** Highlight rectangle in window coordinates, for the overlay outline. */
102
+ frame: {
103
+ x: number;
104
+ y: number;
105
+ width: number;
106
+ height: number;
107
+ } | null;
108
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * RN WebSocket client for live agent streaming.
3
+ *
4
+ * Connects to the same Metro host the feedback POST uses (derived from
5
+ * `devServerBaseUrl()`), swapping the scheme to `ws(s)` and hitting
6
+ * `/__pinagent/ws` — the endpoint the server mounts via
7
+ * `pinagentWebsocketEndpoints` (Metro `config.server.websocketEndpoints`).
8
+ * Because it rides Metro's own port, a physical device needs no port
9
+ * discovery: if the bundle loaded, this URL is reachable.
10
+ *
11
+ * The wire protocol is the web one (`@pinagent/shared`'s ws-protocol): we send
12
+ * `subscribe` / `user_message` / `ask_response` / `interrupt`, and receive
13
+ * `event` / `done` / `error`. On reconnect the server replays the full
14
+ * transcript, so we fire `onReset` first to let the UI rebuild from scratch.
15
+ *
16
+ * Scoped to ONE feedback id per client — the RN widget streams a single
17
+ * conversation at a time (the one just submitted). That keeps this far smaller
18
+ * than the web client's multiplexed map.
19
+ */
20
+ import type { AgentEvent } from './transcript';
21
+ export interface StreamHandlers {
22
+ /** A reconnect is about to replay the transcript — clear and rebuild. */
23
+ onReset(): void;
24
+ onEvent(event: AgentEvent): void;
25
+ /** The run's bus closed (agent finished or idle). */
26
+ onDone(): void;
27
+ onError(message: string): void;
28
+ }
29
+ /** Derive `ws(s)://host:port/__pinagent/ws` from Metro's bundle URL. */
30
+ export declare function devServerWsUrl(): string | null;
31
+ export declare class StreamClient {
32
+ private readonly feedbackId;
33
+ private readonly handlers;
34
+ private socket;
35
+ private reconnectDelay;
36
+ private reconnectTimer;
37
+ private closed;
38
+ private connectedBefore;
39
+ constructor(feedbackId: string, handlers: StreamHandlers);
40
+ /** Open the socket and subscribe. Safe to call once per instance. */
41
+ start(): void;
42
+ /** Tear down for good — no further reconnects. Call on unmount/dismiss. */
43
+ stop(): void;
44
+ /** Queue a follow-up turn for the agent. No-op if the socket isn't open. */
45
+ sendUserMessage(content: string): void;
46
+ /** Answer an `ask_user` prompt. */
47
+ sendAskResponse(askId: string, answer: string): void;
48
+ /** Interrupt the in-flight run. */
49
+ interrupt(): void;
50
+ private connect;
51
+ private onMessage;
52
+ private send;
53
+ private scheduleReconnect;
54
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pinagent/react-native",
3
- "version": "0.1.4",
3
+ "version": "0.2.2",
4
4
  "license": "Apache-2.0",
5
5
  "description": "React Native & Expo plugin for Pinagent — tap a view, leave a comment, and a coding agent fixes it with file:line + a screenshot. The native widget ships as source for Metro; the dev-server middleware and Babel source-tagging plugin are built for Node.",
6
6
  "keywords": [
@@ -25,6 +25,7 @@
25
25
  "type": "module",
26
26
  "exports": {
27
27
  ".": {
28
+ "types": "./dist/native/index.d.ts",
28
29
  "react-native": "./src/native/index.ts",
29
30
  "default": "./src/native/index.ts"
30
31
  },
@@ -49,6 +50,7 @@
49
50
  "peerDependencies": {
50
51
  "react": ">=18",
51
52
  "react-native": ">=0.74",
53
+ "react-native-svg": ">=13",
52
54
  "react-native-view-shot": ">=3"
53
55
  },
54
56
  "peerDependenciesMeta": {
@@ -58,6 +60,9 @@
58
60
  "react-native": {
59
61
  "optional": true
60
62
  },
63
+ "react-native-svg": {
64
+ "optional": true
65
+ },
61
66
  "react-native-view-shot": {
62
67
  "optional": true
63
68
  }
@@ -71,6 +76,7 @@
71
76
  },
72
77
  "devDependencies": {
73
78
  "@types/node": "^25.9.1",
79
+ "@types/react": "^19.2.17",
74
80
  "@types/ws": "^8.18.1",
75
81
  "tsdown": "^0.22.0",
76
82
  "typescript": "^6.0.3",
@@ -79,8 +85,8 @@
79
85
  },
80
86
  "scripts": {
81
87
  "prebuild": "node scripts/copy-drizzle.mjs",
82
- "build": "tsdown",
88
+ "build": "tsdown && tsc -p tsconfig.native.json",
83
89
  "dev": "tsdown --watch",
84
- "typecheck": "tsc --noEmit"
90
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.native.json --noEmit"
85
91
  }
86
92
  }
@@ -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,65 @@ 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
+ const loc = locs[j];
300
+ if (loc) return loc;
301
+ }
302
+ for (let j = i - 1; j >= 0; j--) {
303
+ const loc = locs[j];
304
+ if (loc) return loc;
305
+ }
306
+ return null;
307
+ }
308
+
285
309
  /**
286
310
  * Build the per-segment breadcrumb: each authored component in the hierarchy
287
311
  * paired with its own `data-pa-loc` (so a press can re-anchor onto that
288
312
  * ancestor) and a `measure` fn (so the highlight can follow the selection).
289
313
  * Same order as {@link nameChainOf} — root first, tapped last.
314
+ *
315
+ * Each crumb's `loc` falls back to the nearest resolvable source in the
316
+ * hierarchy when its own host props carry none, so pressing ANY breadcrumb
317
+ * re-anchors the comment onto a real `file:line` — not just the tapped
318
+ * (innermost) one. Without the fallback, only the tapped element (which
319
+ * `pickLoc` resolves with its own hierarchy walk) got a path; ancestor crumbs
320
+ * whose first host child is untagged collapsed to `loc: null`, so re-focusing
321
+ * onto them showed a bare component name instead of a source snippet.
322
+ *
323
+ * Exported for unit testing — a pure function over the (duck-typed) inspector
324
+ * payload, so it needs no RN runtime (unlike `resolvePick`).
290
325
  */
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
- });
326
+ export function crumbsOf(data: RawInspectorData): RawCrumb[] {
327
+ // Resolve each hierarchy item's inspector data ONCE. We compute locs over the
328
+ // FULL hierarchy (not just the authored crumbs) so the nearest-source
329
+ // fallback can borrow a location from an untagged host sitting between two
330
+ // authored components.
331
+ const items = (data.hierarchy ?? []).map((h) => {
332
+ const inspector = h.getInspectorData?.(() => null);
333
+ return { name: h.name, loc: paLocOf(inspector?.props), measure: inspector?.measure };
334
+ });
335
+ const locs = items.map((it) => it.loc);
336
+ return items
337
+ .map((it, i) => ({ it, i }))
338
+ .filter(({ it }) => isAuthoredComponentName(it.name))
339
+ .map(({ it, i }) => ({
340
+ name: it.name as string,
341
+ loc: it.loc ?? nearestLoc(locs, i),
342
+ measure: it.measure,
343
+ }));
298
344
  }
299
345
 
300
346
  /**
@@ -0,0 +1,88 @@
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 { ComponentType, 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 as bare component types — `react-native-svg`'s own types
26
+ // aren't a dependency, but `unknown` can't be used as a JSX element, and the
27
+ // native source IS declaration-emitted (see tsconfig.native.json), so these
28
+ // must be valid JSX element types.
29
+ type SvgModule = { Svg: ComponentType<any>; Path: ComponentType<any> } | null;
30
+
31
+ // Resolved once, on first render (dev only — see header). `undefined` = not yet
32
+ // resolved, `null` = peer not installed.
33
+ let svgModule: SvgModule | undefined;
34
+ function resolveSvg(): SvgModule {
35
+ if (svgModule !== undefined) return svgModule;
36
+ try {
37
+ // Lazy require so a release build never pulls the native module in.
38
+ // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
39
+ const mod = require('react-native-svg');
40
+ svgModule = { Svg: mod.Svg ?? mod.default, Path: mod.Path };
41
+ } catch {
42
+ svgModule = null;
43
+ }
44
+ return svgModule;
45
+ }
46
+
47
+ export interface PinIconProps {
48
+ /** Square edge length, in px. */
49
+ size?: number;
50
+ /** Fill colour of the pin (pass a brand token). */
51
+ color: string;
52
+ }
53
+
54
+ export function PinIcon({ size = 26, color }: PinIconProps): ReactElement {
55
+ const svg = resolveSvg();
56
+ if (svg?.Svg && svg.Path) {
57
+ const { Svg, Path } = svg;
58
+ return (
59
+ <Svg width={size} height={size} viewBox={VIEWBOX}>
60
+ <Path d={PIN_PATH} fill={color} />
61
+ </Svg>
62
+ );
63
+ }
64
+ return <FallbackPin size={size} color={color} />;
65
+ }
66
+
67
+ /**
68
+ * react-native-svg-free fallback: a map-pin teardrop drawn from a single View —
69
+ * a rounded square with one squared corner, rotated so the point faces down.
70
+ * The classic CSS `border-radius: 50% 50% 50% 0` recipe, in the brand colour.
71
+ */
72
+ function FallbackPin({ size, color }: { size: number; color: string }): ReactElement {
73
+ const d = Math.round(size * 0.8);
74
+ return (
75
+ <View
76
+ style={{
77
+ width: d,
78
+ height: d,
79
+ backgroundColor: color,
80
+ borderTopLeftRadius: d / 2,
81
+ borderTopRightRadius: d / 2,
82
+ borderBottomRightRadius: d / 2,
83
+ borderBottomLeftRadius: 0,
84
+ transform: [{ rotate: '-45deg' }],
85
+ }}
86
+ />
87
+ );
88
+ }