@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/launch-plan.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RpcOutput } from "@getpaseo/plugin";
|
|
1
2
|
import { usePaseo, useRpc } from "@getpaseo/plugin/client";
|
|
2
3
|
import { useMutation } from "@tanstack/react-query";
|
|
3
4
|
import {
|
|
@@ -7,9 +8,11 @@ import {
|
|
|
7
8
|
updateIssueRpc,
|
|
8
9
|
type WorkflowState,
|
|
9
10
|
} from "../shared/linear";
|
|
11
|
+
import { agentHooksRpc } from "../shared/agent-hooks";
|
|
12
|
+
import { agentToolsRpc, TOOLS_SERVER } from "../shared/agent-tools";
|
|
10
13
|
import { launchAfterExploreRpc } from "../shared/live";
|
|
11
14
|
import { AGENT_LABELS, agentTitle } from "../shared/prompts";
|
|
12
|
-
import type { AgentAction, Isolation, LinearSettings } from "../shared/settings";
|
|
15
|
+
import type { AgentAction, Guidance, Isolation, LinearSettings } from "../shared/settings";
|
|
13
16
|
import { type AgentSelection, agentConfig } from "./agent-options";
|
|
14
17
|
import { rememberLaunch } from "./agent-preferences";
|
|
15
18
|
import type { PaseoProjectOption } from "./queries";
|
|
@@ -144,6 +147,25 @@ export interface LaunchRequest {
|
|
|
144
147
|
keyScope: string | null;
|
|
145
148
|
/** Maps the files with the read-only explore agent first, then starts the agent. */
|
|
146
149
|
explore: boolean;
|
|
150
|
+
/** Guidance options; for Claude some also add hooks. */
|
|
151
|
+
guidance: Guidance;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
type AgentTools = RpcOutput<typeof agentToolsRpc>;
|
|
155
|
+
|
|
156
|
+
/** The config with the plugin's MCP server, whose token only reaches this issue tree. */
|
|
157
|
+
export function withLinearTools<C extends object>(config: C, tools: Pick<AgentTools, "server" | "toolPolicy"> | null) {
|
|
158
|
+
if (!tools) return config;
|
|
159
|
+
return { ...config, mcpServers: { [TOOLS_SERVER]: tools.server }, toolPolicy: tools.toolPolicy };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The issue's guidance needs the MCP tools: Linear edits, or agents started on sub-issues. */
|
|
163
|
+
export const needsLinearTools = (guidance: Guidance) => guidance.updateLinear || guidance.paseoSubagents;
|
|
164
|
+
|
|
165
|
+
/** The config with the issue's Claude Code hooks, loaded as a plugin with --plugin-dir. */
|
|
166
|
+
export function withHookPlugin<C extends object>(config: C, pluginDir: string | null) {
|
|
167
|
+
if (!pluginDir) return config;
|
|
168
|
+
return { ...config, providerOptions: { extraArgs: { "plugin-dir": pluginDir } } };
|
|
147
169
|
}
|
|
148
170
|
|
|
149
171
|
export type Paseo = ReturnType<typeof usePaseo>;
|
|
@@ -217,6 +239,8 @@ export function useLaunchAgent() {
|
|
|
217
239
|
const updateIssue = useRpc(updateIssueRpc);
|
|
218
240
|
const attachCard = useRpc(attachIssueCardRpc);
|
|
219
241
|
const launchAfterExplore = useRpc(launchAfterExploreRpc);
|
|
242
|
+
const prepareHooks = useRpc(agentHooksRpc);
|
|
243
|
+
const prepareTools = useRpc(agentToolsRpc);
|
|
220
244
|
return useMutation({
|
|
221
245
|
mutationFn: async (request: LaunchRequest) => {
|
|
222
246
|
const { issue, action } = request;
|
|
@@ -230,14 +254,38 @@ export function useLaunchAgent() {
|
|
|
230
254
|
stateColor: state.color,
|
|
231
255
|
action,
|
|
232
256
|
};
|
|
257
|
+
// Hooks are a Claude Code feature; a failure to write them never blocks the launch.
|
|
258
|
+
const hooks =
|
|
259
|
+
request.agent.agent.id === "claude"
|
|
260
|
+
? await prepareHooks({
|
|
261
|
+
issue: {
|
|
262
|
+
identifier: issue.identifier,
|
|
263
|
+
title: issue.title,
|
|
264
|
+
children: issue.children.map(({ identifier, title }) => ({ identifier, title })),
|
|
265
|
+
},
|
|
266
|
+
guidance: request.guidance,
|
|
267
|
+
}).catch(() => null)
|
|
268
|
+
: null;
|
|
269
|
+
const config = agentConfig(request.agent);
|
|
270
|
+
// Without the tools the agent still starts; its prompt then names tools it does not have.
|
|
271
|
+
const tools = needsLinearTools(request.guidance)
|
|
272
|
+
? await prepareTools({
|
|
273
|
+
root: issue.identifier,
|
|
274
|
+
home: issue.identifier,
|
|
275
|
+
keyScope: request.keyScope,
|
|
276
|
+
workspaceId: workspace.id,
|
|
277
|
+
config,
|
|
278
|
+
}).catch(() => null)
|
|
279
|
+
: null;
|
|
233
280
|
const agent = {
|
|
234
|
-
config:
|
|
281
|
+
config: withLinearTools(withHookPlugin(config, hooks?.pluginDir ?? null), tools),
|
|
235
282
|
title: agentTitle(action, issue),
|
|
236
283
|
prompt: request.prompt,
|
|
237
284
|
labels: {
|
|
238
285
|
[AGENT_LABELS.issue]: issue.identifier,
|
|
239
286
|
[AGENT_LABELS.action]: action,
|
|
240
287
|
...(request.keyScope ? { [AGENT_LABELS.project]: request.keyScope } : {}),
|
|
288
|
+
...(tools ? { [AGENT_LABELS.tools]: tools.grantId } : {}),
|
|
241
289
|
},
|
|
242
290
|
};
|
|
243
291
|
// The daemon starts the agent once the map is ready, so agentId is null until then.
|
package/client/launch.tsx
CHANGED
|
@@ -15,6 +15,7 @@ import { modeIcon, optionLabel } from "./agent-options";
|
|
|
15
15
|
import { useKeyScope } from "./key-scope";
|
|
16
16
|
import { type LaunchChoices, useLaunchChoices } from "./launch-choices";
|
|
17
17
|
import { Chip, Field, Toggle } from "./launch-fields";
|
|
18
|
+
import { GuidanceToggles, useProjectGuidance } from "./launch-guidance";
|
|
18
19
|
import { linearPatch, type TargetWorkspace, useLaunchAgent } from "./launch-plan";
|
|
19
20
|
import { ModelBrowser } from "./model-browser";
|
|
20
21
|
import { PickerModal } from "./pickers";
|
|
@@ -157,6 +158,7 @@ function LaunchComposer(props: LaunchPageProps & { issue: IssueDetail; settings:
|
|
|
157
158
|
// Null until the user types. Until then the prompt follows the template and options.
|
|
158
159
|
const [customPrompt, setCustomPrompt] = useState<string | null>(null);
|
|
159
160
|
const { projectConfig } = choices;
|
|
161
|
+
const { guidance, change: changeGuidance } = useProjectGuidance(choices.project, projectConfig);
|
|
160
162
|
const generated = useMemo(
|
|
161
163
|
() =>
|
|
162
164
|
composePrompt({
|
|
@@ -166,8 +168,9 @@ function LaunchComposer(props: LaunchPageProps & { issue: IssueDetail; settings:
|
|
|
166
168
|
project: projectConfig,
|
|
167
169
|
includeComments,
|
|
168
170
|
extraInstructions: "",
|
|
171
|
+
guidance,
|
|
169
172
|
}),
|
|
170
|
-
[action, issue, settings, projectConfig, includeComments],
|
|
173
|
+
[action, issue, settings, projectConfig, includeComments, guidance],
|
|
171
174
|
);
|
|
172
175
|
const prompt = customPrompt ?? generated;
|
|
173
176
|
const styles = useMemo(() => {
|
|
@@ -271,6 +274,7 @@ function LaunchComposer(props: LaunchPageProps & { issue: IssueDetail; settings:
|
|
|
271
274
|
patch,
|
|
272
275
|
started,
|
|
273
276
|
keyScope,
|
|
277
|
+
guidance,
|
|
274
278
|
explore: settings.live.enabled && settings.live.mapping === "explore",
|
|
275
279
|
},
|
|
276
280
|
{
|
|
@@ -301,6 +305,7 @@ function LaunchComposer(props: LaunchPageProps & { issue: IssueDetail; settings:
|
|
|
301
305
|
issue,
|
|
302
306
|
prompt,
|
|
303
307
|
keyScope,
|
|
308
|
+
guidance,
|
|
304
309
|
queries,
|
|
305
310
|
toast,
|
|
306
311
|
onStarted,
|
|
@@ -443,6 +448,10 @@ function LaunchComposer(props: LaunchPageProps & { issue: IssueDetail; settings:
|
|
|
443
448
|
/>
|
|
444
449
|
) : null}
|
|
445
450
|
</View>
|
|
451
|
+
<View style={styles.group}>
|
|
452
|
+
<SectionLabel theme={theme}>Agent guidance</SectionLabel>
|
|
453
|
+
<GuidanceToggles theme={theme} guidance={guidance} onChange={changeGuidance} />
|
|
454
|
+
</View>
|
|
446
455
|
{launch.error ? (
|
|
447
456
|
<Text accessibilityRole="alert" style={styles.error}>
|
|
448
457
|
{errorMessage(launch.error)}
|
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}
|