@yannelli/paseo-linear-plugin 1.0.0 → 1.1.0-beta.0

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.
Files changed (47) hide show
  1. package/OVERVIEW.md +19 -1
  2. package/README.md +77 -6
  3. package/client/access.tsx +66 -0
  4. package/client/agent-options.ts +12 -2
  5. package/client/agent-preferences.ts +112 -0
  6. package/client/browser.tsx +12 -9
  7. package/client/graph-labels.ts +137 -0
  8. package/client/launch-choices.ts +18 -5
  9. package/client/launch-plan.ts +88 -39
  10. package/client/launch.tsx +9 -4
  11. package/client/live-agents.tsx +407 -0
  12. package/client/live-cursor.tsx +291 -0
  13. package/client/live-feed.tsx +151 -0
  14. package/client/live-graph.tsx +434 -0
  15. package/client/live-header.tsx +312 -0
  16. package/client/live-issues.tsx +345 -0
  17. package/client/live-map.tsx +345 -0
  18. package/client/live-panel.tsx +444 -0
  19. package/client/live-timeline.ts +341 -0
  20. package/client/pill.tsx +393 -0
  21. package/client/project-init.tsx +168 -0
  22. package/client/settings-access.tsx +56 -0
  23. package/client/settings-live.tsx +121 -0
  24. package/client/settings-projects.tsx +9 -0
  25. package/index.client.tsx +39 -1
  26. package/index.server.ts +24 -4
  27. package/package.json +1 -1
  28. package/server/agent-runs.ts +299 -0
  29. package/server/handlers.ts +12 -1
  30. package/server/init.ts +64 -0
  31. package/server/links.ts +153 -0
  32. package/server/live.ts +334 -0
  33. package/server/stores.ts +119 -0
  34. package/server/sync.ts +127 -0
  35. package/shared/activity.ts +308 -0
  36. package/shared/graph-geometry.ts +122 -0
  37. package/shared/graph-model.ts +319 -0
  38. package/shared/links.ts +252 -0
  39. package/shared/live.ts +200 -0
  40. package/shared/map-model.ts +192 -0
  41. package/shared/project-setup.ts +55 -0
  42. package/shared/prompts.ts +16 -1
  43. package/shared/settings.ts +57 -1
  44. package/shared/shell-activity.ts +309 -0
  45. package/shared/shell-lexer.ts +180 -0
  46. package/shared/subagents.ts +106 -0
  47. package/shared/todo-sync.ts +158 -0
