@yannelli/paseo-linear-plugin 1.1.0-beta.2 → 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.
@@ -1,14 +1,15 @@
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
+ import { type AreaRef, whereLabel } from "../shared/knowledge";
6
7
  import type { IssueDetail, WorkflowState } from "../shared/linear";
7
8
  import type { GraphCamera } from "../shared/settings";
8
9
  import { CameraButtons, useGraphCamera } from "./graph-camera";
9
10
  import { captionWidth, graphLabels, HOVER_GAP, type Label, nodeSize } from "./graph-labels";
10
11
  import type { CursorAgent } from "./live-agents";
11
- import { AgentCursor, CAPTION_ROOM } from "./live-cursor";
12
+ import { AgentCursor, captionFlips, type Hover } from "./live-cursor";
12
13
  import { type IssueProgress, MONO } from "./live-issues";
13
14
  import type { LivePatch } from "./live-panel";
14
15
  import { NATIVE_DRIVER, type OwnerColors } from "./live-timeline";
@@ -29,6 +30,8 @@ export interface GraphViewProps {
29
30
  progress: IssueProgress;
30
31
  owners: OwnerColors;
31
32
  agents: readonly CursorAgent[];
33
+ /** Areas of the project, to say where an off-map file is. */
34
+ areas: readonly AreaRef[];
32
35
  focus: string | null;
33
36
  onFocus(key: string): void;
34
37
  /** Saved camera mode: follow the running agents, or show the whole graph. */
@@ -38,9 +41,11 @@ export interface GraphViewProps {
38
41
  reduceMotion: boolean;
39
42
  }
40
43
 
41
- type Inverse = Animated.AnimatedInterpolation<number>;
44
+ type ItemScale = Animated.AnimatedInterpolation<number>;
42
45
  /** About half the height of a folder name, which scales around its middle. */
43
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 };
44
49
 
45
50
  interface EdgeStyle {
46
51
  color: string;
@@ -52,7 +57,7 @@ interface EdgeProps {
52
57
  from: Point;
53
58
  to: Point;
54
59
  style: EdgeStyle;
55
- inverse: Inverse;
60
+ itemScale: ItemScale;
56
61
  }
57
62
 
58
63
  const EdgeLine = memo(function EdgeLine(props: EdgeProps) {
@@ -68,15 +73,15 @@ const EdgeLine = memo(function EdgeLine(props: EdgeProps) {
68
73
  borderRadius: style.thickness / 2,
69
74
  backgroundColor: style.color,
70
75
  opacity: style.opacity,
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 }],
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 }],
73
78
  } as const;
74
79
  return <Animated.View pointerEvents="none" style={line} />;
75
80
  }, sameEdge);
76
81
 
