@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,173 @@
|
|
|
1
|
+
import type { SettingsState } from "@getpaseo/plugin/client";
|
|
2
|
+
import { TextInput } from "@getpaseo/plugin/client/react-native";
|
|
3
|
+
import { SettingsAction, SettingsRow, SettingsSection } from "@getpaseo/plugin/client/ui";
|
|
4
|
+
import { type ReactNode, useCallback, useMemo, useState } from "react";
|
|
5
|
+
import { Text, View } from "react-native";
|
|
6
|
+
import { TEMPLATE_VARIABLES } from "../shared/prompts";
|
|
7
|
+
import type { LinearSettings, linearSettings } from "../shared/settings";
|
|
8
|
+
import type { Theme } from "./ui";
|
|
9
|
+
|
|
10
|
+
export type ReadySettings = Extract<
|
|
11
|
+
SettingsState<typeof linearSettings.schema>,
|
|
12
|
+
{ status: "ready" }
|
|
13
|
+
>;
|
|
14
|
+
|
|
15
|
+
export function MultilineField(props: {
|
|
16
|
+
theme: Theme;
|
|
17
|
+
label: string;
|
|
18
|
+
hint?: string;
|
|
19
|
+
value: string;
|
|
20
|
+
placeholder?: string;
|
|
21
|
+
minHeight?: number;
|
|
22
|
+
onChange(value: string): void;
|
|
23
|
+
}) {
|
|
24
|
+
const { colors } = props.theme;
|
|
25
|
+
const minHeight = props.minHeight ?? 96;
|
|
26
|
+
const style = useMemo(
|
|
27
|
+
() => ({
|
|
28
|
+
marginTop: 8,
|
|
29
|
+
minHeight,
|
|
30
|
+
padding: 10,
|
|
31
|
+
borderRadius: 8,
|
|
32
|
+
borderWidth: 1,
|
|
33
|
+
borderColor: colors.border,
|
|
34
|
+
backgroundColor: colors.surface2,
|
|
35
|
+
color: colors.foreground,
|
|
36
|
+
fontSize: 13,
|
|
37
|
+
lineHeight: 19,
|
|
38
|
+
textAlignVertical: "top" as const,
|
|
39
|
+
}),
|
|
40
|
+
[colors, minHeight],
|
|
41
|
+
);
|
|
42
|
+
return (
|
|
43
|
+
<SettingsRow label={props.label} hint={props.hint}>
|
|
44
|
+
<TextInput
|
|
45
|
+
value={props.value}
|
|
46
|
+
onChangeText={props.onChange}
|
|
47
|
+
multiline
|
|
48
|
+
placeholder={props.placeholder}
|
|
49
|
+
placeholderTextColor={colors.foregroundMuted}
|
|
50
|
+
accessibilityLabel={props.label}
|
|
51
|
+
autoCapitalize="sentences"
|
|
52
|
+
style={style}
|
|
53
|
+
/>
|
|
54
|
+
</SettingsRow>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const VARIABLES_STYLE = { gap: 2 } as const;
|
|
59
|
+
|
|
60
|
+
export function VariablesHint({ theme }: { theme: Theme }) {
|
|
61
|
+
const { colors } = theme;
|
|
62
|
+
const styles = useMemo(
|
|
63
|
+
() => ({
|
|
64
|
+
line: { color: colors.foregroundMuted, fontSize: 12 },
|
|
65
|
+
name: { color: colors.foreground },
|
|
66
|
+
}),
|
|
67
|
+
[colors],
|
|
68
|
+
);
|
|
69
|
+
return (
|
|
70
|
+
<View style={VARIABLES_STYLE}>
|
|
71
|
+
{TEMPLATE_VARIABLES.map((variable) => (
|
|
72
|
+
<Text key={variable.name} style={styles.line}>
|
|
73
|
+
<Text style={styles.name}>{`{{${variable.name}}}`}</Text>
|
|
74
|
+
{` ${variable.description}`}
|
|
75
|
+
</Text>
|
|
76
|
+
))}
|
|
77
|
+
</View>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type AnySettings = SettingsState<typeof linearSettings.schema>;
|
|
82
|
+
|
|
83
|
+
function SettingsProblem(props: { theme: Theme; title: string; settings: AnySettings }) {
|
|
84
|
+
const { settings } = props;
|
|
85
|
+
const { reload, reset } = settings;
|
|
86
|
+
const style = useMemo(() => ({ color: props.theme.colors.statusDanger }), [props.theme]);
|
|
87
|
+
const retry = useCallback(() => void reload(), [reload]);
|
|
88
|
+
const restore = useCallback(() => void reset(), [reset]);
|
|
89
|
+
return (
|
|
90
|
+
<SettingsSection title={props.title}>
|
|
91
|
+
<Text style={style}>{"error" in settings ? settings.error : ""}</Text>
|
|
92
|
+
<SettingsAction label="Try again" actionLabel="Reload" onPress={retry} />
|
|
93
|
+
{settings.status === "invalid" ? (
|
|
94
|
+
<SettingsAction label="Restore default settings" actionLabel="Reset" onPress={restore} />
|
|
95
|
+
) : null}
|
|
96
|
+
</SettingsSection>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function SettingsGate(props: {
|
|
101
|
+
theme: Theme;
|
|
102
|
+
title: string;
|
|
103
|
+
settings: AnySettings;
|
|
104
|
+
children(settings: ReadySettings): ReactNode;
|
|
105
|
+
}) {
|
|
106
|
+
const { settings, theme } = props;
|
|
107
|
+
const loadingStyle = useMemo(() => ({ color: theme.colors.foregroundMuted }), [theme]);
|
|
108
|
+
if (settings.status === "ready") return props.children(settings);
|
|
109
|
+
if (settings.status === "loading") return <Text style={loadingStyle}>Loading settings…</Text>;
|
|
110
|
+
return <SettingsProblem theme={theme} title={props.title} settings={settings} />;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const SAVE_BAR_STYLE = { gap: 8 } as const;
|
|
114
|
+
|
|
115
|
+
export function SaveBar(props: {
|
|
116
|
+
theme: Theme;
|
|
117
|
+
dirty: boolean;
|
|
118
|
+
saving: boolean;
|
|
119
|
+
error: string | null;
|
|
120
|
+
onSave(): void;
|
|
121
|
+
onDiscard(): void;
|
|
122
|
+
}) {
|
|
123
|
+
const errorStyle = useMemo(() => ({ color: props.theme.colors.statusDanger }), [props.theme]);
|
|
124
|
+
if (!props.dirty && !props.error) return null;
|
|
125
|
+
return (
|
|
126
|
+
<View style={SAVE_BAR_STYLE}>
|
|
127
|
+
{props.error ? (
|
|
128
|
+
<Text accessibilityRole="alert" style={errorStyle}>
|
|
129
|
+
{props.error}
|
|
130
|
+
</Text>
|
|
131
|
+
) : null}
|
|
132
|
+
{props.dirty ? (
|
|
133
|
+
<SettingsAction
|
|
134
|
+
label="Unsaved changes"
|
|
135
|
+
actionLabel="Save"
|
|
136
|
+
disabled={props.saving}
|
|
137
|
+
onPress={props.onSave}
|
|
138
|
+
/>
|
|
139
|
+
) : null}
|
|
140
|
+
{props.dirty ? (
|
|
141
|
+
<SettingsAction
|
|
142
|
+
label="Discard changes"
|
|
143
|
+
actionLabel="Discard"
|
|
144
|
+
disabled={props.saving}
|
|
145
|
+
onPress={props.onDiscard}
|
|
146
|
+
/>
|
|
147
|
+
) : null}
|
|
148
|
+
</View>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// A draft keeps the revision it started from, so a save over someone else's change conflicts
|
|
153
|
+
// instead of silently overwriting it. With no draft, the editor shows the saved values.
|
|
154
|
+
export function useSettingsDraft(settings: ReadySettings) {
|
|
155
|
+
const [draft, setDraft] = useState<{ values: LinearSettings; revision: string } | null>(null);
|
|
156
|
+
const values = draft?.values ?? settings.values;
|
|
157
|
+
const edit = useCallback(
|
|
158
|
+
(change: (current: LinearSettings) => LinearSettings) =>
|
|
159
|
+
setDraft((current) => ({
|
|
160
|
+
values: change(current?.values ?? settings.values),
|
|
161
|
+
revision: current?.revision ?? settings.revision,
|
|
162
|
+
})),
|
|
163
|
+
[settings.values, settings.revision],
|
|
164
|
+
);
|
|
165
|
+
const save = useCallback(async () => {
|
|
166
|
+
if (draft && (await settings.save(draft.values, draft.revision))) setDraft(null);
|
|
167
|
+
}, [draft, settings]);
|
|
168
|
+
const discard = useCallback(() => {
|
|
169
|
+
setDraft(null);
|
|
170
|
+
void settings.reload();
|
|
171
|
+
}, [settings]);
|
|
172
|
+
return { values, dirty: draft !== null, edit, save, discard };
|
|
173
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { useRpc } from "@getpaseo/plugin/client";
|
|
2
|
+
import { useToast } from "@getpaseo/plugin/client/react-native";
|
|
3
|
+
import {
|
|
4
|
+
SettingsAction,
|
|
5
|
+
SettingsCard,
|
|
6
|
+
SettingsRow,
|
|
7
|
+
SettingsSection,
|
|
8
|
+
SettingsSelect,
|
|
9
|
+
} from "@getpaseo/plugin/client/ui";
|
|
10
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
11
|
+
import { useCallback, useMemo, useState } from "react";
|
|
12
|
+
import { View } from "react-native";
|
|
13
|
+
import { authClearRpc } from "../shared/linear";
|
|
14
|
+
import { ApiKeyForm } from "./connect";
|
|
15
|
+
import { useAuthStatus, useProjects } from "./queries";
|
|
16
|
+
import { errorMessage, type Theme } from "./ui";
|
|
17
|
+
|
|
18
|
+
const FORM_STYLE = { gap: 10, paddingTop: 8 } as const;
|
|
19
|
+
|
|
20
|
+
// Keys for single Paseo projects. Workspaces in such a project use its key instead of the default.
|
|
21
|
+
export function ProjectKeysSection({ theme }: { theme: Theme }) {
|
|
22
|
+
const auth = useAuthStatus();
|
|
23
|
+
const projects = useProjects();
|
|
24
|
+
const toast = useToast();
|
|
25
|
+
const queries = useQueryClient();
|
|
26
|
+
const clear = useRpc(authClearRpc);
|
|
27
|
+
const saved = auth.data?.projectKeys ?? [];
|
|
28
|
+
const names = useMemo(
|
|
29
|
+
() => new Map(projects.data?.map((project) => [project.projectId, project.displayName])),
|
|
30
|
+
[projects.data],
|
|
31
|
+
);
|
|
32
|
+
const options = useMemo(
|
|
33
|
+
() =>
|
|
34
|
+
(projects.data ?? []).map((project) => ({
|
|
35
|
+
value: project.projectId,
|
|
36
|
+
label: saved.some((entry) => entry.projectId === project.projectId)
|
|
37
|
+
? `${project.displayName} (has a key)`
|
|
38
|
+
: project.displayName,
|
|
39
|
+
})),
|
|
40
|
+
[projects.data, saved],
|
|
41
|
+
);
|
|
42
|
+
const [selected, setSelected] = useState<string | null>(null);
|
|
43
|
+
const projectId = selected ?? options[0]?.value ?? null;
|
|
44
|
+
const remove = useMutation({
|
|
45
|
+
mutationFn: (id: string) => clear({ projectId: id }),
|
|
46
|
+
onSuccess: () => {
|
|
47
|
+
void queries.invalidateQueries({ queryKey: ["linear"] });
|
|
48
|
+
toast.show("Project key removed. The project uses the default key now.", {
|
|
49
|
+
variant: "success",
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
onError: (error) => toast.error(errorMessage(error)),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<SettingsSection title="Project keys">
|
|
57
|
+
<SettingsCard>
|
|
58
|
+
<SettingsRow
|
|
59
|
+
label="Use a different Linear key for a project"
|
|
60
|
+
hint="Workspaces in the project use its key. Other projects use the default key above."
|
|
61
|
+
/>
|
|
62
|
+
{saved.map((entry) => (
|
|
63
|
+
<SavedKeyRow
|
|
64
|
+
key={entry.projectId}
|
|
65
|
+
name={names.get(entry.projectId) ?? entry.projectId}
|
|
66
|
+
projectId={entry.projectId}
|
|
67
|
+
keyHint={entry.keyHint}
|
|
68
|
+
disabled={remove.isPending}
|
|
69
|
+
onRemove={remove.mutate}
|
|
70
|
+
/>
|
|
71
|
+
))}
|
|
72
|
+
{projectId ? (
|
|
73
|
+
<>
|
|
74
|
+
<SettingsSelect
|
|
75
|
+
label="Project"
|
|
76
|
+
value={projectId}
|
|
77
|
+
options={options}
|
|
78
|
+
onValueChange={setSelected}
|
|
79
|
+
/>
|
|
80
|
+
<SettingsRow label="Project API key" hint="Replaces the project's key if it has one.">
|
|
81
|
+
<View style={FORM_STYLE}>
|
|
82
|
+
<ApiKeyForm
|
|
83
|
+
key={projectId}
|
|
84
|
+
theme={theme}
|
|
85
|
+
projectId={projectId}
|
|
86
|
+
submitLabel="Save project key"
|
|
87
|
+
/>
|
|
88
|
+
</View>
|
|
89
|
+
</SettingsRow>
|
|
90
|
+
</>
|
|
91
|
+
) : (
|
|
92
|
+
<SettingsRow label="Project" hint="Add a project to Paseo first." />
|
|
93
|
+
)}
|
|
94
|
+
</SettingsCard>
|
|
95
|
+
</SettingsSection>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function SavedKeyRow(props: {
|
|
100
|
+
name: string;
|
|
101
|
+
projectId: string;
|
|
102
|
+
keyHint: string;
|
|
103
|
+
disabled: boolean;
|
|
104
|
+
onRemove(projectId: string): void;
|
|
105
|
+
}) {
|
|
106
|
+
const { onRemove, projectId } = props;
|
|
107
|
+
const press = useCallback(() => onRemove(projectId), [onRemove, projectId]);
|
|
108
|
+
return (
|
|
109
|
+
<SettingsAction
|
|
110
|
+
label={props.name}
|
|
111
|
+
hint={`Key ${props.keyHint}`}
|
|
112
|
+
actionLabel="Remove"
|
|
113
|
+
disabled={props.disabled}
|
|
114
|
+
onPress={press}
|
|
115
|
+
/>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { type PluginSurfaceProps, useSettings } from "@getpaseo/plugin/client";
|
|
2
|
+
import {
|
|
3
|
+
SettingsAction,
|
|
4
|
+
SettingsCard,
|
|
5
|
+
SettingsRow,
|
|
6
|
+
SettingsSection,
|
|
7
|
+
SettingsSelect,
|
|
8
|
+
SettingsSwitch,
|
|
9
|
+
} from "@getpaseo/plugin/client/ui";
|
|
10
|
+
import { useCallback, useMemo, useState } from "react";
|
|
11
|
+
import { Text, View } from "react-native";
|
|
12
|
+
import type { LinearTeam } from "../shared/linear";
|
|
13
|
+
import { ACTION_LABELS } from "../shared/prompts";
|
|
14
|
+
import {
|
|
15
|
+
AGENT_ACTIONS,
|
|
16
|
+
type AgentAction,
|
|
17
|
+
emptyProjectConfig,
|
|
18
|
+
linearSettings,
|
|
19
|
+
type ProjectConfig,
|
|
20
|
+
type PromptMode,
|
|
21
|
+
upsertProject,
|
|
22
|
+
} from "../shared/settings";
|
|
23
|
+
import { effectiveKeyScope, KeyScopeProvider } from "./key-scope";
|
|
24
|
+
import { type PaseoProjectOption, useAuthStatus, useCatalog, useProjects } from "./queries";
|
|
25
|
+
import {
|
|
26
|
+
MultilineField,
|
|
27
|
+
type ReadySettings,
|
|
28
|
+
SaveBar,
|
|
29
|
+
SettingsGate,
|
|
30
|
+
useSettingsDraft,
|
|
31
|
+
} from "./settings-fields";
|
|
32
|
+
import type { Theme } from "./ui";
|
|
33
|
+
|
|
34
|
+
const MODE_OPTIONS: readonly { label: string; value: PromptMode }[] = [
|
|
35
|
+
{ label: "Use the default prompt", value: "default" },
|
|
36
|
+
{ label: "Append to the default prompt", value: "append" },
|
|
37
|
+
{ label: "Replace the default prompt", value: "replace" },
|
|
38
|
+
];
|
|
39
|
+
const STEPS_PLACEHOLDER = "Run the type checker\nUpdate the changelog\nOpen a draft pull request";
|
|
40
|
+
|
|
41
|
+
type Change = (patch: Partial<ProjectConfig>) => void;
|
|
42
|
+
|
|
43
|
+
export function ProjectSettings({ theme }: PluginSurfaceProps) {
|
|
44
|
+
const settings = useSettings(linearSettings);
|
|
45
|
+
const render = useCallback(
|
|
46
|
+
(ready: ReadySettings) => <ProjectEditor theme={theme} settings={ready} />,
|
|
47
|
+
[theme],
|
|
48
|
+
);
|
|
49
|
+
return (
|
|
50
|
+
<SettingsGate theme={theme} title="Projects" settings={settings}>
|
|
51
|
+
{render}
|
|
52
|
+
</SettingsGate>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function projectOptions(
|
|
57
|
+
projects: readonly PaseoProjectOption[] | undefined,
|
|
58
|
+
stored: readonly ProjectConfig[],
|
|
59
|
+
) {
|
|
60
|
+
const known = (projects ?? []).map((project) => {
|
|
61
|
+
const customized = stored.some((entry) => entry.projectId === project.projectId);
|
|
62
|
+
return {
|
|
63
|
+
value: project.projectId,
|
|
64
|
+
label: customized ? `${project.displayName} · customized` : project.displayName,
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
const missing = stored
|
|
68
|
+
.filter((entry) => !projects?.some((project) => project.projectId === entry.projectId))
|
|
69
|
+
.map((entry) => ({ value: entry.projectId, label: `${entry.displayName} · not on this host` }));
|
|
70
|
+
return [...known, ...missing];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function ProjectEditor({ theme, settings }: { theme: Theme; settings: ReadySettings }) {
|
|
74
|
+
const { values, dirty, edit, save, discard } = useSettingsDraft(settings);
|
|
75
|
+
const projects = useProjects();
|
|
76
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
77
|
+
const options = useMemo(
|
|
78
|
+
() => projectOptions(projects.data, values.projects),
|
|
79
|
+
[projects.data, values.projects],
|
|
80
|
+
);
|
|
81
|
+
const projectId = selectedId ?? options[0]?.value ?? null;
|
|
82
|
+
const paseoProject = projects.data?.find((project) => project.projectId === projectId) ?? null;
|
|
83
|
+
const stored = values.projects.find((entry) => entry.projectId === projectId) ?? null;
|
|
84
|
+
const config = useMemo(
|
|
85
|
+
() =>
|
|
86
|
+
stored ??
|
|
87
|
+
emptyProjectConfig({
|
|
88
|
+
projectId: projectId ?? "",
|
|
89
|
+
displayName: paseoProject?.displayName ?? projectId ?? "",
|
|
90
|
+
rootPath: paseoProject?.rootPath ?? "",
|
|
91
|
+
}),
|
|
92
|
+
[stored, projectId, paseoProject],
|
|
93
|
+
);
|
|
94
|
+
const change = useCallback<Change>(
|
|
95
|
+
(patch) =>
|
|
96
|
+
edit((current) => ({
|
|
97
|
+
...current,
|
|
98
|
+
projects: upsertProject(current, { ...config, ...patch }),
|
|
99
|
+
})),
|
|
100
|
+
[edit, config],
|
|
101
|
+
);
|
|
102
|
+
const remove = useCallback(
|
|
103
|
+
() =>
|
|
104
|
+
edit((current) => ({
|
|
105
|
+
...current,
|
|
106
|
+
projects: current.projects.filter((entry) => entry.projectId !== config.projectId),
|
|
107
|
+
})),
|
|
108
|
+
[edit, config.projectId],
|
|
109
|
+
);
|
|
110
|
+
const saveDraft = useCallback(() => void save(), [save]);
|
|
111
|
+
const mutedStyle = useMemo(() => ({ color: theme.colors.foregroundMuted }), [theme]);
|
|
112
|
+
|
|
113
|
+
if (projects.isPending) return <Text style={mutedStyle}>Loading projects…</Text>;
|
|
114
|
+
if (!projectId) {
|
|
115
|
+
return (
|
|
116
|
+
<SettingsSection title="Projects">
|
|
117
|
+
<Text style={mutedStyle}>Add a project to Paseo to customize its Linear prompts.</Text>
|
|
118
|
+
</SettingsSection>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return (
|
|
122
|
+
<View>
|
|
123
|
+
<SettingsSection title="Project">
|
|
124
|
+
<SettingsCard>
|
|
125
|
+
<SettingsSelect
|
|
126
|
+
label="Customize"
|
|
127
|
+
value={projectId}
|
|
128
|
+
options={options}
|
|
129
|
+
onValueChange={setSelectedId}
|
|
130
|
+
/>
|
|
131
|
+
<SettingsRow label="Folder" hint={config.rootPath || "Unknown"} />
|
|
132
|
+
</SettingsCard>
|
|
133
|
+
</SettingsSection>
|
|
134
|
+
<TeamsSection config={config} change={change} />
|
|
135
|
+
<InstructionsSection theme={theme} config={config} change={change} />
|
|
136
|
+
{AGENT_ACTIONS.map((action) => (
|
|
137
|
+
<ActionSection key={action} theme={theme} action={action} config={config} change={change} />
|
|
138
|
+
))}
|
|
139
|
+
{stored ? (
|
|
140
|
+
<SettingsSection title="Reset">
|
|
141
|
+
<SettingsCard>
|
|
142
|
+
<SettingsAction
|
|
143
|
+
label="Remove this project's customization"
|
|
144
|
+
actionLabel="Remove"
|
|
145
|
+
onPress={remove}
|
|
146
|
+
/>
|
|
147
|
+
</SettingsCard>
|
|
148
|
+
</SettingsSection>
|
|
149
|
+
) : null}
|
|
150
|
+
<SaveBar
|
|
151
|
+
theme={theme}
|
|
152
|
+
dirty={dirty}
|
|
153
|
+
saving={settings.saving}
|
|
154
|
+
error={settings.saveError}
|
|
155
|
+
onSave={saveDraft}
|
|
156
|
+
onDiscard={discard}
|
|
157
|
+
/>
|
|
158
|
+
</View>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function TeamsSection({ config, change }: { config: ProjectConfig; change: Change }) {
|
|
163
|
+
const auth = useAuthStatus();
|
|
164
|
+
const scope = effectiveKeyScope(config.projectId, auth.data?.projectKeys);
|
|
165
|
+
return (
|
|
166
|
+
<KeyScopeProvider key={scope ?? "default"} projectId={scope}>
|
|
167
|
+
<TeamSwitches
|
|
168
|
+
config={config}
|
|
169
|
+
change={change}
|
|
170
|
+
enabled={scope !== null || auth.data?.configured === true}
|
|
171
|
+
/>
|
|
172
|
+
</KeyScopeProvider>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function TeamSwitches(props: { config: ProjectConfig; change: Change; enabled: boolean }) {
|
|
177
|
+
const { config, change } = props;
|
|
178
|
+
const catalog = useCatalog(props.enabled);
|
|
179
|
+
return (
|
|
180
|
+
<SettingsSection title="Linear teams">
|
|
181
|
+
<SettingsCard>
|
|
182
|
+
{catalog.data ? (
|
|
183
|
+
catalog.data.teams.map((team) => (
|
|
184
|
+
<TeamSwitch key={team.id} team={team} config={config} change={change} />
|
|
185
|
+
))
|
|
186
|
+
) : (
|
|
187
|
+
<SettingsRow label="Teams" hint="Connect Linear to map its teams to this project." />
|
|
188
|
+
)}
|
|
189
|
+
</SettingsCard>
|
|
190
|
+
</SettingsSection>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function TeamSwitch(props: { team: LinearTeam; config: ProjectConfig; change: Change }) {
|
|
195
|
+
const { team, config, change } = props;
|
|
196
|
+
const toggle = useCallback(
|
|
197
|
+
(enabled: boolean) =>
|
|
198
|
+
change({
|
|
199
|
+
teamIds: enabled
|
|
200
|
+
? [...config.teamIds, team.id]
|
|
201
|
+
: config.teamIds.filter((id) => id !== team.id),
|
|
202
|
+
}),
|
|
203
|
+
[change, config.teamIds, team.id],
|
|
204
|
+
);
|
|
205
|
+
return (
|
|
206
|
+
<SettingsSwitch
|
|
207
|
+
label={`${team.name} (${team.key})`}
|
|
208
|
+
hint="Issues from this team default to this project when you start an agent."
|
|
209
|
+
value={config.teamIds.includes(team.id)}
|
|
210
|
+
onValueChange={toggle}
|
|
211
|
+
/>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function InstructionsSection(props: { theme: Theme; config: ProjectConfig; change: Change }) {
|
|
216
|
+
const { change } = props;
|
|
217
|
+
const setInstructions = useCallback((instructions: string) => change({ instructions }), [change]);
|
|
218
|
+
const setSteps = useCallback((text: string) => change({ steps: text.split("\n") }), [change]);
|
|
219
|
+
return (
|
|
220
|
+
<SettingsSection title="Instructions and steps">
|
|
221
|
+
<SettingsCard>
|
|
222
|
+
<MultilineField
|
|
223
|
+
theme={props.theme}
|
|
224
|
+
label="Project instructions"
|
|
225
|
+
hint="Added to every implement and review prompt for this project."
|
|
226
|
+
value={props.config.instructions}
|
|
227
|
+
placeholder="Example: This is a pnpm monorepo. Run pnpm test --filter <package> before finishing."
|
|
228
|
+
onChange={setInstructions}
|
|
229
|
+
/>
|
|
230
|
+
<MultilineField
|
|
231
|
+
theme={props.theme}
|
|
232
|
+
label="Steps"
|
|
233
|
+
hint="One step per line. Agents receive them as a numbered checklist."
|
|
234
|
+
value={props.config.steps.join("\n")}
|
|
235
|
+
placeholder={STEPS_PLACEHOLDER}
|
|
236
|
+
onChange={setSteps}
|
|
237
|
+
/>
|
|
238
|
+
</SettingsCard>
|
|
239
|
+
</SettingsSection>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function ActionSection(props: {
|
|
244
|
+
theme: Theme;
|
|
245
|
+
action: AgentAction;
|
|
246
|
+
config: ProjectConfig;
|
|
247
|
+
change: Change;
|
|
248
|
+
}) {
|
|
249
|
+
const { action, change } = props;
|
|
250
|
+
const override = props.config[action];
|
|
251
|
+
const setMode = useCallback(
|
|
252
|
+
(mode: PromptMode) => change({ [action]: { ...override, mode } }),
|
|
253
|
+
[change, action, override],
|
|
254
|
+
);
|
|
255
|
+
const setText = useCallback(
|
|
256
|
+
(text: string) => change({ [action]: { ...override, text } }),
|
|
257
|
+
[change, action, override],
|
|
258
|
+
);
|
|
259
|
+
return (
|
|
260
|
+
<SettingsSection title={`${ACTION_LABELS[action].title} prompt`}>
|
|
261
|
+
<SettingsCard>
|
|
262
|
+
<SettingsSelect
|
|
263
|
+
label="Prompt"
|
|
264
|
+
value={override.mode}
|
|
265
|
+
options={MODE_OPTIONS}
|
|
266
|
+
onValueChange={setMode}
|
|
267
|
+
/>
|
|
268
|
+
{override.mode === "default" ? null : (
|
|
269
|
+
<MultilineField
|
|
270
|
+
theme={props.theme}
|
|
271
|
+
label={override.mode === "append" ? "Text to append" : "Replacement prompt"}
|
|
272
|
+
hint="Supports {{identifier}}, {{title}}, {{url}}, {{branch}}, and {{issue}}."
|
|
273
|
+
value={override.text}
|
|
274
|
+
minHeight={140}
|
|
275
|
+
onChange={setText}
|
|
276
|
+
/>
|
|
277
|
+
)}
|
|
278
|
+
</SettingsCard>
|
|
279
|
+
</SettingsSection>
|
|
280
|
+
);
|
|
281
|
+
}
|