@yannelli/paseo-linear-plugin 1.1.0-beta.3 → 1.1.0-beta.4

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.
package/OVERVIEW.md CHANGED
@@ -27,13 +27,13 @@ Select **Start agent** or **Start review** on an issue. The setup page shows the
27
27
 
28
28
  ## Linear Live
29
29
 
30
- Open **Linear Live** from the Linear pill in an agent's composer. It shows the issue's sub-issues with their statuses and the agent's todos, a map of the files the agent reads and edits, or a graph of those files joined by their imports and links, with the agent gliding to what it works on, the agents it starts, and its latest commands. It works with Claude, Codex, and other providers. The map comes from paths in the ticket text, or, if you choose it in **Settings → Plugins → Linear → Live**, from an explore agent that reads the repository before the agent starts. The implement agent waits for the map. The explore agent uses provider usage. Buttons on the map reload it, build it again with the explore agent, or clear it. On the graph, a button follows the running agents and zooms to their files. You can also zoom with the buttons, the wheel, or a pinch, and lock the view so the page scrolls. Agents in worktrees of the same project show on the map and the graph. Explore and setup agents are told to only read. The plugin starts Claude in Always Ask and answers the permission requests these agents raise: it allows reads and search commands in the project folder and denies the rest. Codex has no read-only mode, so it runs in its default mode, and the plugin answers only the requests Codex raises.
30
+ Open **Linear Live** from the Linear pill in an agent's composer. It shows the issue's sub-issues with their statuses and the agent's todos, a map of the files the agent reads and edits, or a graph of those files joined by their imports and links, with the agent gliding to what it works on, the agents it starts, and its latest commands. It works with Claude, Codex, and other providers. The map comes from paths in the ticket text, or, if you choose it in **Settings → Plugins → Linear → Live**, from an explore agent that reads the repository before the agent starts. The implement agent waits for the map. The explore agent uses provider usage. Buttons on the map reload it, build it again with the explore agent, or clear it. On the graph, a button follows the running agents and zooms to their files. You can also zoom with the buttons, the wheel, or a pinch, and lock the view so the page scrolls. Nodes, labels, and agent cursors grow as you zoom in. Agents in worktrees of the same project show on the map and the graph. Explore and setup agents are told to only read. The plugin starts Claude in Always Ask and answers the permission requests these agents raise: it allows reads and search commands in the project folder and denies the rest. Codex has no read-only mode, so it runs in its default mode, and the plugin answers only the requests Codex raises.
31
31
 
32
32
  On agents not started from an issue, the Linear pill searches Linear and sends an issue to the agent.
33
33
 
34
34
  ## Plugin icon
35
35
 
36
- In **Settings → Plugins → Linear → Icon**, choose an SVG file or paste SVG markup. Composer pills, the Linear sidebar row, the timeline card, and the Linear screens use it. Paseo menus and panel tabs keep the built-in icon. Keep the original colors, or paint the icon with the theme color, the theme accent, Linear indigo, or a custom color. **Solid** fills outline shapes. The plugin refuses SVGs with scripts, event handlers, or links to other files. The iOS and Android apps show the built-in icon in the chosen color.
36
+ In **Settings → Plugins → Linear → Icon**, choose an SVG file or paste SVG markup. Composer pills, the Linear sidebar row, the timeline card, and the Linear screens use it. Panel tabs, the new tab menu, and Command Center take built-in icons only, so pick one of those for them. Keep the original colors, or paint the icon with the theme color, the theme accent, Linear indigo, or a custom color. **Solid** fills outline shapes. The plugin refuses SVGs with scripts, event handlers, or links to other files. The iOS and Android apps show the built-in icon in the chosen color.
37
37
 
38
38
  ## Update Linear from todos
39
39
 
package/README.md CHANGED
@@ -158,7 +158,7 @@ Linear Live is an agent panel. Open it from the Linear pill, or with **Linear: O
158
158
  - **Sub-issues:** each sub-issue shows its Linear status and the agent's todo progress. The todos of the current sub-issue show under it. Select a sub-issue to show only its files on the map. Hide the issues column with the button on the **Issue** card to give the map or the graph the full width. The column becomes a strip with the status of each issue. Select an issue there to show only its files.
