@yannelli/paseo-linear-plugin 1.0.1 → 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 (45) hide show
  1. package/OVERVIEW.md +19 -1
  2. package/README.md +73 -3
  3. package/client/access.tsx +66 -0
  4. package/client/browser.tsx +12 -9
  5. package/client/graph-labels.ts +137 -0
  6. package/client/launch-choices.ts +15 -4
  7. package/client/launch-plan.ts +36 -14
  8. package/client/launch.tsx +9 -4
  9. package/client/live-agents.tsx +407 -0
  10. package/client/live-cursor.tsx +291 -0
  11. package/client/live-feed.tsx +151 -0
  12. package/client/live-graph.tsx +434 -0
  13. package/client/live-header.tsx +312 -0
  14. package/client/live-issues.tsx +345 -0
  15. package/client/live-map.tsx +345 -0
  16. package/client/live-panel.tsx +444 -0
  17. package/client/live-timeline.ts +341 -0
  18. package/client/pill.tsx +393 -0
  19. package/client/project-init.tsx +168 -0
  20. package/client/settings-access.tsx +56 -0
  21. package/client/settings-live.tsx +121 -0
  22. package/client/settings-projects.tsx +9 -0
  23. package/index.client.tsx +39 -1
  24. package/index.server.ts +24 -4
  25. package/package.json +1 -1
  26. package/server/agent-runs.ts +299 -0
  27. package/server/handlers.ts +12 -1
  28. package/server/init.ts +64 -0
  29. package/server/links.ts +153 -0
  30. package/server/live.ts +334 -0
  31. package/server/stores.ts +119 -0
  32. package/server/sync.ts +127 -0
  33. package/shared/activity.ts +308 -0
  34. package/shared/graph-geometry.ts +122 -0
  35. package/shared/graph-model.ts +319 -0
  36. package/shared/links.ts +252 -0
  37. package/shared/live.ts +200 -0
  38. package/shared/map-model.ts +192 -0
  39. package/shared/project-setup.ts +55 -0
  40. package/shared/prompts.ts +16 -1
  41. package/shared/settings.ts +57 -1
  42. package/shared/shell-activity.ts +309 -0
  43. package/shared/shell-lexer.ts +180 -0
  44. package/shared/subagents.ts +106 -0
  45. package/shared/todo-sync.ts +158 -0
