@yannelli/paseo-linear-plugin 0.1.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/NOTICE +8 -4
  2. package/OVERVIEW.md +23 -13
  3. package/README.md +138 -29
  4. package/client/agent-options.ts +200 -0
  5. package/client/autosave.ts +77 -0
  6. package/client/breadcrumbs.tsx +110 -0
  7. package/client/browser.tsx +275 -0
  8. package/client/compat.ts +107 -0
  9. package/client/connect.tsx +136 -0
  10. package/client/create-issue.tsx +291 -0
  11. package/client/glyphs.tsx +117 -0
  12. package/client/issue-description.tsx +206 -0
  13. package/client/issue-detail.tsx +358 -0
  14. package/client/issue-list.tsx +373 -0
  15. package/client/issue-row.tsx +152 -0
  16. package/client/issue-sections.tsx +325 -0
  17. package/client/issue-tree.ts +132 -0
  18. package/client/key-scope.tsx +23 -0
  19. package/client/launch-choices.ts +125 -0
  20. package/client/launch-fields.tsx +152 -0
  21. package/client/launch-plan.ts +227 -0
  22. package/client/launch.tsx +484 -0
  23. package/client/markdown.tsx +302 -0
  24. package/client/model-browser.tsx +296 -0
  25. package/client/pickers.tsx +238 -0
  26. package/client/provider-icon.tsx +78 -0
  27. package/client/queries.ts +242 -0
  28. package/client/settings-connection.tsx +109 -0
  29. package/client/settings-fields.tsx +173 -0
  30. package/client/settings-keys.tsx +117 -0
  31. package/client/settings-projects.tsx +281 -0
  32. package/client/settings-prompts.tsx +198 -0
  33. package/client/store.ts +71 -0
  34. package/client/timeline-card.tsx +63 -0
  35. package/client/ui.tsx +352 -0
  36. package/index.client.tsx +111 -0
  37. package/index.server.ts +7 -3
  38. package/package.json +4 -3
  39. package/paseo-plugin.json +2 -2
  40. package/server/cache.ts +68 -0
  41. package/server/credentials.ts +101 -0
  42. package/server/graphql.ts +68 -0
  43. package/server/handlers.ts +172 -0
  44. package/server/linear.ts +11 -64
  45. package/server/queries.ts +280 -0
  46. package/shared/linear.ts +268 -0
  47. package/shared/markdown.ts +461 -0
  48. package/shared/prompts.ts +150 -0
  49. package/shared/settings.ts +68 -0
  50. package/index.client.ts +0 -7
  51. package/server/issues.ts +0 -10
