@yannelli/paseo-linear-plugin 1.0.0 → 1.1.0-beta.0
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 +19 -1
- package/README.md +77 -6
- package/client/access.tsx +66 -0
- package/client/agent-options.ts +12 -2
- package/client/agent-preferences.ts +112 -0
- package/client/browser.tsx +12 -9
- package/client/graph-labels.ts +137 -0
- package/client/launch-choices.ts +18 -5
- package/client/launch-plan.ts +88 -39
- package/client/launch.tsx +9 -4
- package/client/live-agents.tsx +407 -0
- package/client/live-cursor.tsx +291 -0
- package/client/live-feed.tsx +151 -0
- package/client/live-graph.tsx +434 -0
- package/client/live-header.tsx +312 -0
- package/client/live-issues.tsx +345 -0
- package/client/live-map.tsx +345 -0
- package/client/live-panel.tsx +444 -0
- package/client/live-timeline.ts +341 -0
- package/client/pill.tsx +393 -0
- package/client/project-init.tsx +168 -0
- package/client/settings-access.tsx +56 -0
- package/client/settings-live.tsx +121 -0
- package/client/settings-projects.tsx +9 -0
- package/index.client.tsx +39 -1
- package/index.server.ts +24 -4
- package/package.json +1 -1
- package/server/agent-runs.ts +299 -0
- package/server/handlers.ts +12 -1
- package/server/init.ts +64 -0
- package/server/links.ts +153 -0
- package/server/live.ts +334 -0
- package/server/stores.ts +119 -0
- package/server/sync.ts +127 -0
- package/shared/activity.ts +308 -0
- package/shared/graph-geometry.ts +122 -0
- package/shared/graph-model.ts +319 -0
- package/shared/links.ts +252 -0
- package/shared/live.ts +200 -0
- package/shared/map-model.ts +192 -0
- package/shared/project-setup.ts +55 -0
- package/shared/prompts.ts +16 -1
- package/shared/settings.ts +57 -1
- package/shared/shell-activity.ts +309 -0
- package/shared/shell-lexer.ts +180 -0
- package/shared/subagents.ts +106 -0
- package/shared/todo-sync.ts +158 -0
package/client/launch-plan.ts
CHANGED
|
@@ -7,9 +7,11 @@ import {
|
|
|
7
7
|
updateIssueRpc,
|
|
8
8
|
type WorkflowState,
|
|
9
9
|
} from "../shared/linear";
|
|
10
|
+
import { launchAfterExploreRpc } from "../shared/live";
|
|
10
11
|
import { AGENT_LABELS, agentTitle } from "../shared/prompts";
|
|
11
12
|
import type { AgentAction, Isolation, LinearSettings } from "../shared/settings";
|
|
12
13
|
import { type AgentSelection, agentConfig } from "./agent-options";
|
|
14
|
+
import { rememberLaunch } from "./agent-preferences";
|
|
13
15
|
import type { PaseoProjectOption } from "./queries";
|
|
14
16
|
import { errorMessage } from "./ui";
|
|
15
17
|
|
|
@@ -91,10 +93,13 @@ export function placementOptions(input: {
|
|
|
91
93
|
return options;
|
|
92
94
|
}
|
|
93
95
|
|
|
94
|
-
// Reviews look at existing work: the pull request,
|
|
96
|
+
// Reviews look at existing work: the pull request, the implementing agent's workspace, then
|
|
97
|
+
// the issue branch. Checking out a branch nobody pushed yet starts a new worktree instead.
|
|
95
98
|
function preferredPlacement(action: AgentAction, isolation: Isolation): Placement[] {
|
|
96
|
-
|
|
97
|
-
|
|
99
|
+
const review = action === "review";
|
|
100
|
+
const existing: Placement[] = review ? ["pull-request", "agent-workspace"] : [];
|
|
101
|
+
const fresh: Placement[] = isolation === "worktree" ? [review ? "branch" : "worktree"] : [];
|
|
102
|
+
return [...existing, ...fresh, "workspace"];
|
|
98
103
|
}
|
|
99
104
|
|
|
100
105
|
export function resolvePlacement(
|
|
@@ -137,23 +142,36 @@ export interface LaunchRequest {
|
|
|
137
142
|
started: WorkflowState | null;
|
|
138
143
|
/** The Paseo project whose Linear key loaded the issue. */
|
|
139
144
|
keyScope: string | null;
|
|
145
|
+
/** Maps the files with the read-only explore agent first, then starts the agent. */
|
|
146
|
+
explore: boolean;
|
|
140
147
|
}
|
|
141
148
|
|
|
142
|
-
type Paseo = ReturnType<typeof usePaseo>;
|
|
149
|
+
export type Paseo = ReturnType<typeof usePaseo>;
|
|
143
150
|
|
|
144
|
-
|
|
151
|
+
// Linear suggests a branch name for every issue, even when nobody has pushed that branch.
|
|
152
|
+
// The plugin API passes only the daemon's message, so match its "Unknown branch" text.
|
|
153
|
+
function isUnknownBranch(error: unknown): boolean {
|
|
154
|
+
return errorMessage(error).includes("Unknown branch");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function createIssueWorktree(paseo: Paseo, request: LaunchRequest, title: string) {
|
|
158
|
+
const { issue, project } = request;
|
|
159
|
+
return paseo.workspaces.create({
|
|
160
|
+
title,
|
|
161
|
+
source: {
|
|
162
|
+
kind: "worktree",
|
|
163
|
+
cwd: project.rootPath,
|
|
164
|
+
projectId: project.projectId,
|
|
165
|
+
action: "branch-off",
|
|
166
|
+
branchName: issue.branchName,
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function openWorkspace(paseo: Paseo, request: LaunchRequest) {
|
|
145
172
|
const { issue, project } = request;
|
|
146
173
|
if (request.placement === "worktree") {
|
|
147
|
-
return paseo.
|
|
148
|
-
title: `${issue.identifier} ${issue.title}`,
|
|
149
|
-
source: {
|
|
150
|
-
kind: "worktree",
|
|
151
|
-
cwd: project.rootPath,
|
|
152
|
-
projectId: project.projectId,
|
|
153
|
-
action: "branch-off",
|
|
154
|
-
branchName: issue.branchName,
|
|
155
|
-
},
|
|
156
|
-
});
|
|
174
|
+
return createIssueWorktree(paseo, request, `${issue.identifier} ${issue.title}`);
|
|
157
175
|
}
|
|
158
176
|
if (request.placement === "pull-request" && request.prNumber !== null) {
|
|
159
177
|
return paseo.workspaces.create({
|
|
@@ -168,16 +186,22 @@ async function openWorkspace(paseo: Paseo, request: LaunchRequest) {
|
|
|
168
186
|
});
|
|
169
187
|
}
|
|
170
188
|
if (request.placement === "branch") {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
189
|
+
const title = `Review ${issue.identifier}`;
|
|
190
|
+
try {
|
|
191
|
+
return await paseo.workspaces.create({
|
|
192
|
+
title,
|
|
193
|
+
source: {
|
|
194
|
+
kind: "worktree",
|
|
195
|
+
cwd: project.rootPath,
|
|
196
|
+
projectId: project.projectId,
|
|
197
|
+
action: "checkout",
|
|
198
|
+
refName: issue.branchName,
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (!isUnknownBranch(error)) throw error;
|
|
203
|
+
return createIssueWorktree(paseo, request, title);
|
|
204
|
+
}
|
|
181
205
|
}
|
|
182
206
|
if (request.placement === "agent-workspace" && request.agentWorkspace) {
|
|
183
207
|
return paseo.workspaces.ref(request.agentWorkspace.id);
|
|
@@ -192,17 +216,52 @@ export function useLaunchAgent() {
|
|
|
192
216
|
const paseo = usePaseo();
|
|
193
217
|
const updateIssue = useRpc(updateIssueRpc);
|
|
194
218
|
const attachCard = useRpc(attachIssueCardRpc);
|
|
219
|
+
const launchAfterExplore = useRpc(launchAfterExploreRpc);
|
|
195
220
|
return useMutation({
|
|
196
221
|
mutationFn: async (request: LaunchRequest) => {
|
|
197
222
|
const { issue, action } = request;
|
|
198
223
|
const workspace = await openWorkspace(paseo, request);
|
|
199
|
-
const
|
|
224
|
+
const state = request.patch.stateId && request.started ? request.started : issue.state;
|
|
225
|
+
const card = {
|
|
226
|
+
identifier: issue.identifier,
|
|
227
|
+
title: issue.title,
|
|
228
|
+
url: issue.url,
|
|
229
|
+
stateName: state.name,
|
|
230
|
+
stateColor: state.color,
|
|
231
|
+
action,
|
|
232
|
+
};
|
|
233
|
+
const agent = {
|
|
200
234
|
config: agentConfig(request.agent),
|
|
201
235
|
title: agentTitle(action, issue),
|
|
202
236
|
prompt: request.prompt,
|
|
203
|
-
labels: {
|
|
204
|
-
|
|
237
|
+
labels: {
|
|
238
|
+
[AGENT_LABELS.issue]: issue.identifier,
|
|
239
|
+
[AGENT_LABELS.action]: action,
|
|
240
|
+
...(request.keyScope ? { [AGENT_LABELS.project]: request.keyScope } : {}),
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
// The daemon starts the agent once the map is ready, so agentId is null until then.
|
|
244
|
+
let agentId: string | null;
|
|
245
|
+
if (request.explore && action === "implement") {
|
|
246
|
+
const launched = await launchAfterExplore({
|
|
247
|
+
workspaceId: workspace.id,
|
|
248
|
+
identifier: issue.identifier,
|
|
249
|
+
keyScope: request.keyScope,
|
|
250
|
+
agent,
|
|
251
|
+
card,
|
|
252
|
+
});
|
|
253
|
+
agentId = launched.agentId;
|
|
254
|
+
} else {
|
|
255
|
+
agentId = (await workspace.agents.create(agent)).id;
|
|
256
|
+
void attachCard({ agentId, card }).catch(() => undefined);
|
|
257
|
+
}
|
|
205
258
|
lastProjectByTeam.set(issue.team.id, request.project.projectId);
|
|
259
|
+
rememberLaunch({
|
|
260
|
+
agent: request.agent.agent.id,
|
|
261
|
+
model: request.agent.model.id,
|
|
262
|
+
effort: request.agent.effort?.id ?? null,
|
|
263
|
+
mode: request.agent.mode?.id ?? null,
|
|
264
|
+
});
|
|
206
265
|
let warning: string | null = null;
|
|
207
266
|
if (Object.keys(request.patch).length > 0) {
|
|
208
267
|
try {
|
|
@@ -211,17 +270,7 @@ export function useLaunchAgent() {
|
|
|
211
270
|
warning = errorMessage(error);
|
|
212
271
|
}
|
|
213
272
|
}
|
|
214
|
-
|
|
215
|
-
const card = {
|
|
216
|
-
identifier: issue.identifier,
|
|
217
|
-
title: issue.title,
|
|
218
|
-
url: issue.url,
|
|
219
|
-
stateName: state.name,
|
|
220
|
-
stateColor: state.color,
|
|
221
|
-
action,
|
|
222
|
-
};
|
|
223
|
-
void attachCard({ agentId: agent.id, card }).catch(() => undefined);
|
|
224
|
-
return { agentId: agent.id, warning };
|
|
273
|
+
return { agentId, workspaceId: workspace.id, warning };
|
|
225
274
|
},
|
|
226
275
|
});
|
|
227
276
|
}
|
package/client/launch.tsx
CHANGED
|
@@ -271,15 +271,20 @@ function LaunchComposer(props: LaunchPageProps & { issue: IssueDetail; settings:
|
|
|
271
271
|
patch,
|
|
272
272
|
started,
|
|
273
273
|
keyScope,
|
|
274
|
+
explore: settings.live.enabled && settings.live.mapping === "explore",
|
|
274
275
|
},
|
|
275
276
|
{
|
|
276
|
-
onSuccess: ({ agentId, warning }) => {
|
|
277
|
+
onSuccess: ({ agentId, workspaceId, warning }) => {
|
|
277
278
|
void queries.invalidateQueries({ queryKey: ["linear"] });
|
|
279
|
+
const started = agentId
|
|
280
|
+
? "Agent started."
|
|
281
|
+
: "Mapping files first. The agent starts when the map is ready.";
|
|
278
282
|
if (warning) {
|
|
279
|
-
toast.show(
|
|
280
|
-
} else toast.show(
|
|
283
|
+
toast.show(`${started} Linear was not updated: ${warning}`, { variant: "warning" });
|
|
284
|
+
} else toast.show(started, { variant: "success" });
|
|
281
285
|
onStarted();
|
|
282
|
-
navigation?.openAgent({ agentId });
|
|
286
|
+
if (agentId) navigation?.openAgent({ agentId });
|
|
287
|
+
else navigation?.openWorkspace({ workspaceId });
|
|
283
288
|
},
|
|
284
289
|
},
|
|
285
290
|
);
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import type { PluginAgentSnapshot } from "@getpaseo/plugin";
|
|
2
|
+
import { usePaseo } from "@getpaseo/plugin/client";
|
|
3
|
+
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
4
|
+
import { useQuery } from "@tanstack/react-query";
|
|
5
|
+
import { useCallback, useMemo } from "react";
|
|
6
|
+
import { Pressable, StyleSheet, Text, View } from "react-native";
|
|
7
|
+
import {
|
|
8
|
+
type Activity,
|
|
9
|
+
type ActivityEvent,
|
|
10
|
+
type AgentFocus,
|
|
11
|
+
type DirTouch,
|
|
12
|
+
deriveActivity,
|
|
13
|
+
type FileTouch,
|
|
14
|
+
} from "../shared/activity";
|
|
15
|
+
import type { SubagentRun } from "../shared/subagents";
|
|
16
|
+
import { MONO } from "./live-issues";
|
|
17
|
+
import { useAgentTimelines } from "./live-timeline";
|
|
18
|
+
import { ProviderIcon } from "./provider-icon";
|
|
19
|
+
import { type Theme, usePressableStyle } from "./ui";
|
|
20
|
+
|
|
21
|
+
// Agents working on the issue besides the one the panel belongs to: Paseo child agents, which
|
|
22
|
+
// have their own timelines, and provider subagents, which only report what they were asked.
|
|
23
|
+
|
|
24
|
+
const CHILD_LABEL = "paseo.parent-agent-id";
|
|
25
|
+
const MAX_CHILDREN = 4;
|
|
26
|
+
const CHILD_COLORS = ["#e879f9", "#38bdf8", "#facc15", "#fb7185"];
|
|
27
|
+
const POLL_MS = 10_000;
|
|
28
|
+
|
|
29
|
+
export type AgentStatus = PluginAgentSnapshot["status"];
|
|
30
|
+
|
|
31
|
+
export interface ChildAgent {
|
|
32
|
+
id: string;
|
|
33
|
+
title: string;
|
|
34
|
+
provider: string;
|
|
35
|
+
status: AgentStatus;
|
|
36
|
+
cwd: string;
|
|
37
|
+
color: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ChildView {
|
|
41
|
+
agent: ChildAgent;
|
|
42
|
+
activity: Activity | null;
|
|
43
|
+
/** Works in the parent's folder, so its files can go on the same map. */
|
|
44
|
+
sameFolder: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface AgentMarker {
|
|
48
|
+
key: string;
|
|
49
|
+
kind: "main" | "child" | "subagent";
|
|
50
|
+
color: string;
|
|
51
|
+
provider: string | null;
|
|
52
|
+
label: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Child agents of an agent, newest running ones first. Polls only while the parent works. */
|
|
56
|
+
export function useChildAgents(agentId: string, polling: boolean): ChildAgent[] {
|
|
57
|
+
const paseo = usePaseo();
|
|
58
|
+
const query = useQuery({
|
|
59
|
+
queryKey: ["linear", "live", "children", agentId],
|
|
60
|
+
queryFn: async () => {
|
|
61
|
+
const result = await paseo.agents.list({
|
|
62
|
+
filter: { labels: { [CHILD_LABEL]: agentId }, includeArchived: true },
|
|
63
|
+
page: { limit: 20 },
|
|
64
|
+
});
|
|
65
|
+
return result.entries.map((entry) => entry.agent);
|
|
66
|
+
},
|
|
67
|
+
refetchInterval: polling ? POLL_MS : false,
|
|
68
|
+
staleTime: 5_000,
|
|
69
|
+
});
|
|
70
|
+
return useMemo(() => {
|
|
71
|
+
const agents = [...(query.data ?? [])].sort((a, b) => {
|
|
72
|
+
const running = Number(b.status === "running") - Number(a.status === "running");
|
|
73
|
+
return running || String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? ""));
|
|
74
|
+
});
|
|
75
|
+
return agents.slice(0, MAX_CHILDREN).map((agent, index) => ({
|
|
76
|
+
id: agent.id,
|
|
77
|
+
title: agent.title ?? "Child agent",
|
|
78
|
+
provider: agent.provider,
|
|
79
|
+
status: agent.status as AgentStatus,
|
|
80
|
+
cwd: agent.cwd,
|
|
81
|
+
color: CHILD_COLORS[index % CHILD_COLORS.length] ?? "#38bdf8",
|
|
82
|
+
}));
|
|
83
|
+
}, [query.data]);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function sameFolder(a: string, b: string): boolean {
|
|
87
|
+
return a.replace(/\/+$/, "") === b.replace(/\/+$/, "");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function add(markers: Map<string, AgentMarker[]>, paths: readonly string[], marker: AgentMarker) {
|
|
91
|
+
for (const path of paths) {
|
|
92
|
+
if (path.endsWith("/")) continue;
|
|
93
|
+
const list = markers.get(path) ?? [];
|
|
94
|
+
if (!list.some((entry) => entry.key === marker.key)) markers.set(path, [...list, marker]);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Which agents are on which file: where the main and child agents work, and subagent targets. */
|
|
99
|
+
export function agentMarkers(input: {
|
|
100
|
+
theme: Theme;
|
|
101
|
+
provider: string;
|
|
102
|
+
working: boolean;
|
|
103
|
+
current: ActivityEvent | null;
|
|
104
|
+
children: readonly ChildView[];
|
|
105
|
+
subagents: readonly SubagentRun[];
|
|
106
|
+
}): ReadonlyMap<string, AgentMarker[]> {
|
|
107
|
+
const { colors } = input.theme;
|
|
108
|
+
const markers = new Map<string, AgentMarker[]>();
|
|
109
|
+
if (input.working && input.current) {
|
|
110
|
+
const main = { key: "main", kind: "main", color: colors.accent, provider: input.provider, label: "Agent" } as const;
|
|
111
|
+
add(markers, input.current.paths, main);
|
|
112
|
+
}
|
|
113
|
+
for (const child of input.children) {
|
|
114
|
+
const current = child.activity?.current;
|
|
115
|
+
if (child.agent.status !== "running" || !child.sameFolder || !current) continue;
|
|
116
|
+
add(markers, current.paths, {
|
|
117
|
+
key: child.agent.id,
|
|
118
|
+
kind: "child",
|
|
119
|
+
color: child.agent.color,
|
|
120
|
+
provider: child.agent.provider,
|
|
121
|
+
label: child.agent.title,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
for (const run of input.subagents) {
|
|
125
|
+
if (run.status !== "running") continue;
|
|
126
|
+
add(markers, run.targets, {
|
|
127
|
+
key: run.key,
|
|
128
|
+
kind: "subagent",
|
|
129
|
+
color: colors.foregroundMuted,
|
|
130
|
+
provider: null,
|
|
131
|
+
label: run.description || run.type,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return markers;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function subagentStatus(run: SubagentRun, parentRunning: boolean): string {
|
|
138
|
+
if (run.status === "completed") return "Done";
|
|
139
|
+
if (run.status === "failed") return "Failed";
|
|
140
|
+
if (run.status === "canceled") return "Canceled";
|
|
141
|
+
return parentRunning ? "Running" : "Started, no report since";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function names(paths: readonly string[]): string {
|
|
145
|
+
const shown = paths.slice(0, 2).map((path) => path.replace(/\/$/, "").split("/").pop() ?? path);
|
|
146
|
+
return `${shown.join(", ")}${paths.length > 2 ? ` +${paths.length - 2}` : ""}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
interface RowProps {
|
|
150
|
+
theme: Theme;
|
|
151
|
+
color: string;
|
|
152
|
+
icon: "provider" | "bot";
|
|
153
|
+
provider: string | null;
|
|
154
|
+
title: string;
|
|
155
|
+
status: string;
|
|
156
|
+
detail: string | null;
|
|
157
|
+
live: boolean;
|
|
158
|
+
onPress?: () => void;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function AgentRow(props: RowProps) {
|
|
162
|
+
const { theme } = props;
|
|
163
|
+
const { colors } = theme;
|
|
164
|
+
const styles = useMemo(() => {
|
|
165
|
+
const idle = { flexDirection: "row", gap: 10, padding: 8, borderRadius: 8 } as const;
|
|
166
|
+
return {
|
|
167
|
+
idle,
|
|
168
|
+
hover: { ...idle, backgroundColor: colors.surface1 },
|
|
169
|
+
badge: {
|
|
170
|
+
width: 24,
|
|
171
|
+
height: 24,
|
|
172
|
+
borderRadius: 7,
|
|
173
|
+
alignItems: "center",
|
|
174
|
+
justifyContent: "center",
|
|
175
|
+
backgroundColor: colors.surface2,
|
|
176
|
+
borderWidth: 1,
|
|
177
|
+
borderColor: props.icon === "bot" ? colors.border : props.color,
|
|
178
|
+
},
|
|
179
|
+
tint: { ...StyleSheet.absoluteFillObject, borderRadius: 6, backgroundColor: props.color, opacity: 0.18 },
|
|
180
|
+
body: { flex: 1, minWidth: 0, gap: 2 },
|
|
181
|
+
line: { flexDirection: "row", alignItems: "center", gap: 6 },
|
|
182
|
+
title: { flex: 1, color: colors.foreground, fontSize: 13, fontWeight: "500" },
|
|
183
|
+
status: { color: props.live ? colors.statusSuccess : colors.foregroundMuted, fontSize: 11 },
|
|
184
|
+
detail: { color: colors.foregroundMuted, fontSize: 11, fontFamily: MONO },
|
|
185
|
+
} as const;
|
|
186
|
+
}, [colors, props.color, props.icon, props.live]);
|
|
187
|
+
const pressStyle = usePressableStyle(styles.idle, styles.hover);
|
|
188
|
+
const content = (
|
|
189
|
+
<>
|
|
190
|
+
<View style={styles.badge}>
|
|
191
|
+
{props.icon === "bot" ? null : <View style={styles.tint} />}
|
|
192
|
+
{props.icon === "provider" && props.provider ? (
|
|
193
|
+
<ProviderIcon provider={props.provider} size={12} color={colors.foreground} />
|
|
194
|
+
) : (
|
|
195
|
+
<Icon name="Bot" size={12} color={colors.foregroundMuted} />
|
|
196
|
+
)}
|
|
197
|
+
</View>
|
|
198
|
+
<View style={styles.body}>
|
|
199
|
+
<View style={styles.line}>
|
|
200
|
+
<Text numberOfLines={1} style={styles.title}>
|
|
201
|
+
{props.title}
|
|
202
|
+
</Text>
|
|
203
|
+
<Text style={styles.status}>{props.status}</Text>
|
|
204
|
+
</View>
|
|
205
|
+
{props.detail ? (
|
|
206
|
+
<Text numberOfLines={1} style={styles.detail}>
|
|
207
|
+
{props.detail}
|
|
208
|
+
</Text>
|
|
209
|
+
) : null}
|
|
210
|
+
</View>
|
|
211
|
+
</>
|
|
212
|
+
);
|
|
213
|
+
if (!props.onPress) return <View style={styles.idle}>{content}</View>;
|
|
214
|
+
return (
|
|
215
|
+
<Pressable accessibilityRole="button" accessibilityLabel={`Open ${props.title}`} onPress={props.onPress} style={pressStyle}>
|
|
216
|
+
{content}
|
|
217
|
+
</Pressable>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const STATUS_COPY: Record<AgentStatus, string> = {
|
|
222
|
+
initializing: "Starting",
|
|
223
|
+
idle: "Idle",
|
|
224
|
+
running: "Working",
|
|
225
|
+
error: "Error",
|
|
226
|
+
closed: "Closed",
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
export function AgentsList(props: {
|
|
230
|
+
theme: Theme;
|
|
231
|
+
provider: string;
|
|
232
|
+
status: AgentStatus;
|
|
233
|
+
current: ActivityEvent | null;
|
|
234
|
+
childAgents: readonly ChildView[];
|
|
235
|
+
subagents: readonly SubagentRun[];
|
|
236
|
+
onOpenAgent?: (agentId: string) => void;
|
|
237
|
+
}) {
|
|
238
|
+
const { theme, onOpenAgent } = props;
|
|
239
|
+
const parentRunning = props.status === "running";
|
|
240
|
+
const open = useCallback((id: string) => () => onOpenAgent?.(id), [onOpenAgent]);
|
|
241
|
+
return (
|
|
242
|
+
<View>
|
|
243
|
+
<AgentRow
|
|
244
|
+
theme={theme}
|
|
245
|
+
color={theme.colors.accent}
|
|
246
|
+
icon="provider"
|
|
247
|
+
provider={props.provider}
|
|
248
|
+
title={props.provider ? props.provider.charAt(0).toUpperCase() + props.provider.slice(1) : "Agent"}
|
|
249
|
+
status={STATUS_COPY[props.status] ?? props.status}
|
|
250
|
+
detail={props.current?.text ?? null}
|
|
251
|
+
live={parentRunning}
|
|
252
|
+
/>
|
|
253
|
+
{props.childAgents.map(({ agent, activity, sameFolder: shared }) => (
|
|
254
|
+
<AgentRow
|
|
255
|
+
key={agent.id}
|
|
256
|
+
theme={theme}
|
|
257
|
+
color={agent.color}
|
|
258
|
+
icon="provider"
|
|
259
|
+
provider={agent.provider}
|
|
260
|
+
title={agent.title}
|
|
261
|
+
status={STATUS_COPY[agent.status] ?? agent.status}
|
|
262
|
+
detail={shared ? (activity?.current?.text ?? null) : `In ${agent.cwd.split("/").pop() ?? agent.cwd}`}
|
|
263
|
+
live={agent.status === "running"}
|
|
264
|
+
onPress={onOpenAgent ? open(agent.id) : undefined}
|
|
265
|
+
/>
|
|
266
|
+
))}
|
|
267
|
+
{props.subagents.map((run) => (
|
|
268
|
+
<AgentRow
|
|
269
|
+
key={run.key}
|
|
270
|
+
theme={theme}
|
|
271
|
+
color={theme.colors.foregroundMuted}
|
|
272
|
+
icon="bot"
|
|
273
|
+
provider={null}
|
|
274
|
+
title={run.description ? `${run.type}: ${run.description}` : run.type}
|
|
275
|
+
status={subagentStatus(run, parentRunning)}
|
|
276
|
+
detail={run.targets.length > 0 ? `Asked about ${names(run.targets)}` : null}
|
|
277
|
+
live={run.status === "running" && parentRunning}
|
|
278
|
+
/>
|
|
279
|
+
))}
|
|
280
|
+
</View>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Files the main agent and its same-folder child agents touched, counted together. */
|
|
285
|
+
function mergeTouches(own: readonly FileTouch[], children: readonly ChildView[]): FileTouch[] {
|
|
286
|
+
const merged = new Map(own.map((file) => [file.path, { ...file }]));
|
|
287
|
+
for (const child of children) {
|
|
288
|
+
if (!child.sameFolder) continue;
|
|
289
|
+
for (const file of child.activity?.files ?? []) {
|
|
290
|
+
const known = merged.get(file.path);
|
|
291
|
+
if (!known) {
|
|
292
|
+
merged.set(file.path, { ...file });
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
known.reads += file.reads;
|
|
296
|
+
known.edits += file.edits;
|
|
297
|
+
known.added += file.added;
|
|
298
|
+
known.removed += file.removed;
|
|
299
|
+
known.created ||= file.created;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return [...merged.values()];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Child agents with their activity, every agent's place on the map, and all touched files. */
|
|
306
|
+
export function useLiveAgents(input: {
|
|
307
|
+
theme: Theme;
|
|
308
|
+
agentId: string;
|
|
309
|
+
cwd: string;
|
|
310
|
+
status: AgentStatus;
|
|
311
|
+
provider: string;
|
|
312
|
+
activity: Activity;
|
|
313
|
+
}) {
|
|
314
|
+
const { theme, agentId, cwd, status, provider, activity } = input;
|
|
315
|
+
const children = useChildAgents(agentId, status === "running");
|
|
316
|
+
const ids = useMemo(() => children.map((child) => child.id), [children]);
|
|
317
|
+
const timelines = useAgentTimelines(ids);
|
|
318
|
+
const views = useMemo(
|
|
319
|
+
() =>
|
|
320
|
+
children.map((agent): ChildView => {
|
|
321
|
+
const timeline = timelines.get(agent.id);
|
|
322
|
+
return {
|
|
323
|
+
agent,
|
|
324
|
+
activity: timeline ? deriveActivity(timeline.items, agent.cwd) : null,
|
|
325
|
+
sameFolder: sameFolder(agent.cwd, cwd),
|
|
326
|
+
};
|
|
327
|
+
}),
|
|
328
|
+
[children, timelines, cwd],
|
|
329
|
+
);
|
|
330
|
+
const markers = useMemo(
|
|
331
|
+
() =>
|
|
332
|
+
agentMarkers({
|
|
333
|
+
theme,
|
|
334
|
+
provider,
|
|
335
|
+
working: status === "running",
|
|
336
|
+
current: activity.current,
|
|
337
|
+
children: views,
|
|
338
|
+
subagents: activity.subagents,
|
|
339
|
+
}),
|
|
340
|
+
[theme, provider, status, activity.current, activity.subagents, views],
|
|
341
|
+
);
|
|
342
|
+
const files = useMemo(() => mergeTouches(activity.files, views), [activity.files, views]);
|
|
343
|
+
const dirs = useMemo(
|
|
344
|
+
(): DirTouch[] => [
|
|
345
|
+
...activity.dirs,
|
|
346
|
+
...views.flatMap((view) => (view.sameFolder ? (view.activity?.dirs ?? []) : [])),
|
|
347
|
+
],
|
|
348
|
+
[activity.dirs, views],
|
|
349
|
+
);
|
|
350
|
+
return { children: views, markers, files, dirs };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** An agent that moves over the graph to what it works on. */
|
|
354
|
+
export interface CursorAgent {
|
|
355
|
+
id: string;
|
|
356
|
+
kind: "main" | "child";
|
|
357
|
+
label: string;
|
|
358
|
+
color: string;
|
|
359
|
+
provider: string | null;
|
|
360
|
+
focus: AgentFocus | null;
|
|
361
|
+
running: boolean;
|
|
362
|
+
/** What it does now, such as "Read page.tsx". */
|
|
363
|
+
caption: string | null;
|
|
364
|
+
/** Subagents running inside it. */
|
|
365
|
+
satellites: number;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function cursorAgents(input: {
|
|
369
|
+
theme: Theme;
|
|
370
|
+
provider: string;
|
|
371
|
+
working: boolean;
|
|
372
|
+
activity: Activity;
|
|
373
|
+
children: readonly ChildView[];
|
|
374
|
+
}): CursorAgent[] {
|
|
375
|
+
const { activity } = input;
|
|
376
|
+
const name = input.provider ? input.provider.charAt(0).toUpperCase() + input.provider.slice(1) : "Agent";
|
|
377
|
+
const agents: CursorAgent[] = [
|
|
378
|
+
{
|
|
379
|
+
id: "main",
|
|
380
|
+
kind: "main",
|
|
381
|
+
label: name,
|
|
382
|
+
color: input.theme.colors.accent,
|
|
383
|
+
provider: input.provider,
|
|
384
|
+
focus: activity.focus,
|
|
385
|
+
running: input.working,
|
|
386
|
+
caption: input.working ? (activity.current?.text ?? null) : null,
|
|
387
|
+
satellites: input.working ? activity.subagents.filter((run) => run.status === "running").length : 0,
|
|
388
|
+
},
|
|
389
|
+
];
|
|
390
|
+
for (const child of input.children) {
|
|
391
|
+
// Paths are relative to each agent's folder, so only agents in this folder fit the graph.
|
|
392
|
+
if (!child.sameFolder || !child.activity) continue;
|
|
393
|
+
const running = child.agent.status === "running";
|
|
394
|
+
agents.push({
|
|
395
|
+
id: child.agent.id,
|
|
396
|
+
kind: "child",
|
|
397
|
+
label: child.agent.title,
|
|
398
|
+
color: child.agent.color,
|
|
399
|
+
provider: null,
|
|
400
|
+
focus: child.activity.focus,
|
|
401
|
+
running,
|
|
402
|
+
caption: running ? (child.activity.current?.text ?? null) : null,
|
|
403
|
+
satellites: 0,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
return agents;
|
|
407
|
+
}
|