77
82
  function sameEdge(a: EdgeProps, b: EdgeProps) {
78
83
  return (
79
- a.inverse === b.inverse &&
84
+ a.itemScale === b.itemScale &&
80
85
  a.from === b.from &&
81
86
  a.to === b.to &&
82
87
  a.style.color === b.style.color &&
@@ -96,7 +101,7 @@ interface NodeProps {
96
101
  /** An agent is working on it. */
97
102
  current: boolean;
98
103
  label: Label | null;
99
- inverse: Inverse;
104
+ itemScale: ItemScale;
100
105
  reduce: boolean;
101
106
  onPress(id: string): void;
102
107
  }
@@ -117,7 +122,7 @@ function sameNode(a: NodeProps, b: NodeProps): boolean {
117
122
  a.label?.lines[0] === b.label?.lines[0] &&
118
123
  a.label?.lines[1] === b.label?.lines[1] &&
119
124
  a.theme === b.theme &&
120
- a.inverse === b.inverse &&
125
+ a.itemScale === b.itemScale &&
121
126
  a.reduce === b.reduce &&
122
127
  a.onPress === b.onPress &&
123
128
  x.id === y.id &&
@@ -165,7 +170,7 @@ const GraphNodeView = memo(function GraphNodeView(props: NodeProps) {
165
170
  width: size,
166
171
  height: size,
167
172
  opacity: props.dim ? 0.22 : 1,
168
- transform: [{ scale: props.inverse }],
173
+ transform: [{ scale: props.itemScale }],
169
174
  },
170
175
  shape: {
171
176
  width: size,
@@ -197,7 +202,7 @@ const GraphNodeView = memo(function GraphNodeView(props: NodeProps) {
197
202
  },
198
203
  second: { color: colors.foregroundMuted, fontSize: 10, textAlign: align },
199
204
  } as const;
200
- }, [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]);
201
206
  const press = useCallback(() => props.onPress(node.id), [props.onPress, node.id]);
202
207
  return (
203
208
  <Animated.View style={styles.wrap} pointerEvents="box-none">
@@ -223,7 +228,7 @@ const GraphNodeView = memo(function GraphNodeView(props: NodeProps) {
223
228
  );
224
229
  }, sameNode);
225
230
 
226
- function caption(node: GraphNode, issue: IssueDetail, graph: Graph): string {
231
+ function caption(node: GraphNode, issue: IssueDetail, graph: Graph, areas: readonly AreaRef[]): string {
227
232
  if (node.kind === "issue") return `${issue.identifier} · ${issue.title}`;
228
233
  if (node.kind === "subissue") {
229
234
  const child = issue.children.find((entry) => entry.identifier.toUpperCase() === node.label);
@@ -231,7 +236,7 @@ function caption(node: GraphNode, issue: IssueDetail, graph: Graph): string {
231
236
  return `${node.label} · ${child?.state.name ?? ""} · ${child?.title ?? ""}${folded}`;
232
237
  }
233
238
  const tile = node.tile;
234
- const where = tile?.onMap ? `on the map for ${node.owner}` : "not on the map";
239
+ const where = tile?.onMap ? `on the map for ${node.owner}` : `not on the map, in ${whereLabel(node.path ?? "", areas)}`;
235
240
  const touch = !tile || tile.touch === "none" ? "not touched yet" : tile.touch;
236
241
  const lines = tile && (tile.added > 0 || tile.removed > 0) ? ` · +${tile.added} -${tile.removed}` : "";
237
242
  const linked = graph.edges.filter((edge) => edge.kind !== "owns" && (edge.from === node.id || edge.to === node.id)).length;
@@ -325,11 +330,11 @@ export function LiveGraph(props: GraphViewProps) {
325
330
  targets.map(({ agent, target }) => {
326
331
  const slot = seen.get(target) ?? 0;
327
332
  seen.set(target, slot + 1);
328
- const hover = (id: string): Point | null => {
333
+ const hover = (id: string): Hover | null => {
329
334
  const at = positions.get(id);
330
335
  const node = byId.get(id);
331
336
  if (!at || !node) return null;
332
- 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 };
333
338
  };
334
339
  return [agent.id, hover] as const;
335
340
  }),
@@ -341,9 +346,9 @@ export function LiveGraph(props: GraphViewProps) {
341
346
  const at = hovers.get(agent.id)?.(target);
342
347
  if (!at) return [];
343
348
  const text = agent.caption ?? (agent.kind === "child" ? agent.label : null);
344
- return [{ at, caption: captionWidth(text), flip: at.x > width - CAPTION_ROOM }];
349
+ return [{ at, caption: captionWidth(text) }];
345
350
  }),
346
- [targets, hovers, width],
351
+ [targets, hovers],
347
352
  );
348
353
  const { saveLive } = props;
349
354
  const [auto, setAuto] = useState(props.camera === "auto");
@@ -362,18 +367,40 @@ export function LiveGraph(props: GraphViewProps) {
362
367
  }, [locked, saveLive]);
363
368
  const box = useMemo(() => focusBox(graph, positions, working), [graph, positions, working]);
364
369
  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.
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.
366
372
  const view = camera.settled;
367
373
  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);
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
+ };
370
379
  const seen = new Map<string, Placed>();
371
380
  for (const [id, at] of positions) {
372
381
  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 });
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
+ }
374
385
  }
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 });
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
+ });
377
404
  const nodes = new Map<string, Label>();
