@pinagent/react-native 0.2.3 → 0.2.5
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/AgentDock.d.ts +32 -0
- package/dist/native/MarkdownView.d.ts +24 -0
- package/dist/native/Pinagent.d.ts +5 -4
- package/dist/native/StreamSheet.d.ts +18 -13
- package/dist/native/inspector.d.ts +60 -0
- package/dist/native/keyboard-height.d.ts +2 -0
- package/dist/native/keyboard.d.ts +35 -0
- package/dist/native/markdown.d.ts +65 -0
- package/dist/native/run-state.d.ts +120 -0
- package/dist/native/scroll-follow.d.ts +32 -0
- package/dist/native/ws-client.d.ts +8 -2
- package/package.json +3 -3
- package/src/native/AgentDock.tsx +232 -0
- package/src/native/MarkdownView.tsx +154 -0
- package/src/native/Pinagent.tsx +80 -58
- package/src/native/StreamSheet.tsx +188 -135
- package/src/native/inspector.ts +228 -34
- package/src/native/keyboard-height.ts +34 -0
- package/src/native/keyboard.ts +40 -0
- package/src/native/markdown.ts +194 -0
- package/src/native/run-state.ts +204 -0
- package/src/native/scroll-follow.ts +47 -0
- package/src/native/ws-client.ts +13 -5
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Pure run-state model for the RN widget's live agent runs.
|
|
4
|
+
*
|
|
5
|
+
* The minimized agent UI used to derive its status ad-hoc inside `StreamSheet`
|
|
6
|
+
* from a tangle of booleans (`running`/`done`/`transportError`/`askOpen`),
|
|
7
|
+
* encoded purely by dot color. This module replaces that with one explicit,
|
|
8
|
+
* unit-testable state machine: a {@link RunState} per run, a presentation map
|
|
9
|
+
* (glyph + tone + label, not color-alone), and the aggregation
|
|
10
|
+
* ({@link dockModel}) the compact bottom-left dock renders.
|
|
11
|
+
*
|
|
12
|
+
* It's deliberately dependency-free (only the sibling `transcript` reducer) so
|
|
13
|
+
* it's testable without React Native — the device UI (`AgentDock`, the expanded
|
|
14
|
+
* `StreamSheet`) stays a thin renderer over these pure functions, the same way
|
|
15
|
+
* `transcript.ts` / `restore.ts` keep their logic out of the un-testable
|
|
16
|
+
* RN-runtime layer.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { type AgentEvent, pendingAsk } from './transcript';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The lifecycle state of one streamed agent run.
|
|
23
|
+
*
|
|
24
|
+
* - `connecting` — socket up, transcript not replayed yet (no events). The
|
|
25
|
+
* brief window after spawn/restore and again right after a reconnect.
|
|
26
|
+
* - `working` — events are flowing and the run hasn't ended.
|
|
27
|
+
* - `awaiting` — blocked on an `ask_user` the developer hasn't answered. The
|
|
28
|
+
* one state that needs the developer's attention; it pulses.
|
|
29
|
+
* - `done` — ended cleanly (success result, or the bus simply closed).
|
|
30
|
+
* - `failed` — ended on an error (server error frame, transport error, or a
|
|
31
|
+
* non-success result subtype).
|
|
32
|
+
*/
|
|
33
|
+
export type RunState = 'connecting' | 'working' | 'awaiting' | 'done' | 'failed';
|
|
34
|
+
|
|
35
|
+
export interface RunStateInput {
|
|
36
|
+
/** The folded agent event stream (same array `StreamSheet` accumulates). */
|
|
37
|
+
events: AgentEvent[];
|
|
38
|
+
/** Set once the run reached a terminal `result`/`done`. */
|
|
39
|
+
done: boolean;
|
|
40
|
+
/** Non-null when a server `error` frame (or "no dev server") arrived. */
|
|
41
|
+
transportError: string | null;
|
|
42
|
+
/** `askId` → answer, so an answered question no longer reads as `awaiting`. */
|
|
43
|
+
answered: Record<string, string>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Did the run's last terminal event signal failure rather than success? */
|
|
47
|
+
function endedInFailure(events: AgentEvent[]): boolean {
|
|
48
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
49
|
+
const e = events[i];
|
|
50
|
+
if (!e) continue;
|
|
51
|
+
if (e.type === 'error') return true;
|
|
52
|
+
if (e.type === 'result') return e.subtype !== 'success';
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Collapse the raw run booleans into a single {@link RunState}. Pure and
|
|
59
|
+
* order-sensitive: a transport error wins over everything (you can't answer or
|
|
60
|
+
* keep working over a dead socket), then an unanswered question, then terminal
|
|
61
|
+
* done/failure, else working-vs-connecting by whether any event has arrived.
|
|
62
|
+
*/
|
|
63
|
+
export function deriveRunState({
|
|
64
|
+
events,
|
|
65
|
+
done,
|
|
66
|
+
transportError,
|
|
67
|
+
answered,
|
|
68
|
+
}: RunStateInput): RunState {
|
|
69
|
+
if (transportError) return 'failed';
|
|
70
|
+
const ask = pendingAsk(events);
|
|
71
|
+
if (ask && !answered[ask.askId]) return 'awaiting';
|
|
72
|
+
if (done) return endedInFailure(events) ? 'failed' : 'done';
|
|
73
|
+
return events.length > 0 ? 'working' : 'connecting';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Semantic color bucket; mapped to concrete colors in the component layer. */
|
|
77
|
+
export type RunTone = 'neutral' | 'active' | 'attention' | 'success' | 'danger';
|
|
78
|
+
|
|
79
|
+
export interface RunPresentation {
|
|
80
|
+
/** Short status word for labels / accessibility. */
|
|
81
|
+
label: string;
|
|
82
|
+
/** Monochrome glyph so state never rides on color alone. */
|
|
83
|
+
glyph: string;
|
|
84
|
+
/** Semantic tone the renderer maps to a palette entry. */
|
|
85
|
+
tone: RunTone;
|
|
86
|
+
/** Whether the chip should pulse to pull the developer back. */
|
|
87
|
+
pulse: boolean;
|
|
88
|
+
/** Non-terminal (connecting/working/awaiting) — i.e. still an active run. */
|
|
89
|
+
active: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const PRESENTATION: Record<RunState, RunPresentation> = {
|
|
93
|
+
connecting: { label: 'Connecting', glyph: '◌', tone: 'neutral', pulse: false, active: true },
|
|
94
|
+
working: { label: 'Working', glyph: '◐', tone: 'active', pulse: false, active: true },
|
|
95
|
+
awaiting: { label: 'Needs you', glyph: '!', tone: 'attention', pulse: true, active: true },
|
|
96
|
+
done: { label: 'Done', glyph: '✓', tone: 'success', pulse: false, active: false },
|
|
97
|
+
failed: { label: 'Failed', glyph: '✕', tone: 'danger', pulse: false, active: false },
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Does an "interrupting" overlay actually apply right now?
|
|
102
|
+
*
|
|
103
|
+
* Stop is purely a client-side affordance over the existing `interrupt` frame
|
|
104
|
+
* (ticket 015): tapping it sets a local flag, and we keep showing "Stopping…"
|
|
105
|
+
* until the server lands a terminal event. The overlay therefore applies only
|
|
106
|
+
* while the run is still {@link RunPresentation.active active} — once it reaches
|
|
107
|
+
* a terminal `done`/`failed` state the interrupt resolved, so the overlay drops
|
|
108
|
+
* even if the caller's flag hasn't been cleared yet. Modeling it as an overlay
|
|
109
|
+
* (rather than a sixth {@link RunState}) keeps the state machine — and the dock
|
|
110
|
+
* aggregation — unchanged and unit-testable.
|
|
111
|
+
*/
|
|
112
|
+
export function interruptOverlayActive(state: RunState, interrupting: boolean): boolean {
|
|
113
|
+
return interrupting && PRESENTATION[state].active;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Presentation for a run, optionally overlaid with an interrupting affordance.
|
|
118
|
+
*
|
|
119
|
+
* With `interrupting` true on a still-active run we relabel to "Stopping…" and
|
|
120
|
+
* stop any pulse (the developer asked it to halt — pulsing for attention is the
|
|
121
|
+
* wrong signal), keeping the underlying state's glyph/tone/active so the dock
|
|
122
|
+
* partitioning is undisturbed. Terminal states ignore the flag (see
|
|
123
|
+
* {@link interruptOverlayActive}).
|
|
124
|
+
*/
|
|
125
|
+
export function runPresentation(state: RunState, interrupting = false): RunPresentation {
|
|
126
|
+
const base = PRESENTATION[state];
|
|
127
|
+
if (!interruptOverlayActive(state, interrupting)) return base;
|
|
128
|
+
return { ...base, label: 'Stopping…', pulse: false };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A run as the dock sees it: identity, header label, and derived state. */
|
|
132
|
+
export interface DockRun {
|
|
133
|
+
id: string;
|
|
134
|
+
/** Header label — `file:line` if anchored, else the component name. */
|
|
135
|
+
target: string;
|
|
136
|
+
state: RunState;
|
|
137
|
+
/**
|
|
138
|
+
* The developer tapped Stop and we're awaiting the run's terminal event
|
|
139
|
+
* (ticket 015). An overlay over `state`, not a state — the chip relabels to
|
|
140
|
+
* "Stopping…" while it's still active. Ignored once terminal.
|
|
141
|
+
*/
|
|
142
|
+
interrupting?: boolean;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface DockModel {
|
|
146
|
+
/** connecting/working/awaiting, sorted attention-first (stable within tone). */
|
|
147
|
+
active: DockRun[];
|
|
148
|
+
/** done/failed, in input order. */
|
|
149
|
+
finished: DockRun[];
|
|
150
|
+
/** Collapse the active runs into a single count bar (true at ≥2 active). */
|
|
151
|
+
collapseActive: boolean;
|
|
152
|
+
/** How many active runs are blocked on input. */
|
|
153
|
+
awaitingCount: number;
|
|
154
|
+
/** State driving the collapsed bar's glyph/tone (most attention-worthy). */
|
|
155
|
+
summaryState: RunState;
|
|
156
|
+
/** Bar text, e.g. `3 agents · 1 needs you`. */
|
|
157
|
+
activeHeadline: string;
|
|
158
|
+
/** True when any finished run failed (lets the summary flag failures). */
|
|
159
|
+
finishedHasFailure: boolean;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Attention priority for ordering/summarizing active runs (higher = first). */
|
|
163
|
+
const ACTIVE_PRIORITY: Record<RunState, number> = {
|
|
164
|
+
awaiting: 3,
|
|
165
|
+
working: 2,
|
|
166
|
+
connecting: 1,
|
|
167
|
+
done: 0,
|
|
168
|
+
failed: 0,
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Aggregate runs into what the dock draws. Active runs surface attention-first
|
|
173
|
+
* (a blocked run jumps above a busy one); ≥2 of them collapse into one count
|
|
174
|
+
* bar. Finished runs always roll into a `▸ N finished` summary. Pure so the
|
|
175
|
+
* hybrid + done-summary behavior is covered without rendering anything.
|
|
176
|
+
*/
|
|
177
|
+
export function dockModel(runs: readonly DockRun[]): DockModel {
|
|
178
|
+
// Stable attention-first sort for active runs: decorate with the input index
|
|
179
|
+
// so equal-tone runs keep their arrival order.
|
|
180
|
+
const active = runs
|
|
181
|
+
.filter((r) => runPresentation(r.state).active)
|
|
182
|
+
.map((run, i) => ({ run, i }))
|
|
183
|
+
.sort((a, b) => ACTIVE_PRIORITY[b.run.state] - ACTIVE_PRIORITY[a.run.state] || a.i - b.i)
|
|
184
|
+
.map(({ run }) => run);
|
|
185
|
+
const finished = runs.filter((r) => !runPresentation(r.state).active);
|
|
186
|
+
|
|
187
|
+
const awaitingCount = active.filter((r) => r.state === 'awaiting').length;
|
|
188
|
+
const summaryState = active[0]?.state ?? 'working';
|
|
189
|
+
const noun = active.length === 1 ? 'agent' : 'agents';
|
|
190
|
+
const activeHeadline =
|
|
191
|
+
awaitingCount > 0
|
|
192
|
+
? `${active.length} ${noun} · ${awaitingCount} needs you`
|
|
193
|
+
: `${active.length} ${noun}`;
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
active,
|
|
197
|
+
finished,
|
|
198
|
+
collapseActive: active.length >= 2,
|
|
199
|
+
awaitingCount,
|
|
200
|
+
summaryState,
|
|
201
|
+
activeHeadline,
|
|
202
|
+
finishedHasFailure: finished.some((r) => r.state === 'failed'),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Pure scroll-follow predicate for the RN widget's live transcript.
|
|
4
|
+
*
|
|
5
|
+
* The expanded `StreamSheet` transcript used to call `scrollToEnd` on every
|
|
6
|
+
* content change, so scrolling up to re-read earlier output during an active run
|
|
7
|
+
* got yanked back to the bottom by the next event. The fix is the standard
|
|
8
|
+
* chat-log "stick to bottom only while already near the bottom" behavior — and
|
|
9
|
+
* the near-bottom test is the one piece of math worth pulling out of the
|
|
10
|
+
* un-testable RN-runtime layer (mirrors how `run-state.ts` / `transcript.ts`
|
|
11
|
+
* keep their logic pure, see packages/react-native/src/native).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Default slack (in px) for treating "almost at the bottom" as "at bottom". */
|
|
15
|
+
export const NEAR_BOTTOM_THRESHOLD = 24;
|
|
16
|
+
|
|
17
|
+
export interface NearBottomInput {
|
|
18
|
+
/** `contentOffset.y` — how far the content is scrolled up from the top. */
|
|
19
|
+
offsetY: number;
|
|
20
|
+
/** `layoutMeasurement.height` — the visible viewport height. */
|
|
21
|
+
viewportH: number;
|
|
22
|
+
/** `contentSize.height` — the full scrollable content height. */
|
|
23
|
+
contentH: number;
|
|
24
|
+
/** Slack in px; the gap to the bottom that still counts as "at bottom". */
|
|
25
|
+
threshold?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* True when the scroll position is within `threshold` px of the bottom (so
|
|
30
|
+
* auto-follow should keep pinning new content into view). Pure and tolerant of
|
|
31
|
+
* the bouncy/over-scroll values RN can report: a negative remaining distance
|
|
32
|
+
* (rubber-band past the end) still reads as at-bottom, and content shorter than
|
|
33
|
+
* the viewport is always at-bottom. A non-finite or non-positive threshold
|
|
34
|
+
* falls back to {@link NEAR_BOTTOM_THRESHOLD}.
|
|
35
|
+
*/
|
|
36
|
+
export function isNearBottom({
|
|
37
|
+
offsetY,
|
|
38
|
+
viewportH,
|
|
39
|
+
contentH,
|
|
40
|
+
threshold = NEAR_BOTTOM_THRESHOLD,
|
|
41
|
+
}: NearBottomInput): boolean {
|
|
42
|
+
const slack = Number.isFinite(threshold) && threshold > 0 ? threshold : NEAR_BOTTOM_THRESHOLD;
|
|
43
|
+
// Content that doesn't fill the viewport can't be scrolled — always pinned.
|
|
44
|
+
if (contentH <= viewportH) return true;
|
|
45
|
+
const distanceFromBottom = contentH - (offsetY + viewportH);
|
|
46
|
+
return distanceFromBottom <= slack;
|
|
47
|
+
}
|
package/src/native/ws-client.ts
CHANGED
|
@@ -84,9 +84,14 @@ export class StreamClient {
|
|
|
84
84
|
this.send({ type: 'ask_response', askId, answer });
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
/**
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
/**
|
|
88
|
+
* Interrupt the in-flight run. Returns whether the frame was actually written
|
|
89
|
+
* to an OPEN socket — `false` means it was dropped (socket closed/reconnecting)
|
|
90
|
+
* so the UI can say "couldn't stop" instead of silently no-opping. The server
|
|
91
|
+
* tears the run down on its own terminal event; this is just the request.
|
|
92
|
+
*/
|
|
93
|
+
interrupt(): boolean {
|
|
94
|
+
return this.send({ type: 'interrupt', feedbackId: this.feedbackId });
|
|
90
95
|
}
|
|
91
96
|
|
|
92
97
|
private connect(): void {
|
|
@@ -151,13 +156,16 @@ export class StreamClient {
|
|
|
151
156
|
}
|
|
152
157
|
}
|
|
153
158
|
|
|
154
|
-
|
|
159
|
+
/** Write a frame if the socket is OPEN. Returns whether it was sent. */
|
|
160
|
+
private send(msg: Record<string, unknown>): boolean {
|
|
155
161
|
const s = this.socket;
|
|
156
|
-
if (!s || s.readyState !== 1 /* OPEN */) return;
|
|
162
|
+
if (!s || s.readyState !== 1 /* OPEN */) return false;
|
|
157
163
|
try {
|
|
158
164
|
s.send(JSON.stringify(msg));
|
|
165
|
+
return true;
|
|
159
166
|
} catch {
|
|
160
167
|
// socket closing mid-write
|
|
168
|
+
return false;
|
|
161
169
|
}
|
|
162
170
|
}
|
|
163
171
|
|