@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.
Files changed (65) hide show
  1. package/OVERVIEW.md +32 -2
  2. package/README.md +138 -11
  3. package/client/access.tsx +67 -0
  4. package/client/agent-options.ts +12 -2
  5. package/client/agent-preferences.ts +112 -0
  6. package/client/browser.tsx +12 -9
  7. package/client/compat.ts +23 -5
  8. package/client/connect.tsx +3 -2
  9. package/client/launch-assignments.tsx +182 -0
  10. package/client/launch-choices.ts +23 -6
  11. package/client/launch-guidance.tsx +103 -0
  12. package/client/launch-plan.ts +166 -42
  13. package/client/launch.tsx +55 -9
  14. package/client/live-agents.tsx +446 -0
  15. package/client/live-feed.tsx +151 -0
  16. package/client/live-header.tsx +310 -0
  17. package/client/live-issues.tsx +345 -0
  18. package/client/live-map.tsx +351 -0
  19. package/client/live-panel.tsx +392 -0
  20. package/client/live-timeline.ts +379 -0
  21. package/client/pill.tsx +394 -0
  22. package/client/plugin-icon.tsx +75 -0
  23. package/client/project-init.tsx +192 -0
  24. package/client/project-knowledge.tsx +87 -0
  25. package/client/settings-access.tsx +56 -0
  26. package/client/settings-agents.tsx +113 -0
  27. package/client/settings-fields.tsx +11 -3
  28. package/client/settings-icon.tsx +277 -0
  29. package/client/settings-live.tsx +128 -0
  30. package/client/settings-overrides.tsx +148 -0
  31. package/client/settings-projects.tsx +26 -4
  32. package/client/settings-prompts.tsx +3 -3
  33. package/client/timeline-card.tsx +2 -1
  34. package/client/tool-buttons.tsx +68 -0
  35. package/client/ui.tsx +16 -7
  36. package/client/web.ts +81 -0
  37. package/index.client.tsx +160 -51
  38. package/index.server.ts +32 -5
  39. package/package.json +1 -1
  40. package/server/agent-hooks.ts +37 -0
  41. package/server/agent-runs.ts +312 -0
  42. package/server/agent-tools.ts +276 -0
  43. package/server/handlers.ts +12 -1
  44. package/server/init.ts +89 -0
  45. package/server/inspect.ts +392 -0
  46. package/server/knowledge.ts +157 -0
  47. package/server/live.ts +371 -0
  48. package/server/mcp-http.ts +156 -0
  49. package/server/stores.ts +126 -0
  50. package/server/subagent-logs.ts +146 -0
  51. package/server/sync.ts +133 -0
  52. package/shared/activity.ts +268 -0
  53. package/shared/agent-hooks.ts +68 -0
  54. package/shared/agent-tools.ts +157 -0
  55. package/shared/custom-icon.ts +188 -0
  56. package/shared/knowledge.ts +255 -0
  57. package/shared/live.ts +248 -0
  58. package/shared/map-model.ts +240 -0
  59. package/shared/project-setup.ts +56 -0
  60. package/shared/prompts.ts +109 -2
  61. package/shared/settings.ts +145 -3
  62. package/shared/shell-activity.ts +309 -0
  63. package/shared/shell-lexer.ts +180 -0
  64. package/shared/subagents.ts +106 -0
  65. 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 { linearPatch, type TargetWorkspace, useLaunchAgent } from "./launch-plan";
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
- const [includeComments, setIncludeComments] = useState(settings.launch.includeComments);
154
- const [moveToStarted, setMoveToStarted] = useState(settings.launch.moveToStarted);
155
- const [assignToMe, setAssignToMe] = useState(settings.launch.assignToMe);
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(`Agent started. Linear was not updated: ${warning}`, { variant: "warning" });
280
- } else toast.show("Agent started", { variant: "success" });
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
+ }