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

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 (44) hide show
  1. package/OVERVIEW.md +7 -3
  2. package/README.md +38 -11
  3. package/client/access.tsx +2 -1
  4. package/client/compat.ts +23 -5
  5. package/client/connect.tsx +3 -2
  6. package/client/graph-camera.tsx +219 -0
  7. package/client/graph-labels.ts +14 -4
  8. package/client/launch-guidance.tsx +86 -0
  9. package/client/launch-plan.ts +50 -2
  10. package/client/launch.tsx +10 -1
  11. package/client/live-agents.tsx +138 -23
  12. package/client/live-cursor.tsx +3 -1
  13. package/client/live-graph.tsx +159 -75
  14. package/client/live-header.tsx +29 -0
  15. package/client/live-panel.tsx +29 -8
  16. package/client/live-timeline.ts +38 -3
  17. package/client/pill.tsx +4 -3
  18. package/client/plugin-icon.tsx +43 -0
  19. package/client/settings-fields.tsx +11 -3
  20. package/client/settings-icon.tsx +228 -0
  21. package/client/settings-live.tsx +25 -1
  22. package/client/timeline-card.tsx +2 -1
  23. package/client/tool-buttons.tsx +68 -0
  24. package/client/ui.tsx +16 -7
  25. package/client/web.ts +91 -0
  26. package/index.client.tsx +9 -0
  27. package/index.server.ts +5 -1
  28. package/package.json +1 -1
  29. package/server/agent-hooks.ts +37 -0
  30. package/server/agent-runs.ts +15 -2
  31. package/server/agent-tools.ts +240 -0
  32. package/server/init.ts +1 -0
  33. package/server/live.ts +27 -1
  34. package/server/mcp-http.ts +152 -0
  35. package/server/stores.ts +8 -1
  36. package/server/subagent-logs.ts +146 -0
  37. package/shared/activity.ts +5 -1
  38. package/shared/agent-hooks.ts +68 -0
  39. package/shared/agent-tools.ts +130 -0
  40. package/shared/custom-icon.ts +148 -0
  41. package/shared/graph-camera.ts +93 -0
  42. package/shared/live.ts +43 -0
  43. package/shared/prompts.ts +49 -1
  44. package/shared/settings.ts +34 -0
@@ -1,12 +1,16 @@
1
1
  import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { Animated, type LayoutChangeEvent, Pressable, StyleSheet, Text, View } from "react-native";
3
+ import { focusBox, project, unproject } from "../shared/graph-camera";
3
4
  import type { Placed, Point } from "../shared/graph-geometry";
4
5
  import { focusNode, type Graph, type GraphEdge, type GraphNode, graphKey, layoutGraph } from "../shared/graph-model";
5
6
  import type { IssueDetail, WorkflowState } from "../shared/linear";
7
+ import type { GraphCamera } from "../shared/settings";
8
+ import { CameraButtons, useGraphCamera } from "./graph-camera";
6
9
  import { captionWidth, graphLabels, HOVER_GAP, type Label, nodeSize } from "./graph-labels";
7
10
  import type { CursorAgent } from "./live-agents";
8
11
  import { AgentCursor, CAPTION_ROOM } from "./live-cursor";
9
12
  import { type IssueProgress, MONO } from "./live-issues";
13
+ import type { LivePatch } from "./live-panel";
10
14
  import { NATIVE_DRIVER, type OwnerColors } from "./live-timeline";
11
15
  import { StateIcon, type Theme } from "./ui";
12
16
 
@@ -27,16 +31,31 @@ export interface GraphViewProps {
27
31
  agents: readonly CursorAgent[];
28
32
  focus: string | null;
29
33
  onFocus(key: string): void;
34
+ /** Saved camera mode: follow the running agents, or show the whole graph. */
35
+ camera: GraphCamera;
36
+ locked: boolean;
37
+ saveLive(patch: LivePatch): void;
30
38
  reduceMotion: boolean;
31
39
  }
32
40
 
41
+ type Inverse = Animated.AnimatedInterpolation<number>;
42
+ /** About half the height of a folder name, which scales around its middle. */
43
+ const FOLDER_HALF = 7;
44
+
33
45
  interface EdgeStyle {
34
46
  color: string;
35
47
  thickness: number;
36
48
  opacity: number;
37
49
  }
38
50
 
