@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.
- package/dist/native/Pinagent.d.ts +50 -0
- package/dist/native/StreamSheet.d.ts +39 -0
- package/dist/native/brand.d.ts +13 -0
- package/dist/native/index.d.ts +7 -0
- package/dist/native/inspector.d.ts +117 -0
- package/dist/native/multi-pick.d.ts +55 -0
- package/dist/native/pin-icon.d.ts +21 -0
- package/dist/native/restore.d.ts +50 -0
- package/dist/native/screenshot.d.ts +1 -0
- package/dist/native/submit-outcome.d.ts +53 -0
- package/dist/native/transcript.d.ts +112 -0
- package/dist/native/transport.d.ts +49 -0
- package/dist/native/types.d.ts +108 -0
- package/dist/native/ws-client.d.ts +54 -0
- package/package.json +9 -3
- package/src/native/Pinagent.tsx +156 -22
- package/src/native/brand.ts +15 -0
- package/src/native/inspector.ts +53 -7
- package/src/native/pin-icon.tsx +88 -0
|
@@ -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.
|
|
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
|
}
|
package/src/native/Pinagent.tsx
CHANGED
|
@@ -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
|
|
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.
|
|
541
|
-
sending. Hidden during `capturing` so it stays
|
|
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
|
-
<
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
|
|
555
|
-
|
|
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
|
-
|
|
678
|
-
|
|
806
|
+
left: 0,
|
|
807
|
+
top: 0,
|
|
679
808
|
width: 52,
|
|
680
809
|
height: 52,
|
|
681
810
|
borderRadius: 26,
|
|
682
|
-
backgroundColor:
|
|
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
|
-
|
|
692
|
-
|
|
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';
|
package/src/native/inspector.ts
CHANGED
|
@@ -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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
+
}
|