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