@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.
@@ -0,0 +1,232 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Compact dock for minimized agent runs (bottom-left).
4
+ *
5
+ * Replaces the old one-fat-pill-per-run stack. It renders the pure
6
+ * {@link dockModel} aggregation:
7
+ *
8
+ * - **Active runs** (connecting/working/awaiting) follow a hybrid rule — a
9
+ * single slim chip when one runs, and a collapsed `◐ N agents · M needs you`
10
+ * count bar (tap to expand the chip list) once two or more do.
11
+ * - **Finished runs** (done/failed) always roll into a `▸ N finished` summary
12
+ * you tap to review and clear.
13
+ *
14
+ * Each `StreamSheet` stays mounted (it renders `null` while minimized) so its
15
+ * WebSocket keeps streaming; this dock is fed each run's derived `state` from
16
+ * the parent and is purely presentational. Tapping a chip expands that run's
17
+ * full sheet; ✕ tears it down.
18
+ *
19
+ * All decision logic (partitioning, ordering, headline text) lives in the pure
20
+ * `run-state` module — this file is intentionally just layout + animation, the
21
+ * un-testable RN-runtime layer.
22
+ */
23
+ import type { ReactElement } from 'react';
24
+ import { useEffect, useRef, useState } from 'react';
25
+ import { Animated, Pressable, StyleSheet, Text, View } from 'react-native';
26
+ import { type DockRun, dockModel, type RunTone, runPresentation } from './run-state';
27
+
28
+ export interface AgentDockProps {
29
+ /** Minimized runs with their derived state (the expanded one is excluded). */
30
+ runs: DockRun[];
31
+ /** Expand a run to its full sheet. */
32
+ onExpand: (id: string) => void;
33
+ /** Tear a run down (stops its WS, removes it). */
34
+ onClose: (id: string) => void;
35
+ }
36
+
37
+ /** Concrete colors per semantic tone (kept out of the pure layer). */
38
+ const TONE: Record<RunTone, string> = {
39
+ neutral: '#9aa0a6',
40
+ active: '#60a5fa',
41
+ attention: '#c4b5fd',
42
+ success: '#34d399',
43
+ danger: '#f87171',
44
+ };
45
+
46
+ export function AgentDock({ runs, onExpand, onClose }: AgentDockProps): ReactElement | null {
47
+ const model = dockModel(runs);
48
+ // Inline-expand the collapsed active bar / the finished summary into lists.
49
+ const [activeOpen, setActiveOpen] = useState(false);
50
+ const [finishedOpen, setFinishedOpen] = useState(false);
51
+
52
+ if (model.active.length === 0 && model.finished.length === 0) return null;
53
+
54
+ const showActiveList = !model.collapseActive || activeOpen;
55
+
56
+ return (
57
+ <View style={styles.dock} pointerEvents="box-none">
58
+ {/* Active runs: chips when ≤1 (or expanded), else a count bar. */}
59
+ {showActiveList
60
+ ? model.active.map((run) => (
61
+ <AgentChip key={run.id} run={run} onExpand={onExpand} onClose={onClose} />
62
+ ))
63
+ : null}
64
+ {model.collapseActive ? (
65
+ <SummaryBar
66
+ glyph={runPresentation(model.summaryState).glyph}
67
+ tone={runPresentation(model.summaryState).tone}
68
+ pulse={model.awaitingCount > 0}
69
+ label={model.activeHeadline}
70
+ open={activeOpen}
71
+ onPress={() => setActiveOpen((v) => !v)}
72
+ />
73
+ ) : null}
74
+
75
+ {/* Finished runs: a roll-up summary, expandable to review/clear. */}
76
+ {model.finished.length > 0 ? (
77
+ <>
78
+ {finishedOpen
79
+ ? model.finished.map((run) => (
80
+ <AgentChip key={run.id} run={run} onExpand={onExpand} onClose={onClose} />
81
+ ))
82
+ : null}
83
+ <SummaryBar
84
+ glyph="▸"
85
+ tone={model.finishedHasFailure ? 'danger' : 'neutral'}
86
+ pulse={false}
87
+ label={`${model.finished.length} finished`}
88
+ open={finishedOpen}
89
+ onPress={() => setFinishedOpen((v) => !v)}
90
+ muted
91
+ />
92
+ </>
93
+ ) : null}
94
+ </View>
95
+ );
96
+ }
97
+
98
+ /** One run as a slim chip: glyph (tone-colored, pulses when blocked) + label. */
99
+ function AgentChip({
100
+ run,
101
+ onExpand,
102
+ onClose,
103
+ }: {
104
+ run: DockRun;
105
+ onExpand: (id: string) => void;
106
+ onClose: (id: string) => void;
107
+ }): ReactElement {
108
+ // Pass the interrupting overlay through so a stopping chip relabels to
109
+ // "Stopping…" (and stops pulsing) without changing the underlying state.
110
+ const p = runPresentation(run.state, run.interrupting);
111
+ const pulse = usePulse(p.pulse);
112
+ return (
113
+ <Pressable
114
+ onPress={() => onExpand(run.id)}
115
+ accessibilityRole="button"
116
+ accessibilityLabel={`${p.label}: ${run.target}`}
117
+ style={[styles.chip, p.tone === 'attention' && styles.chipAttention]}
118
+ >
119
+ <Animated.Text style={[styles.chipGlyph, { color: TONE[p.tone], opacity: pulse }]}>
120
+ {p.glyph}
121
+ </Animated.Text>
122
+ <Text style={styles.chipText} numberOfLines={1}>
123
+ {p.label === 'Stopping…' ? `${p.label} · ${run.target}` : run.target}
124
+ </Text>
125
+ <Pressable
126
+ onPress={() => onClose(run.id)}
127
+ hitSlop={10}
128
+ accessibilityRole="button"
129
+ accessibilityLabel={`Dismiss ${run.target}`}
130
+ >
131
+ <Text style={styles.chipClose}>✕</Text>
132
+ </Pressable>
133
+ </Pressable>
134
+ );
135
+ }
136
+
137
+ /** The collapsed count bar for active runs / the finished roll-up. */
138
+ function SummaryBar({
139
+ glyph,
140
+ tone,
141
+ pulse,
142
+ label,
143
+ open,
144
+ onPress,
145
+ muted,
146
+ }: {
147
+ glyph: string;
148
+ tone: RunTone;
149
+ pulse: boolean;
150
+ label: string;
151
+ open: boolean;
152
+ onPress: () => void;
153
+ muted?: boolean;
154
+ }): ReactElement {
155
+ const pulseVal = usePulse(pulse);
156
+ return (
157
+ <Pressable
158
+ onPress={onPress}
159
+ accessibilityRole="button"
160
+ accessibilityLabel={`${label}, tap to ${open ? 'collapse' : 'expand'}`}
161
+ style={[styles.chip, muted && styles.barMuted]}
162
+ >
163
+ <Animated.Text style={[styles.chipGlyph, { color: TONE[tone], opacity: pulseVal }]}>
164
+ {glyph}
165
+ </Animated.Text>
166
+ <Text style={[styles.chipText, muted && styles.barMutedText]} numberOfLines={1}>
167
+ {label}
168
+ </Text>
169
+ <Text style={styles.barCaret}>{open ? '▾' : '▴'}</Text>
170
+ </Pressable>
171
+ );
172
+ }
173
+
174
+ /**
175
+ * Drive a looping opacity pulse while `on`, resetting to fully opaque otherwise.
176
+ * Returns the animated value to bind to a glyph's `opacity`.
177
+ */
178
+ function usePulse(on: boolean): Animated.Value {
179
+ const pulse = useRef(new Animated.Value(1)).current;
180
+ useEffect(() => {
181
+ if (!on) {
182
+ pulse.setValue(1);
183
+ return;
184
+ }
185
+ const loop = Animated.loop(
186
+ Animated.sequence([
187
+ Animated.timing(pulse, { toValue: 0.35, duration: 600, useNativeDriver: true }),
188
+ Animated.timing(pulse, { toValue: 1, duration: 600, useNativeDriver: true }),
189
+ ]),
190
+ );
191
+ loop.start();
192
+ return () => loop.stop();
193
+ }, [on, pulse]);
194
+ return pulse;
195
+ }
196
+
197
+ const CHIP_HEIGHT = 30;
198
+
199
+ const styles = StyleSheet.create({
200
+ // Bottom-left cluster: chips stack upward, finished summary sits at the
201
+ // bottom. maxWidth keeps it clear of the bottom-right FAB.
202
+ dock: {
203
+ position: 'absolute',
204
+ left: 16,
205
+ bottom: 32,
206
+ maxWidth: '72%',
207
+ gap: 6,
208
+ alignItems: 'flex-start',
209
+ },
210
+ chip: {
211
+ flexDirection: 'row',
212
+ alignItems: 'center',
213
+ gap: 7,
214
+ height: CHIP_HEIGHT,
215
+ paddingHorizontal: 11,
216
+ borderRadius: CHIP_HEIGHT / 2,
217
+ backgroundColor: 'rgba(17,24,39,0.95)',
218
+ shadowColor: '#000',
219
+ shadowOpacity: 0.25,
220
+ shadowRadius: 5,
221
+ shadowOffset: { width: 0, height: 2 },
222
+ elevation: 5,
223
+ },
224
+ // A blocked run tints purple so it reads as attention-needed even past color.
225
+ chipAttention: { backgroundColor: 'rgba(76,29,149,0.97)' },
226
+ chipGlyph: { fontSize: 13, fontWeight: '700', width: 14, textAlign: 'center' },
227
+ chipText: { flexShrink: 1, color: '#f3f4f6', fontSize: 12.5, fontWeight: '600' },
228
+ chipClose: { color: '#9aa0a6', fontSize: 12, paddingLeft: 2 },
229
+ barMuted: { backgroundColor: 'rgba(17,24,39,0.82)' },
230
+ barMutedText: { color: '#c7cad1', fontWeight: '500' },
231
+ barCaret: { color: '#9aa0a6', fontSize: 11, paddingLeft: 2 },
232
+ });
@@ -0,0 +1,154 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Render agent Markdown with React Native primitives.
4
+ *
5
+ * `parseMarkdown` (markdown.ts) does the parsing; this maps its block/inline
6
+ * tree onto <View>/<Text>. Inline marks ride as nested <Text> inside the
7
+ * paragraph's <Text>, which inherits the caller's `baseStyle` (the sheet's text
8
+ * row), so plain prose keeps its existing look and only the marked-up runs pick
9
+ * up bold/italic/code/link styling. Like the rest of `src/native`, it leans on
10
+ * RN core only — no markdown renderer dependency — so it ships as source and
11
+ * Metro bundles it onto the device unchanged.
12
+ *
13
+ * The file is `MarkdownView` (not `Markdown`) so it doesn't collide with
14
+ * `markdown.ts` on case-insensitive filesystems — tsc treats the two as the
15
+ * same module otherwise.
16
+ */
17
+ import type { ReactElement } from 'react';
18
+ import type { StyleProp } from 'react-native';
19
+ import { Linking, Platform, StyleSheet, Text, type TextStyle, View } from 'react-native';
20
+ import { type InlineSpan, type MdBlock, parseMarkdown } from './markdown';
21
+
22
+ export interface MarkdownViewProps {
23
+ text: string;
24
+ /** Base text style for paragraphs/inline runs (the sheet's text row). */
25
+ baseStyle?: StyleProp<TextStyle>;
26
+ }
27
+
28
+ export function MarkdownView({ text, baseStyle }: MarkdownViewProps): ReactElement {
29
+ const blocks = parseMarkdown(text);
30
+ return (
31
+ <View style={styles.root}>
32
+ {blocks.map((block, i) => (
33
+ // biome-ignore lint/suspicious/noArrayIndexKey: blocks are re-derived from `text` every render, append-only and never reordered
34
+ <Block key={i} block={block} baseStyle={baseStyle} />
35
+ ))}
36
+ </View>
37
+ );
38
+ }
39
+
40
+ function Block({
41
+ block,
42
+ baseStyle,
43
+ }: {
44
+ block: MdBlock;
45
+ baseStyle?: StyleProp<TextStyle>;
46
+ }): ReactElement {
47
+ switch (block.type) {
48
+ case 'heading':
49
+ return (
50
+ <Text style={[baseStyle, styles.heading, HEADING_SIZE[block.level - 1]]}>
51
+ <Spans spans={block.spans} />
52
+ </Text>
53
+ );
54
+ case 'code':
55
+ return (
56
+ <View style={styles.codeBlock}>
57
+ <Text style={styles.codeText}>{block.text}</Text>
58
+ </View>
59
+ );
60
+ case 'list':
61
+ return (
62
+ <View style={styles.list}>
63
+ {block.items.map((item, i) => (
64
+ // biome-ignore lint/suspicious/noArrayIndexKey: items are re-derived from `text` every render, append-only and never reordered
65
+ <View key={i} style={styles.listItem}>
66
+ <Text style={baseStyle}>{block.ordered ? `${i + 1}.` : '•'}</Text>
67
+ <Text style={[baseStyle, styles.listText]}>
68
+ <Spans spans={item} />
69
+ </Text>
70
+ </View>
71
+ ))}
72
+ </View>
73
+ );
74
+ case 'quote':
75
+ return (
76
+ <View style={styles.quote}>
77
+ <Text style={[baseStyle, styles.quoteText]}>
78
+ <Spans spans={block.spans} />
79
+ </Text>
80
+ </View>
81
+ );
82
+ case 'hr':
83
+ return <View style={styles.hr} />;
84
+ default:
85
+ return (
86
+ <Text style={baseStyle}>
87
+ <Spans spans={block.spans} />
88
+ </Text>
89
+ );
90
+ }
91
+ }
92
+
93
+ function Spans({ spans }: { spans: InlineSpan[] }): ReactElement {
94
+ return (
95
+ <>
96
+ {spans.map((span, i) => (
97
+ // biome-ignore lint/suspicious/noArrayIndexKey: spans are re-derived from `text` every render, append-only and never reordered
98
+ <Span key={i} span={span} />
99
+ ))}
100
+ </>
101
+ );
102
+ }
103
+
104
+ function Span({ span }: { span: InlineSpan }): ReactElement {
105
+ if (span.href) {
106
+ const href = span.href;
107
+ return (
108
+ <Text style={styles.link} onPress={() => openUrl(href)}>
109
+ {span.text}
110
+ </Text>
111
+ );
112
+ }
113
+ const style: TextStyle[] = [];
114
+ if (span.bold) style.push(styles.bold);
115
+ if (span.italic) style.push(styles.italic);
116
+ if (span.code) style.push(styles.code);
117
+ // No marks: a bare <Text> inherits the enclosing paragraph's style.
118
+ return style.length ? <Text style={style}>{span.text}</Text> : <Text>{span.text}</Text>;
119
+ }
120
+
121
+ function openUrl(href: string): void {
122
+ // Dev widget: opening a tapped link is best-effort. Linking.openURL rejects
123
+ // on unsupported schemes / no handler — swallow it rather than throw.
124
+ void Linking.openURL(href).catch(() => {});
125
+ }
126
+
127
+ const MONO = Platform.OS === 'ios' ? 'Menlo' : 'monospace';
128
+
129
+ /** Heading font sizes by level (index = level - 1). */
130
+ const HEADING_SIZE: TextStyle[] = [
131
+ { fontSize: 20 },
132
+ { fontSize: 18 },
133
+ { fontSize: 16 },
134
+ { fontSize: 15 },
135
+ { fontSize: 14 },
136
+ { fontSize: 13 },
137
+ ];
138
+
139
+ const styles = StyleSheet.create({
140
+ root: { gap: 6 },
141
+ heading: { fontWeight: '700', color: '#111827' },
142
+ bold: { fontWeight: '700' },
143
+ italic: { fontStyle: 'italic' },
144
+ code: { fontFamily: MONO, fontSize: 13, backgroundColor: '#f3f4f6', color: '#b91c1c' },
145
+ codeBlock: { backgroundColor: '#f6f8fa', borderRadius: 8, padding: 10 },
146
+ codeText: { fontFamily: MONO, fontSize: 13, color: '#1f2937', lineHeight: 18 },
147
+ link: { color: '#2563eb', textDecorationLine: 'underline' },
148
+ list: { gap: 4 },
149
+ listItem: { flexDirection: 'row', gap: 8, alignItems: 'flex-start' },
150
+ listText: { flex: 1 },
151
+ quote: { borderLeftWidth: 3, borderLeftColor: '#d1d5db', paddingLeft: 10 },
152
+ quoteText: { color: '#4b5563', fontStyle: 'italic' },
153
+ hr: { height: 1, backgroundColor: '#e5e7eb', marginVertical: 4 },
154
+ });
@@ -25,16 +25,16 @@
25
25
  * (MCP) pickup. "+ Add element" multi-picks several targets into one comment