159
159
  - **Map:** files are grouped by folder. The map marks the files the agent read, edited, or created, and the file it is on now. Files the agent touched but the map did not predict have a dashed border. A folder the agent listed or searched gets a solid border, and its header shows how many of its files the agent touched. The header also names the service, package, or main folder the folder is in, from the [project knowledge](#project-knowledge). When the agent touches files the map did not predict, the line under the map tells where they are, such as `Off the map: billing-api · service (2), docs (1)`, and the **Now** line in the header shows **Off the map** with the place of the current file. Reads through the shell count too: `cat`, `sed -n`, `head`, `grep`, `rg`, `ls`, `find`, `git show`, and redirects to files.
160
160
  - **Graph:** the files and the links between them. A line joins two files when one imports the other, a Markdown or style file links to the other, `package.json` names it, or the text of one names the path of the other. This works for TypeScript and JavaScript (with `tsconfig` paths and workspace packages), Python, Go, Rust, Ruby, CSS and Sass, Markdown, HTML, and config files. Files of one folder sit together under the folder name and take the color of their sub-issue. The issue is in the center, with its sub-issues joined to the files the map gives them. The agent hovers over what it works on: the file it reads or edits, or the sub-issue whose todo it just started. When the work moves, it glides along the links to the next node, and the lines at that node take its color. New files join without moving the rest. Agents that this agent started get their own cursor when they work in the same folder or in a worktree of the same project. An agent with no file activity yet hovers over the sub-issue whose key starts its title. Running subagents circle the agent that started them. Select a sub-issue to show only its files. Select any node to see what it is, how many files it links to, and for an off-map file, the service or folder it is in. Switch between **Map** and **Graph** on the card, or set the default in **Show files as** in the Live settings.
161
- - **Graph view:** the icon buttons at the top right of the graph move the view. **Follow the running agents** zooms to the nodes the running agents work on and the files next to them, and moves with the agents. **Show the whole graph** zooms out. **Zoom out** and **Zoom in** change the zoom in steps. Scroll to zoom at the pointer, drag to move when zoomed in, or pinch on a touch screen. Each of these stops following. The lock button lets the wheel and drags scroll the page instead. Nodes, lines, and labels keep their size when you zoom, and more file names show when there is room. The graph keeps your follow and lock choices.
161
+ - **Graph view:** the icon buttons at the top right of the graph move the view. **Follow the running agents** zooms to the nodes the running agents work on and the files next to them, centers on the agents as far as those files stay in view, and moves with the agents. **Show the whole graph** zooms out. **Zoom out** and **Zoom in** change the zoom in steps. Scroll to zoom at the pointer, drag to move when zoomed in, or pinch on a touch screen. Each of these stops following. The lock button lets the wheel and drags scroll the page instead. Nodes, lines, labels, and agent cursors grow with the square root of the zoom, so at 4x zoom they are twice their size, and more file names show when there is room. Labels stay clear of the buttons, and an agent's caption moves to its left side near the right edge. The graph keeps your follow and lock choices.
162
162
  - **Agents:** the agents that work under this agent. Paseo agents that this agent started show their status, their files, and their own markers on the map when they work in the same folder or in a worktree of the same project. Select one to open it. Subagents that run inside the provider, such as Claude's Agent tool, show their type, description, and status. For Claude, the daemon reads each subagent's transcript, also for subagents that run in the background, so a subagent shows what it does now, gets its own cursor, and adds its files to the map. Other providers do not send subagent file reads, so the map shows only the files named in the subagent's prompt.
163
163
  - **Activity:** the latest reads, edits, searches, and commands, with line counts and exit codes.
164
164
 
@@ -181,10 +181,11 @@ Each agent's composer has a **Linear** pill. On an agent started from an issue,
181
181
 
182
182
  ### Plugin icon
183
183
 
184
- In **Settings → Plugins → Linear → Icon**, choose an SVG file or paste SVG markup. Composer pills, the Linear sidebar row on Paseo 0.11 and newer, the issue card in the timeline, and the Linear screens then use it. Paseo menus, panel tabs, and settings take only built-in icon names, so they keep the Linear board icon. The plugin accepts SVG only. It refuses scripts, event handlers, HTML content, entities, and links to other files or bitmap images. The SVG needs a viewBox, or a width and height in pixels.
184
+ In **Settings → Plugins → Linear → Icon**, choose an SVG file or paste SVG markup. Composer pills, the Linear sidebar row on Paseo 0.11 and newer, the issue card in the timeline, and the Linear screens then use it. The plugin accepts SVG only. It refuses scripts, event handlers, HTML content, entities, and links to other files or bitmap images. The SVG needs a viewBox, or a width and height in pixels.
185
185
 
186
186
  - **Color:** **Original colors** keeps the SVG as drawn, and `currentColor` takes the color Paseo gives the icon. **Theme color**, **Theme accent**, **Linear indigo**, and **Custom color** paint the whole icon one color and keep its transparency. These choices also apply to the built-in icon.
187
187
  - **Solid:** fills shapes that only have an outline, with their stroke color, so a line icon shows as a solid shape.
188
+ - **Built-in icon:** Paseo draws panel tabs, the new tab menu, and Command Center with built-in Lucide icons only, so your SVG cannot show there. Pick the built-in icon for those places, such as Rocket or Target. Without an SVG, every place uses it. Tabs that are open change when you reload Paseo or open them again. The web app keeps the choice on the device, so the tabs get it as Paseo starts.
188
189
 
189
190
  The preview shows each change before you save. The iOS and Android apps cannot draw SVG images from plugins, so they show the built-in icon in the chosen color.
190
191
 
@@ -224,7 +225,7 @@ In **Settings → Plugins → Linear → Projects**, select **Start** under **Se
224
225
  - **Linear tools for agents:** the daemon runs the `linear` MCP server on `127.0.0.1` only, and only after an agent gets it. Each agent gets its own random token, and its tools reach only its issue and that issue's sub-issues. Edits use the Linear key of the project that loaded the issue. Requests from a web page are refused. `agent-tools.json` beside the key file keeps the port and a hash of each token, so agents keep their tools after a restart. The token itself is in the agent's Paseo config.
225
226
  - **Claude hooks:** when an **Agent guidance** switch is on for a Claude agent, the daemon writes a Claude Code plugin to `claude-hooks/` beside the key file, one folder for each issue and set of hooks. It holds the issue key and title and the sub-issue keys and titles. The agent starts with `--plugin-dir` set to that folder. The hooks print that text and run no other command. The daemon does not write hooks on Windows hosts.
226
227
  - **Todo sync:** when it is on, the daemon changes issue statuses in Linear with the key of the project that loaded the issue.
227
- - **Plugin icon:** the SVG is saved in the plugin settings on the daemon host. The app draws it as an image, which does not run scripts or load other files.
228
+ - **Plugin icon:** the SVG is saved in the plugin settings on the daemon host. The app draws it as an image, which does not run scripts or load other files. The web app keeps the name of the built-in icon in its local storage.
228
229
  - **Cached results:** the daemon keeps up to 300 recent Linear responses in memory for up to 24 hours. It groups them by a hash of the key, so two keys never share results. The app shows saved results at once with **Showing saved results. Updating…** while it gets new data. An edit clears the cached lists and issues for that key. Saving or removing a key clears the whole cache. The cache is not written to disk, and a daemon restart clears it.
229
230
 
230
231
  Linear errors, such as a rejected key or a rate limit, show in the panel or as a message.
@@ -1,19 +1,30 @@
1
1
  import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { Animated, Easing, type GestureResponderHandlers, PanResponder, View } from "react-native";
3
- import { type Box, type Camera, clampCamera, FIT, frameBox, MAX_ZOOM, MIN_ZOOM, zoomAt } from "../shared/graph-camera";
3
+ import {
4
+ type Box,
5
+ type Camera,
6
+ clampCamera,
7
+ FIT,
8
+ type Focus,
9
+ frameBox,
10
+ growth,
11
+ MAX_ZOOM,
12
+ MIN_ZOOM,
13
+ zoomAt,
14
+ } from "../shared/graph-camera";
4
15
  import { NATIVE_DRIVER } from "./live-timeline";
5
16
  import { ToolButton, ToolGroup } from "./tool-buttons";
6
17
  import type { Theme } from "./ui";
7
18
  import { listenWheel } from "./web";
8
19
 
9
20
  // Animated values move the graph canvas, so a wheel or a drag never renders the graph again.
10
- // Nodes, edges, and cursors scale by the inverse zoom, so they keep their size on screen.
21
+ // Nodes, edges, labels, and cursors scale back part of the zoom, so they grow less than it.
11
22
 
12
23
  export interface CameraOptions {
13
24
  width: number;
14
25
  height: number;
15
26
  /** Where auto mode looks; null shows the whole graph. */
16
- focus: Box | null;
27
+ focus: Box | Focus | null;
17
28
  auto: boolean;
18
29
  /** Wheel and drag move the page, not the camera. */
19
30
  locked: boolean;
@@ -28,8 +39,8 @@ export interface CameraControl {
28
39
  ref: RefObject<View | null>;
29
40
  handlers: GestureResponderHandlers;
30
41
  transform: [{ translateX: Animated.Value }, { translateY: Animated.Value }, { scale: Animated.Value }];
31
- /** One over the zoom: the scale that keeps a canvas item its own size on screen. */
32
- inverse: Animated.AnimatedInterpolation<number>;
42
+ /** The scale of canvas items, so they draw at growth(zoom) times their size on screen. */
43
+ itemScale: Animated.AnimatedInterpolation<number>;
33
44
  limit: ZoomLimit;
34
45
  /** The camera once it stops moving; labels are placed for it. */
35
46
  settled: Camera;
@@ -40,6 +51,8 @@ export interface CameraControl {
40
51
  const ZOOM_STEP = 1.5;
41
52
  const DRAG_START = 6;
42
53
  const SETTLE_MS = 160;
54
+ /** Zooms where the item scale is exact; it is linear between them. */
55
+ const ZOOM_SAMPLES = [1, 1.25, 1.5, 1.75, 2, 2.5, 3, 3.5, 4];
43
56
 
44
57
  type Touch = { pageX: number; pageY: number };
45
58
  const spreadOf = (touches: readonly Touch[]) => {
@@ -49,7 +62,13 @@ const spreadOf = (touches: readonly Touch[]) => {
49
62
 
50
63
  export function useGraphCamera(options: CameraOptions): CameraControl {
51
64
  const [values] = useState(() => ({ x: new Animated.Value(0), y: new Animated.Value(0), zoom: new Animated.Value(1) }));
52
- const [inverse] = useState(() => Animated.divide<number>(new Animated.Value(1), values.zoom));
65
+ const [itemScale] = useState(() =>
66
+ values.zoom.interpolate({
67
+ inputRange: ZOOM_SAMPLES,
68
+ outputRange: ZOOM_SAMPLES.map((zoom) => growth(zoom) / zoom),
69
+ extrapolate: "clamp",
70
+ }),
71
+ );
53
72
  const [limit, setLimit] = useState<ZoomLimit>("min");
54
73
  const [settled, setSettled] = useState<Camera>(FIT);
55
74
  const pause = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -99,7 +118,8 @@ export function useGraphCamera(options: CameraOptions): CameraControl {
99
118
  );
100
119
 
101
120
  const { width, height, auto, locked, focus } = options;
102
- const focusKey = focus ? [focus.left, focus.top, focus.right, focus.bottom].map(Math.round).join(",") : "";
121
+ const center = focus && "center" in focus ? [focus.center.x, focus.center.y] : [];
122
+ const focusKey = focus ? [focus.left, focus.top, focus.right, focus.bottom, ...center].map(Math.round).join(",") : "";
103
123
  useEffect(() => {
104
124
  if (width === 0 || !auto) return;
105
125
  const box = latest.current.focus;
@@ -168,7 +188,7 @@ export function useGraphCamera(options: CameraOptions): CameraControl {
168
188
  [move],
169
189
  );
170
190
  const fit = useCallback(() => move(FIT, true), [move]);
171
- return { ref, handlers, transform, inverse, limit, settled, zoomBy, fit };
191
+ return { ref, handlers, transform, itemScale, limit, settled, zoomBy, fit };
172
192
  }
173
193
 
174
194
  export function CameraButtons(props: {
@@ -1,4 +1,4 @@
1
- import { type LabelRequest, labelBox, labelSides, type Placed, type Point, placeLabels } from "../shared/graph-geometry";
1
+ import { type LabelRequest, labelBox, labelSides, type Placed, type Point, placeLabels, type Rect } from "../shared/graph-geometry";
2
2
  import { folderCenters, type Graph, type GraphNode } from "../shared/graph-model";
3
3
  import type { IssueDetail } from "../shared/linear";
4
4
  import { todoStatus } from "../shared/todo-sync";
@@ -52,6 +52,8 @@ export interface LabelInput {
52
52
  height: number;
53
53
  /** Zoomed in, so files no agent touched get labels too when they fit. */
54
54
  every?: boolean;
55
+ /** Areas covered by controls, such as the camera buttons. */
56
+ blocked?: readonly Rect[];
55
57
  }
56
58
 
57
59
  function linesOf(node: GraphNode, input: LabelInput): readonly [string, string | null] {
@@ -114,10 +116,11 @@ export function graphLabels(input: LabelInput): { nodes: Map<string, Label>; fol
114
116
  requests.push({ id, width, height: 14, spots: [{ left: center.x - width / 2, top: center.top - 30 }, { left: center.x - width / 2, top: center.y - 7 }] });
115
117
  }
116
118
  for (const node of wanted.filter((entry) => order(entry) > 2)) push(node);
117
- const shapes: { at: Point; radius: number }[] = graph.nodes.flatMap((node) => {
119
+ const shapes: ({ at: Point; radius: number } | Rect)[] = [...(input.blocked ?? [])];
120
+ for (const node of graph.nodes) {
118
121
  const at = positions.get(node.id);
119
- return at ? [{ at, radius: nodeSize(node) / 2 }] : [];
120
- });
122
+ if (at) shapes.push({ at, radius: nodeSize(node) / 2 });
123
+ }
121
124
  // Keep labels out from under the agents and their captions, as a row of small circles.
122
125
  for (const cursor of input.cursors) {
123
126
  shapes.push({ at: cursor.at, radius: 16 });
@@ -1,6 +1,7 @@
1
1
  import { Icon } from "@getpaseo/plugin/client/react-native";
2
2
  import { memo, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { Animated, Easing, Text, View } from "react-native";
4
+ import { type Camera, growth, project } from "../shared/graph-camera";
4
5
  import { type Point, pointAt, type Route, route } from "../shared/graph-geometry";
5
6
  import { type Graph, graphPath } from "../shared/graph-model";
6
7
  import type { CursorAgent } from "./live-agents";
@@ -13,12 +14,23 @@ import type { Theme } from "./ui";
13
14
 
14
15
  type Coordinate = number | Animated.Value | Animated.AnimatedInterpolation<number>;
15
16
 
17
+ /** A node's middle on the canvas, and the pin's offset from it, which grows with the node. */
18
+ export interface Hover extends Point {
19
+ dx: number;
20
+ dy: number;
21
+ }
22
+
16
23
  interface Motion {
17
24
  x: Coordinate;
18
25
  y: Coordinate;
26
+ dx: Coordinate;
27
+ dy: Coordinate;
19
28
  }
20
29
 
21
30
  const BADGE = 26;
31
+ /** The pin runs from the badge top to the pointer tip; the pulse rings its middle. */
32
+ const PIN_MIDDLE = 3;
33
+ const RING = 34;
22
34
  export const CAPTION_ROOM = 210;
23
35
  const MAX_SATELLITES = 3;
24
36
 
@@ -26,6 +38,12 @@ function glideTime(length: number): number {
26
38
  return Math.min(1200, Math.max(420, 380 + length * 0.8));
27
39
  }
28
40
 
41
+ /** The caption goes left when it would run past the right edge of the view. */
42
+ export function captionFlips(at: Hover, view: Camera, width: number, height: number): boolean {
43
+ const grow = growth(view.zoom);
44
+ return project(view, at, width, height).x + at.dx * grow > width - CAPTION_ROOM * grow;
45
+ }
46
+
29
47
  export interface CursorProps {
30
48
  theme: Theme;
31
49
  agent: CursorAgent;
@@ -35,11 +53,13 @@ export interface CursorProps {
35
53
  /** Changes when node positions change. */
36
54
  layoutKey: string;
37
55
  /** Where the cursor hovers over a node. */
38
- hover(id: string): Point | null;
39
- /** Canvas width, to keep the caption inside it. */
56
+ hover(id: string): Hover | null;
57
+ /** Canvas size and the settled camera, to keep the caption inside the view. */
40
58
  width: number;
41
- /** One over the graph zoom, so the cursor keeps its size on screen. */
42
- inverse: Animated.AnimatedInterpolation<number>;
59
+ height: number;
60
+ view: Camera;
61
+ /** The camera's item scale, so the cursor grows as nodes do. */
62
+ itemScale: Animated.AnimatedInterpolation<number>;
43
63
  reduce: boolean;
44
64
  }
45
65
 
@@ -50,31 +70,31 @@ export const AgentCursor = memo(function AgentCursor(props: CursorProps) {
50
70
  const [halo] = useState(() => new Animated.Value(0));
51
71
  const [spin] = useState(() => new Animated.Value(0));
52
72
  const [motion, setMotion] = useState<Motion | null>(null);
53
- const [flip, setFlip] = useState(false);
73
+ const [landed, setLanded] = useState<Hover | null>(null);
54
74
  const latest = useRef(props);
55
75
  latest.current = props;
56
76
  // Each glide gets its own value, so binding a new route never jumps to the old start.
57
- const active = useRef<{ value: Animated.Value; path: Route; layoutKey: string } | null>(null);
77
+ const active = useRef<{ value: Animated.Value; path: Route<Hover>; layoutKey: string } | null>(null);
58
78
  const state = useRef({
59
79
  at: null as string | null,
60
- point: null as Point | null,
80
+ point: null as Hover | null,
61
81
  moving: false,
62
82
  pending: null as string | null,
63
83
  });
64
84
 
65
85
  // These read only refs, so the animation callbacks never see stale props.
66
- const land = (point: Point) => {
86
+ const land = (point: Hover) => {
67
87
  state.current.point = point;
68
- setFlip(point.x > latest.current.width - CAPTION_ROOM);
88
+ setLanded(point);
69
89
  };
70
90
  const rest = (id: string) => {
71
91
  state.current.at = id;
72
92
  const point = latest.current.hover(id);
73
93
  if (!point) return;
74
94
  land(point);
75
- setMotion({ x: point.x, y: point.y });
95
+ setMotion({ x: point.x, y: point.y, dx: point.dx, dy: point.dy });
76
96
  };
77
- const glide = (points: Point[], to: string) => {
97
+ const glide = (points: Hover[], to: string) => {
78
98
  const path = route(points);
79
99
  if (latest.current.reduce || !path) {
80
100
  state.current.moving = false;
@@ -85,11 +105,10 @@ export const AgentCursor = memo(function AgentCursor(props: CursorProps) {
85
105
  const value = new Animated.Value(0);
86
106
  active.current = { value, path, layoutKey: latest.current.layoutKey };
87
107
  state.current.moving = true;
88
- land(path.points[path.points.length - 1] as Point);
89
- setMotion({
90
- x: value.interpolate({ inputRange: path.stops, outputRange: path.points.map((point) => point.x) }),
91
- y: value.interpolate({ inputRange: path.stops, outputRange: path.points.map((point) => point.y) }),
92
- });
108
+ land(path.points[path.points.length - 1] as Hover);
109
+ const along = (pick: (point: Hover) => number) =>
110
+ value.interpolate({ inputRange: path.stops, outputRange: path.points.map(pick) });
111
+ setMotion({ x: along((point) => point.x), y: along((point) => point.y), dx: along((point) => point.dx), dy: along((point) => point.dy) });
93
112
  Animated.timing(value, {
94
113
  toValue: 1,
95
114
  duration: glideTime(path.length),
@@ -135,7 +154,7 @@ export const AgentCursor = memo(function AgentCursor(props: CursorProps) {
135
154
  if (glideNow.layoutKey === props.layoutKey) return;
136
155
  current.pending = null;
137
156
  glideNow.value.stopAnimation((share) => glide([pointAt(glideNow.path, share), end], goal));
138
- } else if (current.point && (current.point.x !== end.x || current.point.y !== end.y)) {
157
+ } else if (current.point && ["x", "y", "dx", "dy"].some((key) => current.point?.[key as keyof Hover] !== end[key as keyof Hover])) {
139
158
  glide([current.point, end], goal);
140
159
  }
141
160
  }, [props.layoutKey]);
@@ -178,6 +197,11 @@ export const AgentCursor = memo(function AgentCursor(props: CursorProps) {
178
197
  }, [satellites, reduce, spin]);
179
198
 
180
199
  const caption = agent.caption ?? (agent.kind === "child" ? agent.label : null);
200
+ const flip = landed ? captionFlips(landed, props.view, props.width, props.height) : false;
201
+ const offset = useMemo(
202
+ () => (motion ? { transform: [{ translateX: motion.dx }, { translateY: motion.dy }] } : null),
203
+ [motion],
204
+ );
181
205
  const styles = useMemo(() => {
182
206
  const half = BADGE / 2;
183
207
  return {
@@ -185,14 +209,15 @@ export const AgentCursor = memo(function AgentCursor(props: CursorProps) {
185
209
  float: { transform: [{ translateY: bob.interpolate({ inputRange: [0, 1], outputRange: [0, -3] }) }] },
186
210
  halo: {
187
211
  position: "absolute",
188
- left: -half - 6,
189
- top: -half - 6,
190
- width: BADGE + 12,
191
- height: BADGE + 12,
192
- borderRadius: (BADGE + 12) / 2,
193
- backgroundColor: agent.color,
194
- opacity: halo.interpolate({ inputRange: [0, 1], outputRange: [0.35, 0] }),
195
- transform: [{ scale: halo.interpolate({ inputRange: [0, 1], outputRange: [0.8, 1.5] }) }],
212
+ left: -RING / 2,
213
+ top: PIN_MIDDLE - RING / 2,
214
+ width: RING,
215
+ height: RING,
216
+ borderRadius: RING / 2,
217
+ borderWidth: 2,
218
+ borderColor: agent.color,
219
+ opacity: halo.interpolate({ inputRange: [0, 1], outputRange: [0.6, 0] }),
220
+ transform: [{ scale: halo.interpolate({ inputRange: [0, 1], outputRange: [1, 1.7] }) }],
196
221
  },
197
222
  badge: {
198
223
  position: "absolute",
@@ -261,32 +286,34 @@ export const AgentCursor = memo(function AgentCursor(props: CursorProps) {
261
286
  return (
262
287
  <Animated.View
263
288
  pointerEvents="none"
264
- style={[styles.anchor, { transform: [{ translateX: motion.x }, { translateY: motion.y }, { scale: props.inverse }] }]}
289
+ style={[styles.anchor, { transform: [{ translateX: motion.x }, { translateY: motion.y }, { scale: props.itemScale }] }]}
265
290
  >
266
- <Animated.View style={styles.float}>
267
- {agent.running ? <Animated.View style={styles.halo} /> : null}
268
- {moons.length > 0 ? (
269
- <Animated.View style={styles.orbit}>
270
- {moons.map((moon, index) => (
271
- <View key={index} style={[styles.moon, moon]} />
272
- ))}
273
- </Animated.View>
274
- ) : null}
275
- <View style={styles.pointer} />
276
- <View style={styles.badge}>
277
- {agent.provider ? (
278
- <ProviderIcon provider={agent.provider} size={13} color={colors.accentForeground} />
279
- ) : (
280
- <Icon name="Bot" size={13} color={colors.surface0} />
281
- )}
282
- </View>
283
- {caption ? (
284
- <View style={styles.caption}>
285
- <Text numberOfLines={1} style={styles.captionText}>
286
- {caption}
287
- </Text>
291
+ <Animated.View style={offset}>
292
+ <Animated.View style={styles.float}>
293
+ {agent.running ? <Animated.View style={styles.halo} /> : null}
294
+ {moons.length > 0 ? (
295
+ <Animated.View style={styles.orbit}>
296
+ {moons.map((moon, index) => (
297
+ <View key={index} style={[styles.moon, moon]} />
298
+ ))}
299
+ </Animated.View>
300
+ ) : null}
301
+ <View style={styles.pointer} />
302
+ <View style={styles.badge}>
303
+ {agent.provider ? (
304
+ <ProviderIcon provider={agent.provider} size={13} color={colors.accentForeground} />
305
+ ) : (
306
+ <Icon name="Bot" size={13} color={colors.surface0} />
307
+ )}
288
308
  </View>
289
- ) : null}
309
+ {caption ? (
310
+ <View style={styles.caption}>
311
+ <Text numberOfLines={1} style={styles.captionText}>
312
+ {caption}
313
+ </Text>
314
+ </View>
315
+ ) : null}
316
+ </Animated.View>
290
317
  </Animated.View>
291
318
  </Animated.View>
292
319
  );
@@ -1,6 +1,6 @@
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
+ import { focusBox, growth, project, unproject } from "../shared/graph-camera";
4
4
  import type { Placed, Point } from "../shared/graph-geometry";
5
5
  import { focusNode, type Graph, type GraphEdge, type GraphNode, graphKey, layoutGraph } from "../shared/graph-model";
6
6
  import { type AreaRef, whereLabel } from "../shared/knowledge";
@@ -9,7 +9,7 @@ import type { GraphCamera } from "../shared/settings";
9
9
  import { CameraButtons, useGraphCamera } from "./graph-camera";
10
10
  import { captionWidth, graphLabels, HOVER_GAP, type Label, nodeSize } from "./graph-labels";
11
11
  import type { CursorAgent } from "./live-agents";
12
- import { AgentCursor, CAPTION_ROOM } from "./live-cursor";
12
+ import { AgentCursor, captionFlips, type Hover } from "./live-cursor";
13
13
  import { type IssueProgress, MONO } from "./live-issues";
14
14
  import type { LivePatch } from "./live-panel";
15
15
  import { NATIVE_DRIVER, type OwnerColors } from "./live-timeline";
@@ -41,9 +41,11 @@ export interface GraphViewProps {
41
41
  reduceMotion: boolean;
42
42
  }
43
43
 
44
- type Inverse = Animated.AnimatedInterpolation<number>;
44
+ type ItemScale = Animated.AnimatedInterpolation<number>;
45
45
  /** About half the height of a folder name, which scales around its middle. */
46
46
  const FOLDER_HALF = 7;
47
+ /** The camera buttons in the top right corner, with a margin; labels stay out of it. */
48
+ const TOOLBAR = { width: 160, height: 44 };
47
49
 
48
50
  interface EdgeStyle {
49
51
  color: string;
@@ -55,7 +57,7 @@ interface EdgeProps {
55
57
  from: Point;
56
58
  to: Point;
57
59
  style: EdgeStyle;
58
- inverse: Inverse;
60
+ itemScale: ItemScale;
59
61
  }
60
62
 
61
63
  const EdgeLine = memo(function EdgeLine(props: EdgeProps) {
@@ -71,15 +73,15 @@ const EdgeLine = memo(function EdgeLine(props: EdgeProps) {
71
73
  borderRadius: style.thickness / 2,
72
74
  backgroundColor: style.color,
73
75
  opacity: style.opacity,
74
- // Rotate first, so the inverse zoom thins the line and leaves its length to the camera.
75
- transform: [{ rotate: `${angle}rad` }, { scaleY: props.inverse }],
76
+ // Rotate first, so the item scale sets the thickness and leaves the length to the camera.
77
+ transform: [{ rotate: `${angle}rad` }, { scaleY: props.itemScale }],
76
78
  } as const;
77
79
  return <Animated.View pointerEvents="none" style={line} />;
78
80
  }, sameEdge);
79
81
 
80
82
  function sameEdge(a: EdgeProps, b: EdgeProps) {
81
83
  return (
82
- a.inverse === b.inverse &&
84
+ a.itemScale === b.itemScale &&
83
85
  a.from === b.from &&
84
86
  a.to === b.to &&
85
87
  a.style.color === b.style.color &&
@@ -99,7 +101,7 @@ interface NodeProps {
99
101
  /** An agent is working on it. */
100
102
  current: boolean;
101
103
  label: Label | null;
102
- inverse: Inverse;
104
+ itemScale: ItemScale;
103
105
  reduce: boolean;
104
106
  onPress(id: string): void;
105
107
  }
@@ -120,7 +122,7 @@ function sameNode(a: NodeProps, b: NodeProps): boolean {
120
122
  a.label?.lines[0] === b.label?.lines[0] &&
121
123
  a.label?.lines[1] === b.label?.lines[1] &&
122
124
  a.theme === b.theme &&
123
- a.inverse === b.inverse &&
125
+ a.itemScale === b.itemScale &&
124
126
  a.reduce === b.reduce &&
125
127
  a.onPress === b.onPress &&
126
128
  x.id === y.id &&
@@ -168,7 +170,7 @@ const GraphNodeView = memo(function GraphNodeView(props: NodeProps) {
168
170
  width: size,
169
171
  height: size,
170
172
  opacity: props.dim ? 0.22 : 1,
171
- transform: [{ scale: props.inverse }],
173
+ transform: [{ scale: props.itemScale }],
172
174
  },
173
175
  shape: {
174
176
  width: size,
@@ -200,7 +202,7 @@ const GraphNodeView = memo(function GraphNodeView(props: NodeProps) {
200
202
  },
201
203
  second: { color: colors.foregroundMuted, fontSize: 10, textAlign: align },
202
204
  } as const;
203
- }, [node, size, at, color, colors, touched, label, props.dim, props.selected, props.current, props.inverse, enter, beat]);
205
+ }, [node, size, at, color, colors, touched, label, props.dim, props.selected, props.current, props.itemScale, enter, beat]);
204
206
  const press = useCallback(() => props.onPress(node.id), [props.onPress, node.id]);
205
207
  return (
206
208
  <Animated.View style={styles.wrap} pointerEvents="box-none">
@@ -328,11 +330,11 @@ export function LiveGraph(props: GraphViewProps) {
328
330
  targets.map(({ agent, target }) => {
329
331
  const slot = seen.get(target) ?? 0;
330
332
  seen.set(target, slot + 1);
331
- const hover = (id: string): Point | null => {
333
+ const hover = (id: string): Hover | null => {
332
334
  const at = positions.get(id);
333
335
  const node = byId.get(id);
334
336
  if (!at || !node) return null;
335
- return { x: at.x + slot * 30, y: at.y - nodeSize(node) / 2 - HOVER_GAP };
337
+ return { x: at.x, y: at.y, dx: slot * 30, dy: -nodeSize(node) / 2 - HOVER_GAP };
336
338
  };
337
339
  return [agent.id, hover] as const;
338
340
  }),
@@ -344,9 +346,9 @@ export function LiveGraph(props: GraphViewProps) {
344
346
  const at = hovers.get(agent.id)?.(target);
345
347
  if (!at) return [];
346
348
  const text = agent.caption ?? (agent.kind === "child" ? agent.label : null);
347
- return [{ at, caption: captionWidth(text), flip: at.x > width - CAPTION_ROOM }];
349
+ return [{ at, caption: captionWidth(text) }];
348
350
  }),
349
- [targets, hovers, width],
351
+ [targets, hovers],
350
352
  );
351
353
  const { saveLive } = props;
352
354
  const [auto, setAuto] = useState(props.camera === "auto");
@@ -365,18 +367,40 @@ export function LiveGraph(props: GraphViewProps) {
365
367
  }, [locked, saveLive]);
366
368
  const box = useMemo(() => focusBox(graph, positions, working), [graph, positions, working]);
367
369
  const camera = useGraphCamera({ width, height, focus: box, auto, locked, reduce: props.reduceMotion, onManual: stopAuto });
368
- // Labels are placed in the view of the settled camera, then moved back onto the canvas.
370
+ // Labels are placed in the view of the settled camera, in units of the grown items so their
371
+ // sizes hold, then moved back onto the canvas.
369
372
  const view = camera.settled;
370
373
  const labels = useMemo(() => {
371
- if (view.zoom === 1) return graphLabels({ graph, positions, issue, progress, selected, working, cursors, width, height });
372
- const toView = (at: Point) => project(view, at, width, height);
374
+ const grow = growth(view.zoom);
375
+ const toView = (at: Point) => {
376
+ const shown = project(view, at, width, height);
377
+ return { x: shown.x / grow, y: shown.y / grow };
378
+ };
373
379
  const seen = new Map<string, Placed>();
374
380
  for (const [id, at] of positions) {
375
381
  const shown = toView(at);
376
- if (shown.x > -20 && shown.x < width + 20 && shown.y > -20 && shown.y < height + 20) seen.set(id, { ...at, ...shown });
382
+ if (shown.x * grow > -20 && shown.x * grow < width + 20 && shown.y * grow > -20 && shown.y * grow < height + 20) {
383
+ seen.set(id, { ...at, ...shown });
384
+ }
377
385
  }
378
- const viewCursors = cursors.map((cursor) => ({ ...cursor, at: toView(cursor.at) }));
379
- const placed = graphLabels({ graph, positions: seen, issue, progress, selected, working, cursors: viewCursors, width, height, every: view.zoom >= 1.5 });
386
+ const viewCursors = cursors.map(({ at, caption }) => {
387
+ const middle = toView(at);
388
+ return { at: { x: middle.x + at.dx, y: middle.y + at.dy }, caption, flip: captionFlips(at, view, width, height) };
389
+ });
390
+ const toolbar = { left: (width - TOOLBAR.width) / grow, top: 0, right: width / grow, bottom: TOOLBAR.height / grow };
391
+ const placed = graphLabels({
392
+ graph,
393
+ positions: seen,
394
+ issue,
395
+ progress,
396
+ selected,
397
+ working,
398
+ cursors: viewCursors,
399
+ width: width / grow,
400
+ height: height / grow,
401
+ every: view.zoom >= 1.5,
402
+ blocked: [toolbar],
403
+ });
380
404
  const nodes = new Map<string, Label>();
381
405
  for (const [id, label] of placed.nodes) {
382
406
  const at = positions.get(id);
@@ -384,7 +408,8 @@ export function LiveGraph(props: GraphViewProps) {
384
408
  if (at && shown) nodes.set(id, { ...label, left: label.left + at.x - shown.x, top: label.top + at.y - shown.y });
385
409
  }
386
410
  const folders = placed.folders.map((folder) => {
387
- const middle = unproject(view, { x: folder.left + folder.width / 2, y: folder.top + FOLDER_HALF }, width, height);
411
+ const shown = { x: (folder.left + folder.width / 2) * grow, y: (folder.top + FOLDER_HALF) * grow };
412
+ const middle = unproject(view, shown, width, height);
388
413
  return { ...folder, left: middle.x - folder.width / 2, top: middle.y - FOLDER_HALF };
389
414
  });
390
415
  return { nodes, folders };
@@ -429,7 +454,7 @@ export function LiveGraph(props: GraphViewProps) {
429
454
  const from = positions.get(edge.from);
430
455
  const to = positions.get(edge.to);
431
456
  if (!from || !to) return null;
432
- return <EdgeLine key={edge.id} from={from} to={to} style={edgeStyle(edge)} inverse={camera.inverse} />;
457
+ return <EdgeLine key={edge.id} from={from} to={to} style={edgeStyle(edge)} itemScale={camera.itemScale} />;
433
458
  })
434
459
  : null}
435
460
  {shown
@@ -440,7 +465,7 @@ export function LiveGraph(props: GraphViewProps) {
440
465
  numberOfLines={1}
441
466
  style={[
442
467
  styles.folder,
443
- { left: folder.left, top: folder.top, width: folder.width, transform: [{ scale: camera.inverse }] },
468
+ { left: folder.left, top: folder.top, width: folder.width, transform: [{ scale: camera.itemScale }] },
444
469
  ]}
445
470
  >
446
471
  {folder.folder === "." ? "/" : `${folder.folder}/`}
@@ -463,7 +488,7 @@ export function LiveGraph(props: GraphViewProps) {
463
488
  selected={selected === node.id}
464
489
  current={working.has(node.id)}
465
490
  label={labels.nodes.get(node.id) ?? null}
466
- inverse={camera.inverse}
491
+ itemScale={camera.itemScale}
467
492
  reduce={props.reduceMotion}
468
493
  onPress={press}
469
494
  />
@@ -484,7 +509,9 @@ export function LiveGraph(props: GraphViewProps) {
484
509
  layoutKey={layoutKey}
485
510
  hover={hover}
486
511
  width={width}
487
- inverse={camera.inverse}
512
+ height={height}
513
+ view={view}
514
+ itemScale={camera.itemScale}
488
515
  reduce={props.reduceMotion}
489
516
  />
490
517
  );