@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
package/client/pill.tsx
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PluginButtonContentProps,
|
|
3
|
+
PluginButtonRegistration,
|
|
4
|
+
PluginClientContext,
|
|
5
|
+
} from "@getpaseo/plugin/client";
|
|
6
|
+
import { useAgent, usePaseo, useRpc, useSettings } from "@getpaseo/plugin/client";
|
|
7
|
+
import { ScrollView, TextInput, useToast } from "@getpaseo/plugin/client/react-native";
|
|
8
|
+
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
|
|
9
|
+
import { type ComponentType, useEffect, useMemo, useState } from "react";
|
|
10
|
+
import { Pressable, Text, View } from "react-native";
|
|
11
|
+
import { deriveActivity, type TimelineItemLike } from "../shared/activity";
|
|
12
|
+
import { searchIssuesRpc } from "../shared/issues";
|
|
13
|
+
import { syncNowRpc } from "../shared/live";
|
|
14
|
+
import { AGENT_LABELS, isInternalAgent } from "../shared/prompts";
|
|
15
|
+
import { accessRpc, linearSettings, projectEnabled } from "../shared/settings";
|
|
16
|
+
import { progressByKey } from "../shared/todo-sync";
|
|
17
|
+
import { currentAccess, onAccessChange, publishAccess } from "./access";
|
|
18
|
+
import { KeyScopeProvider } from "./key-scope";
|
|
19
|
+
import { MONO } from "./live-issues";
|
|
20
|
+
import { PluginIcon } from "./plugin-icon";
|
|
21
|
+
import { useIssue } from "./queries";
|
|
22
|
+
import { focusIssue, panelScope } from "./store";
|
|
23
|
+
import { Button, errorMessage, StateIcon, type Theme } from "./ui";
|
|
24
|
+
|
|
25
|
+
type Agent = Awaited<
|
|
26
|
+
ReturnType<PluginClientContext["paseo"]["agents"]["list"]>
|
|
27
|
+
>["entries"][number]["agent"];
|
|
28
|
+
|
|
29
|
+
const ACCESS_POLL_MS = 60_000;
|
|
30
|
+
|
|
31
|
+
// Paseo marks delegated sub-agents with this label; they share the parent's composer.
|
|
32
|
+
const PARENT_LABEL = "paseo.parent-agent-id";
|
|
33
|
+
|
|
34
|
+
type ContentProps = PluginButtonContentProps & { context: "agent"; agentId: string };
|
|
35
|
+
type Open = (panel: string, workspaceId: string, agentId?: string) => void;
|
|
36
|
+
|
|
37
|
+
function asAgentContent(Component: ComponentType<ContentProps>) {
|
|
38
|
+
return function AgentContent(props: PluginButtonContentProps) {
|
|
39
|
+
return props.context === "agent" ? <Component {...props} /> : null;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function useTodos(agentId: string, cwd: string) {
|
|
44
|
+
const paseo = usePaseo();
|
|
45
|
+
return useQuery({
|
|
46
|
+
queryKey: ["linear", "pill-todos", agentId],
|
|
47
|
+
queryFn: async () => {
|
|
48
|
+
const page = await paseo.agents
|
|
49
|
+
.ref(agentId)
|
|
50
|
+
.timeline.refetch({ direction: "tail", limit: 200, projection: "projected" });
|
|
51
|
+
const items = page.entries.map((entry) => entry.item as unknown as TimelineItemLike);
|
|
52
|
+
return deriveActivity(items, cwd).todos;
|
|
53
|
+
},
|
|
54
|
+
refetchInterval: 5_000,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function textStyles(theme: Theme, compact: boolean) {
|
|
59
|
+
const { colors } = theme;
|
|
60
|
+
return {
|
|
61
|
+
body: { width: compact ? ("100%" as const) : 320, gap: 6 },
|
|
62
|
+
title: { color: colors.foreground, fontSize: 13, fontWeight: "600" as const, flexShrink: 1 },
|
|
63
|
+
plain: { color: colors.foreground, fontSize: 13, flex: 1, minWidth: 0 },
|
|
64
|
+
child: { color: colors.foreground, fontSize: 12, flex: 1 },
|
|
65
|
+
// Keys never wrap, and a fixed column lines the titles up.
|
|
66
|
+
key: { color: colors.foregroundMuted, fontSize: 12, fontFamily: MONO, flexShrink: 0, minWidth: 64 },
|
|
67
|
+
muted: { color: colors.foregroundMuted, fontSize: 12 },
|
|
68
|
+
danger: { color: colors.statusDanger, fontSize: 12 },
|
|
69
|
+
row: { flexDirection: "row" as const, alignItems: "center" as const, gap: 8, paddingVertical: 5 },
|
|
70
|
+
track: { width: 40, height: 3, borderRadius: 2, backgroundColor: colors.border },
|
|
71
|
+
fill: { height: 3, borderRadius: 2, backgroundColor: colors.accent },
|
|
72
|
+
actions: { flexDirection: "row" as const, flexWrap: "wrap" as const, gap: 6, paddingTop: 4 },
|
|
73
|
+
list: { maxHeight: 240 },
|
|
74
|
+
option: { borderRadius: 6, paddingHorizontal: 6 },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function LinkedIssue(props: ContentProps & { open: Open }) {
|
|
79
|
+
const { theme, layout, agentId, workspaceId, close, open } = props;
|
|
80
|
+
const identifier = useAgent(agentId, (agent) => agent.labels[AGENT_LABELS.issue] ?? null);
|
|
81
|
+
const cwd = useAgent(agentId, (agent) => agent.cwd);
|
|
82
|
+
const settings = useSettings(linearSettings);
|
|
83
|
+
const issue = useIssue(identifier);
|
|
84
|
+
const todos = useTodos(agentId, cwd ?? "");
|
|
85
|
+
const sync = useRpc(syncNowRpc);
|
|
86
|
+
const toast = useToast();
|
|
87
|
+
const syncNow = useMutation({
|
|
88
|
+
mutationFn: () => sync({ agentId }),
|
|
89
|
+
onSuccess: ({ moves, skipped }) => {
|
|
90
|
+
const message = moves.length
|
|
91
|
+
? `Moved ${moves.map((move) => `${move.identifier} to ${move.to.name}`).join(", ")}`
|
|
92
|
+
: (skipped ?? "Linear is already up to date");
|
|
93
|
+
toast.show(message, { variant: moves.length ? "success" : "default" });
|
|
94
|
+
void issue.refetch();
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
const styles = useMemo(() => textStyles(theme, layout.compact), [theme, layout.compact]);
|
|
98
|
+
const progress = useMemo(() => progressByKey(todos.data ?? []), [todos.data]);
|
|
99
|
+
const live = settings.status === "ready" && settings.values.live;
|
|
100
|
+
if (!identifier) return null;
|
|
101
|
+
const data = issue.data;
|
|
102
|
+
return (
|
|
103
|
+
<View style={styles.body}>
|
|
104
|
+
<View style={styles.row}>
|
|
105
|
+
{data ? <StateIcon state={data.state} size={16} /> : null}
|
|
106
|
+
<Text style={styles.key}>{identifier}</Text>
|
|
107
|
+
<Text numberOfLines={1} style={styles.title}>
|
|
108
|
+
{data?.title ?? (issue.isError ? "Issue unavailable" : "Loading…")}
|
|
109
|
+
</Text>
|
|
110
|
+
</View>
|
|
111
|
+
{issue.isError ? <Text style={styles.danger}>{errorMessage(issue.error)}</Text> : null}
|
|
112
|
+
<ScrollView style={styles.list}>
|
|
113
|
+
{(data?.children ?? []).map((child) => {
|
|
114
|
+
const entry = progress.get(child.identifier.toUpperCase());
|
|
115
|
+
const ratio = entry ? entry.completed / entry.total : 0;
|
|
116
|
+
return (
|
|
117
|
+
<View key={child.id} style={styles.row}>
|
|
118
|
+
<StateIcon state={child.state} size={14} />
|
|
119
|
+
<Text numberOfLines={1} style={styles.key}>
|
|
120
|
+
{child.identifier}
|
|
121
|
+
</Text>
|
|
122
|
+
<Text numberOfLines={1} style={styles.child}>
|
|
123
|
+
{child.title}
|
|
124
|
+
</Text>
|
|
125
|
+
<View style={styles.track}>
|
|
126
|
+
<View style={[styles.fill, { width: `${Math.round(ratio * 100)}%` }]} />
|
|
127
|
+
</View>
|
|
128
|
+
</View>
|
|
129
|
+
);
|
|
130
|
+
})}
|
|
131
|
+
</ScrollView>
|
|
132
|
+
<View style={styles.actions}>
|
|
133
|
+
{live && live.enabled ? (
|
|
134
|
+
<Button
|
|
135
|
+
theme={theme}
|
|
136
|
+
size="xs"
|
|
137
|
+
icon="Radar"
|
|
138
|
+
label="Open Linear Live"
|
|
139
|
+
onPress={() => {
|
|
140
|
+
close();
|
|
141
|
+
open("live", workspaceId, agentId);
|
|
142
|
+
}}
|
|
143
|
+
/>
|
|
144
|
+
) : null}
|
|
145
|
+
<Button
|
|
146
|
+
theme={theme}
|
|
147
|
+
size="xs"
|
|
148
|
+
icon={PluginIcon}
|
|
149
|
+
label="Open issue"
|
|
150
|
+
onPress={() => {
|
|
151
|
+
focusIssue(panelScope(workspaceId), identifier);
|
|
152
|
+
close();
|
|
153
|
+
open("issues", workspaceId);
|
|
154
|
+
}}
|
|
155
|
+
/>
|
|
156
|
+
{live && live.syncTodos ? (
|
|
157
|
+
<Button
|
|
158
|
+
theme={theme}
|
|
159
|
+
size="xs"
|
|
160
|
+
icon="RefreshCw"
|
|
161
|
+
label="Sync now"
|
|
162
|
+
busy={syncNow.isPending}
|
|
163
|
+
onPress={() => syncNow.mutate()}
|
|
164
|
+
/>
|
|
165
|
+
) : null}
|
|
166
|
+
</View>
|
|
167
|
+
{syncNow.error ? <Text style={styles.danger}>{errorMessage(syncNow.error)}</Text> : null}
|
|
168
|
+
</View>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function LinkedIssueScoped(props: ContentProps & { open: Open }) {
|
|
173
|
+
const project = useAgent(props.agentId, (agent) => agent.labels[AGENT_LABELS.project] ?? null);
|
|
174
|
+
return (
|
|
175
|
+
<KeyScopeProvider projectId={project}>
|
|
176
|
+
<LinkedIssue {...props} />
|
|
177
|
+
</KeyScopeProvider>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function useDebounced(value: string, delay: number) {
|
|
182
|
+
const [debounced, setDebounced] = useState(value);
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
const timer = setTimeout(() => setDebounced(value), delay);
|
|
185
|
+
return () => clearTimeout(timer);
|
|
186
|
+
}, [value, delay]);
|
|
187
|
+
return debounced;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Agents not started from an issue: find one and send its snapshot as the next message.
|
|
191
|
+
function SendIssue({ theme, layout, agentId, close }: ContentProps) {
|
|
192
|
+
const paseo = usePaseo();
|
|
193
|
+
const search = useRpc(searchIssuesRpc);
|
|
194
|
+
const toast = useToast();
|
|
195
|
+
const [query, setQuery] = useState("");
|
|
196
|
+
const term = useDebounced(query.trim(), 250);
|
|
197
|
+
const results = useQuery({
|
|
198
|
+
queryKey: ["linear", "pill-search", term],
|
|
199
|
+
queryFn: () => search({ query: term }),
|
|
200
|
+
placeholderData: keepPreviousData,
|
|
201
|
+
});
|
|
202
|
+
const send = useMutation({
|
|
203
|
+
mutationFn: async (item: { identifier: string; text: string }) => {
|
|
204
|
+
await paseo.agents.ref(agentId).send(item.text);
|
|
205
|
+
return item.identifier;
|
|
206
|
+
},
|
|
207
|
+
onSuccess: (identifier) => {
|
|
208
|
+
toast.show(`Sent ${identifier} to the agent`, { variant: "success" });
|
|
209
|
+
close();
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
const styles = useMemo(() => textStyles(theme, layout.compact), [theme, layout.compact]);
|
|
213
|
+
const input = useMemo(
|
|
214
|
+
() => ({
|
|
215
|
+
color: theme.colors.foreground,
|
|
216
|
+
backgroundColor: theme.colors.surface2,
|
|
217
|
+
borderColor: theme.colors.border,
|
|
218
|
+
borderWidth: 1,
|
|
219
|
+
borderRadius: 8,
|
|
220
|
+
paddingHorizontal: 10,
|
|
221
|
+
paddingVertical: 7,
|
|
222
|
+
fontSize: 13,
|
|
223
|
+
}),
|
|
224
|
+
[theme],
|
|
225
|
+
);
|
|
226
|
+
return (
|
|
227
|
+
<View style={styles.body}>
|
|
228
|
+
<TextInput
|
|
229
|
+
accessibilityLabel="Search Linear issues"
|
|
230
|
+
value={query}
|
|
231
|
+
onChangeText={setQuery}
|
|
232
|
+
autoFocus={!layout.compact}
|
|
233
|
+
autoCapitalize="none"
|
|
234
|
+
autoCorrect={false}
|
|
235
|
+
placeholder="Search by key or title"
|
|
236
|
+
placeholderTextColor={theme.colors.foregroundMuted}
|
|
237
|
+
style={input}
|
|
238
|
+
/>
|
|
239
|
+
{results.error ? <Text style={styles.danger}>{errorMessage(results.error)}</Text> : null}
|
|
240
|
+
{send.error ? <Text style={styles.danger}>{errorMessage(send.error)}</Text> : null}
|
|
241
|
+
<ScrollView keyboardShouldPersistTaps="handled" style={styles.list}>
|
|
242
|
+
{(results.data?.items ?? []).slice(0, 20).map((item) => (
|
|
243
|
+
<Pressable
|
|
244
|
+
key={item.id}
|
|
245
|
+
accessibilityRole="button"
|
|
246
|
+
accessibilityLabel={`Send ${item.identifier} to the agent`}
|
|
247
|
+
disabled={send.isPending}
|
|
248
|
+
onPress={() => send.mutate(item)}
|
|
249
|
+
style={({ pressed }) => [
|
|
250
|
+
styles.row,
|
|
251
|
+
styles.option,
|
|
252
|
+
pressed ? { backgroundColor: theme.colors.surface2 } : null,
|
|
253
|
+
]}
|
|
254
|
+
>
|
|
255
|
+
<Text numberOfLines={1} style={styles.key}>
|
|
256
|
+
{item.identifier}
|
|
257
|
+
</Text>
|
|
258
|
+
<Text numberOfLines={1} style={styles.plain}>
|
|
259
|
+
{item.title}
|
|
260
|
+
</Text>
|
|
261
|
+
</Pressable>
|
|
262
|
+
))}
|
|
263
|
+
</ScrollView>
|
|
264
|
+
{results.data?.items.length === 0 ? (
|
|
265
|
+
<Text style={styles.muted}>No matching issues.</Text>
|
|
266
|
+
) : null}
|
|
267
|
+
</View>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function contributeComposerPills(client: PluginClientContext) {
|
|
272
|
+
const pills = new Map<
|
|
273
|
+
string,
|
|
274
|
+
{ registration: PluginButtonRegistration; identifier: string | null }
|
|
275
|
+
>();
|
|
276
|
+
const lifetime = new AbortController();
|
|
277
|
+
const open: Open = (panel, workspaceId, agentId) =>
|
|
278
|
+
client.openPanel(panel, agentId ? { workspaceId, agentId } : { workspaceId });
|
|
279
|
+
const Linked = asAgentContent((props) => <LinkedIssueScoped {...props} open={open} />);
|
|
280
|
+
const Search = asAgentContent(SendIssue);
|
|
281
|
+
|
|
282
|
+
const agents = new Map<string, Agent>();
|
|
283
|
+
const projects = new Map<string, string | null>();
|
|
284
|
+
const loading = new Set<string>();
|
|
285
|
+
const remove = (agentId: string) => {
|
|
286
|
+
pills.get(agentId)?.registration.remove();
|
|
287
|
+
pills.delete(agentId);
|
|
288
|
+
};
|
|
289
|
+
const applyAll = () => {
|
|
290
|
+
for (const agent of agents.values()) apply(agent);
|
|
291
|
+
};
|
|
292
|
+
// A workspace never changes project, so each one is looked up once.
|
|
293
|
+
const projectOf = (workspaceId: string): string | null | undefined => {
|
|
294
|
+
if (projects.has(workspaceId)) return projects.get(workspaceId);
|
|
295
|
+
if (!loading.has(workspaceId)) {
|
|
296
|
+
loading.add(workspaceId);
|
|
297
|
+
void client.paseo.workspaces
|
|
298
|
+
.ref(workspaceId)
|
|
299
|
+
.refresh()
|
|
300
|
+
.then((workspace) => projects.set(workspaceId, workspace?.projectId ?? null))
|
|
301
|
+
.catch(() => projects.set(workspaceId, null))
|
|
302
|
+
.finally(() => {
|
|
303
|
+
loading.delete(workspaceId);
|
|
304
|
+
applyAll();
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
return undefined;
|
|
308
|
+
};
|
|
309
|
+
const register = (agent: Agent) => {
|
|
310
|
+
agents.set(agent.id, agent);
|
|
311
|
+
apply(agent);
|
|
312
|
+
};
|
|
313
|
+
const forget = (agentId: string) => {
|
|
314
|
+
agents.delete(agentId);
|
|
315
|
+
remove(agentId);
|
|
316
|
+
};
|
|
317
|
+
const apply = (agent: Agent) => {
|
|
318
|
+
if (lifetime.signal.aborted) return;
|
|
319
|
+
const labels = agent.labels ?? {};
|
|
320
|
+
if (!agent.workspaceId || labels[PARENT_LABEL] || isInternalAgent(labels) || agent.archivedAt) {
|
|
321
|
+
remove(agent.id);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const projectId = projectOf(agent.workspaceId);
|
|
325
|
+
const access = currentAccess();
|
|
326
|
+
if (projectId === undefined || !access) return;
|
|
327
|
+
if (!projectEnabled(access, projectId)) {
|
|
328
|
+
remove(agent.id);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
const identifier = labels[AGENT_LABELS.issue] ?? null;
|
|
332
|
+
if (pills.get(agent.id)?.identifier === identifier) return;
|
|
333
|
+
remove(agent.id);
|
|
334
|
+
const registration = client.addComposerPill({
|
|
335
|
+
id: "linear",
|
|
336
|
+
workspaceId: agent.workspaceId,
|
|
337
|
+
agentId: agent.id,
|
|
338
|
+
button: identifier
|
|
339
|
+
? {
|
|
340
|
+
title: `Linear issue ${identifier}`,
|
|
341
|
+
icon: PluginIcon,
|
|
342
|
+
label: identifier,
|
|
343
|
+
behavior: { kind: "popover", Content: Linked },
|
|
344
|
+
}
|
|
345
|
+
: {
|
|
346
|
+
title: "Send a Linear issue to this agent",
|
|
347
|
+
icon: PluginIcon,
|
|
348
|
+
label: "Linear",
|
|
349
|
+
behavior: { kind: "popover", Content: Search },
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
pills.set(agent.id, { registration, identifier });
|
|
353
|
+
};
|
|
354
|
+
const removeAll = () => {
|
|
355
|
+
for (const agentId of [...pills.keys()]) remove(agentId);
|
|
356
|
+
};
|
|
357
|
+
// Settings screens publish switch changes at once; the poll covers other devices.
|
|
358
|
+
const readAccess = () =>
|
|
359
|
+
client
|
|
360
|
+
.rpc(accessRpc, {})
|
|
361
|
+
.then(publishAccess)
|
|
362
|
+
.catch(() => undefined);
|
|
363
|
+
const stopListening = onAccessChange(applyAll);
|
|
364
|
+
const poll = setInterval(() => void readAccess(), ACCESS_POLL_MS);
|
|
365
|
+
void readAccess();
|
|
366
|
+
void client.paseo.agents
|
|
367
|
+
.list({ subscribe: {}, signal: lifetime.signal })
|
|
368
|
+
.then(({ subscription }) => {
|
|
369
|
+
subscription.subscribe({
|
|
370
|
+
snapshot: ({ entries }) => {
|
|
371
|
+
removeAll();
|
|
372
|
+
agents.clear();
|
|
373
|
+
for (const { agent } of entries) register(agent);
|
|
374
|
+
void readAccess();
|
|
375
|
+
},
|
|
376
|
+
update: (message) => {
|
|
377
|
+
if (message.type !== "agent_update") return;
|
|
378
|
+
const update = message.payload;
|
|
379
|
+
if (update.kind === "upsert") register(update.agent);
|
|
380
|
+
else forget(update.agentId);
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
return undefined;
|
|
384
|
+
})
|
|
385
|
+
.catch((error: unknown) => {
|
|
386
|
+
if (!lifetime.signal.aborted) console.error("Linear pills could not watch agents", error);
|
|
387
|
+
});
|
|
388
|
+
return () => {
|
|
389
|
+
lifetime.abort();
|
|
390
|
+
clearInterval(poll);
|
|
391
|
+
stopListening();
|
|
392
|
+
removeAll();
|
|
393
|
+
};
|
|
394
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { useSettings } from "@getpaseo/plugin/client";
|
|
2
|
+
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
3
|
+
import { useEffect, useMemo } from "react";
|
|
4
|
+
import { Image, Platform } from "react-native";
|
|
5
|
+
import { BUILT_IN_ICON, type IconSettings, iconMarkup, paintColor } from "../shared/custom-icon";
|
|
6
|
+
import { linearSettings } from "../shared/settings";
|
|
7
|
+
import { svgDataUri } from "./provider-icon";
|
|
8
|
+
import type { Theme } from "./ui";
|
|
9
|
+
import { readStored, writeStored } from "./web";
|
|
10
|
+
|
|
11
|
+
// The plugin's icon in pills, the sidebar, and plugin screens. Without an SVG, and in native
|
|
12
|
+
// apps, which cannot draw SVG images from plugins, it is the chosen built-in icon.
|
|
13
|
+
|
|
14
|
+
// Panels and Command Center items take a Lucide name when they register, outside React.
|
|
15
|
+
// Components that read the settings share the chosen name here. Paseo keeps the icon an open
|
|
16
|
+
// tab got, so the web app remembers the name and registers with it on the next start.
|
|
17
|
+
const MENU_ICON_KEY = "paseo-linear-plugin:menu-icon";
|
|
18
|
+
let menuIcon = readStored(MENU_ICON_KEY) ?? BUILT_IN_ICON;
|
|
19
|
+
|
|
20
|
+
/** The menu icon to register with first: the last one chosen on this device. */
|
|
21
|
+
export function startMenuIcon(): string {
|
|
22
|
+
return menuIcon;
|
|
23
|
+
}
|
|
24
|
+
const menuListeners = new Set<(name: string) => void>();
|
|
25
|
+
|
|
26
|
+
export function publishMenuIcon(name: string): void {
|
|
27
|
+
if (name === menuIcon) return;
|
|
28
|
+
menuIcon = name;
|
|
29
|
+
writeStored(MENU_ICON_KEY, name);
|
|
30
|
+
for (const listener of menuListeners) listener(name);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function onMenuIconChange(listener: (name: string) => void): () => void {
|
|
34
|
+
menuListeners.add(listener);
|
|
35
|
+
return () => menuListeners.delete(listener);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The saved icon settings, or null while they load. Shares the menu icon on the way. */
|
|
39
|
+
export function useIconSettings(): IconSettings | null {
|
|
40
|
+
const settings = useSettings(linearSettings);
|
|
41
|
+
const icon = settings.status === "ready" ? settings.values.icon : null;
|
|
42
|
+
const menu = icon?.menu;
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (menu) publishMenuIcon(menu);
|
|
45
|
+
}, [menu]);
|
|
46
|
+
return icon;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface PluginIconProps {
|
|
50
|
+
size: number;
|
|
51
|
+
/** The color Paseo gives the icon in this place. */
|
|
52
|
+
color: string;
|
|
53
|
+
theme?: Theme | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Draws icon settings, saved or not yet saved. */
|
|
57
|
+
export function IconGlyph(props: PluginIconProps & { icon: IconSettings | null }) {
|
|
58
|
+
const { icon, size, color } = props;
|
|
59
|
+
const accent = props.theme?.colors.accent ?? color;
|
|
60
|
+
const paint = icon ? paintColor(icon, { ink: color, accent }) : null;
|
|
61
|
+
const svg = Platform.OS === "web" ? (icon?.svg ?? "") : "";
|
|
62
|
+
const solid = icon?.solid === true;
|
|
63
|
+
const source = useMemo(() => {
|
|
64
|
+
const markup = svg ? iconMarkup(svg, { ink: color, paint, solid }) : null;
|
|
65
|
+
return markup ? { uri: svgDataUri(markup, color) } : null;
|
|
66
|
+
}, [svg, color, paint, solid]);
|
|
67
|
+
const style = useMemo(() => ({ width: size, height: size }), [size]);
|
|
68
|
+
if (!source) return <Icon name={icon?.menu ?? BUILT_IN_ICON} size={size} color={paint ?? color} />;
|
|
69
|
+
return <Image source={source} style={style} resizeMode="contain" accessibilityIgnoresInvertColors />;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The plugin icon from the saved settings. The built-in icon shows while they load. */
|
|
73
|
+
export function PluginIcon(props: PluginIconProps) {
|
|
74
|
+
return <IconGlyph {...props} icon={useIconSettings()} />;
|
|
75
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { useRpc } from "@getpaseo/plugin/client";
|
|
2
|
+
import {
|
|
3
|
+
SettingsAction,
|
|
4
|
+
SettingsCard,
|
|
5
|
+
SettingsRow,
|
|
6
|
+
SettingsSection,
|
|
7
|
+
SettingsSwitch,
|
|
8
|
+
} from "@getpaseo/plugin/client/ui";
|
|
9
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
10
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
11
|
+
import { Text, View } from "react-native";
|
|
12
|
+
import { initStartRpc, initStatusRpc } from "../shared/live";
|
|
13
|
+
import {
|
|
14
|
+
applyProposal,
|
|
15
|
+
type FieldDiff,
|
|
16
|
+
type ProposalField,
|
|
17
|
+
proposalDiffs,
|
|
18
|
+
} from "../shared/project-setup";
|
|
19
|
+
import type { ProjectConfig } from "../shared/settings";
|
|
20
|
+
import { knowledgeKey } from "./project-knowledge";
|
|
21
|
+
import { Button, errorMessage, type Theme } from "./ui";
|
|
22
|
+
|
|
23
|
+
// Setup runs on the daemon. Remember the job per project so leaving the screen keeps it.
|
|
24
|
+
const jobsByProject = new Map<string, string>();
|
|
25
|
+
|
|
26
|
+
export function ProjectSetup(props: {
|
|
27
|
+
theme: Theme;
|
|
28
|
+
config: ProjectConfig;
|
|
29
|
+
change(patch: Partial<ProjectConfig>): void;
|
|
30
|
+
/** Opens the setup agent while it runs; absent on hosts without navigation. */
|
|
31
|
+
openAgent?: (agentId: string) => void;
|
|
32
|
+
}) {
|
|
33
|
+
const { theme, config, change, openAgent } = props;
|
|
34
|
+
const start = useRpc(initStartRpc);
|
|
35
|
+
const status = useRpc(initStatusRpc);
|
|
36
|
+
const [jobId, setJobId] = useState(() => jobsByProject.get(config.projectId) ?? null);
|
|
37
|
+
useEffect(() => setJobId(jobsByProject.get(config.projectId) ?? null), [config.projectId]);
|
|
38
|
+
const begin = useMutation({
|
|
39
|
+
mutationFn: () => start({ projectId: config.projectId, rootPath: config.rootPath }),
|
|
40
|
+
onSuccess: ({ job }) => {
|
|
41
|
+
jobsByProject.set(config.projectId, job.id);
|
|
42
|
+
setJobId(job.id);
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
const job = useQuery({
|
|
46
|
+
queryKey: ["linear", "init", jobId],
|
|
47
|
+
queryFn: () => status({ jobId: jobId ?? "" }),
|
|
48
|
+
enabled: jobId !== null,
|
|
49
|
+
refetchInterval: (query) => (query.state.data?.job?.status === "running" ? 3_000 : false),
|
|
50
|
+
});
|
|
51
|
+
const state = job.data?.job ?? null;
|
|
52
|
+
const proposal = job.data?.proposal ?? null;
|
|
53
|
+
// The daemon saved the agent's area summaries; show them in Project knowledge.
|
|
54
|
+
const queries = useQueryClient();
|
|
55
|
+
const finished = state?.status === "done";
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (finished) void queries.invalidateQueries({ queryKey: knowledgeKey(config.projectId) });
|
|
58
|
+
}, [finished, queries, config.projectId]);
|
|
59
|
+
const diffs = useMemo(
|
|
60
|
+
() => (proposal ? proposalDiffs(config, proposal) : []),
|
|
61
|
+
[config, proposal],
|
|
62
|
+
);
|
|
63
|
+
const [rejected, setRejected] = useState<ReadonlySet<ProposalField>>(new Set());
|
|
64
|
+
const apply = useCallback(() => {
|
|
65
|
+
if (!proposal) return;
|
|
66
|
+
const chosen = new Set(diffs.map((diff) => diff.field).filter((f) => !rejected.has(f)));
|
|
67
|
+
change(applyProposal(proposal, chosen));
|
|
68
|
+
jobsByProject.delete(config.projectId);
|
|
69
|
+
setJobId(null);
|
|
70
|
+
setRejected(new Set());
|
|
71
|
+
}, [proposal, diffs, rejected, change, config.projectId]);
|
|
72
|
+
const dismiss = useCallback(() => {
|
|
73
|
+
jobsByProject.delete(config.projectId);
|
|
74
|
+
setJobId(null);
|
|
75
|
+
}, [config.projectId]);
|
|
76
|
+
|
|
77
|
+
const running = begin.isPending || state?.status === "running";
|
|
78
|
+
const failure = begin.error
|
|
79
|
+
? errorMessage(begin.error)
|
|
80
|
+
: state?.status === "failed"
|
|
81
|
+
? (state.error ?? "Setup failed")
|
|
82
|
+
: null;
|
|
83
|
+
return (
|
|
84
|
+
<SettingsSection title="Set up with an agent">
|
|
85
|
+
<SettingsCard>
|
|
86
|
+
<SettingsAction
|
|
87
|
+
label="Read this repository's guidelines"
|
|
88
|
+
hint="The daemon inspects the project first, and the agent starts from that project map. The agent reads AGENTS.md, CONTRIBUTING, scripts, and CI, then proposes instructions and steps, and describes each area for Project knowledge. It is told not to edit. You review each prompt change before it is used."
|
|
89
|
+
actionLabel={running ? "Reading…" : proposal ? "Run again" : "Start"}
|
|
90
|
+
disabled={running || !config.rootPath}
|
|
91
|
+
onPress={() => begin.mutate()}
|
|
92
|
+
/>
|
|
93
|
+
{running ? (
|
|
94
|
+
<SettingsRow
|
|
95
|
+
label={state?.agentId ? "The setup agent is reading the repository." : "Inspecting the project…"}
|
|
96
|
+
>
|
|
97
|
+
{state?.agentId && openAgent ? (
|
|
98
|
+
<View style={ACTIONS_STYLE}>
|
|
99
|
+
<Button
|
|
100
|
+
theme={theme}
|
|
101
|
+
icon="Bot"
|
|
102
|
+
label="Open agent"
|
|
103
|
+
onPress={() => openAgent(state.agentId ?? "")}
|
|
104
|
+
/>
|
|
105
|
+
</View>
|
|
106
|
+
) : null}
|
|
107
|
+
</SettingsRow>
|
|
108
|
+
) : null}
|
|
109
|
+
{failure ? <SettingsRow label="Setup did not finish" error={failure} /> : null}
|
|
110
|
+
{state?.status === "done" && diffs.length === 0 ? (
|
|
111
|
+
<SettingsAction
|
|
112
|
+
label="The agent found nothing new to add."
|
|
113
|
+
actionLabel="Dismiss"
|
|
114
|
+
onPress={dismiss}
|
|
115
|
+
/>
|
|
116
|
+
) : null}
|
|
117
|
+
{diffs.map((diff) => (
|
|
118
|
+
<ProposalRow
|
|
119
|
+
key={diff.field}
|
|
120
|
+
theme={theme}
|
|
121
|
+
diff={diff}
|
|
122
|
+
accepted={!rejected.has(diff.field)}
|
|
123
|
+
onToggle={(accepted) =>
|
|
124
|
+
setRejected((current) => {
|
|
125
|
+
const next = new Set(current);
|
|
126
|
+
if (accepted) next.delete(diff.field);
|
|
127
|
+
else next.add(diff.field);
|
|
128
|
+
return next;
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
/>
|
|
132
|
+
))}
|
|
133
|
+
{diffs.length > 0 ? (
|
|
134
|
+
<SettingsRow label="Accepted changes go into the draft. Save to keep them.">
|
|
135
|
+
<View style={ACTIONS_STYLE}>
|
|
136
|
+
<Button theme={theme} variant="primary" label="Use accepted changes" onPress={apply} />
|
|
137
|
+
<Button theme={theme} label="Dismiss" onPress={dismiss} />
|
|
138
|
+
</View>
|
|
139
|
+
</SettingsRow>
|
|
140
|
+
) : null}
|
|
141
|
+
</SettingsCard>
|
|
142
|
+
</SettingsSection>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const ACTIONS_STYLE = { flexDirection: "row", gap: 8, marginTop: 8, flexWrap: "wrap" } as const;
|
|
147
|
+
|
|
148
|
+
function ProposalRow(props: {
|
|
149
|
+
theme: Theme;
|
|
150
|
+
diff: FieldDiff;
|
|
151
|
+
accepted: boolean;
|
|
152
|
+
onToggle(accepted: boolean): void;
|
|
153
|
+
}) {
|
|
154
|
+
const { colors } = props.theme;
|
|
155
|
+
const styles = useMemo(
|
|
156
|
+
() => ({
|
|
157
|
+
block: {
|
|
158
|
+
marginTop: 6,
|
|
159
|
+
padding: 10,
|
|
160
|
+
borderRadius: 8,
|
|
161
|
+
borderWidth: 1,
|
|
162
|
+
borderColor: colors.border,
|
|
163
|
+
backgroundColor: colors.surface2,
|
|
164
|
+
gap: 4,
|
|
165
|
+
},
|
|
166
|
+
caption: { color: colors.foregroundMuted, fontSize: 12 },
|
|
167
|
+
removed: { color: colors.foregroundMuted, fontSize: 13, lineHeight: 19 },
|
|
168
|
+
added: { color: colors.foreground, fontSize: 13, lineHeight: 19 },
|
|
169
|
+
}),
|
|
170
|
+
[colors],
|
|
171
|
+
);
|
|
172
|
+
const { diff } = props;
|
|
173
|
+
return (
|
|
174
|
+
<SettingsSwitch
|
|
175
|
+
label={diff.label}
|
|
176
|
+
hint={props.accepted ? "Accepted" : "Skipped"}
|
|
177
|
+
value={props.accepted}
|
|
178
|
+
onValueChange={props.onToggle}
|
|
179
|
+
>
|
|
180
|
+
{diff.current ? (
|
|
181
|
+
<View style={styles.block}>
|
|
182
|
+
<Text style={styles.caption}>Now</Text>
|
|
183
|
+
<Text style={styles.removed}>{diff.current}</Text>
|
|
184
|
+
</View>
|
|
185
|
+
) : null}
|
|
186
|
+
<View style={styles.block}>
|
|
187
|
+
<Text style={styles.caption}>Proposed</Text>
|
|
188
|
+
<Text style={styles.added}>{diff.proposed}</Text>
|
|
189
|
+
</View>
|
|
190
|
+
</SettingsSwitch>
|
|
191
|
+
);
|
|
192
|
+
}
|