@yannelli/paseo-linear-plugin 1.0.0 → 1.1.0-alpha.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 +32 -2
- package/README.md +138 -11
- package/client/access.tsx +67 -0
- package/client/agent-options.ts +12 -2
- package/client/agent-preferences.ts +112 -0
- package/client/browser.tsx +12 -9
- package/client/compat.ts +23 -5
- package/client/connect.tsx +3 -2
- package/client/launch-assignments.tsx +182 -0
- package/client/launch-choices.ts +23 -6
- package/client/launch-guidance.tsx +103 -0
- package/client/launch-plan.ts +166 -42
- package/client/launch.tsx +55 -9
- package/client/live-agents.tsx +446 -0
- package/client/live-feed.tsx +151 -0
- package/client/live-header.tsx +310 -0
- package/client/live-issues.tsx +345 -0
- package/client/live-map.tsx +351 -0
- package/client/live-panel.tsx +392 -0
- package/client/live-timeline.ts +379 -0
- package/client/pill.tsx +394 -0
- package/client/plugin-icon.tsx +75 -0
- package/client/project-init.tsx +192 -0
- package/client/project-knowledge.tsx +87 -0
- package/client/settings-access.tsx +56 -0
- package/client/settings-agents.tsx +113 -0
- package/client/settings-fields.tsx +11 -3
- package/client/settings-icon.tsx +277 -0
- package/client/settings-live.tsx +128 -0
- package/client/settings-overrides.tsx +148 -0
- package/client/settings-projects.tsx +26 -4
- package/client/settings-prompts.tsx +3 -3
- package/client/timeline-card.tsx +2 -1
- package/client/tool-buttons.tsx +68 -0
- package/client/ui.tsx +16 -7
- package/client/web.ts +81 -0
- package/index.client.tsx +160 -51
- package/index.server.ts +32 -5
- package/package.json +1 -1
- package/server/agent-hooks.ts +37 -0
- package/server/agent-runs.ts +312 -0
- package/server/agent-tools.ts +276 -0
- package/server/handlers.ts +12 -1
- package/server/init.ts +89 -0
- package/server/inspect.ts +392 -0
- package/server/knowledge.ts +157 -0
- package/server/live.ts +371 -0
- package/server/mcp-http.ts +156 -0
- package/server/stores.ts +126 -0
- package/server/subagent-logs.ts +146 -0
- package/server/sync.ts +133 -0
- package/shared/activity.ts +268 -0
- package/shared/agent-hooks.ts +68 -0
- package/shared/agent-tools.ts +157 -0
- package/shared/custom-icon.ts +188 -0
- package/shared/knowledge.ts +255 -0
- package/shared/live.ts +248 -0
- package/shared/map-model.ts +240 -0
- package/shared/project-setup.ts +56 -0
- package/shared/prompts.ts +109 -2
- package/shared/settings.ts +145 -3
- 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.tsx
CHANGED
|
@@ -13,9 +13,11 @@ import {
|
|
|
13
13
|
} from "../shared/settings";
|
|
14
14
|
import { modeIcon, optionLabel } from "./agent-options";
|
|
15
15
|
import { useKeyScope } from "./key-scope";
|
|
16
|
+
import { promptAssignments, SubIssueAgents, useSubIssueAgents } from "./launch-assignments";
|
|
16
17
|
import { type LaunchChoices, useLaunchChoices } from "./launch-choices";
|
|
17
18
|
import { Chip, Field, Toggle } from "./launch-fields";
|
|
18
|
-
import {
|
|
19
|
+
import { GuidanceToggles, useProjectGuidance } from "./launch-guidance";
|
|
20
|
+
import { linearPatch, type SubIssueAgent, type TargetWorkspace, useLaunchAgent } from "./launch-plan";
|
|
19
21
|
import { ModelBrowser } from "./model-browser";
|
|
20
22
|
import { PickerModal } from "./pickers";
|
|
21
23
|
import { ProviderIcon } from "./provider-icon";
|
|
@@ -42,6 +44,7 @@ type PickerKind = "prompt" | "model" | "effort" | "mode" | "project" | "placemen
|
|
|
42
44
|
type ListPickerKind = Exclude<PickerKind, "model">;
|
|
43
45
|
|
|
44
46
|
const SCROLL_STYLE = { flex: 1 } as const;
|
|
47
|
+
const NO_ASSIGNMENTS: readonly SubIssueAgent[] = [];
|
|
45
48
|
const PROMPT_CHOICES = AGENT_ACTIONS.map((action) => ({
|
|
46
49
|
value: action,
|
|
47
50
|
label: ACTION_LABELS[action].title,
|
|
@@ -150,13 +153,23 @@ function LaunchComposer(props: LaunchPageProps & { issue: IssueDetail; settings:
|
|
|
150
153
|
?.states.find((state) => state.type === "started") ?? null;
|
|
151
154
|
const canStart = action === "implement" && !["started", "completed"].includes(issue.state.type);
|
|
152
155
|
const canAssign = action === "implement" && viewerId !== null && issue.assignee?.id !== viewerId;
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
const [
|
|
156
|
+
// Null until the user flips the option. Until then it follows the chosen project.
|
|
157
|
+
const { defaults } = choices;
|
|
158
|
+
const [commentsChoice, setIncludeComments] = useState<boolean | null>(null);
|
|
159
|
+
const [startedChoice, setMoveToStarted] = useState<boolean | null>(null);
|
|
160
|
+
const [assignChoice, setAssignToMe] = useState<boolean | null>(null);
|
|
161
|
+
const includeComments = commentsChoice ?? defaults.launch.includeComments;
|
|
162
|
+
const moveToStarted = startedChoice ?? defaults.launch.moveToStarted;
|
|
163
|
+
const assignToMe = assignChoice ?? defaults.launch.assignToMe;
|
|
156
164
|
const [picker, setPicker] = useState<PickerKind | null>(null);
|
|
157
165
|
// Null until the user types. Until then the prompt follows the template and options.
|
|
158
166
|
const [customPrompt, setCustomPrompt] = useState<string | null>(null);
|
|
159
167
|
const { projectConfig } = choices;
|
|
168
|
+
const { guidance, change: changeGuidance } = useProjectGuidance(choices.project, projectConfig);
|
|
169
|
+
const { tools } = defaults;
|
|
170
|
+
const subAgents = useSubIssueAgents(issue, choices);
|
|
171
|
+
const assigning = action === "implement" && tools.assignAgents && issue.children.length > 0;
|
|
172
|
+
const assignments = assigning && tools.enabled ? subAgents.assignments : NO_ASSIGNMENTS;
|
|
160
173
|
const generated = useMemo(
|
|
161
174
|
() =>
|
|
162
175
|
composePrompt({
|
|
@@ -166,8 +179,10 @@ function LaunchComposer(props: LaunchPageProps & { issue: IssueDetail; settings:
|
|
|
166
179
|
project: projectConfig,
|
|
167
180
|
includeComments,
|
|
168
181
|
extraInstructions: "",
|
|
182
|
+
guidance,
|
|
183
|
+
tools: { ...tools, assignments: promptAssignments(assignments) },
|
|
169
184
|
}),
|
|
170
|
-
[action, issue, settings, projectConfig, includeComments],
|
|
185
|
+
[action, issue, settings, projectConfig, includeComments, guidance, tools, assignments],
|
|
171
186
|
);
|
|
172
187
|
const prompt = customPrompt ?? generated;
|
|
173
188
|
const styles = useMemo(() => {
|
|
@@ -271,15 +286,23 @@ function LaunchComposer(props: LaunchPageProps & { issue: IssueDetail; settings:
|
|
|
271
286
|
patch,
|
|
272
287
|
started,
|
|
273
288
|
keyScope,
|
|
289
|
+
guidance,
|
|
290
|
+
tools,
|
|
291
|
+
assignments,
|
|
292
|
+
explore: settings.live.enabled && settings.live.mapping === "explore",
|
|
274
293
|
},
|
|
275
294
|
{
|
|
276
|
-
onSuccess: ({ agentId, warning }) => {
|
|
295
|
+
onSuccess: ({ agentId, workspaceId, warning }) => {
|
|
277
296
|
void queries.invalidateQueries({ queryKey: ["linear"] });
|
|
297
|
+
const started = agentId
|
|
298
|
+
? "Agent started."
|
|
299
|
+
: "Mapping files first. The agent starts when the map is ready.";
|
|
278
300
|
if (warning) {
|
|
279
|
-
toast.show(
|
|
280
|
-
} else toast.show(
|
|
301
|
+
toast.show(`${started} Linear was not updated: ${warning}`, { variant: "warning" });
|
|
302
|
+
} else toast.show(started, { variant: "success" });
|
|
281
303
|
onStarted();
|
|
282
|
-
navigation?.openAgent({ agentId });
|
|
304
|
+
if (agentId) navigation?.openAgent({ agentId });
|
|
305
|
+
else navigation?.openWorkspace({ workspaceId });
|
|
283
306
|
},
|
|
284
307
|
},
|
|
285
308
|
);
|
|
@@ -296,6 +319,9 @@ function LaunchComposer(props: LaunchPageProps & { issue: IssueDetail; settings:
|
|
|
296
319
|
issue,
|
|
297
320
|
prompt,
|
|
298
321
|
keyScope,
|
|
322
|
+
guidance,
|
|
323
|
+
tools,
|
|
324
|
+
assignments,
|
|
299
325
|
queries,
|
|
300
326
|
toast,
|
|
301
327
|
onStarted,
|
|
@@ -438,6 +464,26 @@ function LaunchComposer(props: LaunchPageProps & { issue: IssueDetail; settings:
|
|
|
438
464
|
/>
|
|
439
465
|
) : null}
|
|
440
466
|
</View>
|
|
467
|
+
<View style={styles.group}>
|
|
468
|
+
<SectionLabel theme={theme}>Agent guidance</SectionLabel>
|
|
469
|
+
<GuidanceToggles theme={theme} guidance={guidance} tools={tools} onChange={changeGuidance} />
|
|
470
|
+
</View>
|
|
471
|
+
{assigning ? (
|
|
472
|
+
<View style={styles.group}>
|
|
473
|
+
<SectionLabel theme={theme}>Sub-issue agents</SectionLabel>
|
|
474
|
+
<SubIssueAgents
|
|
475
|
+
theme={theme}
|
|
476
|
+
issue={issue}
|
|
477
|
+
choices={choices}
|
|
478
|
+
state={subAgents}
|
|
479
|
+
blocked={
|
|
480
|
+
tools.enabled
|
|
481
|
+
? null
|
|
482
|
+
: "Sub-issue agents need the plugin's Linear tools, which are off for this project. Turn them on in Linear settings, under Agents."
|
|
483
|
+
}
|
|
484
|
+
/>
|
|
485
|
+
</View>
|
|
486
|
+
) : null}
|
|
441
487
|
{launch.error ? (
|
|
442
488
|
<Text accessibilityRole="alert" style={styles.error}>
|
|
443
489
|
{errorMessage(launch.error)}
|
|
@@ -0,0 +1,446 @@
|
|
|
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 { keepPreviousData, 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 DirTouch,
|
|
11
|
+
deriveActivity,
|
|
12
|
+
type FileTouch,
|
|
13
|
+
} from "../shared/activity";
|
|
14
|
+
import type { SubagentLog } from "../shared/live";
|
|
15
|
+
import type { SubagentRun } from "../shared/subagents";
|
|
16
|
+
import { MONO } from "./live-issues";
|
|
17
|
+
import { useAgentTimelines, useSubagentLogs } 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 = 8;
|
|
26
|
+
const CHILD_COLORS = ["#e879f9", "#38bdf8", "#facc15", "#fb7185", "#4ade80", "#f97316", "#818cf8", "#2dd4bf"];
|
|
27
|
+
const POLL_MS = 10_000;
|
|
28
|
+
const SUBAGENT_COLORS = ["#a78bfa", "#34d399", "#f97316", "#22d3ee", "#f472b6", "#a3e635", "#fbbf24", "#60a5fa"];
|
|
29
|
+
// A transcript that has not grown for this long belongs to a subagent that stopped.
|
|
30
|
+
const SUBAGENT_IDLE_MS = 3 * 60_000;
|
|
31
|
+
|
|
32
|
+
export type AgentStatus = PluginAgentSnapshot["status"];
|
|
33
|
+
|
|
34
|
+
export interface ChildAgent {
|
|
35
|
+
id: string;
|
|
36
|
+
title: string;
|
|
37
|
+
provider: string;
|
|
38
|
+
status: AgentStatus;
|
|
39
|
+
cwd: string;
|
|
40
|
+
color: string;
|
|
41
|
+
workspaceId?: string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ChildView {
|
|
45
|
+
agent: ChildAgent;
|
|
46
|
+
activity: Activity | null;
|
|
47
|
+
/** Works in the parent's folder or a worktree of the same project, so its paths fit the map. */
|
|
48
|
+
sameRepo: boolean;
|
|
49
|
+
/** A provider subagent read from its transcript, which Paseo cannot open as an agent. */
|
|
50
|
+
subagent?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const runKey = (log: SubagentLog) => `${log.type}\u0000${log.description}`;
|
|
54
|
+
const runTitle = (type: string, description: string) => (description ? `${type}: ${description}` : type);
|
|
55
|
+
|
|
56
|
+
/** Subagents with a transcript, as views like child agents in the parent's folder. */
|
|
57
|
+
export function subagentViews(logs: readonly SubagentLog[], cwd: string, checkedAt: number): ChildView[] {
|
|
58
|
+
return logs.map((log, index) => {
|
|
59
|
+
const running = !log.finished && checkedAt - log.updatedAt < SUBAGENT_IDLE_MS;
|
|
60
|
+
return {
|
|
61
|
+
agent: {
|
|
62
|
+
id: `sub:${log.id}`,
|
|
63
|
+
title: runTitle(log.type, log.description),
|
|
64
|
+
provider: "claude",
|
|
65
|
+
status: running ? "running" : "idle",
|
|
66
|
+
cwd,
|
|
67
|
+
color: SUBAGENT_COLORS[index % SUBAGENT_COLORS.length] ?? "#a78bfa",
|
|
68
|
+
},
|
|
69
|
+
activity: deriveActivity(log.items, cwd),
|
|
70
|
+
sameRepo: true,
|
|
71
|
+
subagent: true,
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface AgentMarker {
|
|
77
|
+
key: string;
|
|
78
|
+
kind: "main" | "child" | "subagent";
|
|
79
|
+
color: string;
|
|
80
|
+
provider: string | null;
|
|
81
|
+
label: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Child agents of an agent, newest running ones first. Polls only while the parent works. */
|
|
85
|
+
export function useChildAgents(agentId: string, polling: boolean): ChildAgent[] {
|
|
86
|
+
const paseo = usePaseo();
|
|
87
|
+
const query = useQuery({
|
|
88
|
+
queryKey: ["linear", "live", "children", agentId],
|
|
89
|
+
queryFn: async () => {
|
|
90
|
+
const result = await paseo.agents.list({
|
|
91
|
+
filter: { labels: { [CHILD_LABEL]: agentId }, includeArchived: true },
|
|
92
|
+
page: { limit: 20 },
|
|
93
|
+
});
|
|
94
|
+
return result.entries.map((entry) => entry.agent);
|
|
95
|
+
},
|
|
96
|
+
refetchInterval: polling ? POLL_MS : false,
|
|
97
|
+
staleTime: 5_000,
|
|
98
|
+
});
|
|
99
|
+
return useMemo(() => {
|
|
100
|
+
const agents = [...(query.data ?? [])].sort((a, b) => {
|
|
101
|
+
const running = Number(b.status === "running") - Number(a.status === "running");
|
|
102
|
+
return running || String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? ""));
|
|
103
|
+
});
|
|
104
|
+
return agents.slice(0, MAX_CHILDREN).map((agent, index) => ({
|
|
105
|
+
id: agent.id,
|
|
106
|
+
title: agent.title ?? "Child agent",
|
|
107
|
+
provider: agent.provider,
|
|
108
|
+
status: agent.status as AgentStatus,
|
|
109
|
+
cwd: agent.cwd,
|
|
110
|
+
color: CHILD_COLORS[index % CHILD_COLORS.length] ?? "#38bdf8",
|
|
111
|
+
workspaceId: agent.workspaceId ?? null,
|
|
112
|
+
}));
|
|
113
|
+
}, [query.data]);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const projectOf = new Map<string, string | null>();
|
|
117
|
+
|
|
118
|
+
/** The Paseo project of each workspace. A workspace never changes project, so each is read once. */
|
|
119
|
+
export function useWorkspaceProjects(workspaceIds: readonly (string | null | undefined)[]) {
|
|
120
|
+
const paseo = usePaseo();
|
|
121
|
+
const ids = useMemo(
|
|
122
|
+
() => [...new Set(workspaceIds.filter((id): id is string => Boolean(id)))].sort(),
|
|
123
|
+
[workspaceIds],
|
|
124
|
+
);
|
|
125
|
+
const query = useQuery({
|
|
126
|
+
queryKey: ["linear", "live", "workspace-projects", ids],
|
|
127
|
+
queryFn: async () => {
|
|
128
|
+
await Promise.all(
|
|
129
|
+
ids
|
|
130
|
+
.filter((id) => !projectOf.has(id))
|
|
131
|
+
.map(async (id) => {
|
|
132
|
+
const handle = paseo.workspaces.ref(id);
|
|
133
|
+
const info = await handle.refresh().catch(() => null);
|
|
134
|
+
projectOf.set(id, info?.projectId ?? handle.projectId ?? null);
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
return new Map(ids.map((id) => [id, projectOf.get(id) ?? null]));
|
|
138
|
+
},
|
|
139
|
+
enabled: ids.length > 0,
|
|
140
|
+
staleTime: Number.POSITIVE_INFINITY,
|
|
141
|
+
placeholderData: keepPreviousData,
|
|
142
|
+
});
|
|
143
|
+
return query.data;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Both workspaces belong to one Paseo project, so a worktree's paths match the parent's. */
|
|
147
|
+
export function sameProject(
|
|
148
|
+
projects: ReadonlyMap<string, string | null> | undefined,
|
|
149
|
+
a: string | null | undefined,
|
|
150
|
+
b: string | null | undefined,
|
|
151
|
+
): boolean {
|
|
152
|
+
const first = a ? projects?.get(a) : null;
|
|
153
|
+
return Boolean(first) && first === (b ? projects?.get(b) : null);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function sameFolder(a: string, b: string): boolean {
|
|
157
|
+
return a.replace(/\/+$/, "") === b.replace(/\/+$/, "");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function add(markers: Map<string, AgentMarker[]>, paths: readonly string[], marker: AgentMarker) {
|
|
161
|
+
for (const path of paths) {
|
|
162
|
+
if (path.endsWith("/")) continue;
|
|
163
|
+
const list = markers.get(path) ?? [];
|
|
164
|
+
if (!list.some((entry) => entry.key === marker.key)) markers.set(path, [...list, marker]);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Which agents are on which file: where the main and child agents work, and subagent targets. */
|
|
169
|
+
export function agentMarkers(input: {
|
|
170
|
+
theme: Theme;
|
|
171
|
+
provider: string;
|
|
172
|
+
working: boolean;
|
|
173
|
+
current: ActivityEvent | null;
|
|
174
|
+
children: readonly ChildView[];
|
|
175
|
+
subagents: readonly SubagentRun[];
|
|
176
|
+
}): ReadonlyMap<string, AgentMarker[]> {
|
|
177
|
+
const { colors } = input.theme;
|
|
178
|
+
const markers = new Map<string, AgentMarker[]>();
|
|
179
|
+
if (input.working && input.current) {
|
|
180
|
+
const main = { key: "main", kind: "main", color: colors.accent, provider: input.provider, label: "Agent" } as const;
|
|
181
|
+
add(markers, input.current.paths, main);
|
|
182
|
+
}
|
|
183
|
+
for (const child of input.children) {
|
|
184
|
+
const current = child.activity?.current;
|
|
185
|
+
if (child.agent.status !== "running" || !child.sameRepo || !current) continue;
|
|
186
|
+
add(markers, current.paths, {
|
|
187
|
+
key: child.agent.id,
|
|
188
|
+
kind: "child",
|
|
189
|
+
color: child.agent.color,
|
|
190
|
+
provider: child.agent.provider,
|
|
191
|
+
label: child.agent.title,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
for (const run of input.subagents) {
|
|
195
|
+
if (run.status !== "running") continue;
|
|
196
|
+
add(markers, run.targets, {
|
|
197
|
+
key: run.key,
|
|
198
|
+
kind: "subagent",
|
|
199
|
+
color: colors.foregroundMuted,
|
|
200
|
+
provider: null,
|
|
201
|
+
label: run.description || run.type,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
return markers;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function subagentStatus(run: SubagentRun, parentRunning: boolean): string {
|
|
208
|
+
if (run.status === "completed") return "Done";
|
|
209
|
+
if (run.status === "failed") return "Failed";
|
|
210
|
+
if (run.status === "canceled") return "Canceled";
|
|
211
|
+
return parentRunning ? "Running" : "Started, no report since";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function names(paths: readonly string[]): string {
|
|
215
|
+
const shown = paths.slice(0, 2).map((path) => path.replace(/\/$/, "").split("/").pop() ?? path);
|
|
216
|
+
return `${shown.join(", ")}${paths.length > 2 ? ` +${paths.length - 2}` : ""}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
interface RowProps {
|
|
220
|
+
theme: Theme;
|
|
221
|
+
color: string;
|
|
222
|
+
icon: "provider" | "bot";
|
|
223
|
+
provider: string | null;
|
|
224
|
+
title: string;
|
|
225
|
+
status: string;
|
|
226
|
+
detail: string | null;
|
|
227
|
+
live: boolean;
|
|
228
|
+
onPress?: () => void;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function AgentRow(props: RowProps) {
|
|
232
|
+
const { theme } = props;
|
|
233
|
+
const { colors } = theme;
|
|
234
|
+
const styles = useMemo(() => {
|
|
235
|
+
const idle = { flexDirection: "row", gap: 10, padding: 8, borderRadius: 8 } as const;
|
|
236
|
+
return {
|
|
237
|
+
idle,
|
|
238
|
+
hover: { ...idle, backgroundColor: colors.surface1 },
|
|
239
|
+
badge: {
|
|
240
|
+
width: 24,
|
|
241
|
+
height: 24,
|
|
242
|
+
borderRadius: 7,
|
|
243
|
+
alignItems: "center",
|
|
244
|
+
justifyContent: "center",
|
|
245
|
+
backgroundColor: colors.surface2,
|
|
246
|
+
borderWidth: 1,
|
|
247
|
+
borderColor: props.icon === "bot" ? colors.border : props.color,
|
|
248
|
+
},
|
|
249
|
+
tint: { ...StyleSheet.absoluteFillObject, borderRadius: 6, backgroundColor: props.color, opacity: 0.18 },
|
|
250
|
+
body: { flex: 1, minWidth: 0, gap: 2 },
|
|
251
|
+
line: { flexDirection: "row", alignItems: "center", gap: 6 },
|
|
252
|
+
title: { flex: 1, color: colors.foreground, fontSize: 13, fontWeight: "500" },
|
|
253
|
+
status: { color: props.live ? colors.statusSuccess : colors.foregroundMuted, fontSize: 11 },
|
|
254
|
+
detail: { color: colors.foregroundMuted, fontSize: 11, fontFamily: MONO },
|
|
255
|
+
} as const;
|
|
256
|
+
}, [colors, props.color, props.icon, props.live]);
|
|
257
|
+
const pressStyle = usePressableStyle(styles.idle, styles.hover);
|
|
258
|
+
const content = (
|
|
259
|
+
<>
|
|
260
|
+
<View style={styles.badge}>
|
|
261
|
+
{props.icon === "bot" ? null : <View style={styles.tint} />}
|
|
262
|
+
{props.icon === "provider" && props.provider ? (
|
|
263
|
+
<ProviderIcon provider={props.provider} size={12} color={colors.foreground} />
|
|
264
|
+
) : (
|
|
265
|
+
<Icon name="Bot" size={12} color={colors.foregroundMuted} />
|
|
266
|
+
)}
|
|
267
|
+
</View>
|
|
268
|
+
<View style={styles.body}>
|
|
269
|
+
<View style={styles.line}>
|
|
270
|
+
<Text numberOfLines={1} style={styles.title}>
|
|
271
|
+
{props.title}
|
|
272
|
+
</Text>
|
|
273
|
+
<Text style={styles.status}>{props.status}</Text>
|
|
274
|
+
</View>
|
|
275
|
+
{props.detail ? (
|
|
276
|
+
<Text numberOfLines={1} style={styles.detail}>
|
|
277
|
+
{props.detail}
|
|
278
|
+
</Text>
|
|
279
|
+
) : null}
|
|
280
|
+
</View>
|
|
281
|
+
</>
|
|
282
|
+
);
|
|
283
|
+
if (!props.onPress) return <View style={styles.idle}>{content}</View>;
|
|
284
|
+
return (
|
|
285
|
+
<Pressable accessibilityRole="button" accessibilityLabel={`Open ${props.title}`} onPress={props.onPress} style={pressStyle}>
|
|
286
|
+
{content}
|
|
287
|
+
</Pressable>
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const STATUS_COPY: Record<AgentStatus, string> = {
|
|
292
|
+
initializing: "Starting",
|
|
293
|
+
idle: "Idle",
|
|
294
|
+
running: "Working",
|
|
295
|
+
error: "Error",
|
|
296
|
+
closed: "Closed",
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
export function AgentsList(props: {
|
|
300
|
+
theme: Theme;
|
|
301
|
+
provider: string;
|
|
302
|
+
status: AgentStatus;
|
|
303
|
+
current: ActivityEvent | null;
|
|
304
|
+
childAgents: readonly ChildView[];
|
|
305
|
+
subagents: readonly SubagentRun[];
|
|
306
|
+
onOpenAgent?: (agentId: string) => void;
|
|
307
|
+
}) {
|
|
308
|
+
const { theme, onOpenAgent } = props;
|
|
309
|
+
const parentRunning = props.status === "running";
|
|
310
|
+
const open = useCallback((id: string) => () => onOpenAgent?.(id), [onOpenAgent]);
|
|
311
|
+
return (
|
|
312
|
+
<View>
|
|
313
|
+
<AgentRow
|
|
314
|
+
theme={theme}
|
|
315
|
+
color={theme.colors.accent}
|
|
316
|
+
icon="provider"
|
|
317
|
+
provider={props.provider}
|
|
318
|
+
title={props.provider ? props.provider.charAt(0).toUpperCase() + props.provider.slice(1) : "Agent"}
|
|
319
|
+
status={STATUS_COPY[props.status] ?? props.status}
|
|
320
|
+
detail={props.current?.text ?? null}
|
|
321
|
+
live={parentRunning}
|
|
322
|
+
/>
|
|
323
|
+
{props.childAgents.map(({ agent, activity, sameRepo: shared, subagent: subagentView }) => (
|
|
324
|
+
<AgentRow
|
|
325
|
+
key={agent.id}
|
|
326
|
+
theme={theme}
|
|
327
|
+
color={agent.color}
|
|
328
|
+
icon="provider"
|
|
329
|
+
provider={agent.provider}
|
|
330
|
+
title={agent.title}
|
|
331
|
+
status={subagentView && agent.status !== "running" ? "Done" : (STATUS_COPY[agent.status] ?? agent.status)}
|
|
332
|
+
detail={shared ? (activity?.current?.text ?? null) : `In ${agent.cwd.split("/").pop() ?? agent.cwd}`}
|
|
333
|
+
live={agent.status === "running"}
|
|
334
|
+
onPress={onOpenAgent && !subagentView ? open(agent.id) : undefined}
|
|
335
|
+
/>
|
|
336
|
+
))}
|
|
337
|
+
{props.subagents.map((run) => (
|
|
338
|
+
<AgentRow
|
|
339
|
+
key={run.key}
|
|
340
|
+
theme={theme}
|
|
341
|
+
color={theme.colors.foregroundMuted}
|
|
342
|
+
icon="bot"
|
|
343
|
+
provider={null}
|
|
344
|
+
title={run.description ? `${run.type}: ${run.description}` : run.type}
|
|
345
|
+
status={subagentStatus(run, parentRunning)}
|
|
346
|
+
detail={run.targets.length > 0 ? `Asked about ${names(run.targets)}` : null}
|
|
347
|
+
live={run.status === "running" && parentRunning}
|
|
348
|
+
/>
|
|
349
|
+
))}
|
|
350
|
+
</View>
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Files the main agent and its same-folder child agents touched, counted together. */
|
|
355
|
+
function mergeTouches(own: readonly FileTouch[], children: readonly ChildView[]): FileTouch[] {
|
|
356
|
+
const merged = new Map(own.map((file) => [file.path, { ...file }]));
|
|
357
|
+
for (const child of children) {
|
|
358
|
+
if (!child.sameRepo) continue;
|
|
359
|
+
for (const file of child.activity?.files ?? []) {
|
|
360
|
+
const known = merged.get(file.path);
|
|
361
|
+
if (!known) {
|
|
362
|
+
merged.set(file.path, { ...file });
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
known.reads += file.reads;
|
|
366
|
+
known.edits += file.edits;
|
|
367
|
+
known.added += file.added;
|
|
368
|
+
known.removed += file.removed;
|
|
369
|
+
known.created ||= file.created;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return [...merged.values()];
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Child agents with their activity, every agent's place on the map, and all touched files. */
|
|
376
|
+
export function useLiveAgents(input: {
|
|
377
|
+
theme: Theme;
|
|
378
|
+
agentId: string;
|
|
379
|
+
cwd: string;
|
|
380
|
+
workspaceId: string | null;
|
|
381
|
+
status: AgentStatus;
|
|
382
|
+
provider: string;
|
|
383
|
+
activity: Activity;
|
|
384
|
+
}) {
|
|
385
|
+
const { theme, agentId, cwd, status, provider, activity } = input;
|
|
386
|
+
const children = useChildAgents(agentId, status === "running");
|
|
387
|
+
const projects = useWorkspaceProjects(
|
|
388
|
+
useMemo(() => [input.workspaceId, ...children.map((child) => child.workspaceId)], [input.workspaceId, children]),
|
|
389
|
+
);
|
|
390
|
+
const ids = useMemo(() => children.map((child) => child.id), [children]);
|
|
391
|
+
const timelines = useAgentTimelines(ids);
|
|
392
|
+
const logs = useSubagentLogs(
|
|
393
|
+
agentId,
|
|
394
|
+
provider.split("/")[0] === "claude" && activity.subagents.length > 0,
|
|
395
|
+
status === "running",
|
|
396
|
+
);
|
|
397
|
+
const subViews = useMemo(() => subagentViews(logs.runs, cwd, logs.checkedAt), [logs, cwd]);
|
|
398
|
+
const views = useMemo(
|
|
399
|
+
() => [
|
|
400
|
+
...children.map((agent): ChildView => {
|
|
401
|
+
const timeline = timelines.get(agent.id);
|
|
402
|
+
return {
|
|
403
|
+
agent,
|
|
404
|
+
activity: timeline ? deriveActivity(timeline.items, agent.cwd) : null,
|
|
405
|
+
sameRepo: sameFolder(agent.cwd, cwd) || sameProject(projects, input.workspaceId, agent.workspaceId),
|
|
406
|
+
};
|
|
407
|
+
}),
|
|
408
|
+
...subViews,
|
|
409
|
+
],
|
|
410
|
+
[children, timelines, cwd, subViews, projects, input.workspaceId],
|
|
411
|
+
);
|
|
412
|
+
// Subagents with a transcript show as agents; the rest only report what they were asked.
|
|
413
|
+
const subagents = useMemo(() => {
|
|
414
|
+
const covered = new Set(logs.runs.map(runKey));
|
|
415
|
+
return activity.subagents.filter((run) => !covered.has(run.key));
|
|
416
|
+
}, [activity.subagents, logs.runs]);
|
|
417
|
+
const markers = useMemo(
|
|
418
|
+
() =>
|
|
419
|
+
agentMarkers({
|
|
420
|
+
theme,
|
|
421
|
+
provider,
|
|
422
|
+
working: status === "running",
|
|
423
|
+
current: activity.current,
|
|
424
|
+
children: views,
|
|
425
|
+
subagents,
|
|
426
|
+
}),
|
|
427
|
+
[theme, provider, status, activity.current, subagents, views],
|
|
428
|
+
);
|
|
429
|
+
const files = useMemo(() => mergeTouches(activity.files, views), [activity.files, views]);
|
|
430
|
+
const dirs = useMemo(
|
|
431
|
+
(): DirTouch[] => [
|
|
432
|
+
...activity.dirs,
|
|
433
|
+
...views.flatMap((view) => (view.sameRepo ? (view.activity?.dirs ?? []) : [])),
|
|
434
|
+
],
|
|
435
|
+
[activity.dirs, views],
|
|
436
|
+
);
|
|
437
|
+
// A subagent's row in the feed shows what it does now.
|
|
438
|
+
const events = useMemo(() => {
|
|
439
|
+
const doing = new Map(subViews.map((view) => [view.agent.title, view.activity?.current?.text ?? null]));
|
|
440
|
+
return activity.events.map((event) => {
|
|
441
|
+
const now = event.kind === "agent" ? doing.get(event.text) : undefined;
|
|
442
|
+
return now ? { ...event, detail: now } : event;
|
|
443
|
+
});
|
|
444
|
+
}, [activity.events, subViews]);
|
|
445
|
+
return { children: views, subagents, markers, files, dirs, events };
|
|
446
|
+
}
|