26
26
  * (sent as `additionalAnchors`); a single pick leaves them null.
27
27
  *
28
- * The transcript sheet can be minimized to a pill, freeing the screen to pick
29
- * another element and spawn a second agent. Each run keeps its own live sheet,
30
- * so multiple agents can stream concurrently — the expanded one shows its full
31
- * sheet; the rest sit as pills that keep streaming until tapped open.
28
+ * The transcript sheet can be minimized into the compact bottom-left dock,
29
+ * freeing the screen to pick another element and spawn a second agent. Each run
30
+ * keeps its own live sheet, so multiple agents can stream concurrently — the
31
+ * expanded one shows its full sheet; the rest collapse into the dock (a chip, or
32
+ * a count bar at 2+) and keep streaming until tapped open.
32
33
  */
33
34
  import type { ReactElement } from 'react';
34
35
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
35
36
  import {
36
37
  Animated,
37
- Keyboard,
38
38
  Modal,
39
39
  PanResponder,
40
40
  Platform,
@@ -45,11 +45,15 @@ import {
45
45
  useWindowDimensions,
46
46
  View,
47
47
  } from 'react-native';
48
+ import { AgentDock } from './AgentDock';
48
49
  import { BRAND_CREAM, BRAND_GOLD, BRAND_INK } from './brand';
49
50
  import { resolvePick } from './inspector';
51
+ import { isDismissKey } from './keyboard';
52
+ import { useKeyboardHeight } from './keyboard-height';
50
53
  import { buildAdditionalAnchors, type ChipPick, removeChip } from './multi-pick';
51
54
  import { PinIcon } from './pin-icon';
52
55
  import { restorePills } from './restore';
56
+ import type { RunState } from './run-state';
53
57
  import { StreamSheet } from './StreamSheet';
54
58
  import { captureScreenshot } from './screenshot';
55
59
  import { submitOutcome } from './submit-outcome';
@@ -83,29 +87,6 @@ function nextPaint(): Promise<void> {
83
87
  });
