@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.
- package/OVERVIEW.md +8 -4
- package/README.md +24 -9
- package/client/graph-camera.tsx +28 -8
- package/client/graph-labels.ts +7 -4
- package/client/live-cursor.tsx +76 -49
- package/client/live-graph.tsx +59 -29
- package/client/live-header.tsx +26 -2
- package/client/live-map.tsx +7 -1
- package/client/live-panel.tsx +9 -2
- package/client/live-timeline.ts +36 -5
- package/client/plugin-icon.tsx +41 -9
- package/client/project-init.tsx +28 -4
- package/client/project-knowledge.tsx +87 -0
- package/client/settings-icon.tsx +57 -8
- package/client/settings-projects.tsx +16 -5
- package/client/web.ts +32 -0
- package/index.client.tsx +134 -79
- package/index.server.ts +5 -2
- package/package.json +1 -1
- package/server/init.ts +31 -7
- package/server/inspect.ts +392 -0
- package/server/knowledge.ts +157 -0
- package/server/live.ts +21 -3
- package/server/stores.ts +1 -1
- package/shared/custom-icon.ts +40 -0
- package/shared/graph-camera.ts +28 -8
- package/shared/graph-geometry.ts +30 -19
- package/shared/knowledge.ts +255 -0
- package/shared/live.ts +19 -3
- package/shared/map-model.ts +50 -2
- package/shared/project-setup.ts +3 -2
package/client/live-graph.tsx
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
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
|
|
72
|
-
transform: [{ rotate: `${angle}rad` }, { scaleY: props.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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}` :
|
|
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):
|
|
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
|
|
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)
|
|
349
|
+
return [{ at, caption: captionWidth(text) }];
|
|
345
350
|
}),
|
|
346
|
-
[targets, hovers
|
|
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,
|
|
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
|
-
|
|
369
|
-
const toView = (at: Point) =>
|
|
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)
|
|
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((
|
|
376
|
-
|
|
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
|
|
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)}
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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>
|
package/client/live-header.tsx
CHANGED
|
@@ -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
|
|
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
|
);
|
package/client/live-map.tsx
CHANGED
|
@@ -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: {
|
|
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}>
|
package/client/live-panel.tsx
CHANGED
|
@@ -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}
|
package/client/live-timeline.ts
CHANGED
|
@@ -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
|
|
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
|
|
package/client/plugin-icon.tsx
CHANGED
|
@@ -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.
|
|
11
|
-
// images from plugins,
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
const icon = settings.status === "ready" ? settings.values.icon : null;
|
|
42
|
-
return <IconGlyph {...props} icon={icon} />;
|
|
74
|
+
return <IconGlyph {...props} icon={useIconSettings()} />;
|
|
43
75
|
}
|
package/client/project-init.tsx
CHANGED
|
@@ -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="
|
|
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 ?
|
|
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
|