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