84
88
  }
85
89
 
86
- /**
87
- * Track the soft keyboard's height so the composer can sit directly above it.
88
- * `KeyboardAvoidingView` is unreliable inside a `Modal` — the modal presents
89
- * in its own window, so the view's measured origin is wrong and the computed
90
- * inset never lifts the sheet. Driving the inset off the keyboard frame is the
91
- * robust cross-platform path. iOS fires the `*Will*` events (in sync with the
92
- * slide animation); Android only fires `*Did*`.
93
- */
94
- function useKeyboardHeight(): number {
95
- const [height, setHeight] = useState(0);
96
- useEffect(() => {
97
- const showEvt = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
98
- const hideEvt = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
99
- const show = Keyboard.addListener(showEvt, (e) => setHeight(e.endCoordinates.height));
100
- const hide = Keyboard.addListener(hideEvt, () => setHeight(0));
101
- return () => {
102
- show.remove();
103
- hide.remove();
104
- };
105
- }, []);
106
- return height;
107
- }
108
-
109
90
  const FAB_SIZE = 52;
110
91
  // Resting insets matching the old fixed layout (right/bottom), plus a uniform
111
92
  // edge margin used to keep the button on-screen once it's free to roam.
@@ -250,12 +231,18 @@ function PinagentDev({ projectRoot = '', screenName }: PinagentProps): ReactElem
250
231
  // instead of losing everything to a vanishing toast (ticket 002).