@@ -0,0 +1,345 @@
1
+ import { Icon } from "@getpaseo/plugin/client/react-native";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import { Animated, type LayoutChangeEvent, StyleSheet, Text, View } from "react-native";
4
+ import { MONO } from "./live-issues";
5
+ import {
6
+ type MapModel,
7
+ type MapTile,
8
+ type MapZone,
9
+ masonry,
10
+ ZONE_GAP,
11
+ } from "../shared/map-model";
12
+ import { NATIVE_DRIVER, type OwnerColors } from "./live-timeline";
13
+ import type { AgentMarker } from "./live-agents";
14
+ import { ProviderIcon } from "./provider-icon";
15
+ import type { Theme } from "./ui";
16
+
17
+ const ZONE_ICONS = { none: "Folder", listed: "FolderOpen", searched: "FolderSearch" } as const;
18
+ const MIN_ZONE_WIDTH = 260;
19
+ const MAX_COLUMNS = 4;
20
+ interface TileProps {
21
+ theme: Theme;
22
+ tile: MapTile;
23
+ color: string | null;
24
+ dim: boolean;
25
+ /** Agents on this file now, and subagents asked about it. */
26
+ markers: readonly AgentMarker[];
27
+ reduceMotion: boolean;
28
+ }
29
+
30
+ const NO_MARKERS: readonly AgentMarker[] = [];
31
+
32
+ function MarkerBadge({ theme, marker }: { theme: Theme; marker: AgentMarker }) {
33
+ const { colors } = theme;
34
+ const sub = marker.kind === "subagent";
35
+ const style = useMemo(
36
+ () =>
37
+ ({
38
+ width: 16,
39
+ height: 16,
40
+ borderRadius: 8,
41
+ alignItems: "center",
42
+ justifyContent: "center",
43
+ borderWidth: sub ? 1 : 0,
44
+ borderColor: colors.border,
45
+ backgroundColor: sub ? colors.surface2 : marker.color,
46
+ }) as const,
47
+ [colors, sub, marker.color],
48
+ );
49
+ const tint = sub ? colors.foregroundMuted : colors.accentForeground;
50
+ return (
51
+ <View style={style} accessibilityLabel={marker.label}>
52
+ {marker.provider ? (
53
+ <ProviderIcon provider={marker.provider} size={10} color={tint} />
54
+ ) : (
55
+ <Icon name="Bot" size={10} color={tint} />
56
+ )}
57
+ </View>
58
+ );
59
+ }
60
+
61
+ function tileLabel(tile: MapTile): string {
62
+ if (tile.ghost) return `${tile.path}, not on the map`;
63
+ const where = tile.onMap ? `on the map for ${tile.owner}` : "off the map";
64
+ const touch = tile.touch === "none" ? "not touched" : tile.touch;
65
+ return `${tile.path}, ${touch}, ${where}`;
66
+ }
67
+
68
+ function Tile({ theme, tile, color, dim, markers, reduceMotion }: TileProps) {
69
+ const { colors } = theme;
70
+ const active = markers.find((marker) => marker.kind !== "subagent");
71
+ const current = active !== undefined;
72
+ const touched = tile.touch !== "none";
73
+ const offMap = touched && !tile.onMap;
74
+ const tone = offMap ? colors.statusWarning : (color ?? colors.foregroundMuted);
75
+ const filled = tile.touch === "edited" || tile.touch === "created";
76
+ const [pulse] = useState(() => new Animated.Value(1));
77
+ const [born] = useState(() => new Animated.Value(1));
78
+ const wasTouched = useRef(touched);
79
+ const styles = useMemo(() => {
80
+ let borderColor = colors.border;
81
+ if (active) borderColor = active.color;
82
+ else if (offMap) borderColor = colors.statusWarning;
83
+ else if (touched) borderColor = tone;
84
+ return {
85
+ root: {
86
+ height: 26,
87
+ paddingLeft: 8,
88
+ paddingRight: 9,
89
+ borderRadius: 7,
90
+ borderWidth: tile.ghost ? 0 : 1,
91
+ borderStyle: offMap ? "dashed" : "solid",
92
+ borderColor,
93
+ backgroundColor: tile.ghost ? "transparent" : touched ? colors.surface2 : colors.surface1,
94
+ flexDirection: "row",
95
+ alignItems: "center",
96
+ gap: 6,
97
+ maxWidth: "100%",
98
+ opacity: (tile.ghost ? 0.4 : 1) * (dim ? 0.25 : 1),
99
+ },
100
+ fill: { ...StyleSheet.absoluteFillObject, borderRadius: 6, backgroundColor: tone, opacity: 0.16 },
101
+ dot: {
102
+ width: 6,
103
+ height: 6,
104
+ borderRadius: 3,
105
+ borderWidth: 1.5,
106
+ borderColor: tile.ghost ? colors.surface2 : tone,
107
+ backgroundColor: touched ? tone : "transparent",
108
+ },
109
+ name: {
110
+ flexShrink: 1,
111
+ fontFamily: MONO,
112
+ fontSize: 11.5,
113
+ color: touched ? colors.foreground : colors.foregroundMuted,
114
+ },
115
+ chip: { color: colors.foregroundMuted, fontSize: 10 },
116
+ added: { color: colors.statusSuccess, fontSize: 10 },
117
+ removed: { color: colors.statusDanger, fontSize: 10 },
118
+ badges: { position: "absolute", top: -7, right: -7, flexDirection: "row-reverse", gap: 2 },
119
+ } as const;
120
+ }, [colors, tile.ghost, touched, offMap, tone, current, active, dim]);
121
+ const motion = useMemo(
122
+ () => ({ maxWidth: "100%", transform: [{ scale: Animated.multiply(pulse, born) }] }) as const,
123
+ [pulse, born],
124
+ );
125
+ useEffect(() => {
126
+ if (touched === wasTouched.current) return;
127
+ wasTouched.current = touched;
128
+ if (!touched || reduceMotion) return;
129
+ born.setValue(0.82);
130
+ const animation = Animated.spring(born, {
131
+ toValue: 1,
132
+ friction: 5,
133
+ tension: 160,
134
+ useNativeDriver: NATIVE_DRIVER,
135
+ });
136
+ animation.start();
137
+ return () => animation.stop();
138
+ }, [touched, reduceMotion, born]);
139
+ useEffect(() => {
140
+ if (!current || reduceMotion) return;
141
+ const animation = Animated.sequence([
142
+ Animated.timing(pulse, { toValue: 1.08, duration: 140, useNativeDriver: NATIVE_DRIVER }),
143
+ Animated.timing(pulse, { toValue: 1, duration: 220, useNativeDriver: NATIVE_DRIVER }),
144
+ ]);
145
+ animation.start();
146
+ return () => {
147
+ animation.stop();
148
+ pulse.setValue(1);
149
+ };
150
+ }, [current, reduceMotion, pulse]);
151
+ return (
152
+ <Animated.View style={motion} accessible accessibilityLabel={tileLabel(tile)}>
153
+ <View style={styles.root}>
154
+ {filled ? <View style={styles.fill} /> : null}
155
+ <View style={styles.dot} />
156
+ <Text numberOfLines={1} style={styles.name}>
157
+ {tile.name}
158
+ </Text>
159
+ {tile.touch === "created" ? <Text style={styles.chip}>new</Text> : null}
160
+ {tile.added > 0 ? <Text style={styles.added}>{`+${tile.added}`}</Text> : null}
161
+ {tile.removed > 0 ? <Text style={styles.removed}>{`-${tile.removed}`}</Text> : null}
162
+ </View>
163
+ {markers.length > 0 ? (
164
+ <View style={styles.badges} pointerEvents="none">
165
+ {markers.slice(0, 2).map((marker) => (
166
+ <MarkerBadge key={marker.key} theme={theme} marker={marker} />
167
+ ))}
168
+ </View>
169
+ ) : null}
170
+ </Animated.View>
171
+ );
172
+ }
173
+
174
+ function Zone(props: RepoMapProps & { zone: MapZone }) {
175
+ const { theme, zone, focus } = props;
176
+ const { colors } = theme;
177
+ const hot = zone.touched > 0;
178
+ const seen = hot || zone.explored !== "none";
179
+ const styles = useMemo(
180
+ () =>
181
+ ({
182
+ zone: {
183
+ minWidth: 0,
184
+ padding: 12,
185
+ gap: 10,
186
+ borderRadius: 12,
187
+ borderWidth: 1,
188
+ borderStyle: seen ? "solid" : "dashed",
189
+ borderColor: colors.border,
190
+ backgroundColor: seen ? colors.surface1 : "transparent",
191
+ },
192
+ ring: {
193
+ ...StyleSheet.absoluteFillObject,
194
+ borderRadius: 12,
195
+ borderWidth: 1,
196
+ borderColor: colors.foregroundMuted,
197
+ opacity: 0.35,
198
+ },
199
+ header: { flexDirection: "row", alignItems: "center", gap: 6 },
200
+ dir: { flex: 1, color: colors.foregroundMuted, fontFamily: MONO, fontSize: 11 },
201
+ count: { color: hot ? colors.foreground : colors.foregroundMuted, fontSize: 11 },
202
+ tiles: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
203
+ }) as const,
204
+ [colors, props.compact, seen, hot],
205
+ );
206
+ const off = zone.touched - zone.hits;
207
+ let count: string | null = null;
208
+ if (zone.predicted > 0) count = `${zone.hits}/${zone.predicted}${off > 0 ? ` +${off} off` : ""}`;
209
+ else if (off > 0) count = `${off} off the map`;
210
+ return (
211
+ <View style={styles.zone}>
212
+ {hot ? <View pointerEvents="none" style={styles.ring} /> : null}
213
+ <View style={styles.header}>
214
+ <Icon
215
+ name={ZONE_ICONS[zone.explored]}
216
+ size={12}
217
+ color={seen ? colors.foreground : colors.foregroundMuted}
218
+ />
219
+ <Text numberOfLines={1} style={styles.dir}>
220
+ {zone.dir || "./"}
221
+ </Text>
222
+ {count ? <Text style={styles.count}>{count}</Text> : null}
223
+ </View>
224
+ <View style={styles.tiles}>
225
+ {zone.tiles.map((tile) => (
226
+ <Tile
227
+ key={tile.path}
228
+ theme={theme}
229
+ tile={tile}
230
+ color={tile.owner ? (props.colors.get(tile.owner) ?? null) : null}
231
+ dim={focus !== null && tile.owner !== focus}
232
+ markers={tile.ghost ? NO_MARKERS : (props.markers.get(tile.path) ?? NO_MARKERS)}
233
+ reduceMotion={props.reduceMotion}
234
+ />
235
+ ))}
236
+ </View>
237
+ </View>
238
+ );
239
+ }
240
+
241
+ export interface RepoMapProps {
242
+ theme: Theme;
243
+ compact: boolean;
244
+ model: MapModel;
245
+ colors: OwnerColors;
246
+ focus: string | null;
247
+ /** Agents on each file now, and subagents asked about it. */
248
+ markers: ReadonlyMap<string, AgentMarker[]>;
249
+ reduceMotion: boolean;
250
+ }
251
+
252
+ export function RepoMap(props: RepoMapProps) {
253
+ const { colors } = props.theme;
254
+ const [width, setWidth] = useState(0);
255
+ const measure = useCallback((event: LayoutChangeEvent) => {
256
+ setWidth(event.nativeEvent.layout.width);
257
+ }, []);
258
+ const columns = props.compact
259
+ ? 1
260
+ : Math.max(1, Math.min(MAX_COLUMNS, Math.floor((width + ZONE_GAP) / (MIN_ZONE_WIDTH + ZONE_GAP))));
261
+ // Zones keep their column while the agent works, so growing zones do not reshuffle the map.
262
+ const placed = useRef({ columns: 0, byDir: new Map<string, number>() });
263
+ const groups = useMemo(() => {
264
+ const count = width > 0 ? columns : 1;
265
+ if (placed.current.columns !== count) placed.current = { columns: count, byDir: new Map() };
266
+ return masonry(props.model.zones, count, width || MIN_ZONE_WIDTH, placed.current.byDir);
267
+ }, [props.model.zones, columns, width]);
268
+ const styles = useMemo(
269
+ () =>
270
+ ({
271
+ zones: { flexDirection: "row", gap: ZONE_GAP, alignItems: "flex-start" },
272
+ column: { flex: 1, minWidth: 0, gap: ZONE_GAP },
273
+ empty: { color: colors.foregroundMuted, fontSize: 13 },
274
+ hidden: { opacity: 0 },
275
+ }) as const,
276
+ [colors],
277
+ );
278
+ // The measured View stays mounted, so the first files do not draw in one column and then reflow.
279
+ return (
280
+ <View onLayout={measure}>
281
+ {props.model.zones.length === 0 ? (
282
+ <Text style={styles.empty}>No files on the map yet. Files the agent opens show here.</Text>
283
+ ) : (
284
+ <View style={[styles.zones, width > 0 ? null : styles.hidden]}>
285
+ {groups.map((group, index) => (
286
+ <View key={index} style={styles.column}>
287
+ {group.map((zone) => (
288
+ <Zone key={zone.dir} {...props} zone={zone} />
289
+ ))}
290
+ </View>
291
+ ))}
292
+ </View>
293
+ )}
294
+ </View>
295
+ );
296
+ }
297
+
298
+ /** Explains the tile styles; shown in the map card header. */
299
+ export function MapLegend({ theme }: { theme: Theme }) {
300
+ const { colors } = theme;
301
+ const styles = useMemo(() => {
302
+ const swatch = {
303
+ width: 18,
304
+ height: 12,
305
+ borderRadius: 4,
306
+ borderWidth: 1,
307
+ alignItems: "center",
308
+ justifyContent: "center",
309
+ } as const;
310
+ const dot = { width: 5, height: 5, borderRadius: 3, borderWidth: 1 } as const;
311
+ return {
312
+ row: { flexDirection: "row", alignItems: "center", gap: 12, flexWrap: "wrap" },
313
+ item: { flexDirection: "row", alignItems: "center", gap: 5 },
314
+ text: { color: colors.foregroundMuted, fontSize: 11 },
315
+ mapped: { ...swatch, borderColor: colors.border, backgroundColor: colors.surface1 },
316
+ touched: { ...swatch, borderColor: colors.accent, backgroundColor: colors.surface2 },
317
+ off: { ...swatch, borderStyle: "dashed", borderColor: colors.statusWarning },
318
+ hollow: { ...dot, borderColor: colors.accent },
319
+ solid: { ...dot, borderColor: colors.accent, backgroundColor: colors.accent },
320
+ warn: { ...dot, borderColor: colors.statusWarning, backgroundColor: colors.statusWarning },
321
+ } as const;
322
+ }, [colors]);
323
+ return (
324
+ <View style={styles.row}>
325
+ <View style={styles.item}>
326
+ <View style={styles.mapped}>
327
+ <View style={styles.hollow} />
328
+ </View>
329
+ <Text style={styles.text}>Predicted</Text>
330
+ </View>
331
+ <View style={styles.item}>
332
+ <View style={styles.touched}>
333
+ <View style={styles.solid} />
334
+ </View>
335
+ <Text style={styles.text}>Touched</Text>
336
+ </View>
337
+ <View style={styles.item}>
338
+ <View style={styles.off}>
339
+ <View style={styles.warn} />
340
+ </View>
341
+ <Text style={styles.text}>Off the map</Text>
342
+ </View>
343
+ </View>
344
+ );
345
+ }