@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
|
@@ -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
|
});
|