251
232
  const [submitError, setSubmitError] = useState<string | null>(null);
252
233
  // Live agent runs. Each spawned agent gets a StreamSheet; one can be expanded
253
- // (full sheet) while the rest sit as minimized pills that keep streaming in
254
- // the background — so you can minimize a run, interact with the app, and
255
- // spawn another. `expandedId` is the one showing its full sheet (null = all
256
- // minimized).
234
+ // (full sheet) while the rest collapse into the compact bottom-left dock and
235
+ // keep streaming in the background — so you can minimize a run, interact with
236
+ // the app, and spawn another. `expandedId` is the one showing its full sheet
237
+ // (null = all minimized into the dock).
257
238
  const [streams, setStreams] = useState<{ id: string; target: string }[]>([]);
258
239
  const [expandedId, setExpandedId] = useState<string | null>(null);
240
+ // Each run's derived lifecycle state, reported up by its StreamSheet, so the
241
+ // dock can render the right chip / count-bar without owning the WS state.
242
+ const [statuses, setStatuses] = useState<Record<string, RunState>>({});
243
+ // Which runs are mid-interrupt (Stop tapped, awaiting teardown) — an overlay
244
+ // over `statuses`, so the dock chip relabels to "Stopping…" (ticket 015).
245
+ const [interrupting, setInterrupting] = useState<Record<string, boolean>>({});
259
246
 
