@yannelli/paseo-linear-plugin 0.1.0 → 0.3.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/NOTICE +8 -4
- package/OVERVIEW.md +22 -13
- package/README.md +134 -29
- package/client/agent-options.ts +200 -0
- package/client/breadcrumbs.tsx +110 -0
- package/client/browser.tsx +275 -0
- package/client/compat.ts +107 -0
- package/client/connect.tsx +136 -0
- package/client/create-issue.tsx +291 -0
- package/client/glyphs.tsx +117 -0
- package/client/issue-detail.tsx +366 -0
- package/client/issue-list.tsx +373 -0
- package/client/issue-row.tsx +152 -0
- package/client/issue-sections.tsx +325 -0
- package/client/issue-tree.ts +132 -0
- package/client/key-scope.tsx +23 -0
- package/client/launch-choices.ts +125 -0
- package/client/launch-fields.tsx +152 -0
- package/client/launch-plan.ts +227 -0
- package/client/launch.tsx +484 -0
- package/client/markdown.tsx +240 -0
- package/client/model-browser.tsx +296 -0
- package/client/pickers.tsx +238 -0
- package/client/provider-icon.tsx +78 -0
- package/client/queries.ts +211 -0
- package/client/settings-connection.tsx +109 -0
- package/client/settings-fields.tsx +173 -0
- package/client/settings-keys.tsx +117 -0
- package/client/settings-projects.tsx +281 -0
- package/client/settings-prompts.tsx +198 -0
- package/client/store.ts +71 -0
- package/client/timeline-card.tsx +63 -0
- package/client/ui.tsx +352 -0
- package/index.client.tsx +111 -0
- package/index.server.ts +7 -3
- package/package.json +4 -3
- package/paseo-plugin.json +2 -2
- package/server/cache.ts +68 -0
- package/server/credentials.ts +101 -0
- package/server/graphql.ts +68 -0
- package/server/handlers.ts +172 -0
- package/server/linear.ts +11 -64
- package/server/queries.ts +280 -0
- package/shared/linear.ts +268 -0
- package/shared/markdown.ts +416 -0
- package/shared/prompts.ts +150 -0
- package/shared/settings.ts +68 -0
- package/index.client.ts +0 -7
- package/server/issues.ts +0 -10
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { openExternalUrl, type PluginSurfaceProps } from "@getpaseo/plugin/client";
|
|
2
|
+
import { Icon, TextInput, useToast } from "@getpaseo/plugin/client/react-native";
|
|
3
|
+
import { useCallback, useMemo, useState } from "react";
|
|
4
|
+
import { Pressable, Text, View } from "react-native";
|
|
5
|
+
import type { IssueDetail } from "../shared/linear";
|
|
6
|
+
import { Markdown } from "./markdown";
|
|
7
|
+
import { type LinkedAgent, useAddComment, useLinkedAgents } from "./queries";
|
|
8
|
+
import {
|
|
9
|
+
Avatar,
|
|
10
|
+
Button,
|
|
11
|
+
errorMessage,
|
|
12
|
+
relativeTime,
|
|
13
|
+
SectionLabel,
|
|
14
|
+
StateIcon,
|
|
15
|
+
type Theme,
|
|
16
|
+
usePressableStyle,
|
|
17
|
+
} from "./ui";
|
|
18
|
+
|
|
19
|
+
type Colors = Theme["colors"];
|
|
20
|
+
type Navigation = PluginSurfaceProps["navigation"];
|
|
21
|
+
|
|
22
|
+
function openUrl(url: string) {
|
|
23
|
+
void openExternalUrl(url);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function LinkRow(props: {
|
|
27
|
+
theme: Theme;
|
|
28
|
+
label: string;
|
|
29
|
+
detail?: string;
|
|
30
|
+
icon?: string;
|
|
31
|
+
state?: { type: string; color: string };
|
|
32
|
+
target: string;
|
|
33
|
+
onOpen(target: string): void;
|
|
34
|
+
}) {
|
|
35
|
+
const { colors } = props.theme;
|
|
36
|
+
const { target, onOpen } = props;
|
|
37
|
+
const styles = useMemo(() => {
|
|
38
|
+
const idle = {
|
|
39
|
+
minHeight: 34,
|
|
40
|
+
paddingHorizontal: 8,
|
|
41
|
+
paddingVertical: 6,
|
|
42
|
+
marginHorizontal: -8,
|
|
43
|
+
borderRadius: 8,
|
|
44
|
+
flexDirection: "row",
|
|
45
|
+
alignItems: "center",
|
|
46
|
+
gap: 8,
|
|
47
|
+
backgroundColor: "transparent",
|
|
48
|
+
} as const;
|
|
49
|
+
return {
|
|
50
|
+
idle,
|
|
51
|
+
active: { ...idle, backgroundColor: colors.surface2 },
|
|
52
|
+
label: { flex: 1, color: colors.foreground, fontSize: 14 },
|
|
53
|
+
detail: { color: colors.foregroundMuted, fontSize: 12, maxWidth: 160 },
|
|
54
|
+
};
|
|
55
|
+
}, [colors]);
|
|
56
|
+
const pressStyle = usePressableStyle(styles.idle, styles.active);
|
|
57
|
+
const press = useCallback(() => onOpen(target), [onOpen, target]);
|
|
58
|
+
return (
|
|
59
|
+
<Pressable accessibilityRole="link" onPress={press} style={pressStyle}>
|
|
60
|
+
{props.state ? (
|
|
61
|
+
<StateIcon state={props.state} size={14} />
|
|
62
|
+
) : (
|
|
63
|
+
<Icon name={props.icon ?? "Link"} size={14} color={colors.foregroundMuted} />
|
|
64
|
+
)}
|
|
65
|
+
<Text numberOfLines={1} style={styles.label}>
|
|
66
|
+
{props.label}
|
|
67
|
+
</Text>
|
|
68
|
+
{props.detail ? (
|
|
69
|
+
<Text numberOfLines={1} style={styles.detail}>
|
|
70
|
+
{props.detail}
|
|
71
|
+
</Text>
|
|
72
|
+
) : null}
|
|
73
|
+
</Pressable>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function IssueRelations(props: {
|
|
78
|
+
theme: Theme;
|
|
79
|
+
issue: IssueDetail;
|
|
80
|
+
onOpenIssue(issueId: string): void;
|
|
81
|
+
}) {
|
|
82
|
+
const { theme, issue } = props;
|
|
83
|
+
if (!issue.parent && issue.children.length === 0) return null;
|
|
84
|
+
return (
|
|
85
|
+
<View>
|
|
86
|
+
<SectionLabel theme={theme}>
|
|
87
|
+
{issue.parent ? "Parent and sub-issues" : "Sub-issues"}
|
|
88
|
+
</SectionLabel>
|
|
89
|
+
{issue.parent ? (
|
|
90
|
+
<LinkRow
|
|
91
|
+
theme={theme}
|
|
92
|
+
icon="CornerLeftUp"
|
|
93
|
+
label={`${issue.parent.identifier} ${issue.parent.title}`}
|
|
94
|
+
target={issue.parent.id}
|
|
95
|
+
onOpen={props.onOpenIssue}
|
|
96
|
+
/>
|
|
97
|
+
) : null}
|
|
98
|
+
{issue.children.map((child) => (
|
|
99
|
+
<LinkRow
|
|
100
|
+
key={child.id}
|
|
101
|
+
theme={theme}
|
|
102
|
+
state={child.state}
|
|
103
|
+
label={`${child.identifier} ${child.title}`}
|
|
104
|
+
target={child.id}
|
|
105
|
+
onOpen={props.onOpenIssue}
|
|
106
|
+
/>
|
|
107
|
+
))}
|
|
108
|
+
</View>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function IssueLinks({ theme, issue }: { theme: Theme; issue: IssueDetail }) {
|
|
113
|
+
if (issue.attachments.length === 0) return null;
|
|
114
|
+
return (
|
|
115
|
+
<View>
|
|
116
|
+
<SectionLabel theme={theme}>Links</SectionLabel>
|
|
117
|
+
{issue.attachments.map((attachment) => (
|
|
118
|
+
<LinkRow
|
|
119
|
+
key={attachment.id}
|
|
120
|
+
theme={theme}
|
|
121
|
+
icon={attachment.sourceType === "github" ? "GitPullRequest" : "Link"}
|
|
122
|
+
label={attachment.title}
|
|
123
|
+
detail={attachment.subtitle ?? undefined}
|
|
124
|
+
target={attachment.url}
|
|
125
|
+
onOpen={openUrl}
|
|
126
|
+
/>
|
|
127
|
+
))}
|
|
128
|
+
</View>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const AGENT_STATUS_COPY: Record<string, string> = {
|
|
133
|
+
initializing: "Starting",
|
|
134
|
+
idle: "Idle",
|
|
135
|
+
running: "Working",
|
|
136
|
+
error: "Error",
|
|
137
|
+
closed: "Closed",
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
function agentStatusColor(agent: LinkedAgent, colors: Colors): string {
|
|
141
|
+
if (agent.requiresAttention) return colors.statusWarning;
|
|
142
|
+
if (agent.status === "running") return colors.statusSuccess;
|
|
143
|
+
if (agent.status === "error") return colors.statusDanger;
|
|
144
|
+
return colors.foregroundMuted;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function AgentRow(props: {
|
|
148
|
+
theme: Theme;
|
|
149
|
+
agent: LinkedAgent;
|
|
150
|
+
first: boolean;
|
|
151
|
+
navigation: Navigation;
|
|
152
|
+
}) {
|
|
153
|
+
const { colors } = props.theme;
|
|
154
|
+
const { agent, first, navigation } = props;
|
|
155
|
+
const statusColor = agentStatusColor(agent, colors);
|
|
156
|
+
const styles = useMemo(() => {
|
|
157
|
+
const idle = {
|
|
158
|
+
minHeight: 44,
|
|
159
|
+
paddingHorizontal: 12,
|
|
160
|
+
flexDirection: "row",
|
|
161
|
+
alignItems: "center",
|
|
162
|
+
gap: 10,
|
|
163
|
+
borderTopWidth: first ? 0 : 1,
|
|
164
|
+
borderTopColor: colors.border,
|
|
165
|
+
backgroundColor: colors.surface1,
|
|
166
|
+
} as const;
|
|
167
|
+
return {
|
|
168
|
+
idle,
|
|
169
|
+
active: { ...idle, backgroundColor: colors.surface2 },
|
|
170
|
+
title: { flex: 1, color: colors.foreground, fontSize: 14 },
|
|
171
|
+
dot: { width: 7, height: 7, borderRadius: 4, backgroundColor: statusColor },
|
|
172
|
+
status: { color: colors.foregroundMuted, fontSize: 12 },
|
|
173
|
+
};
|
|
174
|
+
}, [colors, first, statusColor]);
|
|
175
|
+
const pressStyle = usePressableStyle(styles.idle, styles.active);
|
|
176
|
+
const open = useCallback(
|
|
177
|
+
() => navigation?.openAgent({ agentId: agent.id }),
|
|
178
|
+
[navigation, agent.id],
|
|
179
|
+
);
|
|
180
|
+
return (
|
|
181
|
+
<Pressable
|
|
182
|
+
accessibilityRole="button"
|
|
183
|
+
accessibilityLabel={`Open agent ${agent.title ?? agent.id}`}
|
|
184
|
+
disabled={!navigation}
|
|
185
|
+
onPress={open}
|
|
186
|
+
style={pressStyle}
|
|
187
|
+
>
|
|
188
|
+
<Icon
|
|
189
|
+
name={agent.action === "review" ? "ScanSearch" : "Bot"}
|
|
190
|
+
size={16}
|
|
191
|
+
color={colors.foregroundMuted}
|
|
192
|
+
/>
|
|
193
|
+
<Text numberOfLines={1} style={styles.title}>
|
|
194
|
+
{agent.title ?? agent.id}
|
|
195
|
+
</Text>
|
|
196
|
+
<View style={styles.dot} />
|
|
197
|
+
<Text style={styles.status}>
|
|
198
|
+
{agent.requiresAttention ? "Needs you" : (AGENT_STATUS_COPY[agent.status] ?? agent.status)}
|
|
199
|
+
</Text>
|
|
200
|
+
</Pressable>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function LinkedAgents(props: { theme: Theme; identifier: string; navigation: Navigation }) {
|
|
205
|
+
const { colors } = props.theme;
|
|
206
|
+
const agents = useLinkedAgents(props.identifier);
|
|
207
|
+
const style = useMemo(
|
|
208
|
+
() =>
|
|
209
|
+
({
|
|
210
|
+
borderRadius: 10,
|
|
211
|
+
borderWidth: 1,
|
|
212
|
+
borderColor: colors.border,
|
|
213
|
+
overflow: "hidden",
|
|
214
|
+
}) as const,
|
|
215
|
+
[colors],
|
|
216
|
+
);
|
|
217
|
+
if (!agents.data || agents.data.length === 0) return null;
|
|
218
|
+
return (
|
|
219
|
+
<View>
|
|
220
|
+
<SectionLabel theme={props.theme}>Agents on this issue</SectionLabel>
|
|
221
|
+
<View style={style}>
|
|
222
|
+
{agents.data.map((agent, index) => (
|
|
223
|
+
<AgentRow
|
|
224
|
+
key={agent.id}
|
|
225
|
+
theme={props.theme}
|
|
226
|
+
agent={agent}
|
|
227
|
+
first={index === 0}
|
|
228
|
+
navigation={props.navigation}
|
|
229
|
+
/>
|
|
230
|
+
))}
|
|
231
|
+
</View>
|
|
232
|
+
</View>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
type IssueComment = IssueDetail["comments"][number];
|
|
237
|
+
|
|
238
|
+
function CommentItem({ theme, comment }: { theme: Theme; comment: IssueComment }) {
|
|
239
|
+
const { colors } = theme;
|
|
240
|
+
const styles = useMemo(
|
|
241
|
+
() =>
|
|
242
|
+
({
|
|
243
|
+
root: { flexDirection: "row", gap: 10 },
|
|
244
|
+
body: { flex: 1, minWidth: 0, gap: 2 },
|
|
245
|
+
meta: { color: colors.foregroundMuted, fontSize: 12 },
|
|
246
|
+
author: { color: colors.foreground, fontWeight: "500" },
|
|
247
|
+
}) as const,
|
|
248
|
+
[colors],
|
|
249
|
+
);
|
|
250
|
+
return (
|
|
251
|
+
<View style={styles.root}>
|
|
252
|
+
<Avatar theme={theme} name={comment.user?.displayName ?? "Linear"} size={24} />
|
|
253
|
+
<View style={styles.body}>
|
|
254
|
+
<Text style={styles.meta}>
|
|
255
|
+
<Text style={styles.author}>{comment.user?.displayName ?? "Integration"}</Text>
|
|
256
|
+
{` ${relativeTime(comment.createdAt)}`}
|
|
257
|
+
</Text>
|
|
258
|
+
<Markdown theme={theme} compact source={comment.body} />
|
|
259
|
+
</View>
|
|
260
|
+
</View>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function Comments({ theme, issue }: { theme: Theme; issue: IssueDetail }) {
|
|
265
|
+
const { colors } = theme;
|
|
266
|
+
const toast = useToast();
|
|
267
|
+
const [body, setBody] = useState("");
|
|
268
|
+
const { mutate, isPending } = useAddComment(issue.id);
|
|
269
|
+
const submit = useCallback(() => {
|
|
270
|
+
if (!body.trim()) return;
|
|
271
|
+
mutate(body, {
|
|
272
|
+
onSuccess: () => setBody(""),
|
|
273
|
+
onError: (error) => toast.error(errorMessage(error)),
|
|
274
|
+
});
|
|
275
|
+
}, [body, mutate, toast]);
|
|
276
|
+
const styles = useMemo(
|
|
277
|
+
() =>
|
|
278
|
+
({
|
|
279
|
+
root: { gap: 12 },
|
|
280
|
+
composer: { gap: 8 },
|
|
281
|
+
input: {
|
|
282
|
+
minHeight: 72,
|
|
283
|
+
padding: 10,
|
|
284
|
+
borderRadius: 8,
|
|
285
|
+
borderWidth: 1,
|
|
286
|
+
borderColor: colors.border,
|
|
287
|
+
backgroundColor: colors.surface1,
|
|
288
|
+
color: colors.foreground,
|
|
289
|
+
fontSize: 14,
|
|
290
|
+
textAlignVertical: "top",
|
|
291
|
+
},
|
|
292
|
+
actions: { alignItems: "flex-end" },
|
|
293
|
+
}) as const,
|
|
294
|
+
[colors],
|
|
295
|
+
);
|
|
296
|
+
return (
|
|
297
|
+
<View style={styles.root}>
|
|
298
|
+
<SectionLabel theme={theme}>Activity</SectionLabel>
|
|
299
|
+
{issue.comments.map((comment) => (
|
|
300
|
+
<CommentItem key={comment.id} theme={theme} comment={comment} />
|
|
301
|
+
))}
|
|
302
|
+
<View style={styles.composer}>
|
|
303
|
+
<TextInput
|
|
304
|
+
value={body}
|
|
305
|
+
onChangeText={setBody}
|
|
306
|
+
multiline
|
|
307
|
+
placeholder="Leave a comment…"
|
|
308
|
+
placeholderTextColor={colors.foregroundMuted}
|
|
309
|
+
accessibilityLabel="New comment"
|
|
310
|
+
style={styles.input}
|
|
311
|
+
/>
|
|
312
|
+
<View style={styles.actions}>
|
|
313
|
+
<Button
|
|
314
|
+
theme={theme}
|
|
315
|
+
icon="Send"
|
|
316
|
+
label="Comment"
|
|
317
|
+
busy={isPending}
|
|
318
|
+
disabled={!body.trim()}
|
|
319
|
+
onPress={submit}
|
|
320
|
+
/>
|
|
321
|
+
</View>
|
|
322
|
+
</View>
|
|
323
|
+
</View>
|
|
324
|
+
);
|
|
325
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { IssueSummary } from "../shared/linear";
|
|
2
|
+
|
|
3
|
+
const STATE_ORDER = [
|
|
4
|
+
"started",
|
|
5
|
+
"unstarted",
|
|
6
|
+
"triage",
|
|
7
|
+
"backlog",
|
|
8
|
+
"completed",
|
|
9
|
+
"canceled",
|
|
10
|
+
"duplicate",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
export type IssueListRow =
|
|
14
|
+
| { kind: "header"; key: string; state: IssueSummary["state"]; count: number }
|
|
15
|
+
| {
|
|
16
|
+
kind: "issue";
|
|
17
|
+
key: string;
|
|
18
|
+
issue: IssueSummary;
|
|
19
|
+
depth: number;
|
|
20
|
+
/** Sub-issues of this issue that are in the list. */
|
|
21
|
+
childCount: number;
|
|
22
|
+
collapsed: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export interface RowOptions {
|
|
26
|
+
nested: boolean;
|
|
27
|
+
collapsed: ReadonlySet<string>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Groups issues by workflow state and keeps the server's order in each group. When nested, a
|
|
31
|
+
// sub-issue shows under its parent if the parent is in the list, whatever its own state is.
|
|
32
|
+
export function buildIssueRows(
|
|
33
|
+
issues: readonly IssueSummary[],
|
|
34
|
+
options: RowOptions,
|
|
35
|
+
): IssueListRow[] {
|
|
36
|
+
const children = new Map<string, IssueSummary[]>();
|
|
37
|
+
let roots: readonly IssueSummary[] = issues;
|
|
38
|
+
if (options.nested) {
|
|
39
|
+
const ids = new Set(issues.map((issue) => issue.id));
|
|
40
|
+
const nestedRoots: IssueSummary[] = [];
|
|
41
|
+
for (const issue of issues) {
|
|
42
|
+
const parentId = issue.parent?.id;
|
|
43
|
+
if (parentId && parentId !== issue.id && ids.has(parentId)) {
|
|
44
|
+
children.set(parentId, [...(children.get(parentId) ?? []), issue]);
|
|
45
|
+
} else {
|
|
46
|
+
nestedRoots.push(issue);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
roots = nestedRoots;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const rows: IssueListRow[] = [];
|
|
53
|
+
const placed = new Set<string>();
|
|
54
|
+
const place = (issue: IssueSummary, depth: number) => {
|
|
55
|
+
if (placed.has(issue.id)) return;
|
|
56
|
+
placed.add(issue.id);
|
|
57
|
+
const kids = children.get(issue.id) ?? [];
|
|
58
|
+
const collapsed = kids.length > 0 && options.collapsed.has(issue.id);
|
|
59
|
+
rows.push({ kind: "issue", key: issue.id, issue, depth, childCount: kids.length, collapsed });
|
|
60
|
+
if (collapsed) {
|
|
61
|
+
markPlaced(kids, children, placed);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
for (const child of kids) place(child, depth + 1);
|
|
65
|
+
};
|
|
66
|
+
const addGroups = (issues: readonly IssueSummary[], prefix: string) => {
|
|
67
|
+
for (const group of groupByState(issues)) {
|
|
68
|
+
const state = group[0]?.state;
|
|
69
|
+
if (!state) continue;
|
|
70
|
+
rows.push({ kind: "header", key: `${prefix}-${state.name}`, state, count: group.length });
|
|
71
|
+
for (const issue of group) place(issue, 0);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
addGroups(roots, "header");
|
|
76
|
+
// Parent loops have no root. Show those issues at the top level so none disappear.
|
|
77
|
+
addGroups(
|
|
78
|
+
issues.filter((issue) => !placed.has(issue.id)),
|
|
79
|
+
"loop",
|
|
80
|
+
);
|
|
81
|
+
return rows;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function markPlaced(
|
|
85
|
+
issues: readonly IssueSummary[],
|
|
86
|
+
children: ReadonlyMap<string, IssueSummary[]>,
|
|
87
|
+
placed: Set<string>,
|
|
88
|
+
) {
|
|
89
|
+
for (const issue of issues) {
|
|
90
|
+
if (placed.has(issue.id)) continue;
|
|
91
|
+
placed.add(issue.id);
|
|
92
|
+
markPlaced(children.get(issue.id) ?? [], children, placed);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function groupByState(issues: readonly IssueSummary[]): IssueSummary[][] {
|
|
97
|
+
const groups = new Map<string, IssueSummary[]>();
|
|
98
|
+
for (const issue of issues) {
|
|
99
|
+
const key = issue.state.name;
|
|
100
|
+
groups.set(key, [...(groups.get(key) ?? []), issue]);
|
|
101
|
+
}
|
|
102
|
+
return [...groups.values()].sort((a, b) => {
|
|
103
|
+
const left = a[0]?.state;
|
|
104
|
+
const right = b[0]?.state;
|
|
105
|
+
if (!left || !right) return 0;
|
|
106
|
+
const byType = STATE_ORDER.indexOf(left.type) - STATE_ORDER.indexOf(right.type);
|
|
107
|
+
return byType !== 0 ? byType : left.position - right.position;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Adds or removes one parent in the collapsed list. */
|
|
112
|
+
export function toggleCollapsed(collapsed: readonly string[], issueId: string): string[] {
|
|
113
|
+
return collapsed.includes(issueId)
|
|
114
|
+
? collapsed.filter((id) => id !== issueId)
|
|
115
|
+
: [...collapsed, issueId];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type Crumb<T> = { kind: "issue"; ref: T } | { kind: "more"; hidden: number };
|
|
119
|
+
|
|
120
|
+
/** Keeps the root and the direct parent and folds the middle of a long parent chain. */
|
|
121
|
+
export function crumbTrail<T>(ancestors: readonly T[], maxShown: number): Crumb<T>[] {
|
|
122
|
+
if (ancestors.length <= maxShown || maxShown < 2) {
|
|
123
|
+
return ancestors.map((ref) => ({ kind: "issue", ref }));
|
|
124
|
+
}
|
|
125
|
+
const head = ancestors.slice(0, 1);
|
|
126
|
+
const tail = ancestors.slice(ancestors.length - (maxShown - 1));
|
|
127
|
+
return [
|
|
128
|
+
...head.map((ref) => ({ kind: "issue" as const, ref })),
|
|
129
|
+
{ kind: "more", hidden: ancestors.length - head.length - tail.length },
|
|
130
|
+
...tail.map((ref) => ({ kind: "issue" as const, ref })),
|
|
131
|
+
];
|
|
132
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createContext, type ReactNode, useContext } from "react";
|
|
2
|
+
|
|
3
|
+
// The Paseo project whose Linear key the browser uses. Null means the default key.
|
|
4
|
+
const KeyScopeContext = createContext<string | null>(null);
|
|
5
|
+
|
|
6
|
+
export function KeyScopeProvider(props: { projectId: string | null; children: ReactNode }) {
|
|
7
|
+
return (
|
|
8
|
+
<KeyScopeContext.Provider value={props.projectId}>{props.children}</KeyScopeContext.Provider>
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function useKeyScope(): string | null {
|
|
13
|
+
return useContext(KeyScopeContext);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A project without its own key shares the default key, so it shares the default scope too. */
|
|
17
|
+
export function effectiveKeyScope(
|
|
18
|
+
projectId: string | null | undefined,
|
|
19
|
+
projectKeys: readonly { projectId: string }[] | undefined,
|
|
20
|
+
): string | null {
|
|
21
|
+
if (!projectId) return null;
|
|
22
|
+
return projectKeys?.some((entry) => entry.projectId === projectId) ? projectId : null;
|
|
23
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { usePaseo } from "@getpaseo/plugin/client";
|
|
2
|
+
import { useQuery } from "@tanstack/react-query";
|
|
3
|
+
import { useCallback, useMemo, useState } from "react";
|
|
4
|
+
import type { IssueDetail } from "../shared/linear";
|
|
5
|
+
import type { AgentAction, LinearSettings } from "../shared/settings";
|
|
6
|
+
import { type AgentPicks, resolveAgent } from "./agent-options";
|
|
7
|
+
import {
|
|
8
|
+
defaultProjectId,
|
|
9
|
+
type Placement,
|
|
10
|
+
placementOptions,
|
|
11
|
+
pullRequestNumber,
|
|
12
|
+
resolvePlacement,
|
|
13
|
+
type TargetWorkspace,
|
|
14
|
+
} from "./launch-plan";
|
|
15
|
+
import { useLinkedAgents, useProjects, useProviders } from "./queries";
|
|
16
|
+
|
|
17
|
+
// `useWorkspace` works only inside a workspace panel, so look the workspace up by id instead.
|
|
18
|
+
function useWorkspaceById(workspaceId: string | null) {
|
|
19
|
+
const paseo = usePaseo();
|
|
20
|
+
return (
|
|
21
|
+
useQuery({
|
|
22
|
+
queryKey: ["linear", "workspace", workspaceId],
|
|
23
|
+
enabled: workspaceId !== null,
|
|
24
|
+
staleTime: 60_000,
|
|
25
|
+
queryFn: async (): Promise<TargetWorkspace | null> => {
|
|
26
|
+
const { entries } = await paseo.workspaces.list();
|
|
27
|
+
const match = entries.find((entry) => entry.id === workspaceId);
|
|
28
|
+
return match ? { id: match.id, projectId: match.projectId, name: match.name } : null;
|
|
29
|
+
},
|
|
30
|
+
}).data ?? null
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const NO_PICKS: AgentPicks = { agent: null, model: null, effort: null, mode: null };
|
|
35
|
+
|
|
36
|
+
export function useLaunchChoices(input: {
|
|
37
|
+
issue: IssueDetail;
|
|
38
|
+
action: AgentAction;
|
|
39
|
+
settings: LinearSettings;
|
|
40
|
+
target: TargetWorkspace | null;
|
|
41
|
+
}) {
|
|
42
|
+
const { issue, settings, action } = input;
|
|
43
|
+
const projects = useProjects();
|
|
44
|
+
const providers = useProviders();
|
|
45
|
+
const { target } = input;
|
|
46
|
+
const prNumber = action === "review" ? pullRequestNumber(issue) : null;
|
|
47
|
+
const linked = useLinkedAgents(issue.identifier);
|
|
48
|
+
const agentWorkspaceId =
|
|
49
|
+
linked.data?.find((agent) => agent.action === "implement" && agent.workspaceId)?.workspaceId ??
|
|
50
|
+
null;
|
|
51
|
+
const agentWorkspace = useWorkspaceById(agentWorkspaceId);
|
|
52
|
+
const [projectChoice, setProjectChoice] = useState<string | null>(null);
|
|
53
|
+
const [placementChoice, setPlacementChoice] = useState<Placement | null>(null);
|
|
54
|
+
const [picks, setPicks] = useState<AgentPicks>(NO_PICKS);
|
|
55
|
+
const projectId =
|
|
56
|
+
projectChoice ??
|
|
57
|
+
defaultProjectId({ target, teamId: issue.team.id, settings, projects: projects.data });
|
|
58
|
+
const project = projects.data?.find((entry) => entry.projectId === projectId) ?? null;
|
|
59
|
+
const placements = useMemo(
|
|
60
|
+
() =>
|
|
61
|
+
placementOptions({
|
|
62
|
+
action,
|
|
63
|
+
prNumber,
|
|
64
|
+
project,
|
|
65
|
+
branchName: issue.branchName,
|
|
66
|
+
target,
|
|
67
|
+
agentWorkspace,
|
|
68
|
+
}),
|
|
69
|
+
[action, prNumber, project, issue.branchName, target, agentWorkspace],
|
|
70
|
+
);
|
|
71
|
+
const placement = resolvePlacement(
|
|
72
|
+
placements,
|
|
73
|
+
placementChoice,
|
|
74
|
+
action,
|
|
75
|
+
settings.launch.isolation,
|
|
76
|
+
);
|
|
77
|
+
const agents = providers.data ?? [];
|
|
78
|
+
const agent = resolveAgent(agents, picks, settings.launch.provider);
|
|
79
|
+
const chooseProject = useCallback((value: string) => {
|
|
80
|
+
setProjectChoice(value);
|
|
81
|
+
setPlacementChoice(null);
|
|
82
|
+
}, []);
|
|
83
|
+
const choosePlacement = useCallback(
|
|
84
|
+
(value: string) => setPlacementChoice(value as Placement),
|
|
85
|
+
[],
|
|
86
|
+
);
|
|
87
|
+
// A model from another agent starts from that agent's default effort and mode.
|
|
88
|
+
const chooseModel = useCallback(
|
|
89
|
+
(agentId: string, modelId: string) =>
|
|
90
|
+
setPicks((current) =>
|
|
91
|
+
current.agent === agentId || (current.agent === null && agent?.agent.id === agentId)
|
|
92
|
+
? { ...current, agent: agentId, model: modelId, effort: null }
|
|
93
|
+
: { ...NO_PICKS, agent: agentId, model: modelId },
|
|
94
|
+
),
|
|
95
|
+
[agent?.agent.id],
|
|
96
|
+
);
|
|
97
|
+
const chooseEffort = useCallback(
|
|
98
|
+
(value: string) => setPicks((current) => ({ ...current, effort: value })),
|
|
99
|
+
[],
|
|
100
|
+
);
|
|
101
|
+
const chooseMode = useCallback(
|
|
102
|
+
(value: string) => setPicks((current) => ({ ...current, mode: value })),
|
|
103
|
+
[],
|
|
104
|
+
);
|
|
105
|
+
return {
|
|
106
|
+
projects,
|
|
107
|
+
providers,
|
|
108
|
+
agents,
|
|
109
|
+
agent,
|
|
110
|
+
target,
|
|
111
|
+
agentWorkspace,
|
|
112
|
+
prNumber,
|
|
113
|
+
project,
|
|
114
|
+
projectConfig: settings.projects.find((entry) => entry.projectId === projectId) ?? null,
|
|
115
|
+
placements,
|
|
116
|
+
placement,
|
|
117
|
+
chooseProject,
|
|
118
|
+
choosePlacement,
|
|
119
|
+
chooseModel,
|
|
120
|
+
chooseEffort,
|
|
121
|
+
chooseMode,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export type LaunchChoices = ReturnType<typeof useLaunchChoices>;
|