@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
|
@@ -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
|
+
});
|
package/src/native/Pinagent.tsx
CHANGED
|
@@ -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
|
|
29
|
-
* another element and spawn a second agent. Each run
|
|
30
|
-
* so multiple agents can stream concurrently — the
|
|
31
|
-
* sheet; the rest
|
|
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
|
|
254
|
-
// the background — so you can minimize a run, interact with
|
|
255
|
-
// spawn another. `expandedId` is the one showing its full sheet
|
|
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
|
|
483
|
-
//
|
|
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
|
|
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.
|
|
691
|
-
its
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
{streams.map((s
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
.
|
|
699
|
-
.
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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
|
}
|