260
247
  // The surface this widget is mounted on — the same value we record as the
261
248
  // comment `url` (web sends the page URL). Used to scope restored pills to
@@ -265,6 +252,25 @@ function PinagentDev({ projectRoot = '', screenName }: PinagentProps): ReactElem
265
252
  const closeStream = useCallback((id: string) => {
266
253
  setStreams((prev) => prev.filter((s) => s.id !== id));
267
254
  setExpandedId((cur) => (cur === id ? null : cur));
255
+ setStatuses((prev) => {
256
+ if (!(id in prev)) return prev;
257
+ const { [id]: _drop, ...rest } = prev;
258
+ return rest;
259
+ });
260
+ setInterrupting((prev) => {
261
+ if (!(id in prev)) return prev;
262
+ const { [id]: _drop, ...rest } = prev;
263
+ return rest;
264
+ });
265
+ }, []);
266
+
267
+ // A StreamSheet reports its derived state (and Stop overlay) here (stable
268
+ // identity so the sheet's effect doesn't re-fire on every parent render).
269
+ const onRunState = useCallback((id: string, state: RunState, stopping: boolean) => {
270
+ setStatuses((prev) => (prev[id] === state ? prev : { ...prev, [id]: state }));
271
+ setInterrupting((prev) =>
272
+ (prev[id] ?? false) === stopping ? prev : { ...prev, [id]: stopping },
273
+ );
268
274
  }, []);
