@pinagent/react-native 0.2.4 → 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.
@@ -14,56 +14,62 @@
14
14
  * reconnect replays the agent transcript, so we clear `events` on `onReset`
15
15
  * but keep local follow-ups.
16
16
  *
17
- * The sheet can be **minimized** to a compact status pill so the developer can
18
- * keep interacting with the app e.g. to pick another element and spawn a
19
- * second agent. Minimizing doesn't tear the run down: the component stays
20
- * mounted (only its rendering changes), so the WebSocket keeps streaming in the
21
- * background and the live transcript is intact the moment it's re-expanded.
22
- * `<Pinagent/>` mounts one of these per concurrent run.
17
+ * The sheet can be **minimized**: the run drops into the compact bottom-left
18
+ * `AgentDock` (a chip / count bar) so the developer can keep interacting with
19
+ * the app — e.g. to pick another element and spawn a second agent. Minimizing
20
+ * doesn't tear the run down: this component stays mounted and simply renders
21
+ * `null` (the dock draws the compact UI), so the WebSocket keeps streaming in
22
+ * the background and the live transcript is intact the moment it's re-expanded.
23
+ * `<Pinagent/>` mounts one of these per concurrent run and reads each run's
24
+ * derived {@link RunState} (reported via `onState`) to drive the dock.
23
25
  */
24
26
  import type { ReactElement } from 'react';
