@pinagent/react-native 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +201 -0
- package/dist/babel.cjs +130 -0
- package/dist/babel.cjs.map +1 -0
- package/dist/babel.d.cts +16 -0
- package/dist/babel.d.cts.map +1 -0
- package/dist/babel.d.ts +16 -0
- package/dist/babel.d.ts.map +1 -0
- package/dist/babel.js +130 -0
- package/dist/babel.js.map +1 -0
- package/dist/server.cjs +4684 -0
- package/dist/server.cjs.map +1 -0
- package/dist/server.d.cts +109 -0
- package/dist/server.d.cts.map +1 -0
- package/dist/server.d.ts +110 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +4681 -0
- package/dist/server.js.map +1 -0
- package/package.json +83 -0
- package/src/native/Pinagent.tsx +703 -0
- package/src/native/StreamSheet.tsx +426 -0
- package/src/native/index.ts +9 -0
- package/src/native/inspector.ts +407 -0
- package/src/native/multi-pick.ts +74 -0
- package/src/native/restore.ts +91 -0
- package/src/native/screenshot.ts +34 -0
- package/src/native/submit-outcome.ts +70 -0
- package/src/native/transcript.ts +143 -0
- package/src/native/transport.ts +162 -0
- package/src/native/types.ts +95 -0
- package/src/native/ws-client.ts +173 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Transcript reducer + wire types for the RN widget.
|
|
4
|
+
*
|
|
5
|
+
* Deliberate, dependency-free mirror of `@pinagent/shared`'s `AgentEvent`
|
|
6
|
+
* union (event-bus.ts), `ServerMessage` (ws-protocol.ts), and the canonical
|
|
7
|
+
* `renderTranscript` (render-transcript.ts). We DON'T import the package: the
|
|
8
|
+
* native client ships as **source** and is bundled onto the device by the
|
|
9
|
+
* consumer's Metro, but `@pinagent/shared` is `private` (unpublished) and
|
|
10
|
+
* built for Node — importing it into device code would break a real
|
|
11
|
+
* `npm install @pinagent/react-native`, the same way bundling any unpublished
|
|
12
|
+
* `@pinagent/*` dep does. The package's `./server` entry CAN depend on shared
|
|
13
|
+
* (tsdown bundles it into dist); device source cannot.
|
|
14
|
+
*
|
|
15
|
+
* Keep in sync with those three files. The shapes are stable and the reducer
|
|
16
|
+
* mirrors the shared one, extended with the streaming-only kinds (ask/status)
|
|
17
|
+
* the interactive RN sheet renders.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Flat AgentEvent union — mirror of `@pinagent/shared`'s `event-bus.ts`. */
|
|
21
|
+
export type AgentEvent =
|
|
22
|
+
| { type: 'init'; sessionId: string; model: string; permissionMode: string; apiKeySource: string }
|
|
23
|
+
| { type: 'text'; text: string }
|
|
24
|
+
| { type: 'tool_use'; name: string; summary: string }
|
|
25
|
+
| { type: 'tool_result'; ok: boolean }
|
|
26
|
+
| { type: 'progress'; turn: number }
|
|
27
|
+
| { type: 'ask_user'; askId: string; question: string; context?: string; options?: string[] }
|
|
28
|
+
| { type: 'error'; message: string }
|
|
29
|
+
| { type: 'result'; subtype: string; numTurns: number; totalCostUsd: number; durationMs: number }
|
|
30
|
+
| {
|
|
31
|
+
type: 'status_changed';
|
|
32
|
+
status: 'pending' | 'fixed' | 'wontfix' | 'deferred';
|
|
33
|
+
note: string | null;
|
|
34
|
+
commitSha: string | null;
|
|
35
|
+
resolvedAt: string | null;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Server → client frames the RN client acts on (subset of the web protocol). */
|
|
39
|
+
export type ServerMessage =
|
|
40
|
+
| { type: 'event'; feedbackId: string; event: AgentEvent }
|
|
41
|
+
| { type: 'done'; feedbackId: string }
|
|
42
|
+
| { type: 'error'; feedbackId?: string; message: string }
|
|
43
|
+
| { type: 'worktree_state'; feedbackId: string; state: string; commitSha?: string }
|
|
44
|
+
// Frames the RN client receives but ignores (project / extension fan-out, pong).
|
|
45
|
+
| { type: string; [k: string]: unknown };
|
|
46
|
+
|
|
47
|
+
export type TranscriptKind = 'text' | 'tool' | 'error' | 'result' | 'ask' | 'status';
|
|
48
|
+
|
|
49
|
+
export interface TranscriptRow {
|
|
50
|
+
/** Stable key for React lists; derived from event index. */
|
|
51
|
+
id: string;
|
|
52
|
+
kind: TranscriptKind;
|
|
53
|
+
/** Primary text. For tools, the tool name. */
|
|
54
|
+
text: string;
|
|
55
|
+
/** Tool argument summary / ask options, when present. */
|
|
56
|
+
detail?: string;
|
|
57
|
+
/** tool_result success flag → ✓/✗ marker. */
|
|
58
|
+
ok?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Fold AgentEvents into render-ready rows. Pure and deterministic — mirrors
|
|
63
|
+
* the shared `renderTranscript`, plus `ask_user` / `status_changed` rows the
|
|
64
|
+
* interactive RN sheet shows. `init`, `progress`, `tool_result` produce no row
|
|
65
|
+
* of their own (`tool_result` annotates the preceding tool row with ✓/✗).
|
|
66
|
+
*/
|
|
67
|
+
export function renderTranscript(events: AgentEvent[]): TranscriptRow[] {
|
|
68
|
+
const rows: TranscriptRow[] = [];
|
|
69
|
+
events.forEach((event, i) => {
|
|
70
|
+
switch (event.type) {
|
|
71
|
+
case 'text': {
|
|
72
|
+
const text = event.text.trim();
|
|
73
|
+
if (text) rows.push({ id: `e${i}`, kind: 'text', text });
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
case 'tool_use':
|
|
77
|
+
rows.push({
|
|
78
|
+
id: `e${i}`,
|
|
79
|
+
kind: 'tool',
|
|
80
|
+
text: event.name,
|
|
81
|
+
...(event.summary ? { detail: event.summary } : {}),
|
|
82
|
+
});
|
|
83
|
+
break;
|
|
84
|
+
case 'tool_result':
|
|
85
|
+
for (let j = rows.length - 1; j >= 0; j--) {
|
|
86
|
+
if (rows[j].kind === 'tool') {
|
|
87
|
+
rows[j].ok = event.ok;
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
break;
|
|
92
|
+
case 'ask_user':
|
|
93
|
+
rows.push({
|
|
94
|
+
id: `e${i}`,
|
|
95
|
+
kind: 'ask',
|
|
96
|
+
text: event.question,
|
|
97
|
+
...(event.options?.length ? { detail: event.options.join(' · ') } : {}),
|
|
98
|
+
});
|
|
99
|
+
break;
|
|
100
|
+
case 'error':
|
|
101
|
+
rows.push({ id: `e${i}`, kind: 'error', text: event.message });
|
|
102
|
+
break;
|
|
103
|
+
case 'status_changed':
|
|
104
|
+
rows.push({
|
|
105
|
+
id: `e${i}`,
|
|
106
|
+
kind: 'status',
|
|
107
|
+
text: event.note
|
|
108
|
+
? `Resolved (${event.status}): ${event.note}`
|
|
109
|
+
: `Resolved (${event.status})`,
|
|
110
|
+
});
|
|
111
|
+
break;
|
|
112
|
+
case 'result': {
|
|
113
|
+
const ok = event.subtype === 'success';
|
|
114
|
+
const cost = event.totalCostUsd > 0 ? ` · $${event.totalCostUsd.toFixed(4)}` : '';
|
|
115
|
+
const turns = `${event.numTurns} turn${event.numTurns === 1 ? '' : 's'}`;
|
|
116
|
+
rows.push({
|
|
117
|
+
id: `e${i}`,
|
|
118
|
+
kind: 'result',
|
|
119
|
+
text: ok ? `Done · ${turns}${cost}` : `Ended: ${event.subtype} · ${turns}${cost}`,
|
|
120
|
+
ok,
|
|
121
|
+
});
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
// init, progress: no transcript row.
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
return rows;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The latest unanswered ask_user, if the run is currently blocked on one. */
|
|
131
|
+
export function pendingAsk(
|
|
132
|
+
events: AgentEvent[],
|
|
133
|
+
): { askId: string; question: string; options: string[] } | null {
|
|
134
|
+
let ask: { askId: string; question: string; options: string[] } | null = null;
|
|
135
|
+
for (const event of events) {
|
|
136
|
+
if (event.type === 'ask_user') {
|
|
137
|
+
ask = { askId: event.askId, question: event.question, options: event.options ?? [] };
|
|
138
|
+
}
|
|
139
|
+
// A terminal result/error clears any pending question.
|
|
140
|
+
if (event.type === 'result' || event.type === 'error') ask = null;
|
|
141
|
+
}
|
|
142
|
+
return ask;
|
|
143
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* POST the assembled feedback to the Metro dev server.
|
|
4
|
+
*
|
|
5
|
+
* The web widget POSTs to a same-origin `/__pinagent/feedback`. RN has no
|
|
6
|
+
* origin, so we derive the dev-server base from the bundle URL
|
|
7
|
+
* (`NativeModules.SourceCode.scriptURL`) — that's the host Metro is
|
|
8
|
+
* already serving from, which also resolves the awkward cases for free:
|
|
9
|
+
* a physical device gets the LAN host, the iOS simulator gets localhost,
|
|
10
|
+
* the Android emulator gets `10.0.2.2`. No hard-coded host needed.
|
|
11
|
+
*/
|
|
12
|
+
import { NativeModules, Platform } from 'react-native';
|
|
13
|
+
import type { FeedbackInput } from './types';
|
|
14
|
+
|
|
15
|
+
interface DevServerInfo {
|
|
16
|
+
url?: string;
|
|
17
|
+
bundleLoadedFromServer?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let cachedGetDevServer: (() => DevServerInfo) | null | undefined;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* RN's own dev-server resolver. It reads the `NativeSourceCode` **TurboModule**,
|
|
24
|
+
* which — unlike the legacy `NativeModules.SourceCode` bridge proxy — is
|
|
25
|
+
* populated under the New Architecture (bridgeless, RN 0.82+, the only mode RN
|
|
26
|
+
* ships now). Lazy + cached: a release build (widget `__DEV__`-gated away)
|
|
27
|
+
* never reaches into RN internals. `require` takes a static string literal —
|
|
28
|
+
* Metro forbids `require(variable)`.
|
|
29
|
+
*/
|
|
30
|
+
function loadGetDevServer(): (() => DevServerInfo) | null {
|
|
31
|
+
if (cachedGetDevServer !== undefined) return cachedGetDevServer;
|
|
32
|
+
try {
|
|
33
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
|
|
34
|
+
const mod = require('react-native/Libraries/Core/Devtools/getDevServer');
|
|
35
|
+
const fn = (mod as { default?: unknown })?.default ?? mod;
|
|
36
|
+
cachedGetDevServer = typeof fn === 'function' ? (fn as () => DevServerInfo) : null;
|
|
37
|
+
} catch {
|
|
38
|
+
cachedGetDevServer = null;
|
|
39
|
+
}
|
|
40
|
+
return cachedGetDevServer;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Parse `http://192.168.1.5:8081/index.bundle?...` (or the RN packager's
|
|
45
|
+
* variants) down to `http://192.168.1.5:8081`. Returns null in release builds,
|
|
46
|
+
* where no Metro server is reachable.
|
|
47
|
+
*
|
|
48
|
+
* Prefers RN's `getDevServer()` (TurboModule-backed, works under the New
|
|
49
|
+
* Architecture) and falls back to the legacy `NativeModules.SourceCode.scriptURL`
|
|
50
|
+
* for pre-bridgeless RN. The legacy proxy is empty under bridgeless, which is
|
|
51
|
+
* what made every submit fail with "No dev server" on RN 0.82+.
|
|
52
|
+
*/
|
|
53
|
+
export function devServerBaseUrl(): string | null {
|
|
54
|
+
const getDevServer = loadGetDevServer();
|
|
55
|
+
if (getDevServer) {
|
|
56
|
+
try {
|
|
57
|
+
const info = getDevServer();
|
|
58
|
+
// `bundleLoadedFromServer` is false in release builds (url defaults to
|
|
59
|
+
// localhost:8081 there, so the url alone can't be trusted).
|
|
60
|
+
if (info?.url && info.bundleLoadedFromServer !== false) {
|
|
61
|
+
const m = /^(https?:\/\/[^/]+)/.exec(info.url);
|
|
62
|
+
if (m) return m[1]!;
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
// Fall through to the legacy bridge read.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const scriptURL: string | undefined = NativeModules?.SourceCode?.scriptURL;
|
|
69
|
+
if (!scriptURL) return null;
|
|
70
|
+
const match = /^(https?:\/\/[^/]+)/.exec(scriptURL);
|
|
71
|
+
return match ? match[1]! : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface SubmitResult {
|
|
75
|
+
ok: boolean;
|
|
76
|
+
id?: string;
|
|
77
|
+
agentSpawned?: boolean;
|
|
78
|
+
error?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Send one comment. Mirrors the web widget's POST to
|
|
83
|
+
* `/__pinagent/feedback`; the response (`{ id, agentSpawned }`) is the
|
|
84
|
+
* same one the Vite/Next middleware returns.
|
|
85
|
+
*/
|
|
86
|
+
export async function submitFeedback(input: FeedbackInput): Promise<SubmitResult> {
|
|
87
|
+
const base = devServerBaseUrl();
|
|
88
|
+
if (!base) {
|
|
89
|
+
return { ok: false, error: 'No dev server (release build?)' };
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const res = await fetch(`${base}/__pinagent/feedback`, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: { 'Content-Type': 'application/json' },
|
|
95
|
+
body: JSON.stringify(input),
|
|
96
|
+
});
|
|
97
|
+
if (!res.ok) {
|
|
98
|
+
const text = await res.text().catch(() => '');
|
|
99
|
+
return { ok: false, error: `${res.status} ${text}`.trim() };
|
|
100
|
+
}
|
|
101
|
+
const json = (await res.json()) as { id?: string; agentSpawned?: boolean };
|
|
102
|
+
return { ok: true, id: json.id, agentSpawned: json.agentSpawned };
|
|
103
|
+
} catch (e) {
|
|
104
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Fetch the conversation list from the dev server (`GET /__pinagent/feedback`).
|
|
110
|
+
* Used on `<Pinagent/>` mount to restore minimized pills after an app reload —
|
|
111
|
+
* the server's `.pinagent/db.sqlite` is the source of truth, so RN keeps no
|
|
112
|
+
* device-local mirror. Returns `[]` (degrade silently) when the dev server is
|
|
113
|
+
* unreachable or the request fails — exactly as today when there's no server.
|
|
114
|
+
*
|
|
115
|
+
* The items are the `storage.list()` projection (`FeedbackRecord[]`); the
|
|
116
|
+
* caller filters them with `restorePills`. Typed loosely here so the wire JSON
|
|
117
|
+
* doesn't drag the agent-runner type into RN source.
|
|
118
|
+
*/
|
|
119
|
+
export async function fetchFeedbackList(): Promise<unknown[]> {
|
|
120
|
+
const base = devServerBaseUrl();
|
|
121
|
+
if (!base) return [];
|
|
122
|
+
try {
|
|
123
|
+
const res = await fetch(`${base}/__pinagent/feedback`, {
|
|
124
|
+
method: 'GET',
|
|
125
|
+
headers: { Accept: 'application/json' },
|
|
126
|
+
});
|
|
127
|
+
if (!res.ok) return [];
|
|
128
|
+
const json = (await res.json()) as unknown;
|
|
129
|
+
return Array.isArray(json) ? json : [];
|
|
130
|
+
} catch {
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Ask the dev server to open a source location in the editor on the machine
|
|
137
|
+
* running Metro — the RN analog of the web composer's "navigate to file".
|
|
138
|
+
* Fire-and-forget: the device gets no useful signal beyond "request sent".
|
|
139
|
+
*/
|
|
140
|
+
export async function openInEditor(loc: {
|
|
141
|
+
file: string;
|
|
142
|
+
line: number;
|
|
143
|
+
col: number;
|
|
144
|
+
}): Promise<boolean> {
|
|
145
|
+
const base = devServerBaseUrl();
|
|
146
|
+
if (!base) return false;
|
|
147
|
+
try {
|
|
148
|
+
const res = await fetch(`${base}/__pinagent/open`, {
|
|
149
|
+
method: 'POST',
|
|
150
|
+
headers: { 'Content-Type': 'application/json' },
|
|
151
|
+
body: JSON.stringify(loc),
|
|
152
|
+
});
|
|
153
|
+
return res.ok;
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** `${Platform.OS} ${Platform.Version}` — RN's stand-in for a UA string. */
|
|
160
|
+
export function platformTag(): string {
|
|
161
|
+
return `${Platform.OS} ${String(Platform.Version)}`;
|
|
162
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* The wire shape the RN widget produces. It mirrors `FeedbackInputSchema`
|
|
4
|
+
* in `packages/agent-runner/src/storage.ts` so the existing server
|
|
5
|
+
* accepts a phone-filed comment with zero backend changes. Keep this in
|
|
6
|
+
* lockstep with that zod schema — the middleware re-validates against it.
|
|
7
|
+
*/
|
|
8
|
+
export interface FeedbackInput {
|
|
9
|
+
/** Comment text the developer typed. 1..8000 chars (server-enforced). */
|
|
10
|
+
comment: string;
|
|
11
|
+
/**
|
|
12
|
+
* Source location of the tapped component, from the fiber's
|
|
13
|
+
* `_debugSource` (the RN analog of web's `data-pa-loc`). Null when the
|
|
14
|
+
* tapped view has no resolvable source (a deep native view with no
|
|
15
|
+
* composite owner in dev).
|
|
16
|
+
*/
|
|
17
|
+
loc: { file: string; line: number; col: number } | null;
|
|
18
|
+
/**
|
|
19
|
+
* Web sends a CSS selector here for HMR re-anchoring. RN has no
|
|
20
|
+
* selectors, so v1 sends the component display-name chain (e.g.
|
|
21
|
+
* "App > HomeScreen > PrimaryButton") purely to satisfy the schema and
|
|
22
|
+
* give the agent a human-readable hint. See the design doc's
|
|
23
|
+
* "Deliberate cuts for v1".
|
|
24
|
+
*/
|
|
25
|
+
selector: string;
|
|
26
|
+
/** The current route/screen name (web sends the page URL). */
|
|
27
|
+
url: string;
|
|
28
|
+
/** Window dimensions at pick time. */
|
|
29
|
+
viewport: { w: number; h: number };
|
|
30
|
+
/** `${Platform.OS} ${Platform.Version}` — the RN analog of a UA string. */
|
|
31
|
+
userAgent: string;
|
|
32
|
+
/** base64 PNG (no data: prefix). Capped at 5MB by the middleware. */
|
|
33
|
+
screenshot: string;
|
|
34
|
+
/** ISO timestamp. */
|
|
35
|
+
createdAt: string;
|
|
36
|
+
/**
|
|
37
|
+
* Extra elements the developer multi-picked into the SAME comment (the
|
|
38
|
+
* 2nd…Nth taps). Optional — omitted entirely for a single pick, exactly
|
|
39
|
+
* like the web widget (the server stores `additional_anchors` as null
|
|
40
|
+
* unless this is a non-empty array). The agent receives them as
|
|
41
|
+
* `additionalTargets` and addresses every location. Same per-anchor shape
|
|
42
|
+
* the web sends in `FeedbackInputSchema.additionalAnchors`
|
|
43
|
+
* (`packages/agent-runner/src/storage.ts`).
|
|
44
|
+
*/
|
|
45
|
+
additionalAnchors?: AdditionalAnchor[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* One extra (non-primary) pick. Matches the web `AdditionalAnchorSchema`
|
|
50
|
+
* exactly so the server accepts it unchanged: `clickX`/`clickY` are required
|
|
51
|
+
* integers (the tap point in window coordinates); `component` is optional.
|
|
52
|
+
*/
|
|
53
|
+
export interface AdditionalAnchor {
|
|
54
|
+
file: string | null;
|
|
55
|
+
line: number | null;
|
|
56
|
+
col: number | null;
|
|
57
|
+
selector: string;
|
|
58
|
+
clickX: number;
|
|
59
|
+
clickY: number;
|
|
60
|
+
component?: string | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** One segment of the component breadcrumb. */
|
|
64
|
+
export interface PickCrumb {
|
|
65
|
+
/** Component display name (e.g. `FeatureCard`). */
|
|
66
|
+
name: string;
|
|
67
|
+
/**
|
|
68
|
+
* Source location of that component, from its own `data-pa-loc`. Null when
|
|
69
|
+
* the component carries no resolvable source (e.g. an untagged 3rd-party
|
|
70
|
+
* wrapper). Lets the breadcrumb re-anchor the comment onto an ancestor.
|
|
71
|
+
*/
|
|
72
|
+
loc: FeedbackInput['loc'];
|
|
73
|
+
/**
|
|
74
|
+
* Highlight rectangle for this component (window coordinates), so pressing
|
|
75
|
+
* the crumb moves the on-screen selection outline onto it. Null when it
|
|
76
|
+
* couldn't be measured.
|
|
77
|
+
*/
|
|
78
|
+
frame: { x: number; y: number; width: number; height: number } | null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Result of resolving a tap point to a source location. */
|
|
82
|
+
export interface PickResult {
|
|
83
|
+
/** The precise tapped element's source location (the default target). */
|
|
84
|
+
loc: FeedbackInput['loc'];
|
|
85
|
+
/** Component display-name breadcrumb, newest (tapped) last. */
|
|
86
|
+
nameChain: string[];
|
|
87
|
+
/**
|
|
88
|
+
* Per-segment breadcrumb (same order as {@link nameChain}: root first,
|
|
89
|
+
* tapped component last), each carrying its own `loc` so pressing a
|
|
90
|
+
* breadcrumb can re-anchor the comment onto that ancestor.
|
|
91
|
+
*/
|
|
92
|
+
chain: PickCrumb[];
|
|
93
|
+
/** Highlight rectangle in window coordinates, for the overlay outline. */
|
|
94
|
+
frame: { x: number; y: number; width: number; height: number } | null;
|
|
95
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* RN WebSocket client for live agent streaming.
|
|
4
|
+
*
|
|
5
|
+
* Connects to the same Metro host the feedback POST uses (derived from
|
|
6
|
+
* `devServerBaseUrl()`), swapping the scheme to `ws(s)` and hitting
|
|
7
|
+
* `/__pinagent/ws` — the endpoint the server mounts via
|
|
8
|
+
* `pinagentWebsocketEndpoints` (Metro `config.server.websocketEndpoints`).
|
|
9
|
+
* Because it rides Metro's own port, a physical device needs no port
|
|
10
|
+
* discovery: if the bundle loaded, this URL is reachable.
|
|
11
|
+
*
|
|
12
|
+
* The wire protocol is the web one (`@pinagent/shared`'s ws-protocol): we send
|
|
13
|
+
* `subscribe` / `user_message` / `ask_response` / `interrupt`, and receive
|
|
14
|
+
* `event` / `done` / `error`. On reconnect the server replays the full
|
|
15
|
+
* transcript, so we fire `onReset` first to let the UI rebuild from scratch.
|
|
16
|
+
*
|
|
17
|
+
* Scoped to ONE feedback id per client — the RN widget streams a single
|
|
18
|
+
* conversation at a time (the one just submitted). That keeps this far smaller
|
|
19
|
+
* than the web client's multiplexed map.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { AgentEvent, ServerMessage } from './transcript';
|
|
23
|
+
import { devServerBaseUrl } from './transport';
|
|
24
|
+
|
|
25
|
+
const RECONNECT_MIN_MS = 500;
|
|
26
|
+
const RECONNECT_MAX_MS = 8000;
|
|
27
|
+
|
|
28
|
+
export interface StreamHandlers {
|
|
29
|
+
/** A reconnect is about to replay the transcript — clear and rebuild. */
|
|
30
|
+
onReset(): void;
|
|
31
|
+
onEvent(event: AgentEvent): void;
|
|
32
|
+
/** The run's bus closed (agent finished or idle). */
|
|
33
|
+
onDone(): void;
|
|
34
|
+
onError(message: string): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Derive `ws(s)://host:port/__pinagent/ws` from Metro's bundle URL. */
|
|
38
|
+
export function devServerWsUrl(): string | null {
|
|
39
|
+
const base = devServerBaseUrl();
|
|
40
|
+
if (!base) return null;
|
|
41
|
+
return `${base.replace(/^http/, 'ws')}/__pinagent/ws`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class StreamClient {
|
|
45
|
+
private socket: WebSocket | null = null;
|
|
46
|
+
private reconnectDelay = RECONNECT_MIN_MS;
|
|
47
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
48
|
+
private closed = false;
|
|
49
|
+
private connectedBefore = false;
|
|
50
|
+
|
|
51
|
+
constructor(
|
|
52
|
+
private readonly feedbackId: string,
|
|
53
|
+
private readonly handlers: StreamHandlers,
|
|
54
|
+
) {}
|
|
55
|
+
|
|
56
|
+
/** Open the socket and subscribe. Safe to call once per instance. */
|
|
57
|
+
start(): void {
|
|
58
|
+
this.closed = false;
|
|
59
|
+
this.connect();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Tear down for good — no further reconnects. Call on unmount/dismiss. */
|
|
63
|
+
stop(): void {
|
|
64
|
+
this.closed = true;
|
|
65
|
+
if (this.reconnectTimer) {
|
|
66
|
+
clearTimeout(this.reconnectTimer);
|
|
67
|
+
this.reconnectTimer = null;
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
this.socket?.close();
|
|
71
|
+
} catch {
|
|
72
|
+
// already closing
|
|
73
|
+
}
|
|
74
|
+
this.socket = null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Queue a follow-up turn for the agent. No-op if the socket isn't open. */
|
|
78
|
+
sendUserMessage(content: string): void {
|
|
79
|
+
this.send({ type: 'user_message', feedbackId: this.feedbackId, content });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Answer an `ask_user` prompt. */
|
|
83
|
+
sendAskResponse(askId: string, answer: string): void {
|
|
84
|
+
this.send({ type: 'ask_response', askId, answer });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Interrupt the in-flight run. */
|
|
88
|
+
interrupt(): void {
|
|
89
|
+
this.send({ type: 'interrupt', feedbackId: this.feedbackId });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private connect(): void {
|
|
93
|
+
const url = devServerWsUrl();
|
|
94
|
+
if (!url) {
|
|
95
|
+
this.handlers.onError('No dev server (release build?)');
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
let socket: WebSocket;
|
|
99
|
+
try {
|
|
100
|
+
socket = new WebSocket(url);
|
|
101
|
+
} catch {
|
|
102
|
+
this.scheduleReconnect();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
this.socket = socket;
|
|
106
|
+
|
|
107
|
+
socket.onopen = () => {
|
|
108
|
+
// On a reconnect the server replays the whole transcript; let the UI
|
|
109
|
+
// wipe and rebuild so we don't double-render.
|
|
110
|
+
if (this.connectedBefore) this.handlers.onReset();
|
|
111
|
+
this.connectedBefore = true;
|
|
112
|
+
this.reconnectDelay = RECONNECT_MIN_MS;
|
|
113
|
+
this.send({ type: 'subscribe', feedbackId: this.feedbackId });
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
socket.onmessage = (ev: { data?: unknown }) => {
|
|
117
|
+
this.onMessage(ev.data);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
socket.onclose = () => {
|
|
121
|
+
if (this.closed) return;
|
|
122
|
+
this.scheduleReconnect();
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// RN fires onerror then onclose; reconnect is driven by onclose.
|
|
126
|
+
socket.onerror = () => {};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private onMessage(data: unknown): void {
|
|
130
|
+
if (typeof data !== 'string') return;
|
|
131
|
+
let msg: ServerMessage;
|
|
132
|
+
try {
|
|
133
|
+
msg = JSON.parse(data) as ServerMessage;
|
|
134
|
+
} catch {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
switch (msg.type) {
|
|
138
|
+
case 'event':
|
|
139
|
+
if (msg.feedbackId === this.feedbackId && msg.event) this.handlers.onEvent(msg.event);
|
|
140
|
+
break;
|
|
141
|
+
case 'done':
|
|
142
|
+
if (msg.feedbackId === this.feedbackId) this.handlers.onDone();
|
|
143
|
+
break;
|
|
144
|
+
case 'error':
|
|
145
|
+
// Server scopes some errors to a feedback id; surface ours + global.
|
|
146
|
+
if (!msg.feedbackId || msg.feedbackId === this.feedbackId) {
|
|
147
|
+
this.handlers.onError(msg.message ?? 'unknown error');
|
|
148
|
+
}
|
|
149
|
+
break;
|
|
150
|
+
// worktree_state / pong / project fan-out: ignored by the RN widget.
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private send(msg: Record<string, unknown>): void {
|
|
155
|
+
const s = this.socket;
|
|
156
|
+
if (!s || s.readyState !== 1 /* OPEN */) return;
|
|
157
|
+
try {
|
|
158
|
+
s.send(JSON.stringify(msg));
|
|
159
|
+
} catch {
|
|
160
|
+
// socket closing mid-write
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private scheduleReconnect(): void {
|
|
165
|
+
if (this.closed || this.reconnectTimer) return;
|
|
166
|
+
const delay = this.reconnectDelay;
|
|
167
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS);
|
|
168
|
+
this.reconnectTimer = setTimeout(() => {
|
|
169
|
+
this.reconnectTimer = null;
|
|
170
|
+
if (!this.closed) this.connect();
|
|
171
|
+
}, delay);
|
|
172
|
+
}
|
|
173
|
+
}
|