269
275
 
270
276
  // Tapping the FAB toggles picking; cancelling a pick also drops a pending
@@ -479,8 +485,8 @@ function PinagentDev({ projectRoot = '', screenName }: PinagentProps): ReactElem
479
485
  setPhase('idle');
480
486
 
481
487
  // Agent spawned → stream the run live and expand its sheet (any previously
482
- // expanded run drops to a minimized pill). Otherwise (spawn off) fall back
483
- // to a transient toast; pull mode (MCP) picks it up.
488
+ // expanded run collapses into the dock). Otherwise (spawn off) fall back to
489
+ // a transient toast; pull mode (MCP) picks it up.
484
490
  if (outcome.streamId) {
485
491
  const id = outcome.streamId;
486
492
  setStreams((prev) => (prev.some((s) => s.id === id) ? prev : [...prev, { id, target }]));
@@ -549,7 +555,7 @@ function PinagentDev({ projectRoot = '', screenName }: PinagentProps): ReactElem
549
555
  onRequestClose={onDismissComposer}
550
556
  >
551
557
  {/* Pad the docked composer up by the live keyboard height so the
552
- input and actions clear the soft keyboard (see useKeyboardHeight
558
+ input and actions clear the soft keyboard (see keyboard-height.ts
553
559
  for why KeyboardAvoidingView can't do this inside a Modal). */}
554
560
  <View style={[styles.composerBackdrop, { paddingBottom: keyboardHeight }]}>
555
561
  <View style={styles.composer}>
@@ -634,6 +640,14 @@ function PinagentDev({ projectRoot = '', screenName }: PinagentProps): ReactElem
634
640
  multiline
635
641
  value={comment}
636
642
  onChangeText={setComment}
643
+ // Escape backs out of the composer (web-widget parity) when a
644
+ // hardware keyboard is attached. Enter stays a newline: the
645
+ // composer is multiline (agent prompts run long) and RN's
646
+ // onKeyPress can't see Shift to tell submit from newline, so Send
647
+ // stays the explicit action. See keyboard.ts.
648
+ onKeyPress={(e) => {
649
+ if (isDismissKey(e.nativeEvent.key)) onDismissComposer();
650
+ }}
637
651
  placeholder="What should change here?"
638
652
  placeholderTextColor="#9aa0a6"
639
653
  style={styles.input}
@@ -687,29 +701,37 @@ function PinagentDev({ projectRoot = '', screenName }: PinagentProps): ReactElem
687
701
  </View>
688
702
  )}
