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