39
- const EdgeLine = memo(function EdgeLine(props: { from: Point; to: Point; style: EdgeStyle }) {
51
+ interface EdgeProps {
52
+ from: Point;
53
+ to: Point;
54
+ style: EdgeStyle;
55
+ inverse: Inverse;
56
+ }
57
+
58
+ const EdgeLine = memo(function EdgeLine(props: EdgeProps) {
40
59
  const { from, to, style } = props;
41
60
  const length = Math.hypot(to.x - from.x, to.y - from.y);
42
61
  const angle = Math.atan2(to.y - from.y, to.x - from.x);
@@ -49,13 +68,15 @@ const EdgeLine = memo(function EdgeLine(props: { from: Point; to: Point; style:
49
68
  borderRadius: style.thickness / 2,
50
69
  backgroundColor: style.color,
51
70
  opacity: style.opacity,
52
- transform: [{ rotate: `${angle}rad` }],
71
+ // Rotate first, so the inverse zoom thins the line and leaves its length to the camera.
72
+ transform: [{ rotate: `${angle}rad` }, { scaleY: props.inverse }],
53
73
  } as const;
54
- return <View pointerEvents="none" style={line} />;
74
+ return <Animated.View pointerEvents="none" style={line} />;
55
75
  }, sameEdge);
56
76
 
57
- function sameEdge(a: { from: Point; to: Point; style: EdgeStyle }, b: { from: Point; to: Point; style: EdgeStyle }) {
77
+ function sameEdge(a: EdgeProps, b: EdgeProps) {
58
78
  return (
79
+ a.inverse === b.inverse &&
59
80
  a.from === b.from &&
60
81
  a.to === b.to &&
61
82
  a.style.color === b.style.color &&
@@ -75,6 +96,7 @@ interface NodeProps {
75
96
  /** An agent is working on it. */
76
97
  current: boolean;
77
98
  label: Label | null;
99
+ inverse: Inverse;
78
100
  reduce: boolean;
79
101
  onPress(id: string): void;
80
102
  }
@@ -95,6 +117,7 @@ function sameNode(a: NodeProps, b: NodeProps): boolean {
95
117
  a.label?.lines[0] === b.label?.lines[0] &&
96
118
  a.label?.lines[1] === b.label?.lines[1] &&
97
119
  a.theme === b.theme &&
120
+ a.inverse === b.inverse &&
98
121
  a.reduce === b.reduce &&
99
122
  a.onPress === b.onPress &&
100
123
  x.id === y.id &&
@@ -142,6 +165,7 @@ const GraphNodeView = memo(function GraphNodeView(props: NodeProps) {
142
165
  width: size,
143
166
  height: size,
144
167
  opacity: props.dim ? 0.22 : 1,
168
+ transform: [{ scale: props.inverse }],
145
169
  },
146
170
  shape: {
147
171
  width: size,
@@ -173,10 +197,10 @@ const GraphNodeView = memo(function GraphNodeView(props: NodeProps) {
173
197
  },
174
198
  second: { color: colors.foregroundMuted, fontSize: 10, textAlign: align },
175
199
  } as const;
176
- }, [node, size, at, color, colors, touched, label, props.dim, props.selected, props.current, enter, beat]);
200
+ }, [node, size, at, color, colors, touched, label, props.dim, props.selected, props.current, props.inverse, enter, beat]);
177
201
  const press = useCallback(() => props.onPress(node.id), [props.onPress, node.id]);
178
202
  return (
179
- <View style={styles.wrap} pointerEvents="box-none">
203
+ <Animated.View style={styles.wrap} pointerEvents="box-none">
180
204
  <Pressable accessibilityRole="button" accessibilityLabel={node.path ?? node.label} hitSlop={8} onPress={press}>
181
205
  <Animated.View style={styles.shape}>
182
206
  {node.kind !== "file" ? <View style={styles.tint} /> : null}
@@ -195,7 +219,7 @@ const GraphNodeView = memo(function GraphNodeView(props: NodeProps) {
195
219
  ) : null}
196
220
  </View>
197
221
  ) : null}
198
- </View>
222
+ </Animated.View>
199
223
  );
200
224
  }, sameNode);
201
225
 
@@ -246,7 +270,12 @@ export function LiveGraph(props: GraphViewProps) {
246
270
  return map;
247
271
  }, [graph.root, issue]);
248
272
  const targets = useMemo(
249
- () => props.agents.map((agent) => ({ agent, target: focusNode(graph, agent.focus) })),
273
+ () =>
274
+ props.agents.map((agent) => {
275
+ const target = focusNode(graph, agent.focus);
276
+ // A child agent whose file is off the graph hovers over the issue it works on.
277
+ return { agent, target: target === graph.root && agent.home ? focusNode(graph, agent.home) : target };
278
+ }),
250
279
  [props.agents, graph],
251
280
  );
252
281
  const working = useMemo(
@@ -316,10 +345,47 @@ export function LiveGraph(props: GraphViewProps) {
316
345
  }),
317
346
  [targets, hovers, width],
318
347
  );
