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

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.
@@ -1,7 +1,7 @@
1
1
  import type { PluginAgentSnapshot } from "@getpaseo/plugin";
2
2
  import { usePaseo } from "@getpaseo/plugin/client";
3
3
  import { Icon } from "@getpaseo/plugin/client/react-native";
4
- import { useQuery } from "@tanstack/react-query";
4
+ import { keepPreviousData, useQuery } from "@tanstack/react-query";
5
5
  import { useCallback, useMemo } from "react";
6
6
  import { Pressable, StyleSheet, Text, View } from "react-native";
7
7
  import {
@@ -12,9 +12,10 @@ import {
12
12
  deriveActivity,
13
13
  type FileTouch,
14
14
  } from "../shared/activity";
15
+ import type { SubagentLog } from "../shared/live";
15
16
  import type { SubagentRun } from "../shared/subagents";
16
17
  import { MONO } from "./live-issues";
17
- import { useAgentTimelines } from "./live-timeline";
18
+ import { useAgentTimelines, useSubagentLogs } from "./live-timeline";
18
19
  import { ProviderIcon } from "./provider-icon";
19
20
  import { type Theme, usePressableStyle } from "./ui";
20
21
 
@@ -22,9 +23,12 @@ import { type Theme, usePressableStyle } from "./ui";
22
23
  // have their own timelines, and provider subagents, which only report what they were asked.
23
24
 
24
25
  const CHILD_LABEL = "paseo.parent-agent-id";
25
- const MAX_CHILDREN = 4;
26
- const CHILD_COLORS = ["#e879f9", "#38bdf8", "#facc15", "#fb7185"];
26
+ const MAX_CHILDREN = 8;
27
+ const CHILD_COLORS = ["#e879f9", "#38bdf8", "#facc15", "#fb7185", "#4ade80", "#f97316", "#818cf8", "#2dd4bf"];
27
28
  const POLL_MS = 10_000;
29
+ const SUBAGENT_COLORS = ["#a78bfa", "#34d399", "#f97316", "#22d3ee", "#f472b6", "#a3e635", "#fbbf24", "#60a5fa"];
30
+ // A transcript that has not grown for this long belongs to a subagent that stopped.
31
+ const SUBAGENT_IDLE_MS = 3 * 60_000;
28
32
 
29
33
  export type AgentStatus = PluginAgentSnapshot["status"];
30
34
 
@@ -35,13 +39,49 @@ export interface ChildAgent {
35
39
  status: AgentStatus;
36
40
  cwd: string;
37
41
  color: string;
42
+ workspaceId?: string | null;
43
+ /** The issue it works on, from its Linear label or a key at the start of its title. */
44
+ issueKey?: string | null;
45
+ }
46
+
47
+ const LEADING_KEY = /^\s*([A-Za-z][A-Za-z0-9]*-\d+)\b/;
48
+
49
+ /** The issue a child agent works on, such as ENG-42 for "ENG-42: Add the form". */
50
+ export function childIssueKey(labels: Readonly<Record<string, string>> | undefined, title: string | null) {
51
+ const key = labels?.["linear.issue"] ?? LEADING_KEY.exec(title ?? "")?.[1] ?? null;
52
+ return key ? key.toUpperCase() : null;
38
53
  }
39
54
 
40
55
  export interface ChildView {
41
56
  agent: ChildAgent;
42
57
  activity: Activity | null;
43
- /** Works in the parent's folder, so its files can go on the same map. */
44
- sameFolder: boolean;
58
+ /** Works in the parent's folder or a worktree of the same project, so its paths fit the map. */
59
+ sameRepo: boolean;
60
+ /** A provider subagent read from its transcript, which Paseo cannot open as an agent. */
61
+ subagent?: boolean;
62
+ }
63
+
64
+ const runKey = (log: SubagentLog) => `${log.type}\u0000${log.description}`;
65
+ const runTitle = (type: string, description: string) => (description ? `${type}: ${description}` : type);
66
+
67
+ /** Subagents with a transcript, as views like child agents in the parent's folder. */
68
+ export function subagentViews(logs: readonly SubagentLog[], cwd: string, checkedAt: number): ChildView[] {
69
+ return logs.map((log, index) => {
70
+ const running = !log.finished && checkedAt - log.updatedAt < SUBAGENT_IDLE_MS;
71
+ return {
72
+ agent: {
73
+ id: `sub:${log.id}`,
74
+ title: runTitle(log.type, log.description),
75
+ provider: "claude",
76
+ status: running ? "running" : "idle",
77
+ cwd,
78
+ color: SUBAGENT_COLORS[index % SUBAGENT_COLORS.length] ?? "#a78bfa",
79
+ },
80
+ activity: deriveActivity(log.items, cwd),
81
+ sameRepo: true,
82
+ subagent: true,
83
+ };
84
+ });
45
85
  }
46
86
 
47
87
  export interface AgentMarker {
@@ -79,10 +119,52 @@ export function useChildAgents(agentId: string, polling: boolean): ChildAgent[]
79
119
  status: agent.status as AgentStatus,
80
120
  cwd: agent.cwd,
81
121
  color: CHILD_COLORS[index % CHILD_COLORS.length] ?? "#38bdf8",
122
+ workspaceId: agent.workspaceId ?? null,
123
+ issueKey: childIssueKey(agent.labels, agent.title ?? null),
82
124
  }));