25
- import { useEffect, useMemo, useRef, useState } from 'react';
26
- import {
27
- Animated,
28
- Modal,
29
- Pressable,
30
- ScrollView,
31
- StyleSheet,
32
- Text,
33
- TextInput,
34
- View,
35
- } from 'react-native';
27
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
28
+ import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native';
29
+ import { Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
30
+ import { isDismissKey } from './keyboard';
31
+ import { useKeyboardHeight } from './keyboard-height';
32
+ import { MarkdownView } from './MarkdownView';
33
+ import { deriveRunState, interruptOverlayActive, type RunState } from './run-state';
34
+ import { isNearBottom } from './scroll-follow';
36
35
  import { type AgentEvent, pendingAsk, renderTranscript } from './transcript';
37
36
  import { StreamClient } from './ws-client';
38
37
 
39
- /** Height of a minimized pill — drives the bottom-left stacking offset. */
40
- const PILL_HEIGHT = 40;
38
+ /** Expanded-sheet header text per run state. */
39
+ const HEADER_LABEL: Record<RunState, string> = {
40
+ connecting: 'Connecting',
41
+ working: 'Agent working',
42
+ awaiting: 'Agent needs input',
43
+ done: 'Agent finished',
44
+ failed: 'Agent failed',
45
+ };
41
46
 
42
47
  export interface StreamSheetProps {
43
48
  feedbackId: string;
44
49
  /** Source label shown in the header (e.g. `file:line` or component name). */
45
50
  target: string;
46
- /** Render as a compact pill (WS stays live) instead of the full sheet. */
51
+ /** Minimized render nothing (the dock shows the compact chip); WS stays live. */
47
52
  minimized: boolean;
48
- /** Stack position among minimized pills (0 = bottom-most), for layout. */
49
- stackIndex: number;
50
- /** Collapse the full sheet to its pill. */
53
+ /** Collapse the full sheet back into the dock. */
51
54
  onMinimize: () => void;
52
- /** Expand the pill back to the full sheet. */
53
- onExpand: () => void;
54
55
  /** Dismiss for good — tears down the WS and removes this run's view. */
55
56
  onClose: () => void;
57
+ /**
58
+ * Report the run's derived state up so the dock can render it. `interrupting`
59
+ * is the Stop overlay (ticket 015) — the developer tapped Stop and we're
60
+ * awaiting teardown; the dock relabels the chip to "Stopping…" while active.
61
+ */
62
+ onState: (state: RunState, interrupting: boolean) => void;
56
63
  }
57
64
 
58
65
  export function StreamSheet({
59
66
  feedbackId,
60
67
  target,
61
68
  minimized,
62
- stackIndex,
63
69
  onMinimize,
64
- onExpand,
65
70
  onClose,
66
- }: StreamSheetProps): ReactElement {
71
+ onState,
72
+ }: StreamSheetProps): ReactElement | null {
67
73
  const [events, setEvents] = useState<AgentEvent[]>([]);
68
74
  const [followUps, setFollowUps] = useState<string[]>([]);
69
75
  const [answered, setAnswered] = useState<Record<string, string>>({});
@@ -71,13 +77,38 @@ export function StreamSheet({
71
77
  const [transportError, setTransportError] = useState<string | null>(null);
72
78
  const [draft, setDraft] = useState('');
73
79
  const [askDraft, setAskDraft] = useState('');
80
+ // The developer tapped Stop and we sent the interrupt frame; we keep showing
81
+ // "Interrupting…" (button disabled) until a terminal event lands (ticket 015).
82
+ // Purely a client-side affordance over the fire-and-forget `interrupt` frame.
83
+ const [interrupting, setInterrupting] = useState(false);
74
84
 
75
85
  const clientRef = useRef<StreamClient | null>(null);
76
86
  const scrollRef = useRef<ScrollView>(null);
87
+ // Whether the developer is pinned at (or near) the bottom of the transcript.
88
+ // Starts true so a freshly expanded sheet auto-follows the latest output;
89
+ // flips off the moment they scroll up to re-read, and back on when they
90
+ // return to the bottom. A ref (not state) so updating it on every scroll
91
+ // frame doesn't re-render the sheet.
92
+ const atBottomRef = useRef(true);
93
+ // True until the first auto-scroll on a non-empty transcript lands, so the
94
+ // initial scroll-to-end always fires regardless of measured position.
95
+ const didInitialScrollRef = useRef(false);
96
+ // The sheet is its own Modal, so KeyboardAvoidingView can't lift it — pad the
97
+ // backdrop up by the live keyboard height (matches the composer; keyboard-height.ts).
98
+ const keyboardHeight = useKeyboardHeight();
77
99
 
78
100
  useEffect(() => {
79
101
  const client = new StreamClient(feedbackId, {
80
- onReset: () => setEvents([]),
102
+ // A reconnect replays the transcript from scratch, so clear the events —
103
+ // and the prior transport error, so a recovered run leaves the `failed`
104
+ // state instead of staying stuck red after a successful reconnect.
105
+ onReset: () => {
106
+ setEvents([]);
107
+ setTransportError(null);
108
+ // A reconnect replays a still-live run; drop any stale interrupting
109
+ // affordance so a recovered run reads as working, not stuck "Stopping…".
110
+ setInterrupting(false);
111
+ },
81
112
  onEvent: (event) => {
82
113
  setEvents((prev) => [...prev, event]);
83
114
  if (event.type === 'result') setDone(true);
@@ -92,67 +123,53 @@ export function StreamSheet({
92
123
 
93
124
  const rows = useMemo(() => renderTranscript(events), [events]);
94
125
  const ask = useMemo(() => pendingAsk(events), [events]);
95
- const askOpen = ask && !answered[ask.askId];
96
- const running = !done && !transportError;
97
- // The agent is blocked on an `ask_user` we haven't answered. While minimized
98
- // that would otherwise be invisible the run just stalls so the pill flags
99
- // it and pulses to pull you back. (When expanded, the answer form shows.)
100
- const needsInput = minimized && !!askOpen;
126
+ const state = deriveRunState({ events, done, transportError, answered });
127
+ const askOpen = state === 'awaiting' && !!ask;
128
+ const running = state === 'connecting' || state === 'working' || state === 'awaiting';
129
+ // The interrupt only shows while the run is still active; once a terminal
130
+ // event lands the run is done/failed and the overlay no longer applies.
131
+ const showInterrupting = interruptOverlayActive(state, interrupting);
101
132
 
102
- // Drive the pulse on the minimized pill while it's waiting for input. The
103
- // ref/effect are hooks, so they live above the early return; the loop only
104
- // runs in the `needsInput` state and resets otherwise.
105
- const pulse = useRef(new Animated.Value(1)).current;
133
+ // A terminal event cleared the run drop the local interrupting flag so a
134
+ // subsequent follow-up (which resumes the run) starts fresh, not "Stopping…".
106
135
  useEffect(() => {
107
- if (!needsInput) {
108
- pulse.setValue(1);
109
- return;
110
- }
111
- const loop = Animated.loop(
112
- Animated.sequence([
113
- Animated.timing(pulse, { toValue: 0.35, duration: 600, useNativeDriver: true }),
114
- Animated.timing(pulse, { toValue: 1, duration: 600, useNativeDriver: true }),
115
- ]),
116
- );
117
- loop.start();
118
- return () => loop.stop();
119
- }, [needsInput, pulse]);
136
+ if (interrupting && !interruptOverlayActive(state, true)) setInterrupting(false);
137
+ }, [state, interrupting]);
120
138
 
121
- // Minimized: a compact, tappable status pill. The WS hooks above keep
122
- // running because the component stays mounted only the rendering changes
123
- // so the run streams on in the background and re-expands with full state.
124
- if (minimized) {
125
- return (
126
- <Pressable
127
- onPress={onExpand}
128
- accessibilityRole="button"
129
- style={[
130
- styles.pill,
131
- needsInput && styles.pillAsk,
132
- { bottom: 40 + stackIndex * (PILL_HEIGHT + 8) },
133
- ]}
134
- >
135
- <Animated.View
136
- style={[
137
- styles.pillDot,
138
- needsInput
139
- ? [styles.pillDotAsk, { opacity: pulse }]
140
- : transportError
141
- ? styles.pillDotError
142
- : running
143
- ? styles.pillDotRunning
144
- : styles.pillDotDone,
145
- ]}
146
- />
147
- <Text style={styles.pillText} numberOfLines={1}>
148
- {needsInput ? `Needs input · ${target}` : target}
149
- </Text>
150
- <Pressable onPress={onClose} hitSlop={10} accessibilityRole="button">
151
- <Text style={styles.pillClose}>✕</Text>
152
- </Pressable>
153
- </Pressable>
154
- );
155
- }
139
+ // Report the derived state up to <Pinagent/> so the dock reflects it. The
140
+ // callback is held in a ref so the effect fires only on a real state change
141
+ // (not whenever the parent passes a fresh inline `onState`). Runs while
142
+ // minimized too — this component stays mounted and only renders null.
143
+ const onStateRef = useRef(onState);
144
+ onStateRef.current = onState;
145
+ useEffect(() => {
146
+ onStateRef.current(state, showInterrupting);
147
+ }, [state, showInterrupting]);
148
+
149
+ // Track whether the developer is parked at the bottom so a content change
150
+ // only re-pins when they haven't scrolled up to re-read (chat-log behavior).
151
+ const onScroll = useCallback((e: NativeSyntheticEvent<NativeScrollEvent>) => {
152
+ const { contentOffset, layoutMeasurement, contentSize } = e.nativeEvent;
153
+ atBottomRef.current = isNearBottom({
154
+ offsetY: contentOffset.y,
155
+ viewportH: layoutMeasurement.height,
156
+ contentH: contentSize.height,
157
+ });
158
+ }, []);
159
+
160
+ // On a content change, auto-follow only when pinned at the bottom — except the
161
+ // very first non-empty layout, which always lands at the latest so a freshly
162
+ // expanded sheet opens scrolled to the end.
163
+ const onContentSizeChange = useCallback(() => {
164
+ if (didInitialScrollRef.current && !atBottomRef.current) return;
165
+ didInitialScrollRef.current = true;
166
+ scrollRef.current?.scrollToEnd({ animated: true });
167
+ }, []);
168
+
169
+ // Minimized: render nothing. The WS hooks above keep running because the
170
+ // component stays mounted; the compact chip is drawn by the dock from the
171
+ // state we report. Re-expanding shows the full sheet with live state intact.
172
+ if (minimized) return null;
156
173
 
157
174
  function submitAnswer(answer: string): void {
158
175
  if (!ask || !answer.trim()) return;
@@ -167,21 +184,40 @@ export function StreamSheet({
167
184
  clientRef.current?.sendUserMessage(text);
168
185
  setFollowUps((prev) => [...prev, text]);
169
186
  setDraft('');
170
- setDone(false); // a follow-up resumes the run
187
+ // A follow-up resumes the run: clear the terminal/error flags so it leaves
188
+ // the done/failed state and reads as working again — and drop any pending
189
+ // interrupt (the developer is continuing, not stopping).
190
+ setDone(false);
191
+ setTransportError(null);
192
+ setInterrupting(false);
193
+ }
194
+
195
+ // Stop: send the interrupt frame and immediately show "Interrupting…" until a
196
+ // terminal event lands. `interrupt()` reports whether the frame actually went
197
+ // out — if the socket is mid-reconnect it can't, so surface that instead of a
198
+ // silent no-op (ticket 015). Guarded against repeat taps by the disabled state.
199
+ function handleStop(): void {
200
+ if (interrupting) return;
201
+ const sent = clientRef.current?.interrupt() ?? false;
202
+ if (sent) {
203
+ setInterrupting(true);
204
+ } else {
205
+ setTransportError("Couldn't stop — connection lost. Reconnecting…");
206
+ }
171
207
  }
172
208
 
173
209
  return (
174
210
  <Modal visible transparent animationType="slide" onRequestClose={onClose}>
175
- <View style={styles.backdrop}>
211
+ <View style={[styles.backdrop, { paddingBottom: keyboardHeight }]}>
176
212
  <View style={styles.sheet}>
177
213
  <View style={styles.header}>
178
214
  <Text style={styles.headerTitle} numberOfLines={1}>
179
- {running ? 'Agent working' : 'Agent finished'} · {target}
215
+ {showInterrupting ? 'Stopping' : HEADER_LABEL[state]} · {target}
180
216
  </Text>
181
217
  <View style={styles.headerBtns}>
182
- {/* Minimize: collapse to a pill so the app is interactive again
183
- (e.g. to pick another element and spawn a second agent). The
184
- run keeps streaming in the background. */}
218
+ {/* Minimize: collapse into the dock so the app is interactive
219
+ again (e.g. to pick another element and spawn a second agent).
220
+ The run keeps streaming in the background. */}
185
221
  <Pressable onPress={onMinimize} hitSlop={8} accessibilityRole="button">
186
222
  <Text style={styles.headerBtn}>—</Text>
187
223
  </Pressable>
@@ -195,7 +231,9 @@ export function StreamSheet({
195
231
  ref={scrollRef}
196
232
  style={styles.log}
197
233
  contentContainerStyle={styles.logContent}
198
- onContentSizeChange={() => scrollRef.current?.scrollToEnd({ animated: true })}
234
+ onScroll={onScroll}
235
+ scrollEventThrottle={16}
236
+ onContentSizeChange={onContentSizeChange}
199
237
  >
200
238
  {rows.length === 0 && !transportError ? (
201
239
  <Text style={styles.muted}>Connecting…</Text>
@@ -238,11 +276,9 @@ export function StreamSheet({
238
276
  </Text>
239
277
  );
240
278
  }
241
- return (
242
- <Text key={row.id} style={styles.textRow}>
243
- {row.text}
244
- </Text>
245
- );
279
+ // Agent prose arrives as Markdown — render it as such rather than
280
+ // dumping the raw `**`/`` ` ``/`#` markers into a plain <Text>.
281
+ return <MarkdownView key={row.id} text={row.text} baseStyle={styles.textRow} />;
246
282
  })}
247
283
 
248
284
  {followUps.map((m, i) => (
@@ -259,18 +295,37 @@ export function StreamSheet({
259
295
  {askOpen ? (
260
296
  <View style={styles.inputBar}>
261
297
  {ask.options.length > 0 ? (
262
- <View style={styles.options}>
298
+ // Height-bounded + scrollable so a many-option (or long-option)
299
+ // ask never grows the form past the sheet and shoves the text
300
+ // input + Send below the fold (worse with the keyboard up).
301
+ // `flexShrink` lets the transcript above keep most of the room.
302
+ <ScrollView
303
+ style={styles.optionsScroll}
304
+ contentContainerStyle={styles.options}
305
+ keyboardShouldPersistTaps="handled"
306
+ >
263
307
  {ask.options.map((opt) => (
264
308
  <Pressable key={opt} onPress={() => submitAnswer(opt)} style={styles.optionBtn}>
265
- <Text style={styles.optionText}>{opt}</Text>
309
+ <Text style={styles.optionText} numberOfLines={1}>
310
+ {opt}
311
+ </Text>
266
312
  </Pressable>
267
313
  ))}
268
- </View>
314
+ </ScrollView>
269
315
  ) : null}
270
316
  <View style={styles.row}>
271
317
  <TextInput
272
318
  value={askDraft}
273
319
  onChangeText={setAskDraft}
320
+ // Enter sends the answer; Escape minimizes the sheet
321
+ // (web-widget parity). Single-line, so onSubmitEditing fires
322
+ // reliably on Return; submitBehavior keeps focus. keyboard.ts.
323
+ returnKeyType="send"
324
+ submitBehavior="submit"
325
+ onSubmitEditing={() => submitAnswer(askDraft)}
326
+ onKeyPress={(e) => {
327
+ if (isDismissKey(e.nativeEvent.key)) onMinimize();
328
+ }}
274
329
  placeholder="Answer the agent…"
275
330
  placeholderTextColor="#9aa0a6"
276
331
  style={styles.input}
@@ -290,6 +345,15 @@ export function StreamSheet({
290
345
  <TextInput
291
346
  value={draft}
292
347
  onChangeText={setDraft}
348
+ // Enter sends the follow-up; Escape minimizes the sheet
349
+ // (web-widget parity). submitBehavior keeps the keyboard up so
350
+ // several can be queued in a row. keyboard.ts.
351
+ returnKeyType="send"
352
+ submitBehavior="submit"
353
+ onSubmitEditing={sendFollowUp}
354
+ onKeyPress={(e) => {
355
+ if (isDismissKey(e.nativeEvent.key)) onMinimize();
356
+ }}
293
357
  placeholder={running ? 'Queue a follow-up…' : 'Send a follow-up…'}
294
358
  placeholderTextColor="#9aa0a6"
295
359
  style={styles.input}
@@ -303,10 +367,17 @@ export function StreamSheet({
303
367
  </Pressable>
304
368
  </View>
305
369
  <Pressable
306
- onPress={() => (running ? clientRef.current?.interrupt() : onClose())}
307
- style={styles.bottomBtn}
370
+ onPress={() => (running ? handleStop() : onClose())}
371
+ // Disable repeat taps while the interrupt is in flight; the
372
+ // affordance clears itself on the run's terminal event.
373
+ disabled={running && showInterrupting}
374
+ accessibilityRole="button"
375
+ accessibilityState={{ disabled: running && showInterrupting }}
376
+ style={[styles.bottomBtn, showInterrupting && styles.disabled]}
308
377
  >
309
- <Text style={styles.bottomBtnText}>{running ? 'Stop' : 'Dismiss'}</Text>
378
+ <Text style={styles.bottomBtnText}>
379
+ {running ? (showInterrupting ? 'Interrupting…' : 'Stop') : 'Dismiss'}
380
+ </Text>
310
381
  </Pressable>
311
382
  </View>
312
383
  )}
@@ -337,7 +408,10 @@ const styles = StyleSheet.create({
337
408
  headerTitle: { flex: 1, fontSize: 14, fontWeight: '600', color: '#111827' },
338
409
  headerBtns: { flexDirection: 'row', alignItems: 'center', gap: 16 },
339
410
  headerBtn: { fontSize: 16, color: '#6b7280', paddingLeft: 4 },
340
- log: { paddingHorizontal: 16 },
411
+ // `flexShrink` so a tall transcript yields room to the pinned input bar (and
412
+ // the bounded options) instead of overflowing the 80%-max sheet and pushing
413
+ // the text input + Send off-screen. Short transcripts still size compactly.
414
+ log: { paddingHorizontal: 16, flexShrink: 1 },
341
415
  logContent: { paddingVertical: 12, gap: 8 },
342
416
  muted: { color: '#9aa0a6', fontSize: 13 },
343
417
  textRow: { fontSize: 14, color: '#111827', lineHeight: 20 },
@@ -362,8 +436,14 @@ const styles = StyleSheet.create({
362
436
  gap: 8,
363
437
  borderTopWidth: 1,
364
438
  borderTopColor: '#f0f0f0',
439
+ // Never shrink the answer form — the transcript above yields room instead,
440
+ // so the text input + Send stay reachable however many options the ask has.
441
+ flexShrink: 0,
365
442
  },
366
443
  row: { flexDirection: 'row', alignItems: 'flex-end', gap: 8 },
444
+ // Cap the option area so the text input + Send below it always stay on-screen;
445
+ // `flexShrink` yields room to the transcript first. The options scroll within.
446
+ optionsScroll: { maxHeight: 132, flexShrink: 1 },
367
447
  options: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
368
448
  optionBtn: {
369
449
  borderWidth: 1,
@@ -371,6 +451,8 @@ const styles = StyleSheet.create({
371
451
  borderRadius: 999,
372
452
  paddingHorizontal: 12,
373
453
  paddingVertical: 6,
454
+ // A single long option ellipsizes instead of forcing the row wide.
455
+ maxWidth: '100%',
374
456
  },
375
457
  optionText: { color: '#6d28d9', fontSize: 13, fontWeight: '600' },
376
458
  input: {
@@ -394,33 +476,4 @@ const styles = StyleSheet.create({
394
476
  disabled: { opacity: 0.4 },
395
477
  bottomBtn: { alignSelf: 'center', paddingVertical: 8 },
396
478
  bottomBtnText: { color: '#6b7280', fontWeight: '600' },
397
- // Minimized status pill, bottom-left so it never collides with the FAB
398
- // (bottom-right). `bottom` is set inline from the stack index.
399
- pill: {
400
- position: 'absolute',
401
- left: 20,
402
- maxWidth: '70%',
403
- height: PILL_HEIGHT,
404
- flexDirection: 'row',
405
- alignItems: 'center',
406
- gap: 8,
407
- paddingHorizontal: 12,
408
- borderRadius: PILL_HEIGHT / 2,
409
- backgroundColor: 'rgba(17,24,39,0.95)',
410
- shadowColor: '#000',
411
- shadowOpacity: 0.25,
412
- shadowRadius: 6,
413
- shadowOffset: { width: 0, height: 2 },
414
- elevation: 5,
415
- },
416
- // Waiting on `ask_user`: a purple-tinted pill (matching the expanded ask row)
417
- // so a blocked run reads differently from a busy one at a glance.
418
- pillAsk: { backgroundColor: 'rgba(76,29,149,0.97)' },
419
- pillDot: { width: 8, height: 8, borderRadius: 4 },
420
- pillDotRunning: { backgroundColor: '#3b82f6' },
421
- pillDotDone: { backgroundColor: '#10b981' },
422
- pillDotError: { backgroundColor: '#ef4444' },
423
- pillDotAsk: { backgroundColor: '#c4b5fd' },
424
- pillText: { flexShrink: 1, color: '#fff', fontSize: 13, fontWeight: '600' },
425
- pillClose: { color: '#9aa0a6', fontSize: 13, paddingLeft: 2 },
426
479
  });
@@ -492,16 +492,31 @@ function measureFiberInWindow(fiber: FiberLike): Promise<Frame | null> {
492
492
  * `react-native-pager-view` (and anything that hosts content in a native
493
493
  * container) detaches a page's *native* views from the Fabric shadow tree
494
494
  * `findNodeAtPoint` walks, so the native hit-test bottoms out at the page
495
- * wrapper and never reaches the widget. But the React **fiber** tree is intact
496
- * and the widgets are on-screen, hence measurable so we hit-test ourselves:
497
- * DFS the fiber subtree under `root`, measuring each host, and keep the DEEPEST
498
- * tagged host whose window frame contains the tap.
495
+ * wrapper it returns no touched instance at all (`closestPublicInstance` is
496
+ * null). But the React **fiber** tree is intact and the page's widgets are
497
+ * on-screen, hence measurable so we hit-test ourselves: DFS the fiber subtree
498
+ * under `root` (the app root, an ancestor of every on-screen view), measuring
499
+ * each host, and keep the DEEPEST tagged host inside the tapped region.
499
500
  *
500
- * Pruned like the browser's `elementFromPoint`: a host whose frame misses the
501
- * point can't have a (non-overflowing) descendant that hits, so its subtree is
502
- * skipped. Composite / fragment fibers carry no frame, so we always descend
503
- * through them to reach their hosts. `measure` is injected so the traversal is
504
- * unit-testable without an RN runtime.
501
+ * `region` threads the frame of the nearest measurable host ancestor that
502
+ * CONTAINS the tap (null until we enter one). Two subtleties this handles:
503
+ *
504
+ * - **Pruning.** A host with a real frame that MISSES the tap can't have a
505
+ * (non-overflowing) descendant that hits, so its subtree is skipped — like
506
+ * the browser's `elementFromPoint`.
507
+ * - **Flattened / detached hosts.** RN flattens layout-only `<View>`s (no
508
+ * native view → `measure` returns null) and pager pages are detached, so a
509
+ * widget's own tagged hosts often can't be measured. Once we're inside a
510
+ * measurable containing region we keep recording tagged hosts even when they
511
+ * can't be measured (they borrow the region's frame for the highlight) and
512
+ * descend through them — otherwise geometry bottoms out at the outermost
513
+ * non-flattened wrapper (e.g. an animated card) and every widget collapses to
514
+ * that shared wrapper's source. Measurable siblings still prune wrong
515
+ * branches, so we stay within the tapped element.
516
+ *
517
+ * Composite / fragment fibers carry no frame, so we always descend through them
518
+ * to reach their hosts. `measure` is injected so the traversal is unit-testable
519
+ * without an RN runtime.
505
520
  */
506
521
  export async function measureHitTest(
507
522
  root: FiberLike | null,
@@ -512,29 +527,42 @@ export async function measureHitTest(
512
527
  let best: MeasuredHit | null = null;
513
528
  let bestDepth = -1;
514
529
 
515
- async function visitChildren(parent: FiberLike, depth: number): Promise<void> {
530
+ async function visitChildren(
531
+ parent: FiberLike,
532
+ depth: number,
533
+ region: Frame | null,
534
+ ): Promise<void> {
516
535
  let n = 0;
517
536
  for (let node = parent.child ?? null; node && n < 100_000; node = node.sibling ?? null, n++) {
518
- await visitNode(node, depth);
537
+ await visitNode(node, depth, region);
519
538
  }
520
539
  }
521
540
 
522
- async function visitNode(node: FiberLike, depth: number): Promise<void> {
541
+ async function visitNode(node: FiberLike, depth: number, region: Frame | null): Promise<void> {
542
+ let nextRegion = region;
523
543
  if (node.tag === HOST_COMPONENT) {
524
544
  const frame = await measure(node);
525
- // A host that doesn't contain the point prunes its whole subtree.
526
- if (!frame || !frameContains(frame, x, y)) return;
527
- const loc = paLocOf(node.memoizedProps);
528
- // Deepest tagged host wins; on a tie the later (over-painted) sibling does.
529
- if (loc && depth >= bestDepth) {
530
- best = { fiber: node, loc, name: compOf(node.memoizedProps), frame };
531
- bestDepth = depth;
545
+ if (frame) {
546
+ // A measurable host that misses the tap prunes its whole subtree.
547
+ if (!frameContains(frame, x, y)) return;
548
+ nextRegion = frame; // tighten the containing region to this host
549
+ }
550
+ // Record any tagged host reached INSIDE a measurable containing region —
551
+ // including flattened ones (null frame) geometry can't see. Deepest wins;
552
+ // on a tie the later (over-painted) sibling does. Flattened hosts borrow
553
+ // the region's frame for the highlight.
554
+ if (nextRegion) {
555
+ const loc = paLocOf(node.memoizedProps);
556
+ if (loc && depth >= bestDepth) {
557
+ best = { fiber: node, loc, name: compOf(node.memoizedProps), frame: frame ?? nextRegion };
558
+ bestDepth = depth;
559
+ }
532
560
  }
533
561
  }
534
- await visitChildren(node, depth + 1);
562
+ await visitChildren(node, depth + 1, nextRegion);
535
563
  }
536
564
 
537
- if (root) await visitNode(root, 0);
565
+ if (root) await visitNode(root, 0, null);
538
566
  return best;
539
567
  }
540
568
 
@@ -614,16 +642,27 @@ export function resolvePick(
614
642
  const loc = pickLoc(data, projectRoot);
615
643
  const nameChain = nameChainOf(data);
616
644
 
617
- // Measure fallback: when RN's native `findNodeAtPoint` couldn't
618
- // descend to a tagged element (`tappedLeafLoc` is null e.g.
619
- // react-native-pager-view detaches its pages from the Fabric shadow
620
- // tree the hit-test walks, so it bottoms out at the page wrapper),
621
- // resolve the tap by measuring the still-intact fiber subtree under
622
- // the touched host. Skipped entirely when the native pick already
623
- // found the leaf, so every non-pager screen keeps its existing path.
624
- if (!tappedLeafLoc(data)) {
625
- const root = getHandleFromPublicInstance(data.closestPublicInstance);
626
- const hit = root ? await measureHitTest(root, x, y, measureFiberInWindow) : null;
645
+ // Measure fallback: RN's native `findNodeAtPoint` can't descend into
646
+ // content hosted by a native containerreact-native-pager-view
647
+ // detaches each page from the Fabric shadow tree the hit-test walks,
648
+ // so a tap inside a pager page resolves NO touched instance
649
+ // (`closestPublicInstance` is null) and bottoms out at the page
650
+ // wrapper. The React fiber tree stays intact and the page's widgets
651
+ // are on-screen, so when there's no touched instance we hit-test
652
+ // ourselves: DFS the fiber tree from the app root (an ancestor of
653
+ // every on-screen view), measuring each host, and take the deepest
654
+ // tagged host inside the measurable region under the tap.
655
+ //
656
+ // Gated on the native hit-test failing to resolve an instance, so
657
+ // every screen where it succeeds (the common case) keeps its existing
658
+ // native path untouched — no regression. Paper surfaces only a numeric
659
+ // view tag, so `getHandleFromPublicInstance` returns null there too
660
+ // and the app-root bridge below also fails, degrading to the native
661
+ // path.
662
+ const nativeLeaf = getHandleFromPublicInstance(data.closestPublicInstance);
663
+ if (!nativeLeaf) {
664
+ const appRoot = getHandleFromPublicInstance(inspectedView);
665
+ const hit = appRoot ? await measureHitTest(appRoot, x, y, measureFiberInWindow) : null;
627
666
  if (hit) {
628
667
  const ancestors = taggedAncestors(hit.fiber);
629
668
  const crumbFrames = await Promise.all(
@@ -0,0 +1,34 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+
3
+ /**
4
+ * Soft-keyboard height hook for the RN widget's modal sheets.
5
+ *
6
+ * Both the composer (Pinagent.tsx) and the stream sheet (StreamSheet.tsx)
7
+ * present in their own `Modal`, and both need to lift their pinned input above
8
+ * the soft keyboard. `KeyboardAvoidingView` is unreliable inside a `Modal` —
9
+ * the modal presents in its own window, so the view's measured origin is wrong
10
+ * and the computed inset never lifts the sheet. Driving a `paddingBottom` inset
11
+ * off the live keyboard frame is the robust cross-platform path, so the logic
12
+ * lives here once and both sheets share it.
13
+ *
14
+ * iOS fires the `*Will*` events (in sync with the slide animation); Android
15
+ * only fires `*Did*`.
16
+ */
17
+ import { useEffect, useState } from 'react';
18
+ import { Keyboard, Platform } from 'react-native';
19
+
20
+ /** Live soft-keyboard height in px (0 when hidden). */
21
+ export function useKeyboardHeight(): number {
22
+ const [height, setHeight] = useState(0);
23
+ useEffect(() => {
24
+ const showEvt = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
25
+ const hideEvt = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
26
+ const show = Keyboard.addListener(showEvt, (e) => setHeight(e.endCoordinates.height));
27
+ const hide = Keyboard.addListener(hideEvt, () => setHeight(0));
28
+ return () => {
29
+ show.remove();
30
+ hide.remove();
31
+ };
32
+ }, []);
33
+ return height;
34
+ }