@yannelli/paseo-linear-plugin 1.0.1 → 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 +134 -8
- package/client/access.tsx +67 -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 +21 -6
- package/client/launch-guidance.tsx +103 -0
- package/client/launch-plan.ts +114 -17
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
2
|
+
import { useEffect, useMemo, useState } from "react";
|
|
3
|
+
import { ActivityIndicator, Animated, StyleSheet, Text, View } from "react-native";
|
|
4
|
+
import type { ActivityEvent, EventKind } from "../shared/activity";
|
|
5
|
+
import { MONO } from "./live-issues";
|
|
6
|
+
import { NATIVE_DRIVER } from "./live-timeline";
|
|
7
|
+
import type { Theme } from "./ui";
|
|
8
|
+
|
|
9
|
+
export const KIND_ICONS: Record<EventKind, string> = {
|
|
10
|
+
read: "Eye",
|
|
11
|
+
edit: "Pencil",
|
|
12
|
+
write: "FilePlus",
|
|
13
|
+
search: "Search",
|
|
14
|
+
list: "FolderOpen",
|
|
15
|
+
shell: "SquareTerminal",
|
|
16
|
+
agent: "Bot",
|
|
17
|
+
tool: "Wrench",
|
|
18
|
+
};
|
|
19
|
+
const FEED_SIZE = 14;
|
|
20
|
+
|
|
21
|
+
export function eventFailed(event: ActivityEvent): boolean {
|
|
22
|
+
return event.status === "failed" || (event.exitCode !== null && event.exitCode !== 0);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The color of an event's icon: changes in green, failures in red, the rest muted. */
|
|
26
|
+
export function eventTone(theme: Theme, event: ActivityEvent): string {
|
|
27
|
+
const { colors } = theme;
|
|
28
|
+
if (eventFailed(event) && event.kind !== "search") return colors.statusDanger;
|
|
29
|
+
if (event.kind === "edit" || event.kind === "write") return colors.statusSuccess;
|
|
30
|
+
if (event.kind === "read" || event.kind === "search") return colors.foreground;
|
|
31
|
+
return colors.foregroundMuted;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function FeedRow(props: { theme: Theme; event: ActivityEvent; animate: boolean }) {
|
|
35
|
+
const { theme, event } = props;
|
|
36
|
+
const { colors } = theme;
|
|
37
|
+
const [entering] = useState(props.animate);
|
|
38
|
+
const [progress] = useState(() => new Animated.Value(entering ? 0 : 1));
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!entering) return;
|
|
41
|
+
const animation = Animated.timing(progress, {
|
|
42
|
+
toValue: 1,
|
|
43
|
+
duration: 280,
|
|
44
|
+
useNativeDriver: NATIVE_DRIVER,
|
|
45
|
+
});
|
|
46
|
+
animation.start();
|
|
47
|
+
return () => animation.stop();
|
|
48
|
+
}, [entering, progress]);
|
|
49
|
+
const failed = eventFailed(event);
|
|
50
|
+
const tone = eventTone(theme, event);
|
|
51
|
+
const styles = useMemo(() => {
|
|
52
|
+
const slide = progress.interpolate({ inputRange: [0, 1], outputRange: [-8, 0] });
|
|
53
|
+
return {
|
|
54
|
+
row: {
|
|
55
|
+
flexDirection: "row",
|
|
56
|
+
alignItems: "center",
|
|
57
|
+
gap: 10,
|
|
58
|
+
paddingVertical: 6,
|
|
59
|
+
paddingHorizontal: 8,
|
|
60
|
+
borderRadius: 8,
|
|
61
|
+
opacity: progress,
|
|
62
|
+
transform: [{ translateY: slide }],
|
|
63
|
+
},
|
|
64
|
+
icon: {
|
|
65
|
+
width: 24,
|
|
66
|
+
height: 24,
|
|
67
|
+
borderRadius: 7,
|
|
68
|
+
alignItems: "center",
|
|
69
|
+
justifyContent: "center",
|
|
70
|
+
backgroundColor: colors.surface2,
|
|
71
|
+
},
|
|
72
|
+
tint: { ...StyleSheet.absoluteFillObject, borderRadius: 7, backgroundColor: tone, opacity: 0.12 },
|
|
73
|
+
body: { flex: 1, minWidth: 0, gap: 2 },
|
|
74
|
+
text: { color: colors.foreground, fontSize: 13 },
|
|
75
|
+
detail: { color: colors.foregroundMuted, fontFamily: MONO, fontSize: 11 },
|
|
76
|
+
added: { color: colors.statusSuccess, fontSize: 11 },
|
|
77
|
+
removed: { color: colors.statusDanger, fontSize: 11 },
|
|
78
|
+
exit: { color: failed ? colors.statusDanger : colors.foregroundMuted, fontSize: 11 },
|
|
79
|
+
} as const;
|
|
80
|
+
}, [colors, progress, failed, tone]);
|
|
81
|
+
const showExit = event.exitCode !== null && event.exitCode !== 0 && event.kind !== "search";
|
|
82
|
+
return (
|
|
83
|
+
<Animated.View style={styles.row} accessible accessibilityLabel={event.text}>
|
|
84
|
+
<View style={styles.icon}>
|
|
85
|
+
<View style={styles.tint} />
|
|
86
|
+
<Icon name={KIND_ICONS[event.kind]} size={13} color={tone} />
|
|
87
|
+
</View>
|
|
88
|
+
<View style={styles.body}>
|
|
89
|
+
<Text numberOfLines={1} style={styles.text}>
|
|
90
|
+
{event.text}
|
|
91
|
+
</Text>
|
|
92
|
+
{event.detail ? (
|
|
93
|
+
<Text numberOfLines={1} style={styles.detail}>
|
|
94
|
+
{event.detail}
|
|
95
|
+
</Text>
|
|
96
|
+
) : null}
|
|
97
|
+
</View>
|
|
98
|
+
{event.added > 0 ? <Text style={styles.added}>{`+${event.added}`}</Text> : null}
|
|
99
|
+
{event.removed > 0 ? <Text style={styles.removed}>{`-${event.removed}`}</Text> : null}
|
|
100
|
+
{showExit ? <Text style={styles.exit}>{`exit ${event.exitCode}`}</Text> : null}
|
|
101
|
+
{event.status === "running" ? (
|
|
102
|
+
<ActivityIndicator size="small" color={colors.foregroundMuted} />
|
|
103
|
+
) : null}
|
|
104
|
+
{event.status === "failed" && event.exitCode === null ? (
|
|
105
|
+
<Icon name="TriangleAlert" size={14} color={colors.statusDanger} />
|
|
106
|
+
) : null}
|
|
107
|
+
</Animated.View>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function ActivityFeed(props: {
|
|
112
|
+
theme: Theme;
|
|
113
|
+
events: readonly ActivityEvent[];
|
|
114
|
+
loading: boolean;
|
|
115
|
+
error: string | null;
|
|
116
|
+
reduceMotion: boolean;
|
|
117
|
+
}) {
|
|
118
|
+
const { theme } = props;
|
|
119
|
+
const { colors } = theme;
|
|
120
|
+
const rows = props.events.slice(0, FEED_SIZE);
|
|
121
|
+
// The loaded tail renders in place; only rows that arrive after it slide in.
|
|
122
|
+
const [settled, setSettled] = useState(false);
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
if (!props.loading) setSettled(true);
|
|
125
|
+
}, [props.loading]);
|
|
126
|
+
const styles = useMemo(
|
|
127
|
+
() =>
|
|
128
|
+
({
|
|
129
|
+
list: { gap: 2 },
|
|
130
|
+
note: { color: colors.foregroundMuted, fontSize: 13, padding: 8 },
|
|
131
|
+
error: { color: colors.statusDanger, fontSize: 13, padding: 8 },
|
|
132
|
+
}) as const,
|
|
133
|
+
[colors],
|
|
134
|
+
);
|
|
135
|
+
let empty: string | null = null;
|
|
136
|
+
if (rows.length === 0) empty = props.loading ? "Loading activity" : "No tool calls yet";
|
|
137
|
+
return (
|
|
138
|
+
<View style={styles.list}>
|
|
139
|
+
{props.error ? <Text style={styles.error}>{props.error}</Text> : null}
|
|
140
|
+
{empty ? <Text style={styles.note}>{empty}</Text> : null}
|
|
141
|
+
{rows.map((event) => (
|
|
142
|
+
<FeedRow
|
|
143
|
+
key={event.id}
|
|
144
|
+
theme={theme}
|
|
145
|
+
event={event}
|
|
146
|
+
animate={settled && !props.reduceMotion}
|
|
147
|
+
/>
|
|
148
|
+
))}
|
|
149
|
+
</View>
|
|
150
|
+
);
|
|
151
|
+
}
|