83
125
  }, [query.data]);
84
126
  }
85
127
 
128
+ const projectOf = new Map<string, string | null>();
129
+
130
+ /** The Paseo project of each workspace. A workspace never changes project, so each is read once. */
131
+ export function useWorkspaceProjects(workspaceIds: readonly (string | null | undefined)[]) {
132
+ const paseo = usePaseo();
133
+ const ids = useMemo(
134
+ () => [...new Set(workspaceIds.filter((id): id is string => Boolean(id)))].sort(),
135
+ [workspaceIds],
136
+ );
137
+ const query = useQuery({
138
+ queryKey: ["linear", "live", "workspace-projects", ids],
139
+ queryFn: async () => {
140
+ await Promise.all(
141
+ ids
142
+ .filter((id) => !projectOf.has(id))
143
+ .map(async (id) => {
144
+ const handle = paseo.workspaces.ref(id);
145
+ const info = await handle.refresh().catch(() => null);
146
+ projectOf.set(id, info?.projectId ?? handle.projectId ?? null);
147
+ }),
148
+ );
149
+ return new Map(ids.map((id) => [id, projectOf.get(id) ?? null]));
150
+ },
151
+ enabled: ids.length > 0,
152
+ staleTime: Number.POSITIVE_INFINITY,
153
+ placeholderData: keepPreviousData,
154
+ });
155
+ return query.data;
156
+ }
157
+
158
+ /** Both workspaces belong to one Paseo project, so a worktree's paths match the parent's. */
159
+ export function sameProject(
160
+ projects: ReadonlyMap<string, string | null> | undefined,
161
+ a: string | null | undefined,
162
+ b: string | null | undefined,
163
+ ): boolean {
164
+ const first = a ? projects?.get(a) : null;
165
+ return Boolean(first) && first === (b ? projects?.get(b) : null);
166
+ }
167
+
86
168
  export function sameFolder(a: string, b: string): boolean {
87
169
  return a.replace(/\/+$/, "") === b.replace(/\/+$/, "");
88
170
  }
