@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,407 @@
1
+ import type { PluginAgentSnapshot } from "@getpaseo/plugin";
2
+ import { usePaseo } from "@getpaseo/plugin/client";
3
+ import { Icon } from "@getpaseo/plugin/client/react-native";
4
+ import { useQuery } from "@tanstack/react-query";
5
+ import { useCallback, useMemo } from "react";
6
+ import { Pressable, StyleSheet, Text, View } from "react-native";
7
+ import {
8
+ type Activity,
9
+ type ActivityEvent,
10
+ type AgentFocus,
11
+ type DirTouch,
12
+ deriveActivity,
13
+ type FileTouch,
14
+ } from "../shared/activity";
15
+ import type { SubagentRun } from "../shared/subagents";
16
+ import { MONO } from "./live-issues";
17
+ import { useAgentTimelines } from "./live-timeline";
18
+ import { ProviderIcon } from "./provider-icon";
19
+ import { type Theme, usePressableStyle } from "./ui";
20
+
21
+ // Agents working on the issue besides the one the panel belongs to: Paseo child agents, which
22
+ // have their own timelines, and provider subagents, which only report what they were asked.
23
+
24
+ const CHILD_LABEL = "paseo.parent-agent-id";
25
+ const MAX_CHILDREN = 4;
26
+ const CHILD_COLORS = ["#e879f9", "#38bdf8", "#facc15", "#fb7185"];
27
+ const POLL_MS = 10_000;
28
+
29
+ export type AgentStatus = PluginAgentSnapshot["status"];
30
+
31
+ export interface ChildAgent {
32
+ id: string;
33
+ title: string;
34
+ provider: string;
35
+ status: AgentStatus;
36
+ cwd: string;
37
+ color: string;
38
+ }
39
+
40
+ export interface ChildView {
41
+ agent: ChildAgent;
42
+ activity: Activity | null;
43
+ /** Works in the parent's folder, so its files can go on the same map. */
44
+ sameFolder: boolean;
45
+ }
46
+
47
+ export interface AgentMarker {
48
+ key: string;
49
+ kind: "main" | "child" | "subagent";
50
+ color: string;
51
+ provider: string | null;
52
+ label: string;
53
+ }
54
+
55
+ /** Child agents of an agent, newest running ones first. Polls only while the parent works. */
56
+ export function useChildAgents(agentId: string, polling: boolean): ChildAgent[] {
57
+ const paseo = usePaseo();
58
+ const query = useQuery({
59
+ queryKey: ["linear", "live", "children", agentId],
60
+ queryFn: async () => {
61
+ const result = await paseo.agents.list({
62
+ filter: { labels: { [CHILD_LABEL]: agentId }, includeArchived: true },
63
+ page: { limit: 20 },
64
+ });
65
+ return result.entries.map((entry) => entry.agent);
66
+ },
67
+ refetchInterval: polling ? POLL_MS : false,
68
+ staleTime: 5_000,
69
+ });
70
+ return useMemo(() => {
71
+ const agents = [...(query.data ?? [])].sort((a, b) => {
72
+ const running = Number(b.status === "running") - Number(a.status === "running");
73
+ return running || String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? ""));
74
+ });
75
+ return agents.slice(0, MAX_CHILDREN).map((agent, index) => ({
76
+ id: agent.id,
77
+ title: agent.title ?? "Child agent",
78
+ provider: agent.provider,
79
+ status: agent.status as AgentStatus,
80
+ cwd: agent.cwd,
81
+ color: CHILD_COLORS[index % CHILD_COLORS.length] ?? "#38bdf8",
82
+ }));
83
+ }, [query.data]);
84
+ }
85
+
86
+ export function sameFolder(a: string, b: string): boolean {
87
+ return a.replace(/\/+$/, "") === b.replace(/\/+$/, "");
88
+ }
89
+
90
+ function add(markers: Map<string, AgentMarker[]>, paths: readonly string[], marker: AgentMarker) {
91
+ for (const path of paths) {
92
+ if (path.endsWith("/")) continue;
93
+ const list = markers.get(path) ?? [];
94
+ if (!list.some((entry) => entry.key === marker.key)) markers.set(path, [...list, marker]);
95
+ }
96
+ }
97
+
98
+ /** Which agents are on which file: where the main and child agents work, and subagent targets. */
99
+ export function agentMarkers(input: {
100
+ theme: Theme;
101
+ provider: string;
102
+ working: boolean;
103
+ current: ActivityEvent | null;
104
+ children: readonly ChildView[];
105
+ subagents: readonly SubagentRun[];
106
+ }): ReadonlyMap<string, AgentMarker[]> {
107
+ const { colors } = input.theme;
108
+ const markers = new Map<string, AgentMarker[]>();
109
+ if (input.working && input.current) {
110
+ const main = { key: "main", kind: "main", color: colors.accent, provider: input.provider, label: "Agent" } as const;
111
+ add(markers, input.current.paths, main);
112
+ }
113
+ for (const child of input.children) {
114
+ const current = child.activity?.current;
115
+ if (child.agent.status !== "running" || !child.sameFolder || !current) continue;
116
+ add(markers, current.paths, {
117
+ key: child.agent.id,
118
+ kind: "child",
119
+ color: child.agent.color,
120
+ provider: child.agent.provider,
121
+ label: child.agent.title,
122
+ });
123
+ }
124
+ for (const run of input.subagents) {
125
+ if (run.status !== "running") continue;
126
+ add(markers, run.targets, {
127
+ key: run.key,
128
+ kind: "subagent",
129
+ color: colors.foregroundMuted,
130
+ provider: null,
131
+ label: run.description || run.type,
132
+ });
133
+ }
134
+ return markers;
135
+ }
136
+
137
+ function subagentStatus(run: SubagentRun, parentRunning: boolean): string {
138
+ if (run.status === "completed") return "Done";
139
+ if (run.status === "failed") return "Failed";
140
+ if (run.status === "canceled") return "Canceled";
141
+ return parentRunning ? "Running" : "Started, no report since";
142
+ }
143
+
144
+ function names(paths: readonly string[]): string {
145
+ const shown = paths.slice(0, 2).map((path) => path.replace(/\/$/, "").split("/").pop() ?? path);
146
+ return `${shown.join(", ")}${paths.length > 2 ? ` +${paths.length - 2}` : ""}`;
147
+ }
148
+
149
+ interface RowProps {
150
+ theme: Theme;
151
+ color: string;
152
+ icon: "provider" | "bot";
153
+ provider: string | null;
154
+ title: string;
155
+ status: string;
156
+ detail: string | null;
157
+ live: boolean;
158
+ onPress?: () => void;
159
+ }
160
+
161
+ function AgentRow(props: RowProps) {
162
+ const { theme } = props;
163
+ const { colors } = theme;
164
+ const styles = useMemo(() => {
165
+ const idle = { flexDirection: "row", gap: 10, padding: 8, borderRadius: 8 } as const;
166
+ return {
167
+ idle,
168
+ hover: { ...idle, backgroundColor: colors.surface1 },
169
+ badge: {
170
+ width: 24,
171
+ height: 24,
172
+ borderRadius: 7,
173
+ alignItems: "center",
174
+ justifyContent: "center",
175
+ backgroundColor: colors.surface2,
176
+ borderWidth: 1,
177
+ borderColor: props.icon === "bot" ? colors.border : props.color,
178
+ },
179
+ tint: { ...StyleSheet.absoluteFillObject, borderRadius: 6, backgroundColor: props.color, opacity: 0.18 },
180
+ body: { flex: 1, minWidth: 0, gap: 2 },
181
+ line: { flexDirection: "row", alignItems: "center", gap: 6 },
182
+ title: { flex: 1, color: colors.foreground, fontSize: 13, fontWeight: "500" },
183
+ status: { color: props.live ? colors.statusSuccess : colors.foregroundMuted, fontSize: 11 },
184
+ detail: { color: colors.foregroundMuted, fontSize: 11, fontFamily: MONO },
185
+ } as const;
186
+ }, [colors, props.color, props.icon, props.live]);
187
+ const pressStyle = usePressableStyle(styles.idle, styles.hover);
188
+ const content = (
189
+ <>
190
+ <View style={styles.badge}>
191
+ {props.icon === "bot" ? null : <View style={styles.tint} />}
192
+ {props.icon === "provider" && props.provider ? (
193
+ <ProviderIcon provider={props.provider} size={12} color={colors.foreground} />
194
+ ) : (
195
+ <Icon name="Bot" size={12} color={colors.foregroundMuted} />
196
+ )}
197
+ </View>
198
+ <View style={styles.body}>
199
+ <View style={styles.line}>
200
+ <Text numberOfLines={1} style={styles.title}>
201
+ {props.title}
202
+ </Text>
203
+ <Text style={styles.status}>{props.status}</Text>
204
+ </View>
205
+ {props.detail ? (
206
+ <Text numberOfLines={1} style={styles.detail}>
207
+ {props.detail}
208
+ </Text>
209
+ ) : null}
210
+ </View>
211
+ </>
212
+ );
213
+ if (!props.onPress) return <View style={styles.idle}>{content}</View>;
214
+ return (
215
+ <Pressable accessibilityRole="button" accessibilityLabel={`Open ${props.title}`} onPress={props.onPress} style={pressStyle}>
216
+ {content}
217
+ </Pressable>
218
+ );
219
+ }
220
+
221
+ const STATUS_COPY: Record<AgentStatus, string> = {
222
+ initializing: "Starting",
223
+ idle: "Idle",
224
+ running: "Working",
225
+ error: "Error",
226
+ closed: "Closed",
227
+ };
228
+
229
+ export function AgentsList(props: {
230
+ theme: Theme;
231
+ provider: string;
232
+ status: AgentStatus;
233
+ current: ActivityEvent | null;
234
+ childAgents: readonly ChildView[];
235
+ subagents: readonly SubagentRun[];
236
+ onOpenAgent?: (agentId: string) => void;
237
+ }) {
238
+ const { theme, onOpenAgent } = props;
239
+ const parentRunning = props.status === "running";
240
+ const open = useCallback((id: string) => () => onOpenAgent?.(id), [onOpenAgent]);
241
+ return (
242
+ <View>
243
+ <AgentRow
244
+ theme={theme}
245
+ color={theme.colors.accent}
246
+ icon="provider"
247
+ provider={props.provider}
248
+ title={props.provider ? props.provider.charAt(0).toUpperCase() + props.provider.slice(1) : "Agent"}
249
+ status={STATUS_COPY[props.status] ?? props.status}
250
+ detail={props.current?.text ?? null}
251
+ live={parentRunning}
252
+ />
253
+ {props.childAgents.map(({ agent, activity, sameFolder: shared }) => (
254
+ <AgentRow
255
+ key={agent.id}
256
+ theme={theme}
257
+ color={agent.color}
258
+ icon="provider"
259
+ provider={agent.provider}
260
+ title={agent.title}
261
+ status={STATUS_COPY[agent.status] ?? agent.status}
262
+ detail={shared ? (activity?.current?.text ?? null) : `In ${agent.cwd.split("/").pop() ?? agent.cwd}`}
263
+ live={agent.status === "running"}
264
+ onPress={onOpenAgent ? open(agent.id) : undefined}
265
+ />
266
+ ))}
267
+ {props.subagents.map((run) => (
268
+ <AgentRow
269
+ key={run.key}
270
+ theme={theme}
271
+ color={theme.colors.foregroundMuted}
272
+ icon="bot"
273
+ provider={null}
274
+ title={run.description ? `${run.type}: ${run.description}` : run.type}
275
+ status={subagentStatus(run, parentRunning)}
276
+ detail={run.targets.length > 0 ? `Asked about ${names(run.targets)}` : null}
277
+ live={run.status === "running" && parentRunning}
278
+ />
279
+ ))}
280
+ </View>
281
+ );
282
+ }
283
+
284
+ /** Files the main agent and its same-folder child agents touched, counted together. */
285
+ function mergeTouches(own: readonly FileTouch[], children: readonly ChildView[]): FileTouch[] {
286
+ const merged = new Map(own.map((file) => [file.path, { ...file }]));
287
+ for (const child of children) {
288
+ if (!child.sameFolder) continue;
289
+ for (const file of child.activity?.files ?? []) {
290
+ const known = merged.get(file.path);
291
+ if (!known) {
292
+ merged.set(file.path, { ...file });
293
+ continue;
294
+ }
295
+ known.reads += file.reads;
296
+ known.edits += file.edits;
297
+ known.added += file.added;
298
+ known.removed += file.removed;
299
+ known.created ||= file.created;
300
+ }
301
+ }
302
+ return [...merged.values()];
303
+ }
304
+
305
+ /** Child agents with their activity, every agent's place on the map, and all touched files. */
306
+ export function useLiveAgents(input: {
307
+ theme: Theme;
308
+ agentId: string;
309
+ cwd: string;
310
+ status: AgentStatus;
311
+ provider: string;
312
+ activity: Activity;
313
+ }) {
314
+ const { theme, agentId, cwd, status, provider, activity } = input;
315
+ const children = useChildAgents(agentId, status === "running");
316
+ const ids = useMemo(() => children.map((child) => child.id), [children]);
317
+ const timelines = useAgentTimelines(ids);
318
+ const views = useMemo(
319
+ () =>
320
+ children.map((agent): ChildView => {
321
+ const timeline = timelines.get(agent.id);
322
+ return {
323
+ agent,
324
+ activity: timeline ? deriveActivity(timeline.items, agent.cwd) : null,
325
+ sameFolder: sameFolder(agent.cwd, cwd),
326
+ };
327
+ }),
328
+ [children, timelines, cwd],
329
+ );
330
+ const markers = useMemo(
331
+ () =>
332
+ agentMarkers({
333
+ theme,
334
+ provider,
335
+ working: status === "running",
336
+ current: activity.current,
337
+ children: views,
338
+ subagents: activity.subagents,
339
+ }),
340
+ [theme, provider, status, activity.current, activity.subagents, views],
341
+ );
342
+ const files = useMemo(() => mergeTouches(activity.files, views), [activity.files, views]);
343
+ const dirs = useMemo(
344
+ (): DirTouch[] => [
345
+ ...activity.dirs,
346
+ ...views.flatMap((view) => (view.sameFolder ? (view.activity?.dirs ?? []) : [])),
347
+ ],
348
+ [activity.dirs, views],
349
+ );
350
+ return { children: views, markers, files, dirs };
351
+ }
352
+
353
+ /** An agent that moves over the graph to what it works on. */
354
+ export interface CursorAgent {
355
+ id: string;
356
+ kind: "main" | "child";
357
+ label: string;
358
+ color: string;
359
+ provider: string | null;
360
+ focus: AgentFocus | null;
361
+ running: boolean;
362
+ /** What it does now, such as "Read page.tsx". */
363
+ caption: string | null;
364
+ /** Subagents running inside it. */
365
+ satellites: number;
366
+ }
367
+
368
+ export function cursorAgents(input: {
369
+ theme: Theme;
370
+ provider: string;
371
+ working: boolean;
372
+ activity: Activity;
373
+ children: readonly ChildView[];
374
+ }): CursorAgent[] {
375
+ const { activity } = input;
376
+ const name = input.provider ? input.provider.charAt(0).toUpperCase() + input.provider.slice(1) : "Agent";
377
+ const agents: CursorAgent[] = [
378
+ {
379
+ id: "main",
380
+ kind: "main",
381
+ label: name,
382
+ color: input.theme.colors.accent,
383
+ provider: input.provider,
384
+ focus: activity.focus,
385
+ running: input.working,
386
+ caption: input.working ? (activity.current?.text ?? null) : null,
387
+ satellites: input.working ? activity.subagents.filter((run) => run.status === "running").length : 0,
388
+ },
389
+ ];
390
+ for (const child of input.children) {
391
+ // Paths are relative to each agent's folder, so only agents in this folder fit the graph.
392
+ if (!child.sameFolder || !child.activity) continue;
393
+ const running = child.agent.status === "running";
394
+ agents.push({
395
+ id: child.agent.id,
396
+ kind: "child",
397
+ label: child.agent.title,
398
+ color: child.agent.color,
399
+ provider: null,
400
+ focus: child.activity.focus,
401
+ running,
402
+ caption: running ? (child.activity.current?.text ?? null) : null,
403
+ satellites: 0,
404
+ });
405
+ }
406
+ return agents;
407
+ }
@@ -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
+ });