@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.
@@ -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
+ }
@@ -84,9 +84,14 @@ export class StreamClient {
84
84
  this.send({ type: 'ask_response', askId, answer });
85
85
  }
86
86
 
87
- /** Interrupt the in-flight run. */
88
- interrupt(): void {
89
- this.send({ type: 'interrupt', feedbackId: this.feedbackId });
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
- private send(msg: Record<string, unknown>): void {
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