@@ -0,0 +1,291 @@
1
+ import { Icon } from "@getpaseo/plugin/client/react-native";
2
+ import { memo, useEffect, useMemo, useRef, useState } from "react";
3
+ import { Animated, Easing, Text, View } from "react-native";
4
+ import { type Point, pointAt, type Route, route } from "../shared/graph-geometry";
5
+ import { type Graph, graphPath } from "../shared/graph-model";
6
+ import type { CursorAgent } from "./live-agents";
7
+ import { NATIVE_DRIVER } from "./live-timeline";
8
+ import { ProviderIcon } from "./provider-icon";
9
+ import type { Theme } from "./ui";
10
+
11
+ // An agent hovers over the node it works on. When the work moves, it glides along the shortest
12
+ // walk of imports and issues to the next node, in one eased motion.
13
+
14
+ type Coordinate = number | Animated.Value | Animated.AnimatedInterpolation<number>;
15
+
16
+ interface Motion {
17
+ x: Coordinate;
18
+ y: Coordinate;
19
+ }
20
+
21
+ const BADGE = 26;
22
+ export const CAPTION_ROOM = 210;
23
+ const MAX_SATELLITES = 3;
24
+
25
+ function glideTime(length: number): number {
26
+ return Math.min(1200, Math.max(420, 380 + length * 0.8));
27
+ }
28
+
29
+ export interface CursorProps {
30
+ theme: Theme;
31
+ agent: CursorAgent;
32
+ graph: Graph;
33
+ /** Node id the agent works on. */
34
+ target: string;
35
+ /** Changes when node positions change. */
36
+ layoutKey: string;
37
+ /** Where the cursor hovers over a node. */
38
+ hover(id: string): Point | null;
39
+ /** Canvas width, to keep the caption inside it. */
40
+ width: number;
41
+ reduce: boolean;
42
+ }
43
+
44
+ export const AgentCursor = memo(function AgentCursor(props: CursorProps) {
45
+ const { theme, agent, target, reduce } = props;
46
+ const { colors } = theme;
47
+ const [bob] = useState(() => new Animated.Value(0));
48
+ const [halo] = useState(() => new Animated.Value(0));
49
+ const [spin] = useState(() => new Animated.Value(0));
50
+ const [motion, setMotion] = useState<Motion | null>(null);
51
+ const [flip, setFlip] = useState(false);
52
+ const latest = useRef(props);
53
+ latest.current = props;
54
+ // Each glide gets its own value, so binding a new route never jumps to the old start.
55
+ const active = useRef<{ value: Animated.Value; path: Route; layoutKey: string } | null>(null);
56
+ const state = useRef({
57
+ at: null as string | null,
58
+ point: null as Point | null,
59
+ moving: false,
60
+ pending: null as string | null,
61
+ });
62
+
63
+ // These read only refs, so the animation callbacks never see stale props.
64
+ const land = (point: Point) => {
65
+ state.current.point = point;
66
+ setFlip(point.x > latest.current.width - CAPTION_ROOM);
67
+ };
68
+ const rest = (id: string) => {
69
+ state.current.at = id;
70
+ const point = latest.current.hover(id);
71
+ if (!point) return;
72
+ land(point);
73
+ setMotion({ x: point.x, y: point.y });
74
+ };
75
+ const glide = (points: Point[], to: string) => {
76
+ const path = route(points);
77
+ if (latest.current.reduce || !path) {
78
+ state.current.moving = false;
79
+ rest(to);
80
+ return;
81
+ }
82
+ active.current?.value.stopAnimation();
83
+ const value = new Animated.Value(0);
84
+ active.current = { value, path, layoutKey: latest.current.layoutKey };
85
+ state.current.moving = true;
86
+ land(path.points[path.points.length - 1] as Point);
87
+ setMotion({
88
+ x: value.interpolate({ inputRange: path.stops, outputRange: path.points.map((point) => point.x) }),
89
+ y: value.interpolate({ inputRange: path.stops, outputRange: path.points.map((point) => point.y) }),
90
+ });
91
+ Animated.timing(value, {
92
+ toValue: 1,
93
+ duration: glideTime(path.length),
94
+ easing: Easing.inOut(Easing.cubic),
95
+ useNativeDriver: NATIVE_DRIVER,
96
+ }).start(({ finished }) => {
97
+ if (!finished || active.current?.value !== value) return;
98
+ state.current.moving = false;
99
+ state.current.at = to;
100
+ // Bursts of reads queue only the newest target, so the cursor lands before it moves on.
101
+ const next = state.current.pending;
102
+ state.current.pending = null;
103
+ if (next && next !== to) go(to, next);
104
+ });
105
+ };
106
+ const go = (from: string, to: string) => {
107
+ const { graph, hover } = latest.current;
108
+ const points = graphPath(graph, from, to).flatMap((id) => {
109
+ const point = hover(id);
110
+ return point ? [point] : [];
111
+ });
112
+ // The start node may have left the graph; then the glide starts where the cursor is.
113
+ const start = state.current.point;
114
+ if (start && (points.length === 0 || hover(from) === null)) points.unshift(start);
115
+ glide(points, to);
116
+ };
117
+
118
+ useEffect(() => {
119
+ const current = state.current;
120
+ if (current.at === null) rest(target);
121
+ else if (current.moving) current.pending = target;
122
+ else if (current.at !== target) go(current.at, target);
123
+ }, [target]);
124
+
125
+ // Nodes moved: glide from where the cursor is now to where its target is now.
126
+ useEffect(() => {
127
+ const current = state.current;
128
+ const goal = latest.current.target;
129
+ const end = latest.current.hover(goal);
130
+ const glideNow = active.current;
131
+ if (!end) return;
132
+ if (current.moving && glideNow) {
133
+ if (glideNow.layoutKey === props.layoutKey) return;
134
+ current.pending = null;
135
+ glideNow.value.stopAnimation((share) => glide([pointAt(glideNow.path, share), end], goal));
136
+ } else if (current.point && (current.point.x !== end.x || current.point.y !== end.y)) {
137
+ glide([current.point, end], goal);
138
+ }
139
+ }, [props.layoutKey]);
140
+
141
+ useEffect(() => () => active.current?.value.stopAnimation(), []);
142
+
143
+ const running = agent.running && !reduce;
144
+ useEffect(() => {
145
+ if (!running) {
146
+ bob.setValue(0);
147
+ halo.setValue(0);
148
+ return;
149
+ }
150
+ const float = Animated.loop(
151
+ Animated.sequence([
152
+ Animated.timing(bob, { toValue: 1, duration: 1300, easing: Easing.inOut(Easing.sin), useNativeDriver: NATIVE_DRIVER }),
153
+ Animated.timing(bob, { toValue: 0, duration: 1300, easing: Easing.inOut(Easing.sin), useNativeDriver: NATIVE_DRIVER }),
154
+ ]),
155
+ );
156
+ const pulse = Animated.loop(
157
+ Animated.timing(halo, { toValue: 1, duration: 1600, easing: Easing.out(Easing.quad), useNativeDriver: NATIVE_DRIVER }),
158
+ );
159
+ float.start();
160
+ pulse.start();
161
+ return () => {
162
+ float.stop();
163
+ pulse.stop();
164
+ };
165
+ }, [running, bob, halo]);
166
+
167
+ const satellites = Math.min(MAX_SATELLITES, agent.satellites);
168
+ useEffect(() => {
169
+ if (satellites === 0 || reduce) return;
170
+ spin.setValue(0);
171
+ const loop = Animated.loop(
172
+ Animated.timing(spin, { toValue: 1, duration: 2600, easing: Easing.linear, useNativeDriver: NATIVE_DRIVER }),
173
+ );
174
+ loop.start();
175
+ return () => loop.stop();
176
+ }, [satellites, reduce, spin]);
177
+
178
+ const caption = agent.caption ?? (agent.kind === "child" ? agent.label : null);
179
+ const styles = useMemo(() => {
180
+ const half = BADGE / 2;
181
+ return {
182
+ anchor: { position: "absolute", left: 0, top: 0, width: 0, height: 0, opacity: agent.running ? 1 : 0.75 },
183
+ float: { transform: [{ translateY: bob.interpolate({ inputRange: [0, 1], outputRange: [0, -3] }) }] },
184
+ halo: {
185
+ position: "absolute",
186
+ left: -half - 6,
187
+ top: -half - 6,
188
+ width: BADGE + 12,
189
+ height: BADGE + 12,
190
+ borderRadius: (BADGE + 12) / 2,
191
+ backgroundColor: agent.color,
192
+ opacity: halo.interpolate({ inputRange: [0, 1], outputRange: [0.35, 0] }),
193
+ transform: [{ scale: halo.interpolate({ inputRange: [0, 1], outputRange: [0.8, 1.5] }) }],
194
+ },
195
+ badge: {
196
+ position: "absolute",
197
+ left: -half,
198
+ top: -half,
199
+ width: BADGE,
200
+ height: BADGE,
201
+ borderRadius: half,
202
+ alignItems: "center",
203
+ justifyContent: "center",
204
+ backgroundColor: agent.color,
205
+ borderWidth: 2,
206
+ borderColor: colors.surface0,
207
+ },
208
+ pointer: {
209
+ position: "absolute",
210
+ left: -5,
211
+ top: half - 1,
212
+ width: 0,
213
+ height: 0,
214
+ borderLeftWidth: 5,
215
+ borderRightWidth: 5,
216
+ borderTopWidth: 7,
217
+ borderLeftColor: "transparent",
218
+ borderRightColor: "transparent",
219
+ borderTopColor: agent.color,
220
+ },
221
+ caption: {
222
+ position: "absolute",
223
+ ...(flip ? { right: half + 6 } : { left: half + 6 }),
224
+ top: -10,
225
+ height: 20,
226
+ maxWidth: 190,
227
+ paddingHorizontal: 8,
228
+ justifyContent: "center",
229
+ borderRadius: 10,
230
+ borderWidth: 1,
231
+ borderColor: colors.border,
232
+ backgroundColor: colors.surface2,
233
+ },
234
+ captionText: { color: colors.foreground, fontSize: 11 },
235
+ orbit: {
236
+ position: "absolute",
237
+ left: -22,
238
+ top: -22,
239
+ width: 44,
240
+ height: 44,
241
+ transform: [{ rotate: spin.interpolate({ inputRange: [0, 1], outputRange: ["0deg", "360deg"] }) }],
242
+ },
243
+ moon: {
244
+ position: "absolute",
245
+ width: 8,
246
+ height: 8,
247
+ borderRadius: 4,
248
+ borderWidth: 1.5,
249
+ borderColor: colors.surface0,
250
+ backgroundColor: colors.foregroundMuted,
251
+ },
252
+ } as const;
253
+ }, [agent.color, agent.running, colors, bob, halo, spin, flip]);
254
+ if (!motion) return null;
255
+ const moons = Array.from({ length: satellites }, (_, index) => {
256
+ const angle = (index / Math.max(1, satellites)) * Math.PI * 2;
257
+ return { left: 18 + Math.cos(angle) * 20, top: 18 + Math.sin(angle) * 20 };
258
+ });
259
+ return (
260
+ <Animated.View
261
+ pointerEvents="none"
262
+ style={[styles.anchor, { transform: [{ translateX: motion.x }, { translateY: motion.y }] }]}
263
+ >
264
+ <Animated.View style={styles.float}>
265
+ {agent.running ? <Animated.View style={styles.halo} /> : null}
266
+ {moons.length > 0 ? (
267
+ <Animated.View style={styles.orbit}>
268
+ {moons.map((moon, index) => (
269
+ <View key={index} style={[styles.moon, moon]} />
270
+ ))}
271
+ </Animated.View>
272
+ ) : null}
273
+ <View style={styles.pointer} />
274
+ <View style={styles.badge}>
275
+ {agent.provider ? (
276
+ <ProviderIcon provider={agent.provider} size={13} color={colors.accentForeground} />
277
+ ) : (
278
+ <Icon name="Bot" size={13} color={colors.surface0} />
279
+ )}
280
+ </View>
281
+ {caption ? (
282
+ <View style={styles.caption}>
283
+ <Text numberOfLines={1} style={styles.captionText}>
284
+ {caption}
285
+ </Text>
286
+ </View>
287
+ ) : null}
288
+ </Animated.View>
289
+ </Animated.View>
290
+ );
291
+ });
@@ -0,0 +1,151 @@
1
+ import { Icon } from "@getpaseo/plugin/client/react-native";
2
+ import { useEffect, useMemo, useState } from "react";
3
+ import { ActivityIndicator, Animated, StyleSheet, Text, View } from "react-native";
4
+ import type { ActivityEvent, EventKind } from "../shared/activity";
5
+ import { MONO } from "./live-issues";
6
+ import { NATIVE_DRIVER } from "./live-timeline";
7
+ import type { Theme } from "./ui";
8
+
9
+ export const KIND_ICONS: Record<EventKind, string> = {
10
+ read: "Eye",
11
+ edit: "Pencil",
12
+ write: "FilePlus",
13
+ search: "Search",
14
+ list: "FolderOpen",
15
+ shell: "SquareTerminal",
16
+ agent: "Bot",
17
+ tool: "Wrench",
18
+ };
19
+ const FEED_SIZE = 14;
20
+
21
+ export function eventFailed(event: ActivityEvent): boolean {
22
+ return event.status === "failed" || (event.exitCode !== null && event.exitCode !== 0);
23
+ }
24
+
25
+ /** The color of an event's icon: changes in green, failures in red, the rest muted. */
26
+ export function eventTone(theme: Theme, event: ActivityEvent): string {
27
+ const { colors } = theme;
28
+ if (eventFailed(event) && event.kind !== "search") return colors.statusDanger;
29
+ if (event.kind === "edit" || event.kind === "write") return colors.statusSuccess;
30
+ if (event.kind === "read" || event.kind === "search") return colors.foreground;
31
+ return colors.foregroundMuted;
32
+ }
33
+
34
+ function FeedRow(props: { theme: Theme; event: ActivityEvent; animate: boolean }) {
35
+ const { theme, event } = props;
36
+ const { colors } = theme;
37
+ const [entering] = useState(props.animate);
38
+ const [progress] = useState(() => new Animated.Value(entering ? 0 : 1));
39
+ useEffect(() => {
40
+ if (!entering) return;
41
+ const animation = Animated.timing(progress, {
42
+ toValue: 1,
43
+ duration: 280,
44
+ useNativeDriver: NATIVE_DRIVER,
45
+ });
46
+ animation.start();
47
+ return () => animation.stop();
48
+ }, [entering, progress]);
49
+ const failed = eventFailed(event);
50
+ const tone = eventTone(theme, event);
51
+ const styles = useMemo(() => {
52
+ const slide = progress.interpolate({ inputRange: [0, 1], outputRange: [-8, 0] });
53
+ return {
54
+ row: {
55
+ flexDirection: "row",
56
+ alignItems: "center",
57
+ gap: 10,
58
+ paddingVertical: 6,
59
+ paddingHorizontal: 8,
60
+ borderRadius: 8,
61
+ opacity: progress,
62
+ transform: [{ translateY: slide }],
63
+ },
64
+ icon: {
65
+ width: 24,
66
+ height: 24,
67
+ borderRadius: 7,
68
+ alignItems: "center",
69
+ justifyContent: "center",
70
+ backgroundColor: colors.surface2,
71
+ },
72
+ tint: { ...StyleSheet.absoluteFillObject, borderRadius: 7, backgroundColor: tone, opacity: 0.12 },
73
+ body: { flex: 1, minWidth: 0, gap: 2 },
74
+ text: { color: colors.foreground, fontSize: 13 },
75
+ detail: { color: colors.foregroundMuted, fontFamily: MONO, fontSize: 11 },
76
+ added: { color: colors.statusSuccess, fontSize: 11 },
77
+ removed: { color: colors.statusDanger, fontSize: 11 },
78
+ exit: { color: failed ? colors.statusDanger : colors.foregroundMuted, fontSize: 11 },
79
+ } as const;
80
+ }, [colors, progress, failed, tone]);
81
+ const showExit = event.exitCode !== null && event.exitCode !== 0 && event.kind !== "search";
82
+ return (
83
+ <Animated.View style={styles.row} accessible accessibilityLabel={event.text}>
84
+ <View style={styles.icon}>
85
+ <View style={styles.tint} />
86
+ <Icon name={KIND_ICONS[event.kind]} size={13} color={tone} />
87
+ </View>
88
+ <View style={styles.body}>
89
+ <Text numberOfLines={1} style={styles.text}>
90
+ {event.text}
91
+ </Text>
92
+ {event.detail ? (
93
+ <Text numberOfLines={1} style={styles.detail}>
94
+ {event.detail}
95
+ </Text>
96
+ ) : null}
97
+ </View>
98
+ {event.added > 0 ? <Text style={styles.added}>{`+${event.added}`}</Text> : null}
99
+ {event.removed > 0 ? <Text style={styles.removed}>{`-${event.removed}`}</Text> : null}
100
+ {showExit ? <Text style={styles.exit}>{`exit ${event.exitCode}`}</Text> : null}
101
+ {event.status === "running" ? (
102
+ <ActivityIndicator size="small" color={colors.foregroundMuted} />
103
+ ) : null}
104
+ {event.status === "failed" && event.exitCode === null ? (
105
+ <Icon name="TriangleAlert" size={14} color={colors.statusDanger} />
106
+ ) : null}
107
+ </Animated.View>
108
+ );
109
+ }
110
+
111
+ export function ActivityFeed(props: {
112
+ theme: Theme;
113
+ events: readonly ActivityEvent[];
114
+ loading: boolean;
115
+ error: string | null;
116
+ reduceMotion: boolean;
117
+ }) {
118
+ const { theme } = props;
119
+ const { colors } = theme;
120
+ const rows = props.events.slice(0, FEED_SIZE);
121
+ // The loaded tail renders in place; only rows that arrive after it slide in.
122
+ const [settled, setSettled] = useState(false);
123
+ useEffect(() => {
124
+ if (!props.loading) setSettled(true);
125
+ }, [props.loading]);
126
+ const styles = useMemo(
127
+ () =>
128
+ ({
129
+ list: { gap: 2 },
130
+ note: { color: colors.foregroundMuted, fontSize: 13, padding: 8 },
131
+ error: { color: colors.statusDanger, fontSize: 13, padding: 8 },
132
+ }) as const,
133
+ [colors],
134
+ );
135
+ let empty: string | null = null;
136
+ if (rows.length === 0) empty = props.loading ? "Loading activity" : "No tool calls yet";
137
+ return (
138
+ <View style={styles.list}>
139
+ {props.error ? <Text style={styles.error}>{props.error}</Text> : null}
140
+ {empty ? <Text style={styles.note}>{empty}</Text> : null}
141
+ {rows.map((event) => (
142
+ <FeedRow
143
+ key={event.id}
144
+ theme={theme}
145
+ event={event}
146
+ animate={settled && !props.reduceMotion}
147
+ />
148
+ ))}
149
+ </View>
150
+ );
151
+ }