378
405
  for (const [id, label] of placed.nodes) {
379
406
  const at = positions.get(id);
@@ -381,7 +408,8 @@ export function LiveGraph(props: GraphViewProps) {
381
408
  if (at && shown) nodes.set(id, { ...label, left: label.left + at.x - shown.x, top: label.top + at.y - shown.y });
382
409
  }
383
410
  const folders = placed.folders.map((folder) => {
384
- 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);
385
413
  return { ...folder, left: middle.x - folder.width / 2, top: middle.y - FOLDER_HALF };
386
414
  });
387
415
  return { nodes, folders };
@@ -426,7 +454,7 @@ export function LiveGraph(props: GraphViewProps) {
426
454
  const from = positions.get(edge.from);
427
455
  const to = positions.get(edge.to);
428
456
  if (!from || !to) return null;
429
- 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} />;
430
458
  })
431
459
  : null}
432
460
  {shown
@@ -437,7 +465,7 @@ export function LiveGraph(props: GraphViewProps) {
437
465
  numberOfLines={1}
438
466
  style={[
439
467
  styles.folder,
440
- { 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 }] },
441
469
  ]}
442
470
  >
443
471
  {folder.folder === "." ? "/" : `${folder.folder}/`}
@@ -460,7 +488,7 @@ export function LiveGraph(props: GraphViewProps) {
460
488
  selected={selected === node.id}
461
489
  current={working.has(node.id)}
462
490
  label={labels.nodes.get(node.id) ?? null}
463
- inverse={camera.inverse}
491
+ itemScale={camera.itemScale}
464
492
  reduce={props.reduceMotion}
465
493
  onPress={press}
466
494
  />
@@ -481,7 +509,9 @@ export function LiveGraph(props: GraphViewProps) {
481
509
  layoutKey={layoutKey}
482
510
  hover={hover}
483
511
  width={width}
484
- inverse={camera.inverse}
512
+ height={height}
513
+ view={view}
514
+ itemScale={camera.itemScale}
485
515
  reduce={props.reduceMotion}
486
516
  />
487
517
  );
@@ -507,7 +537,7 @@ export function LiveGraph(props: GraphViewProps) {
507
537
  <View style={styles.caption}>
508
538
  {selectedNode ? (
509
539
  <Text numberOfLines={2} style={styles.captionText}>
510
- {caption(selectedNode, issue, graph)}
540
+ {caption(selectedNode, issue, graph, props.areas)}
511
541
  </Text>
512
542
  ) : (
513
543
  <Text style={styles.hint}>Select a node to see its file or issue.</Text>
@@ -79,7 +79,7 @@ function StatusChip(props: { theme: Theme; status: AgentStatus; provider: string
79
79
  );
80
80
  }
81
81
 
82
- function NowLine(props: { theme: Theme; event: ActivityEvent; live: boolean }) {
82
+ function NowLine(props: { theme: Theme; event: ActivityEvent; live: boolean; offMap: string | null }) {
83
83
  const { theme, event } = props;
84
84
  const { colors } = theme;
85
85
  const tone = eventTone(theme, event);
@@ -106,6 +106,18 @@ function NowLine(props: { theme: Theme; event: ActivityEvent; live: boolean }) {
106
106
  tint: { ...StyleSheet.absoluteFillObject, borderRadius: 6, backgroundColor: tone, opacity: 0.14 },
107
107
  text: { color: props.live ? colors.foreground : colors.foregroundMuted, fontSize: 13 },
108
108
  detail: { flex: 1, color: colors.foregroundMuted, fontFamily: MONO, fontSize: 11 },
109
+ off: {
110
+ flexShrink: 0,
111
+ maxWidth: 220,
112
+ paddingHorizontal: 7,
113
+ paddingVertical: 2,
114
+ borderRadius: 6,
115
+ borderWidth: 1,
116
+ borderStyle: "dashed",
117
+ borderColor: colors.statusWarning,
118
+ color: colors.statusWarning,
119
+ fontSize: 11,
120
+ },
109
121
  }) as const,
110
122
  [colors, tone, props.live],
111
123
  );
@@ -124,6 +136,11 @@ function NowLine(props: { theme: Theme; event: ActivityEvent; live: boolean }) {
124
136
  {event.detail}
125
137
  </Text>
126
138
  ) : null}
139
+ {props.offMap ? (
140
+ <Text numberOfLines={1} style={styles.off} accessibilityLabel={`Off the map, in ${props.offMap}`}>
141
+ {`Off the map · ${props.offMap}`}
142
+ </Text>
143
+ ) : null}
127
144
  </View>
128
145
  );
129
146
  }
@@ -136,6 +153,8 @@ export function LiveHeader(props: {
136
153
  status: AgentStatus;
137
154
  provider: string;
138
155
  current: ActivityEvent | null;
156
+ /** Where the current file is when the agent works off the map. */
157
+ offMap: string | null;
139
158
  reduceMotion: boolean;
140
159
  }) {
141
160
  const { theme, issue, progress, owners } = props;
@@ -204,7 +223,12 @@ export function LiveHeader(props: {
204
223
  </View>
205
224
  {counts.length > 0 ? <Text style={styles.counts}>{counts.join(" · ")}</Text> : null}
206
225
  {props.current ? (
207
- <NowLine theme={theme} event={props.current} live={props.status === "running"} />
226
+ <NowLine
227
+ theme={theme}
228
+ event={props.current}
229
+ live={props.status === "running"}
230
+ offMap={props.offMap}
231
+ />
208
232
  ) : null}
209
233
  </View>
210
234
  );
@@ -8,6 +8,7 @@ import {
8
8
  type MapZone,
9
9
  masonry,
10
10
  ZONE_GAP,
11
+ zoneAreaLabel,
11
12
  } from "../shared/map-model";
12
13
  import { NATIVE_DRIVER, type OwnerColors } from "./live-timeline";
13
14
  import type { AgentMarker } from "./live-agents";
@@ -197,7 +198,8 @@ function Zone(props: RepoMapProps & { zone: MapZone }) {
197
198
  opacity: 0.35,
198
199
  },
199
200
  header: { flexDirection: "row", alignItems: "center", gap: 6 },
200
- dir: { flex: 1, color: colors.foregroundMuted, fontFamily: MONO, fontSize: 11 },
201
+ dir: { flexShrink: 1, color: colors.foregroundMuted, fontFamily: MONO, fontSize: 11 },
202
+ area: { flex: 1, color: colors.foregroundMuted, fontSize: 11, opacity: 0.8 },
201
203
  count: { color: hot ? colors.foreground : colors.foregroundMuted, fontSize: 11 },
202
204
  tiles: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
203
205
  }) as const,
@@ -207,6 +209,7 @@ function Zone(props: RepoMapProps & { zone: MapZone }) {
207
209
  let count: string | null = null;
208
210
  if (zone.predicted > 0) count = `${zone.hits}/${zone.predicted}${off > 0 ? ` +${off} off` : ""}`;
209
211
  else if (off > 0) count = `${off} off the map`;
212
+ const area = zoneAreaLabel(zone);
210
213
  return (
211
214
  <View style={styles.zone}>
212
215
  {hot ? <View pointerEvents="none" style={styles.ring} /> : null}
@@ -219,6 +222,9 @@ function Zone(props: RepoMapProps & { zone: MapZone }) {
219
222
  <Text numberOfLines={1} style={styles.dir}>
220
223
  {zone.dir || "./"}
221
224
  </Text>
225
+ <Text numberOfLines={1} style={styles.area}>
226
+ {area ?? ""}
227
+ </Text>
222
228
  {count ? <Text style={styles.count}>{count}</Text> : null}
223
229
  </View>
224
230
  <View style={styles.tiles}>
@@ -23,7 +23,7 @@ import {
23
23
  useMapModel,
24
24
  useReduceMotion,
25
25
  } from "./live-timeline";
26
- import { hitRateLine } from "../shared/map-model";
26
+ import { hitRateLine, offMapLine, offMapWhere } from "../shared/map-model";
27
27
  import { useIssue } from "./queries";
28
28
  import { Button, EmptyState, errorMessage, IconButton, type Theme } from "./ui";
29
29
 
@@ -219,7 +219,10 @@ function LiveView(props: BodyProps & { issue: IssueDetail }) {
219
219
  provider: agent.provider,
220
220
  activity,
221
221
  });
222
- const model = useMapModel(agentId, liveMap.map.files, agents.files, agents.dirs);
222
+ const model = useMapModel(agentId, liveMap.map.files, agents.files, agents.dirs, liveMap.areas);
223
+ const offMap = offMapLine(model);
224
+ const currentPath = activity.current?.path ?? null;
225
+ const where = useMemo(() => offMapWhere(model, currentPath, liveMap.areas), [model, currentPath, liveMap.areas]);
223
226
  // Hold the map until its first full data, so it does not draw once and then swap.
224
227
  const mapShown = useRef(false);
225
228
  if (liveMap.ready && model.ready) mapShown.current = true;
@@ -300,6 +303,7 @@ function LiveView(props: BodyProps & { issue: IssueDetail }) {
300
303
  main: { flex: 1, minWidth: 0 },
301
304
  mainContent: { gap: 16, paddingBottom: 32 },
302
305
  hit: { color: colors.foregroundMuted, fontSize: 12 },
306
+ off: { color: colors.statusWarning, fontSize: 12 },
303
307
  tools: { flexDirection: "row", alignItems: "center", gap: 12 },
304
308
  mapHead: { gap: 8 },
305
309
  mapWait: { minHeight: 240, alignItems: "center", justifyContent: "center" },
@@ -316,6 +320,7 @@ function LiveView(props: BodyProps & { issue: IssueDetail }) {
316
320
  status={agent.status}
317
321
  provider={agent.provider}
318
322
  current={activity.current}
323
+ offMap={where}
319
324
  reduceMotion={reduceMotion}
320
325
  />
321
326
  );
@@ -351,6 +356,7 @@ function LiveView(props: BodyProps & { issue: IssueDetail }) {
351
356
  ) : (
352
357
  <View style={styles.mapHead}>
353
358
  <Text style={styles.hit}>{hitRateLine(model)}</Text>
359
+ {offMap ? <Text style={styles.off}>{offMap}</Text> : null}
354
360
  {graph ? (
355
361
  <LiveGraph
356
362
  theme={theme}
@@ -362,6 +368,7 @@ function LiveView(props: BodyProps & { issue: IssueDetail }) {
362
368
  progress={progress}
363
369
  owners={owners}
364
370
  agents={cursors}
371
+ areas={liveMap.areas}
365
372
  focus={focus}
366
373
  onFocus={toggleFocus}
367
374
  camera={props.graphCamera}
@@ -3,6 +3,7 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tansta
3
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
+ import { type AreaRef, liveProjectRpc, MAX_RESOLVE_PATHS } from "../shared/knowledge";
6
7
  import type { IssueDetail } from "../shared/linear";
7
8
  import type { FileLink } from "../shared/links";
8
9
  import {
@@ -240,6 +241,8 @@ export function ownerColors(issue: IssueDetail, accent: string): OwnerColors {
240
241
  // runs, and slowly while there is no map, since a launch can start a job after this mounts.
241
242
  export interface LiveMap {
242
243
  map: IssueMap;
244
+ /** Services, packages, and main folders of the agent's project, from its saved knowledge. */
245
+ areas: readonly AreaRef[];
243
246
  stored: boolean;
244
247
  /** The stored map has been read once, so the map no longer swaps from the fallback. */
245
248
  ready: boolean;
@@ -282,13 +285,14 @@ export function useLiveMap(
282
285
  const refresh = useCallback(() => void queries.invalidateQueries({ queryKey: ["linear", "live"] }), [queries]);
283
286
  const clearMap = useRpc(clearMapRpc);
284
287
  const clear = useMutation({ mutationFn: () => clearMap({ agentId }), onSettled: refresh });
285
- const fallback = useMemo(() => semanticMap(issue), [issue]);
288
+ const project = useProjectKnowledge(agentId, issue);
286
289
  const stored = query.data?.map ?? null;
287
290
  const failure = explore.error ?? clear.error ?? query.error;
288
291
  return {
289
- map: stored ?? fallback,
292
+ map: stored ?? project.fallback,
293
+ areas: project.areas,
290
294
  stored: stored !== null,
291
- ready: query.data !== undefined || query.isError,
295
+ ready: (query.data !== undefined || query.isError) && project.ready,
292
296
  job: query.data?.job ?? null,
293
297
  starting: explore.isPending,
294
298
  error: failure ? errorMessage(failure) : null,
@@ -299,6 +303,32 @@ export function useLiveMap(
299
303
  };
300
304
  }
301
305
 
306
+ const NO_AREAS: readonly AreaRef[] = [];
307
+
308
+ /** The project's areas, and the ticket-text map with its paths matched to real files. */
309
+ function useProjectKnowledge(agentId: string, issue: IssueDetail) {
310
+ const read = useRpc(liveProjectRpc);
311
+ const base = useMemo(() => semanticMap(issue), [issue]);
312
+ const paths = useMemo(
313
+ () => base.files.map((file) => file.path).slice(0, MAX_RESOLVE_PATHS).sort(),
314
+ [base],
315
+ );
316
+ const query = useQuery({
317
+ queryKey: ["linear", "live", "project", agentId, paths],
318
+ queryFn: () => read({ agentId, paths }),
319
+ staleTime: 5 * 60_000,
320
+ // Without knowledge the map still works, so a failure shows it at once.
321
+ retry: false,
322
+ });
323
+ const { data } = query;
324
+ const fallback = useMemo(() => {
325
+ if (!data) return base;
326
+ const resolved = new Map(data.resolved.map((entry) => [entry.from, entry.to]));
327
+ return semanticMap(issue, new Date(base.createdAt), { resolved, areas: data.areas });
328
+ }, [issue, base, data]);
329
+ return { fallback, areas: data?.areas ?? NO_AREAS, ready: data !== undefined || query.isError };
330
+ }
331
+
302
332
  const NO_LISTING: ReadonlyMap<string, readonly string[]> = new Map();
303
333
 
304
334
  export function useMapModel(
@@ -306,6 +336,7 @@ export function useMapModel(
306
336
  files: readonly MapFile[],
307
337
  touches: readonly FileTouch[],
308
338
  explored: readonly DirTouch[],
339
+ areas: readonly AreaRef[] = NO_AREAS,
309
340
  ): MapModel & { ready: boolean } {
310
341
  const list = useRpc(listFilesRpc);
311
342
  const dirs = useMemo(
@@ -325,8 +356,8 @@ export function useMapModel(
325
356
  }, [listing.data]);
326
357
  const ready = dirs.length === 0 || listing.data !== undefined || listing.isError;
327
358
  return useMemo(
328
- () => ({ ...buildMapModel(files, touches, byDir, explored), ready }),
329
- [files, touches, byDir, explored, ready],
359
+ () => ({ ...buildMapModel(files, touches, byDir, explored, areas), ready }),
360
+ [files, touches, byDir, explored, areas, ready],
330
361
  );
331
362
  }
332
363
 
@@ -1,16 +1,50 @@
1
1
  import { useSettings } from "@getpaseo/plugin/client";
2
2
  import { Icon } from "@getpaseo/plugin/client/react-native";
3
- import { useMemo } from "react";
3
+ import { useEffect, useMemo } from "react";
4
4
  import { Image, Platform } from "react-native";
5
- import { type IconSettings, iconMarkup, paintColor } from "../shared/custom-icon";
5
+ import { BUILT_IN_ICON, type IconSettings, iconMarkup, paintColor } from "../shared/custom-icon";
6
6
  import { linearSettings } from "../shared/settings";
7
7
  import { svgDataUri } from "./provider-icon";
8
8
  import type { Theme } from "./ui";
9
+ import { readStored, writeStored } from "./web";
9
10
 
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.
11
+ // The plugin's icon in pills, the sidebar, and plugin screens. Without an SVG, and in native
12
+ // apps, which cannot draw SVG images from plugins, it is the chosen built-in icon.
12
13
 
13
- export const BUILT_IN_ICON = "SquareKanban";
14
+ // Panels and Command Center items take a Lucide name when they register, outside React.
15
+ // Components that read the settings share the chosen name here. Paseo keeps the icon an open
16
+ // tab got, so the web app remembers the name and registers with it on the next start.
17
+ const MENU_ICON_KEY = "paseo-linear-plugin:menu-icon";
18
+ let menuIcon = readStored(MENU_ICON_KEY) ?? BUILT_IN_ICON;
19
+
20
+ /** The menu icon to register with first: the last one chosen on this device. */
21
+ export function startMenuIcon(): string {
22
+ return menuIcon;
23
+ }
24
+ const menuListeners = new Set<(name: string) => void>();
25
+
26
+ export function publishMenuIcon(name: string): void {
27
+ if (name === menuIcon) return;
28
+ menuIcon = name;
29
+ writeStored(MENU_ICON_KEY, name);
30
+ for (const listener of menuListeners) listener(name);
31
+ }
32
+
33
+ export function onMenuIconChange(listener: (name: string) => void): () => void {
34
+ menuListeners.add(listener);
35
+ return () => menuListeners.delete(listener);
36
+ }
37
+
38
+ /** The saved icon settings, or null while they load. Shares the menu icon on the way. */
39
+ export function useIconSettings(): IconSettings | null {
40
+ const settings = useSettings(linearSettings);
41
+ const icon = settings.status === "ready" ? settings.values.icon : null;
42
+ const menu = icon?.menu;
43
+ useEffect(() => {
44
+ if (menu) publishMenuIcon(menu);
45
+ }, [menu]);
46
+ return icon;
47
+ }
14
48
 
15
49
  export interface PluginIconProps {
16
50
  size: number;
@@ -31,13 +65,11 @@ export function IconGlyph(props: PluginIconProps & { icon: IconSettings | null }
31
65
  return markup ? { uri: svgDataUri(markup, color) } : null;
32
66
  }, [svg, color, paint, solid]);
33
67
  const style = useMemo(() => ({ width: size, height: size }), [size]);
34
- if (!source) return <Icon name={BUILT_IN_ICON} size={size} color={paint ?? color} />;
68
+ if (!source) return <Icon name={icon?.menu ?? BUILT_IN_ICON} size={size} color={paint ?? color} />;
35
69
  return <Image source={source} style={style} resizeMode="contain" accessibilityIgnoresInvertColors />;
36
70
  }
37
71
 
38
72
  /** The plugin icon from the saved settings. The built-in icon shows while they load. */
39
73
  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} />;
74
+ return <IconGlyph {...props} icon={useIconSettings()} />;
43
75
  }
@@ -6,7 +6,7 @@ import {
6
6
  SettingsSection,
7
7
  SettingsSwitch,
8
8
  } from "@getpaseo/plugin/client/ui";
9
- import { useMutation, useQuery } from "@tanstack/react-query";
9
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
10
10
  import { useCallback, useEffect, useMemo, useState } from "react";
11
11
  import { Text, View } from "react-native";
12
12
  import { initStartRpc, initStatusRpc } from "../shared/live";
@@ -17,6 +17,7 @@ import {
17
17
  proposalDiffs,
18
18
  } from "../shared/project-setup";
19
19
  import type { ProjectConfig } from "../shared/settings";
20
+ import { knowledgeKey } from "./project-knowledge";
20
21
  import { Button, errorMessage, type Theme } from "./ui";
21
22
 
22
23
  // Setup runs on the daemon. Remember the job per project so leaving the screen keeps it.
@@ -26,8 +27,10 @@ export function ProjectSetup(props: {
26
27
  theme: Theme;
27
28
  config: ProjectConfig;
28
29
  change(patch: Partial<ProjectConfig>): void;
30
+ /** Opens the setup agent while it runs; absent on hosts without navigation. */
31
+ openAgent?: (agentId: string) => void;
29
32
  }) {
30
- const { theme, config, change } = props;
33
+ const { theme, config, change, openAgent } = props;
31
34
  const start = useRpc(initStartRpc);
32
35
  const status = useRpc(initStatusRpc);
33
36
  const [jobId, setJobId] = useState(() => jobsByProject.get(config.projectId) ?? null);
@@ -47,6 +50,12 @@ export function ProjectSetup(props: {
47
50
  });
48
51
  const state = job.data?.job ?? null;
49
52
  const proposal = job.data?.proposal ?? null;
53
+ // The daemon saved the agent's area summaries; show them in Project knowledge.
54
+ const queries = useQueryClient();
55
+ const finished = state?.status === "done";
56
+ useEffect(() => {
57
+ if (finished) void queries.invalidateQueries({ queryKey: knowledgeKey(config.projectId) });
58
+ }, [finished, queries, config.projectId]);
50
59
  const diffs = useMemo(
51
60
  () => (proposal ? proposalDiffs(config, proposal) : []),
52
61
  [config, proposal],
@@ -76,12 +85,27 @@ export function ProjectSetup(props: {
76
85
  <SettingsCard>
77
86
  <SettingsAction
78
87
  label="Read this repository's guidelines"
79
- hint="An agent reads AGENTS.md, CONTRIBUTING, scripts, and CI, then proposes instructions and steps. It is told not to edit. You review each change before it is used."
88
+ hint="The daemon inspects the project first, and the agent starts from that project map. The agent reads AGENTS.md, CONTRIBUTING, scripts, and CI, then proposes instructions and steps, and describes each area for Project knowledge. It is told not to edit. You review each prompt change before it is used."
80
89
  actionLabel={running ? "Reading…" : proposal ? "Run again" : "Start"}
81
90
  disabled={running || !config.rootPath}
82
91
  onPress={() => begin.mutate()}
83
92
  />
84
- {running ? <SettingsRow label="The setup agent is reading the repository." /> : null}
93
+ {running ? (
94
+ <SettingsRow
95
+ label={state?.agentId ? "The setup agent is reading the repository." : "Inspecting the project…"}
96
+ >
97
+ {state?.agentId && openAgent ? (
98
+ <View style={ACTIONS_STYLE}>
99
+ <Button
100
+ theme={theme}
101
+ icon="Bot"
102
+ label="Open agent"
103
+ onPress={() => openAgent(state.agentId ?? "")}
104
+ />
105
+ </View>
106
+ ) : null}
107
+ </SettingsRow>
108
+ ) : null}
85
109
  {failure ? <SettingsRow label="Setup did not finish" error={failure} /> : null}
86
110
  {state?.status === "done" && diffs.length === 0 ? (
87
111
  <SettingsAction