@@ -0,0 +1,484 @@
1
+ import { type PluginSurfaceProps, useSettings } from "@getpaseo/plugin/client";
2
+ import { Icon, TextInput, useToast } from "@getpaseo/plugin/client/react-native";
3
+ import { useQueryClient } from "@tanstack/react-query";
4
+ import { useCallback, useMemo, useState } from "react";
5
+ import { ScrollView, Text, View } from "react-native";
6
+ import type { IssueDetail } from "../shared/linear";
7
+ import { ACTION_LABELS, composePrompt } from "../shared/prompts";
8
+ import {
9
+ AGENT_ACTIONS,
10
+ type AgentAction,
11
+ type LinearSettings,
12
+ linearSettings,
13
+ } from "../shared/settings";
14
+ import { modeIcon, optionLabel } from "./agent-options";
15
+ import { useKeyScope } from "./key-scope";
16
+ import { type LaunchChoices, useLaunchChoices } from "./launch-choices";
17
+ import { Chip, Field, Toggle } from "./launch-fields";
18
+ import { linearPatch, type TargetWorkspace, useLaunchAgent } from "./launch-plan";
19
+ import { ModelBrowser } from "./model-browser";
20
+ import { PickerModal } from "./pickers";
21
+ import { ProviderIcon } from "./provider-icon";
22
+ import { useCatalog, useIssue } from "./queries";
23
+ import { Button, EmptyState, errorMessage, IconButton, SectionLabel, type Theme } from "./ui";
24
+
25
+ // The workspace whose panel shows the browser. Null on the Linear screen.
26
+ export interface LaunchTarget {
27
+ workspace: TargetWorkspace | null;
28
+ }
29
+
30
+ export interface LaunchPageProps {
31
+ theme: Theme;
32
+ compact: boolean;
33
+ issueId: string;
34
+ action: AgentAction;
35
+ target: LaunchTarget;
36
+ navigation: PluginSurfaceProps["navigation"];
37
+ onBack(): void;
38
+ onStarted(): void;
39
+ }
40
+
41
+ type PickerKind = "prompt" | "model" | "effort" | "mode" | "project" | "placement";
42
+ type ListPickerKind = Exclude<PickerKind, "model">;
43
+
44
+ const SCROLL_STYLE = { flex: 1 } as const;
45
+ const PROMPT_CHOICES = AGENT_ACTIONS.map((action) => ({
46
+ value: action,
47
+ label: ACTION_LABELS[action].title,
48
+ }));
49
+
50
+ // Full-page composer for a new agent. It replaces the issue browser until the agent starts.
51
+ export function LaunchPage(props: LaunchPageProps) {
52
+ const { theme } = props;
53
+ const settings = useSettings(linearSettings);
54
+ const issue = useIssue(props.issueId);
55
+ if (settings.status === "ready" && issue.data) {
56
+ return <LaunchComposer {...props} issue={issue.data} settings={settings.values} />;
57
+ }
58
+ const failed = settings.status !== "ready" && settings.status !== "loading";
59
+ return (
60
+ <EmptyState
61
+ theme={theme}
62
+ icon={failed || issue.isError ? "TriangleAlert" : "RefreshCw"}
63
+ title={failed || issue.isError ? "Could not open the agent setup" : "Loading"}
64
+ detail={failed ? settings.error : issue.isError ? errorMessage(issue.error) : undefined}
65
+ >
66
+ <Button theme={theme} label="Back to issue" icon="ChevronLeft" onPress={props.onBack} />
67
+ </EmptyState>
68
+ );
69
+ }
70
+
71
+ function pickerConfig(
72
+ kind: ListPickerKind,
73
+ choices: LaunchChoices,
74
+ action: AgentAction,
75
+ theme: Theme,
76
+ ) {
77
+ const { agent } = choices;
78
+ const color = theme.colors.foreground;
79
+ switch (kind) {
80
+ case "prompt":
81
+ return { title: "Prompt", icon: "FileText", value: action, options: PROMPT_CHOICES };
82
+ case "effort":
83
+ return {
84
+ title: "Thinking",
85
+ icon: "Brain",
86
+ value: agent?.effort?.id ?? null,
87
+ options: (agent?.model.efforts ?? []).map((entry) => ({
88
+ value: entry.id,
89
+ label: optionLabel(entry),
90
+ leading: <Icon name="Brain" size={16} color={color} />,
91
+ })),
92
+ };
93
+ case "mode":
94
+ return {
95
+ title: "Mode",
96
+ icon: agent?.mode ? modeIcon(agent.mode) : "ShieldCheck",
97
+ value: agent?.mode?.id ?? null,
98
+ options: (agent?.agent.modes ?? []).map((entry) => ({
99
+ value: entry.id,
100
+ label: optionLabel(entry),
101
+ leading: <Icon name={modeIcon(entry)} size={16} color={color} />,
102
+ })),
103
+ };
104
+ case "project":
105
+ return {
106
+ title: "Project",
107
+ icon: "FolderGit2",
108
+ value: choices.project?.projectId ?? null,
109
+ options: (choices.projects.data ?? []).map((entry) => ({
110
+ value: entry.projectId,
111
+ label: entry.displayName,
112
+ detail: entry.rootPath,
113
+ })),
114
+ };
115
+ case "placement":
116
+ return {
117
+ title: "Run in",
118
+ icon: "GitBranch",
119
+ value: choices.placement?.value ?? null,
120
+ options: choices.placements,
121
+ };
122
+ }
123
+ }
124
+
125
+ function modelLabel(choices: LaunchChoices): string {
126
+ if (choices.agent) return choices.agent.model.label;
127
+ return choices.providers.isPending ? "Loading..." : "No agents available";
128
+ }
129
+
130
+ function LaunchComposer(props: LaunchPageProps & { issue: IssueDetail; settings: LinearSettings }) {
131
+ const { theme, compact, issue, settings, navigation, onStarted } = props;
132
+ const { colors } = theme;
133
+ const toast = useToast();
134
+ const queries = useQueryClient();
135
+ const catalog = useCatalog();
136
+ const keyScope = useKeyScope();
137
+ const [action, setAction] = useState<AgentAction>(props.action);
138
+ const choices = useLaunchChoices({
139
+ issue,
140
+ action,
141
+ settings,
142
+ target: props.target.workspace,
143
+ });
144
+ const launch = useLaunchAgent();
145
+ const label = ACTION_LABELS[action];
146
+ const viewerId = catalog.data?.viewer.id ?? null;
147
+ const started =
148
+ catalog.data?.teams
149
+ .find((team) => team.id === issue.team.id)
150
+ ?.states.find((state) => state.type === "started") ?? null;
151
+ const canStart = action === "implement" && !["started", "completed"].includes(issue.state.type);
152
+ const canAssign = action === "implement" && viewerId !== null && issue.assignee?.id !== viewerId;
153
+ const [includeComments, setIncludeComments] = useState(settings.launch.includeComments);
154
+ const [moveToStarted, setMoveToStarted] = useState(settings.launch.moveToStarted);
155
+ const [assignToMe, setAssignToMe] = useState(settings.launch.assignToMe);
156
+ const [picker, setPicker] = useState<PickerKind | null>(null);
157
+ // Null until the user types. Until then the prompt follows the template and options.
158
+ const [customPrompt, setCustomPrompt] = useState<string | null>(null);
159
+ const { projectConfig } = choices;
160
+ const generated = useMemo(
161
+ () =>
162
+ composePrompt({
163
+ action,
164
+ issue,
165
+ settings,
166
+ project: projectConfig,
167
+ includeComments,
168
+ extraInstructions: "",
169
+ }),
170
+ [action, issue, settings, projectConfig, includeComments],
171
+ );
172
+ const prompt = customPrompt ?? generated;
173
+ const styles = useMemo(() => {
174
+ const padding = compact ? 16 : 24;
175
+ return {
176
+ root: { flex: 1, minHeight: 0, backgroundColor: colors.surface0 },
177
+ bar: {
178
+ height: 48,
179
+ paddingHorizontal: compact ? 8 : 12,
180
+ flexDirection: "row" as const,
181
+ alignItems: "center" as const,
182
+ gap: 8,
183
+ borderBottomWidth: 1,
184
+ borderBottomColor: colors.border,
185
+ },
186
+ heading: { flex: 1, color: colors.foreground, fontSize: 15, fontWeight: "500" as const },
187
+ content: {
188
+ padding,
189
+ gap: 18,
190
+ paddingBottom: 48,
191
+ width: "100%" as const,
192
+ maxWidth: 820,
193
+ alignSelf: "center" as const,
194
+ },
195
+ issue: { color: colors.foregroundMuted, fontSize: 13 },
196
+ box: {
197
+ borderRadius: 12,
198
+ borderWidth: 1,
199
+ borderColor: colors.border,
200
+ backgroundColor: colors.surface1,
201
+ overflow: "hidden" as const,
202
+ },
203
+ input: {
204
+ minHeight: compact ? 200 : 280,
205
+ padding: 14,
206
+ color: colors.foreground,
207
+ fontSize: 14,
208
+ lineHeight: 20,
209
+ textAlignVertical: "top" as const,
210
+ },
211
+ chips: {
212
+ flexDirection: "row" as const,
213
+ flexWrap: "wrap" as const,
214
+ gap: 4,
215
+ paddingHorizontal: 8,
216
+ paddingBottom: 8,
217
+ },
218
+ note: { flexDirection: "row" as const, alignItems: "center" as const, gap: 8 },
219
+ hint: { flex: 1, color: colors.foregroundMuted, fontSize: 12 },
220
+ group: { gap: 8 },
221
+ error: { color: colors.statusDanger, fontSize: 13 },
222
+ start: { alignSelf: compact ? ("stretch" as const) : ("flex-end" as const) },
223
+ };
224
+ }, [colors, compact]);
225
+
226
+ const close = useCallback(() => setPicker(null), []);
227
+ const choosePrompt = useCallback((value: string) => setAction(value as AgentAction), []);
228
+ const select = useCallback(
229
+ (value: string) => {
230
+ setPicker(null);
231
+ const handlers: Record<ListPickerKind, (value: string) => void> = {
232
+ prompt: choosePrompt,
233
+ effort: choices.chooseEffort,
234
+ mode: choices.chooseMode,
235
+ project: choices.chooseProject,
236
+ placement: choices.choosePlacement,
237
+ };
238
+ if (picker && picker !== "model") handlers[picker](value);
239
+ },
240
+ [picker, choices, choosePrompt],
241
+ );
242
+ const { chooseModel } = choices;
243
+ const selectModel = useCallback(
244
+ (agentId: string, modelId: string) => {
245
+ setPicker(null);
246
+ chooseModel(agentId, modelId);
247
+ },
248
+ [chooseModel],
249
+ );
250
+ const resetPrompt = useCallback(() => setCustomPrompt(null), []);
251
+ const { mutate } = launch;
252
+ const start = useCallback(() => {
253
+ if (!choices.project || !choices.agent || !choices.placement || !prompt.trim()) return;
254
+ const patch = linearPatch({
255
+ started,
256
+ moveToStarted: canStart && moveToStarted,
257
+ viewerId,
258
+ assignToMe: canAssign && assignToMe,
259
+ });
260
+ mutate(
261
+ {
262
+ action,
263
+ issue,
264
+ project: choices.project,
265
+ agent: choices.agent,
266
+ placement: choices.placement.value,
267
+ prNumber: choices.prNumber,
268
+ target: choices.target,
269
+ agentWorkspace: choices.agentWorkspace,
270
+ prompt,
271
+ patch,
272
+ started,
273
+ keyScope,
274
+ },
275
+ {
276
+ onSuccess: ({ agentId, warning }) => {
277
+ void queries.invalidateQueries({ queryKey: ["linear"] });
278
+ if (warning) {
279
+ toast.show(`Agent started. Linear was not updated: ${warning}`, { variant: "warning" });
280
+ } else toast.show("Agent started", { variant: "success" });
281
+ onStarted();
282
+ navigation?.openAgent({ agentId });
283
+ },
284
+ },
285
+ );
286
+ }, [
287
+ choices,
288
+ started,
289
+ canStart,
290
+ moveToStarted,
291
+ viewerId,
292
+ canAssign,
293
+ assignToMe,
294
+ mutate,
295
+ action,
296
+ issue,
297
+ prompt,
298
+ keyScope,
299
+ queries,
300
+ toast,
301
+ onStarted,
302
+ navigation,
303
+ ]);
304
+
305
+ const open = (kind: PickerKind) => () => setPicker(kind);
306
+ const config = picker && picker !== "model" ? pickerConfig(picker, choices, action, theme) : null;
307
+ const { agent } = choices;
308
+ const muted = colors.foregroundMuted;
309
+ return (
310
+ <View style={styles.root}>
311
+ <View style={styles.bar}>
312
+ <IconButton theme={theme} icon="ChevronLeft" label="Back to issue" onPress={props.onBack} />
313
+ <Text style={styles.heading} numberOfLines={1}>
314
+ {`${label.verb} · ${issue.identifier}`}
315
+ </Text>
316
+ </View>
317
+ <ScrollView style={SCROLL_STYLE} contentContainerStyle={styles.content}>
318
+ <Text style={styles.issue} numberOfLines={2}>
319
+ {issue.title}
320
+ </Text>
321
+ <View style={styles.box}>
322
+ <TextInput
323
+ value={prompt}
324
+ onChangeText={setCustomPrompt}
325
+ multiline
326
+ placeholder="Tell the agent what to do"
327
+ placeholderTextColor={colors.foregroundMuted}
328
+ accessibilityLabel="Prompt"
329
+ style={styles.input}
330
+ />
331
+ <View style={styles.chips}>
332
+ <Chip
333
+ theme={theme}
334
+ icon={<Icon name="FileText" size={16} color={muted} />}
335
+ value={label.title}
336
+ accessibilityLabel={`Change prompt: ${label.title}`}
337
+ onPress={open("prompt")}
338
+ />
339
+ <Chip
340
+ theme={theme}
341
+ icon={
342
+ agent ? (
343
+ <ProviderIcon
344
+ provider={agent.agent.id}
345
+ iconSvg={agent.agent.iconSvg}
346
+ size={16}
347
+ color={muted}
348
+ />
349
+ ) : (
350
+ <Icon name="Bot" size={16} color={muted} />
351
+ )
352
+ }
353
+ value={modelLabel(choices)}
354
+ accessibilityLabel={`Change model: ${modelLabel(choices)}`}
355
+ disabled={!agent}
356
+ onPress={open("model")}
357
+ />
358
+ {agent?.effort ? (
359
+ <Chip
360
+ theme={theme}
361
+ icon={<Icon name="Brain" size={16} color={muted} />}
362
+ value={optionLabel(agent.effort)}
363
+ accessibilityLabel={`Thinking mode: ${optionLabel(agent.effort)}`}
364
+ onPress={open("effort")}
365
+ />
366
+ ) : null}
367
+ {agent?.mode ? (
368
+ <Chip
369
+ theme={theme}
370
+ icon={<Icon name={modeIcon(agent.mode)} size={16} color={muted} />}
371
+ value={optionLabel(agent.mode)}
372
+ accessibilityLabel={`Change mode: ${optionLabel(agent.mode)}`}
373
+ onPress={open("mode")}
374
+ />
375
+ ) : null}
376
+ </View>
377
+ </View>
378
+ <View style={styles.note}>
379
+ <Text style={styles.hint}>
380
+ {customPrompt === null
381
+ ? "The prompt follows the options below until you edit it."
382
+ : "You edited the prompt. Option changes no longer update it."}
383
+ </Text>
384
+ {customPrompt === null ? null : (
385
+ <Button
386
+ theme={theme}
387
+ size="xs"
388
+ variant="ghost"
389
+ icon="RotateCcw"
390
+ label="Reset prompt"
391
+ onPress={resetPrompt}
392
+ />
393
+ )}
394
+ </View>
395
+ <View style={styles.group}>
396
+ <SectionLabel theme={theme}>Where</SectionLabel>
397
+ <Field
398
+ theme={theme}
399
+ label="Project"
400
+ icon="FolderGit2"
401
+ value={
402
+ choices.project?.displayName ??
403
+ (choices.projects.isPending ? "Loading…" : "Choose a project")
404
+ }
405
+ onPress={open("project")}
406
+ />
407
+ <Field
408
+ theme={theme}
409
+ label="Run in"
410
+ icon="GitBranch"
411
+ value={choices.placement?.label ?? ""}
412
+ detail={choices.placement?.detail}
413
+ onPress={open("placement")}
414
+ />
415
+ </View>
416
+ <View style={styles.group}>
417
+ <SectionLabel theme={theme}>Options</SectionLabel>
418
+ <Toggle
419
+ theme={theme}
420
+ label="Include comments in the prompt"
421
+ value={includeComments}
422
+ onChange={setIncludeComments}
423
+ />
424
+ {canStart ? (
425
+ <Toggle
426
+ theme={theme}
427
+ label="Move the issue to In Progress"
428
+ value={moveToStarted}
429
+ onChange={setMoveToStarted}
430
+ />
431
+ ) : null}
432
+ {canAssign ? (
433
+ <Toggle
434
+ theme={theme}
435
+ label="Assign the issue to me"
436
+ value={assignToMe}
437
+ onChange={setAssignToMe}
438
+ />
439
+ ) : null}
440
+ </View>
441
+ {launch.error ? (
442
+ <Text accessibilityRole="alert" style={styles.error}>
443
+ {errorMessage(launch.error)}
444
+ </Text>
445
+ ) : null}
446
+ <View style={styles.start}>
447
+ <Button
448
+ theme={theme}
449
+ variant="primary"
450
+ size="md"
451
+ icon={label.icon}
452
+ label={label.verb}
453
+ busy={launch.isPending}
454
+ disabled={!choices.project || !choices.agent || !prompt.trim()}
455
+ onPress={start}
456
+ />
457
+ </View>
458
+ </ScrollView>
459
+ {config ? (
460
+ <PickerModal
461
+ theme={theme}
462
+ title={config.title}
463
+ icon={config.icon}
464
+ open
465
+ options={config.options}
466
+ value={config.value}
467
+ searchable={config.options.length > 6}
468
+ onClose={close}
469
+ onSelect={select}
470
+ />
471
+ ) : null}
472
+ {picker === "model" ? (
473
+ <ModelBrowser
474
+ theme={theme}
475
+ agents={choices.agents}
476
+ agentId={agent?.agent.id ?? null}
477
+ modelId={agent?.model.id ?? null}
478
+ onClose={close}
479
+ onSelect={selectModel}
480
+ />
481
+ ) : null}
482
+ </View>
483
+ );
484
+ }