@yannelli/paseo-linear-plugin 1.0.1 → 1.1.0-beta.1

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 (56) hide show
  1. package/OVERVIEW.md +20 -2
  2. package/README.md +91 -5
  3. package/client/access.tsx +66 -0
  4. package/client/browser.tsx +12 -9
  5. package/client/graph-camera.tsx +219 -0
  6. package/client/graph-labels.ts +147 -0
  7. package/client/launch-choices.ts +15 -4
  8. package/client/launch-guidance.tsx +86 -0
  9. package/client/launch-plan.ts +86 -16
  10. package/client/launch.tsx +19 -5
  11. package/client/live-agents.tsx +522 -0
  12. package/client/live-cursor.tsx +293 -0
  13. package/client/live-feed.tsx +151 -0
  14. package/client/live-graph.tsx +518 -0
  15. package/client/live-header.tsx +341 -0
  16. package/client/live-issues.tsx +345 -0
  17. package/client/live-map.tsx +345 -0
  18. package/client/live-panel.tsx +465 -0
  19. package/client/live-timeline.ts +376 -0
  20. package/client/pill.tsx +393 -0
  21. package/client/project-init.tsx +168 -0
  22. package/client/settings-access.tsx +56 -0
  23. package/client/settings-live.tsx +145 -0
  24. package/client/settings-projects.tsx +9 -0
  25. package/client/tool-buttons.tsx +68 -0
  26. package/client/web.ts +45 -0
  27. package/index.client.tsx +39 -1
  28. package/index.server.ts +29 -5
  29. package/package.json +1 -1
  30. package/server/agent-hooks.ts +37 -0
  31. package/server/agent-runs.ts +312 -0
  32. package/server/agent-tools.ts +240 -0
  33. package/server/handlers.ts +12 -1
  34. package/server/init.ts +65 -0
  35. package/server/links.ts +153 -0
  36. package/server/live.ts +360 -0
  37. package/server/mcp-http.ts +152 -0
  38. package/server/stores.ts +126 -0
  39. package/server/subagent-logs.ts +146 -0
  40. package/server/sync.ts +127 -0
  41. package/shared/activity.ts +312 -0
  42. package/shared/agent-hooks.ts +68 -0
  43. package/shared/agent-tools.ts +130 -0
  44. package/shared/graph-camera.ts +93 -0
  45. package/shared/graph-geometry.ts +122 -0
  46. package/shared/graph-model.ts +319 -0
  47. package/shared/links.ts +252 -0
  48. package/shared/live.ts +243 -0
  49. package/shared/map-model.ts +192 -0
  50. package/shared/project-setup.ts +55 -0
  51. package/shared/prompts.ts +65 -2
  52. package/shared/settings.ts +88 -1
  53. package/shared/shell-activity.ts +309 -0
  54. package/shared/shell-lexer.ts +180 -0
  55. package/shared/subagents.ts +106 -0
  56. package/shared/todo-sync.ts +158 -0
@@ -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
+ }