319
- const labels = useMemo(
320
- () => graphLabels({ graph, positions, issue, progress, selected, working, cursors, width, height }),
321
- [graph.nodes, positions, issue, progress, selected, working, cursors, width, height],
322
- );
348
+ const { saveLive } = props;
349
+ const [auto, setAuto] = useState(props.camera === "auto");
350
+ const [locked, setLocked] = useState(props.locked);
351
+ const stopAuto = useCallback(() => {
352
+ setAuto(false);
353
+ saveLive({ graphCamera: "fit" });
354
+ }, [saveLive]);
355
+ const toggleAuto = useCallback(() => {
356
+ setAuto(!auto);
357
+ saveLive({ graphCamera: auto ? "fit" : "auto" });
358
+ }, [auto, saveLive]);
359
+ const toggleLock = useCallback(() => {
360
+ setLocked(!locked);
361
+ saveLive({ graphLocked: !locked });
362
+ }, [locked, saveLive]);
363
+ const box = useMemo(() => focusBox(graph, positions, working), [graph, positions, working]);
364
+ const camera = useGraphCamera({ width, height, focus: box, auto, locked, reduce: props.reduceMotion, onManual: stopAuto });
365
+ // Labels are placed in the view of the settled camera, then moved back onto the canvas.
366
+ const view = camera.settled;
367
+ const labels = useMemo(() => {
368
+ if (view.zoom === 1) return graphLabels({ graph, positions, issue, progress, selected, working, cursors, width, height });
369
+ const toView = (at: Point) => project(view, at, width, height);
370
+ const seen = new Map<string, Placed>();
371
+ for (const [id, at] of positions) {
372
+ const shown = toView(at);
373
+ if (shown.x > -20 && shown.x < width + 20 && shown.y > -20 && shown.y < height + 20) seen.set(id, { ...at, ...shown });
374
+ }
375
+ const viewCursors = cursors.map((cursor) => ({ ...cursor, at: toView(cursor.at) }));
376
+ const placed = graphLabels({ graph, positions: seen, issue, progress, selected, working, cursors: viewCursors, width, height, every: view.zoom >= 1.5 });
377
+ const nodes = new Map<string, Label>();
378
+ for (const [id, label] of placed.nodes) {
379
+ const at = positions.get(id);
380
+ const shown = seen.get(id);
381
+ if (at && shown) nodes.set(id, { ...label, left: label.left + at.x - shown.x, top: label.top + at.y - shown.y });
382
+ }
383
+ const folders = placed.folders.map((folder) => {
384
+ const middle = unproject(view, { x: folder.left + folder.width / 2, y: folder.top + FOLDER_HALF }, width, height);
385
+ return { ...folder, left: middle.x - folder.width / 2, top: middle.y - FOLDER_HALF };
386
+ });
387
+ return { nodes, folders };
388
+ }, [graph.nodes, positions, issue, progress, selected, working, cursors, width, height, view]);
323
389
  const press = useCallback(
324
390
  (id: string) => {
325
391
  setSelected((current) => (current === id ? null : id));
@@ -332,6 +398,7 @@ export function LiveGraph(props: GraphViewProps) {
332
398
  () =>
333
399
  ({
334
400
  canvas: { height, width: "100%", overflow: "hidden" },
401
+ world: { ...StyleSheet.absoluteFillObject, transform: camera.transform },
335
402
  waiting: { ...StyleSheet.absoluteFillObject, alignItems: "center", justifyContent: "center" },
336
403
  waitingText: { color: colors.foregroundMuted, fontSize: 12 },
337
404
  folder: { position: "absolute", color: colors.foregroundMuted, fontFamily: MONO, fontSize: 10, opacity: 0.75 },
@@ -346,79 +413,96 @@ export function LiveGraph(props: GraphViewProps) {
346
413
  captionText: { color: colors.foreground, fontSize: 12, fontFamily: MONO },
347
414
  hint: { color: colors.foregroundMuted, fontSize: 12 },
348
415
  }) as const,
349
- [height, colors],
416
+ [height, colors, camera.transform],
350
417
  );
351
418
  const selectedNode = selected ? byId.get(selected) : undefined;
352
419
  const ordered = [...graph.edges].sort((a, b) => Number(trail.has(a.id)) - Number(trail.has(b.id)));
353
420
  return (
354
421
  <View>
355
- <View style={styles.canvas} onLayout={measure}>
422
+ <View ref={camera.ref} style={styles.canvas} onLayout={measure} {...camera.handlers}>
423
+ <Animated.View pointerEvents="box-none" style={styles.world}>
424
+ {shown
425
+ ? ordered.map((edge) => {
426
+ const from = positions.get(edge.from);
427
+ const to = positions.get(edge.to);
428
+ if (!from || !to) return null;
429
+ return <EdgeLine key={edge.id} from={from} to={to} style={edgeStyle(edge)} inverse={camera.inverse} />;
430
+ })
431
+ : null}
432
+ {shown
433
+ ? labels.folders.map((folder) => (
434
+ <Animated.Text
435
+ key={folder.folder}
436
+ pointerEvents="none"
437
+ numberOfLines={1}
438
+ style={[
439
+ styles.folder,
440
+ { left: folder.left, top: folder.top, width: folder.width, transform: [{ scale: camera.inverse }] },
441
+ ]}
442
+ >
443
+ {folder.folder === "." ? "/" : `${folder.folder}/`}
444
+ </Animated.Text>
445
+ ))
446
+ : null}
447
+ {shown
448
+ ? graph.nodes.map((node) => {
449
+ const at = positions.get(node.id);
450
+ if (!at) return null;
451
+ return (
452
+ <GraphNodeView
453
+ key={node.id}
454
+ theme={theme}
455
+ node={node}
456
+ at={at}
457
+ color={nodeColor(node)}
458
+ state={states.get(node.id) ?? null}
459
+ dim={dimmed(node)}
460
+ selected={selected === node.id}
461
+ current={working.has(node.id)}
462
+ label={labels.nodes.get(node.id) ?? null}
463
+ inverse={camera.inverse}
464
+ reduce={props.reduceMotion}
465
+ onPress={press}
466
+ />
467
+ );
468
+ })
469
+ : null}
470
+ {shown
471
+ ? targets.map(({ agent, target }) => {
472
+ const hover = hovers.get(agent.id);
473
+ if (!hover) return null;
474
+ return (
475
+ <AgentCursor
476
+ key={agent.id}
477
+ theme={theme}
478
+ agent={agent}
479
+ graph={graph}
480
+ target={target}
481
+ layoutKey={layoutKey}
482
+ hover={hover}
483
+ width={width}
484
+ inverse={camera.inverse}
485
+ reduce={props.reduceMotion}
486
+ />
487
+ );
488
+ })
489
+ : null}
490
+ </Animated.View>
356
491
  {!ready ? (
357
492
  <View style={styles.waiting}>
358
493
  <Text style={styles.waitingText}>Linking files…</Text>
359
494
  </View>
360
495
  ) : null}
361
- {shown
362
- ? ordered.map((edge) => {
363
- const from = positions.get(edge.from);
364
- const to = positions.get(edge.to);
365
- if (!from || !to) return null;
366
- return <EdgeLine key={edge.id} from={from} to={to} style={edgeStyle(edge)} />;
367
- })
368
- : null}
369
- {shown
370
- ? labels.folders.map((folder) => (
371
- <Text
372
- key={folder.folder}
373
- pointerEvents="none"
374
- numberOfLines={1}
375
- style={[styles.folder, { left: folder.left, top: folder.top, width: folder.width }]}
376
- >
377
- {folder.folder === "." ? "/" : `${folder.folder}/`}
378
- </Text>
379
- ))
380
- : null}
381
- {shown
382
- ? graph.nodes.map((node) => {
383
- const at = positions.get(node.id);
384
- if (!at) return null;
385
- return (
386
- <GraphNodeView
387
- key={node.id}
388
- theme={theme}
389
- node={node}
390
- at={at}
391
- color={nodeColor(node)}
392
- state={states.get(node.id) ?? null}
393
- dim={dimmed(node)}
394
- selected={selected === node.id}
395
- current={working.has(node.id)}
396
- label={labels.nodes.get(node.id) ?? null}
397
- reduce={props.reduceMotion}
398
- onPress={press}
399
- />
400
- );
401
- })
402
- : null}
403
- {shown
404
- ? targets.map(({ agent, target }) => {
405
- const hover = hovers.get(agent.id);
406
- if (!hover) return null;
407
- return (
408
- <AgentCursor
409
- key={agent.id}
410
- theme={theme}
411
- agent={agent}
412
- graph={graph}
413
- target={target}
414
- layoutKey={layoutKey}
415
- hover={hover}
416
- width={width}
417
- reduce={props.reduceMotion}
418
- />
419
- );
420
- })
421
- : null}
496
+ {shown ? (
497
+ <CameraButtons
498
+ theme={theme}
499
+ camera={camera}
500
+ auto={auto}
501
+ locked={locked}
502
+ onAuto={toggleAuto}
503
+ onLock={toggleLock}
504
+ />
505
+ ) : null}
422
506
  </View>
423
507
  <View style={styles.caption}>
424
508
  {selectedNode ? (
@@ -10,6 +10,7 @@ import { eventTone, KIND_ICONS } from "./live-feed";
10
10
  import { type IssueProgress, MONO, ProgressBar } from "./live-issues";
11
11
  import { type LiveMap, NATIVE_DRIVER, type OwnerColors } from "./live-timeline";
12
12
  import { ProviderIcon } from "./provider-icon";
13
+ import { ToolButton, ToolGroup } from "./tool-buttons";
13
14
  import { Button, StateIcon, type Theme } from "./ui";
14
15
 
15
16
  type AgentStatus = PluginAgentSnapshot["status"];
@@ -256,6 +257,34 @@ export function MapSource(props: { theme: Theme; live: LiveMap; mapping: Mapping
256
257
  );
257
258
  }
258
259
 
260
+ /** Read the file data again, rebuild the map with the explore agent, or clear the saved map. */
261
+ export function MapActions(props: { theme: Theme; live: LiveMap }) {
262
+ const { theme, live } = props;
263
+ const { explore, refresh, clear } = live;
264
+ const running = live.job?.status === "running";
265
+ const rebuild = useCallback(() => explore(true), [explore]);
266
+ return (
267
+ <ToolGroup theme={theme}>
268
+ <ToolButton theme={theme} icon="RefreshCw" label="Read files and links again" onPress={refresh} />
269
+ <ToolButton
270
+ theme={theme}
271
+ icon="Sparkles"
272
+ label={live.stored ? "Rebuild the map with the explore agent" : "Build the map with the explore agent"}
273
+ busy={live.starting || running}
274
+ onPress={rebuild}
275
+ />
276
+ <ToolButton
277
+ theme={theme}
278
+ icon="Eraser"
279
+ label="Clear the saved map and use the ticket text"
280
+ disabled={!live.stored || running}
281
+ busy={live.clearing}
282
+ onPress={clear}
283
+ />
284
+ </ToolGroup>
285
+ );
286
+ }
287
+
259
288
  export function ViewToggle(props: {
260
289
  theme: Theme;
261
290
  value: LiveView;
@@ -6,13 +6,13 @@ import { type LayoutChangeEvent, Text, View } from "react-native";
6
6
  import { deriveActivity } from "../shared/activity";
7
7
  import type { IssueDetail } from "../shared/linear";
8
8
  import { AGENT_LABELS } from "../shared/prompts";
9
- import { type LinearSettings, type LiveView, linearSettings, type MappingMode } from "../shared/settings";
9
+ import { type GraphCamera, type LinearSettings, type LiveView, linearSettings, type MappingMode } from "../shared/settings";
10
10
  import { KeyScopeProvider } from "./key-scope";
11
11
  import { buildGraph, graphFiles } from "../shared/graph-model";
12
12
  import { AgentsList, cursorAgents, useLiveAgents } from "./live-agents";
13
13
  import { ActivityFeed } from "./live-feed";
14
14
  import { LiveGraph } from "./live-graph";
15
- import { LiveHeader, MapSource, ViewToggle } from "./live-header";
15
+ import { LiveHeader, MapActions, MapSource, ViewToggle } from "./live-header";
16
16
  import { type FocusKey, IssueRail, issueProgress, SubIssues } from "./live-issues";
17
17
  import { MapLegend, RepoMap } from "./live-map";
18
18
  import {
@@ -33,6 +33,7 @@ interface AgentInfo {
33
33
  identifier: string | null;
34
34
  project: string | null;
35
35
  cwd: string;
36
+ workspaceId: string | null;
36
37
  status: AgentStatus;
37
38
  provider: string;
38
39
  }
@@ -41,11 +42,14 @@ const selectAgent = (agent: PluginAgentSnapshot): AgentInfo => ({
41
42
  identifier: agent.labels[AGENT_LABELS.issue] ?? null,
42
43
  project: agent.labels[AGENT_LABELS.project] ?? null,
43
44
  cwd: agent.cwd,
45
+ workspaceId: agent.workspaceId ?? null,
44
46
  status: agent.status,
45
47
  provider: agent.provider,
46
48
  });
47
49
 
48
- type LivePatch = Partial<Pick<LinearSettings["live"], "view" | "issuesCollapsed">>;
50
+ export type LivePatch = Partial<
51
+ Pick<LinearSettings["live"], "view" | "issuesCollapsed" | "graphCamera" | "graphLocked">
52
+ >;
49
53
 
50
54
  const STACK_WIDTH = 760;
51
55
  const COLUMN_WIDTH = 330;
@@ -103,6 +107,8 @@ export function LivePanel(props: PluginAgentPanelProps) {
103
107
  mapping={live?.mapping ?? "semantic"}
104
108
  view={live?.view ?? "map"}
105
109
  issuesCollapsed={live?.issuesCollapsed ?? false}
110
+ graphCamera={live?.graphCamera ?? "auto"}
111
+ graphLocked={live?.graphLocked ?? false}
106
112
  saveLive={saveLive}
107
113
  openAgent={props.navigation ? (id) => props.navigation?.openAgent({ agentId: id }) : undefined}
108
114
  />
@@ -119,6 +125,8 @@ interface BodyProps {
119
125
  mapping: MappingMode;
120
126
  view: LiveView;
121
127
  issuesCollapsed: boolean;
128
+ graphCamera: GraphCamera;
129
+ graphLocked: boolean;
122
130
  saveLive(patch: LivePatch): void;
123
131
  openAgent?: (agentId: string) => void;
124
132
  }
@@ -206,6 +214,7 @@ function LiveView(props: BodyProps & { issue: IssueDetail }) {
206
214
  theme,
207
215
  agentId,
208
216
  cwd: agent.cwd,
217
+ workspaceId: agent.workspaceId,
209
218
  status: agent.status,
210
219
  provider: agent.provider,
211
220
  activity,
@@ -249,8 +258,16 @@ function LiveView(props: BodyProps & { issue: IssueDetail }) {
249
258
  [view, graphInput, links.links],
250
259
  );
251
260
  const cursors = useMemo(
252
- () => cursorAgents({ theme, provider: agent.provider, working, activity, children: agents.children }),
253
- [theme, agent.provider, working, activity, agents.children],
261
+ () =>
262
+ cursorAgents({
263
+ theme,
264
+ provider: agent.provider,
265
+ working,
266
+ activity,
267
+ children: agents.children,
268
+ subagents: agents.subagents,
269
+ }),
270
+ [theme, agent.provider, working, activity, agents.children, agents.subagents],
254
271
  );
255
272
  const owners = useMemo(() => ownerColors(issue, colors.accent), [issue, colors.accent]);
256
273
  const progress = useMemo(() => issueProgress(issue, activity.todos), [issue, activity.todos]);
@@ -322,6 +339,7 @@ function LiveView(props: BodyProps & { issue: IssueDetail }) {
322
339
  right={
323
340
  <View style={styles.tools}>
324
341
  {stacked || view === "graph" ? null : <MapLegend theme={theme} />}
342
+ <MapActions theme={theme} live={liveMap} />
325
343
  {toggle}
326
344
  </View>
327
345
  }
@@ -346,6 +364,9 @@ function LiveView(props: BodyProps & { issue: IssueDetail }) {
346
364
  agents={cursors}
347
365
  focus={focus}
348
366
  onFocus={toggleFocus}
367
+ camera={props.graphCamera}
368
+ locked={props.graphLocked}
369
+ saveLive={saveLive}
349
370
  reduceMotion={reduceMotion}
350
371
  />
351
372
  ) : (
@@ -367,14 +388,14 @@ function LiveView(props: BodyProps & { issue: IssueDetail }) {
367
388
  <Card theme={theme} title="Activity">
368
389
  <ActivityFeed
369
390
  theme={theme}
370
- events={activity.events}
391
+ events={agents.events}
371
392
  loading={timeline.loading}
372
393
  error={timeline.error}
373
394
  reduceMotion={reduceMotion}
374
395
  />
375
396
  </Card>
376
397
  );
377
- const helpers = agents.children.length + activity.subagents.length;
398
+ const helpers = agents.children.length + agents.subagents.length;
378
399
  const agentsCard =
379
400
  helpers > 0 ? (
380
401
  <Card theme={theme} title="Agents" meta={<Text style={styles.hit}>{`${helpers + 1}`}</Text>}>
@@ -384,7 +405,7 @@ function LiveView(props: BodyProps & { issue: IssueDetail }) {
384
405
  status={agent.status}
385
406
  current={activity.current}
386
407
  childAgents={agents.children}
387
- subagents={activity.subagents}
408
+ subagents={agents.subagents}
388
409
  onOpenAgent={props.openAgent}
389
410
  />
390
411
  </Card>
@@ -1,17 +1,20 @@
1
1
  import { usePaseo, useRpc } from "@getpaseo/plugin/client";
2
- import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
3
- import { useEffect, useMemo, useState } from "react";
2
+ import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import { useCallback, useEffect, useMemo, useState } from "react";
4
4
  import { AccessibilityInfo, Platform } from "react-native";
5
5
  import type { DirTouch, FileTouch, TimelineItemLike } from "../shared/activity";
6
6
  import type { IssueDetail } from "../shared/linear";
7
7
  import type { FileLink } from "../shared/links";
8
8
  import {
9
+ clearMapRpc,
9
10
  exploreRpc,
10
11
  type IssueMap,
11
12
  type Job,
12
13
  listFilesRpc,
13
14
  linksRpc,
14
15
  liveMapRpc,
16
+ type SubagentLog,
17
+ subagentLogsRpc,
15
18
  MAX_LINK_FILES,
16
19
  MAX_LIST_DIRS,
17
20
  type MapFile,
@@ -244,6 +247,11 @@ export interface LiveMap {
244
247
  starting: boolean;
245
248
  error: string | null;
246
249
  explore(force: boolean): void;
250
+ /** Reads the map, folder listings, links, and subagents again. */
251
+ refresh(): void;
252
+ /** Forgets the saved explore map; the map falls back to the ticket text. */
253
+ clear(): void;
254
+ clearing: boolean;
247
255
  }
248
256
 
249
257
  export function useLiveMap(
@@ -270,9 +278,13 @@ export function useLiveMap(
270
278
  onSettled: () => void refetch(),
271
279
  });
272
280
  const { mutate } = explore;
281
+ const queries = useQueryClient();
282
+ const refresh = useCallback(() => void queries.invalidateQueries({ queryKey: ["linear", "live"] }), [queries]);
283
+ const clearMap = useRpc(clearMapRpc);
284
+ const clear = useMutation({ mutationFn: () => clearMap({ agentId }), onSettled: refresh });
273
285
  const fallback = useMemo(() => semanticMap(issue), [issue]);
274
286
  const stored = query.data?.map ?? null;
275
- const failure = explore.error ?? query.error;
287
+ const failure = explore.error ?? clear.error ?? query.error;
276
288
  return {
277
289
  map: stored ?? fallback,
278
290
  stored: stored !== null,
@@ -281,6 +293,9 @@ export function useLiveMap(
281
293
  starting: explore.isPending,
282
294
  error: failure ? errorMessage(failure) : null,
283
295
  explore: mutate,
296
+ refresh,
297
+ clear: clear.mutate as () => void,
298
+ clearing: clear.isPending,
284
299
  };
285
300
  }
286
301
 
@@ -339,3 +354,23 @@ export function useFileLinks(
339
354
  ready: sorted.length === 0 || query.data !== undefined || query.isError,
340
355
  };
341
356
  }
357
+
358
+ const NO_RUNS: readonly SubagentLog[] = [];
359
+
360
+ /** Tool calls of the agent's subagents, read again every few seconds while the agent works. */
361
+ export function useSubagentLogs(
362
+ agentId: string,
363
+ enabled: boolean,
364
+ polling: boolean,
365
+ ): { runs: readonly SubagentLog[]; checkedAt: number } {
366
+ const read = useRpc(subagentLogsRpc);
367
+ const query = useQuery({
368
+ queryKey: ["linear", "live", "subagents", agentId],
369
+ queryFn: () => read({ agentId }),
370
+ enabled,
371
+ refetchInterval: polling ? 3_000 : false,
372
+ placeholderData: keepPreviousData,
373
+ staleTime: 2_000,
374
+ });
375
+ return { runs: query.data?.runs ?? NO_RUNS, checkedAt: query.dataUpdatedAt };
376
+ }
package/client/pill.tsx CHANGED
@@ -17,6 +17,7 @@ import { progressByKey } from "../shared/todo-sync";
17
17
  import { currentAccess, onAccessChange, publishAccess } from "./access";
18
18
  import { KeyScopeProvider } from "./key-scope";
19
19
  import { MONO } from "./live-issues";
20
+ import { PluginIcon } from "./plugin-icon";
20
21
  import { useIssue } from "./queries";
21
22
  import { focusIssue, panelScope } from "./store";
22
23
  import { Button, errorMessage, StateIcon, type Theme } from "./ui";
@@ -144,7 +145,7 @@ function LinkedIssue(props: ContentProps & { open: Open }) {
144
145
  <Button
145
146
  theme={theme}
146
147
  size="xs"
147
- icon="SquareKanban"
148
+ icon={PluginIcon}
148
149
  label="Open issue"
149
150
  onPress={() => {
150
151
  focusIssue(panelScope(workspaceId), identifier);
@@ -337,13 +338,13 @@ export function contributeComposerPills(client: PluginClientContext) {
337
338
  button: identifier
338
339
  ? {
339
340
  title: `Linear issue ${identifier}`,
340
- icon: "SquareKanban",
341
+ icon: PluginIcon,
341
342
  label: identifier,
342
343
  behavior: { kind: "popover", Content: Linked },
343
344
  }
344
345
  : {
345
346
  title: "Send a Linear issue to this agent",
346
- icon: "SquareKanban",
347
+ icon: PluginIcon,
347
348
  label: "Linear",
348
349
  behavior: { kind: "popover", Content: Search },
349
350
  },
@@ -0,0 +1,43 @@
1
+ import { useSettings } from "@getpaseo/plugin/client";
2
+ import { Icon } from "@getpaseo/plugin/client/react-native";
3
+ import { useMemo } from "react";
4
+ import { Image, Platform } from "react-native";
5
+ import { type IconSettings, iconMarkup, paintColor } from "../shared/custom-icon";
6
+ import { linearSettings } from "../shared/settings";
7
+ import { svgDataUri } from "./provider-icon";
8
+ import type { Theme } from "./ui";
9
+
10
+ // The plugin's icon in pills, the sidebar, and plugin screens. Native apps cannot draw SVG
11
+ // images from plugins, so they show the built-in icon in the chosen color.
12
+
13
+ export const BUILT_IN_ICON = "SquareKanban";
14
+
15
+ export interface PluginIconProps {
16
+ size: number;
17
+ /** The color Paseo gives the icon in this place. */
18
+ color: string;
19
+ theme?: Theme | null;
20
+ }
21
+
22
+ /** Draws icon settings, saved or not yet saved. */
23
+ export function IconGlyph(props: PluginIconProps & { icon: IconSettings | null }) {
24
+ const { icon, size, color } = props;
25
+ const accent = props.theme?.colors.accent ?? color;
26
+ const paint = icon ? paintColor(icon, { ink: color, accent }) : null;
27
+ const svg = Platform.OS === "web" ? (icon?.svg ?? "") : "";
28
+ const solid = icon?.solid === true;
29
+ const source = useMemo(() => {
30
+ const markup = svg ? iconMarkup(svg, { ink: color, paint, solid }) : null;
31
+ return markup ? { uri: svgDataUri(markup, color) } : null;
32
+ }, [svg, color, paint, solid]);
33
+ const style = useMemo(() => ({ width: size, height: size }), [size]);
34
+ if (!source) return <Icon name={BUILT_IN_ICON} size={size} color={paint ?? color} />;
35
+ return <Image source={source} style={style} resizeMode="contain" accessibilityIgnoresInvertColors />;
36
+ }
37
+
38
+ /** The plugin icon from the saved settings. The built-in icon shows while they load. */
39
+ export function PluginIcon(props: PluginIconProps) {
40
+ const settings = useSettings(linearSettings);
41
+ const icon = settings.status === "ready" ? settings.values.icon : null;
42
+ return <IconGlyph {...props} icon={icon} />;
43
+ }