@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.
- 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 +24 -9
- 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 +1 -1
- 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 +70 -31
- 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
|
@@ -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
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
/**
|
|
40
|
-
const
|
|
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
|
-
/**
|
|
51
|
+
/** Minimized → render nothing (the dock shows the compact chip); WS stays live. */
|
|
47
52
|
minimized: boolean;
|
|
48
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
const
|
|
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
|
-
//
|
|
103
|
-
//
|
|
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 (!
|
|
108
|
-
|
|
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
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
215
|
+
{showInterrupting ? 'Stopping' : HEADER_LABEL[state]} · {target}
|
|
180
216
|
</Text>
|
|
181
217
|
<View style={styles.headerBtns}>
|
|
182
|
-
{/* Minimize: collapse
|
|
183
|
-
(e.g. to pick another element and spawn a second agent).
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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}
|
|
309
|
+
<Text style={styles.optionText} numberOfLines={1}>
|
|
310
|
+
{opt}
|
|
311
|
+
</Text>
|
|
266
312
|
</Pressable>
|
|
267
313
|
))}
|
|
268
|
-
</
|
|
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 ?
|
|
307
|
-
|
|
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}>
|
|
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
|
-
|
|
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
|
});
|
package/src/native/inspector.ts
CHANGED
|
@@ -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
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
*
|
|
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
|
-
*
|
|
501
|
-
*
|
|
502
|
-
*
|
|
503
|
-
*
|
|
504
|
-
*
|
|
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(
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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:
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
645
|
+
// Measure fallback: RN's native `findNodeAtPoint` can't descend into
|
|
646
|
+
// content hosted by a native container — react-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
|
+
}
|