689
703
 
690
- {/* Live agent transcripts — one per spawned run. The expanded one shows
691
- its full sheet; the rest render as minimized pills (stacked bottom-
692
- left) that keep streaming. Each stays mounted across minimize/expand
693
- so its WebSocket and live transcript survive. */}
694
- {streams.map((s, i) => {
695
- const minimized = s.id !== expandedId;
696
- // Stack index among the minimized pills only, so they don't overlap.
697
- const stackIndex = streams
698
- .filter((o) => o.id !== expandedId)
699
- .findIndex((o) => o.id === s.id);
700
- return (
701
- <StreamSheet
702
- key={s.id}
703
- feedbackId={s.id}
704
- target={s.target}
705
- minimized={minimized}
706
- stackIndex={stackIndex < 0 ? i : stackIndex}
707
- onMinimize={() => setExpandedId(null)}
708
- onExpand={() => setExpandedId(s.id)}
709
- onClose={() => closeStream(s.id)}
710
- />
711
- );
712
- })}
704
+ {/* Live agent transcripts — one per spawned run. Each stays mounted so
705
+ its WebSocket and transcript survive minimize/expand; the expanded one
706
+ renders its full sheet, the rest render null and surface as compact
707
+ chips in the dock below (each reports its state via onState). */}
708
+ {streams.map((s) => (
709
+ <StreamSheet
710
+ key={s.id}
711
+ feedbackId={s.id}
712
+ target={s.target}
713
+ minimized={s.id !== expandedId}
714
+ onMinimize={() => setExpandedId(null)}
715
+ onClose={() => closeStream(s.id)}
716
+ onState={(state, stopping) => onRunState(s.id, state, stopping)}
717
+ />
718
+ ))}
719
+
720
+ {/* Compact dock for the minimized runs (bottom-left): a chip when one
721
+ runs, a count bar at 2+, with finished runs rolled into a summary.
722
+ The expanded run is excluded — it's showing its full sheet. */}
723
+ <AgentDock
724
+ runs={streams
725
+ .filter((s) => s.id !== expandedId)
726
+ .map((s) => ({
727
+ id: s.id,
728
+ target: s.target,
729
+ state: statuses[s.id] ?? 'connecting',
730
+ interrupting: interrupting[s.id] ?? false,
731
+ }))}
732
+ onExpand={setExpandedId}
733
+ onClose={closeStream}
734
+ />
713
735
  </View>
714
736
  );
715
737
  }