@@ -112,7 +194,7 @@ export function agentMarkers(input: {
112
194
  }
113
195
  for (const child of input.children) {
114
196
  const current = child.activity?.current;
115
- if (child.agent.status !== "running" || !child.sameFolder || !current) continue;
197
+ if (child.agent.status !== "running" || !child.sameRepo || !current) continue;
116
198
  add(markers, current.paths, {
117
199
  key: child.agent.id,
118
200
  kind: "child",
@@ -250,7 +332,7 @@ export function AgentsList(props: {
250
332
  detail={props.current?.text ?? null}
251
333
  live={parentRunning}
252
334
  />
253
- {props.childAgents.map(({ agent, activity, sameFolder: shared }) => (
335
+ {props.childAgents.map(({ agent, activity, sameRepo: shared, subagent: subagentView }) => (
254
336
  <AgentRow
255
337
  key={agent.id}
256
338
  theme={theme}
@@ -258,10 +340,10 @@ export function AgentsList(props: {
258
340
  icon="provider"
259
341
  provider={agent.provider}
260
342
  title={agent.title}
261
- status={STATUS_COPY[agent.status] ?? agent.status}
343
+ status={subagentView && agent.status !== "running" ? "Done" : (STATUS_COPY[agent.status] ?? agent.status)}
262
344
  detail={shared ? (activity?.current?.text ?? null) : `In ${agent.cwd.split("/").pop() ?? agent.cwd}`}
263
345
  live={agent.status === "running"}
264
- onPress={onOpenAgent ? open(agent.id) : undefined}
346
+ onPress={onOpenAgent && !subagentView ? open(agent.id) : undefined}
265
347
  />
266
348
  ))}
267
349
  {props.subagents.map((run) => (
@@ -285,7 +367,7 @@ export function AgentsList(props: {
285
367
  function mergeTouches(own: readonly FileTouch[], children: readonly ChildView[]): FileTouch[] {
286
368
  const merged = new Map(own.map((file) => [file.path, { ...file }]));
287
369
  for (const child of children) {
288
- if (!child.sameFolder) continue;
370
+ if (!child.sameRepo) continue;
289
371
  for (const file of child.activity?.files ?? []) {
290
372
  const known = merged.get(file.path);
291
373
  if (!known) {
@@ -307,26 +389,43 @@ export function useLiveAgents(input: {
307
389
  theme: Theme;
308
390
  agentId: string;
309
391
  cwd: string;
392
+ workspaceId: string | null;
310
393
  status: AgentStatus;
311
394
  provider: string;
312
395
  activity: Activity;
313
396
  }) {
314
397
  const { theme, agentId, cwd, status, provider, activity } = input;
315
398
  const children = useChildAgents(agentId, status === "running");
399
+ const projects = useWorkspaceProjects(
400
+ useMemo(() => [input.workspaceId, ...children.map((child) => child.workspaceId)], [input.workspaceId, children]),
401
+ );
316
402
  const ids = useMemo(() => children.map((child) => child.id), [children]);
317
403
  const timelines = useAgentTimelines(ids);
404
+ const logs = useSubagentLogs(
405
+ agentId,
406
+ provider.split("/")[0] === "claude" && activity.subagents.length > 0,
407
+ status === "running",
408
+ );
409
+ const subViews = useMemo(() => subagentViews(logs.runs, cwd, logs.checkedAt), [logs, cwd]);
318
410
  const views = useMemo(
319
- () =>
320
- children.map((agent): ChildView => {
411
+ () => [
412
+ ...children.map((agent): ChildView => {
321
413
  const timeline = timelines.get(agent.id);
322
414
  return {
323
415
  agent,
324
416
  activity: timeline ? deriveActivity(timeline.items, agent.cwd) : null,
325
- sameFolder: sameFolder(agent.cwd, cwd),
417
+ sameRepo: sameFolder(agent.cwd, cwd) || sameProject(projects, input.workspaceId, agent.workspaceId),
326
418
  };
327
419
  }),
328
- [children, timelines, cwd],
420
+ ...subViews,
421
+ ],
422
+ [children, timelines, cwd, subViews, projects, input.workspaceId],
329
423
  );
424
+ // Subagents with a transcript show as agents; the rest only report what they were asked.
425
+ const subagents = useMemo(() => {
426
+ const covered = new Set(logs.runs.map(runKey));
427
+ return activity.subagents.filter((run) => !covered.has(run.key));
428
+ }, [activity.subagents, logs.runs]);
330
429
  const markers = useMemo(
331
430
  () =>
332
431
  agentMarkers({
@@ -335,19 +434,27 @@ export function useLiveAgents(input: {
335
434
  working: status === "running",
336
435
  current: activity.current,
337
436
  children: views,
338
- subagents: activity.subagents,
437
+ subagents,
339
438
  }),
340
- [theme, provider, status, activity.current, activity.subagents, views],
439
+ [theme, provider, status, activity.current, subagents, views],
341
440
  );
342
441
  const files = useMemo(() => mergeTouches(activity.files, views), [activity.files, views]);
343
442
  const dirs = useMemo(
344
443
  (): DirTouch[] => [
345
444
  ...activity.dirs,
346
- ...views.flatMap((view) => (view.sameFolder ? (view.activity?.dirs ?? []) : [])),
445
+ ...views.flatMap((view) => (view.sameRepo ? (view.activity?.dirs ?? []) : [])),
347
446
  ],
348
447
  [activity.dirs, views],
349
448
  );
350
- return { children: views, markers, files, dirs };
449
+ // A subagent's row in the feed shows what it does now.
450
+ const events = useMemo(() => {
451
+ const doing = new Map(subViews.map((view) => [view.agent.title, view.activity?.current?.text ?? null]));
452
+ return activity.events.map((event) => {
453
+ const now = event.kind === "agent" ? doing.get(event.text) : undefined;
454
+ return now ? { ...event, detail: now } : event;
455
+ });
456
+ }, [activity.events, subViews]);
457
+ return { children: views, subagents, markers, files, dirs, events };
351
458
  }
352
459
 
353
460
  /** An agent that moves over the graph to what it works on. */
@@ -358,6 +465,8 @@ export interface CursorAgent {
358
465
  color: string;
359
466
  provider: string | null;
360
467
  focus: AgentFocus | null;
468
+ /** Where it hovers when its focus is not on the graph: the issue it works on. */
469
+ home: AgentFocus | null;
361
470
  running: boolean;
362
471
  /** What it does now, such as "Read page.tsx". */
363
472
  caption: string | null;
@@ -371,6 +480,8 @@ export function cursorAgents(input: {
371
480
  working: boolean;
372
481
  activity: Activity;
373
482
  children: readonly ChildView[];
483
+ /** Subagents with no cursor of their own, which circle the agent instead. */
484
+ subagents: readonly SubagentRun[];
374
485
  }): CursorAgent[] {
375
486
  const { activity } = input;
376
487
  const name = input.provider ? input.provider.charAt(0).toUpperCase() + input.provider.slice(1) : "Agent";
@@ -382,24 +493,28 @@ export function cursorAgents(input: {
382
493
  color: input.theme.colors.accent,
383
494
  provider: input.provider,
384
495
  focus: activity.focus,
496
+ home: null,
385
497
  running: input.working,
386
498
  caption: input.working ? (activity.current?.text ?? null) : null,
387
- satellites: input.working ? activity.subagents.filter((run) => run.status === "running").length : 0,
499
+ satellites: input.working ? input.subagents.filter((run) => run.status === "running").length : 0,
388
500
  },
389
501
  ];
390
502
  for (const child of input.children) {
391
503
  // Paths are relative to each agent's folder, so only agents in this folder fit the graph.
392
- if (!child.sameFolder || !child.activity) continue;
504
+ if (!child.sameRepo) continue;
393
505
  const running = child.agent.status === "running";
506
+ // Finished subagents can be many, so only working ones get a cursor.
507
+ if (child.subagent && !running) continue;
394
508
  agents.push({
395
509
  id: child.agent.id,
396
510
  kind: "child",
397
511
  label: child.agent.title,
398
512
  color: child.agent.color,
399
513
  provider: null,
400
- focus: child.activity.focus,
514
+ focus: child.activity?.focus ?? null,
515
+ home: child.agent.issueKey ? { kind: "issue", key: child.agent.issueKey } : null,
401
516
  running,
402
- caption: running ? (child.activity.current?.text ?? null) : null,
517
+ caption: running ? (child.activity?.current?.text ?? null) : null,
403
518
  satellites: 0,
404
519
  });
405
520
  }
@@ -38,6 +38,8 @@ export interface CursorProps {
38
38
  hover(id: string): Point | null;
39
39
  /** Canvas width, to keep the caption inside it. */
40
40
  width: number;
41
+ /** One over the graph zoom, so the cursor keeps its size on screen. */
42
+ inverse: Animated.AnimatedInterpolation<number>;
41
43
  reduce: boolean;
42
44
  }
43
45
 
@@ -259,7 +261,7 @@ export const AgentCursor = memo(function AgentCursor(props: CursorProps) {
259
261
  return (
260
262
  <Animated.View
261
263
  pointerEvents="none"
262
- style={[styles.anchor, { transform: [{ translateX: motion.x }, { translateY: motion.y }] }]}
264
+ style={[styles.anchor, { transform: [{ translateX: motion.x }, { translateY: motion.y }, { scale: props.inverse }] }]}
263
265
  >
264
266
  <Animated.View style={styles.float}>
265
267
  {agent.running ? <Animated.View style={styles.halo} /> : null}
@@ -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 ? (