@yannelli/paseo-linear-plugin 1.1.0-beta.0 → 1.1.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/OVERVIEW.md +7 -3
- package/README.md +38 -11
- package/client/access.tsx +2 -1
- package/client/compat.ts +23 -5
- package/client/connect.tsx +3 -2
- package/client/graph-camera.tsx +219 -0
- package/client/graph-labels.ts +14 -4
- package/client/launch-guidance.tsx +86 -0
- package/client/launch-plan.ts +50 -2
- package/client/launch.tsx +10 -1
- package/client/live-agents.tsx +138 -23
- package/client/live-cursor.tsx +3 -1
- package/client/live-graph.tsx +159 -75
- package/client/live-header.tsx +29 -0
- package/client/live-panel.tsx +29 -8
- package/client/live-timeline.ts +38 -3
- package/client/pill.tsx +4 -3
- package/client/plugin-icon.tsx +43 -0
- package/client/settings-fields.tsx +11 -3
- package/client/settings-icon.tsx +228 -0
- package/client/settings-live.tsx +25 -1
- package/client/timeline-card.tsx +2 -1
- package/client/tool-buttons.tsx +68 -0
- package/client/ui.tsx +16 -7
- package/client/web.ts +91 -0
- package/index.client.tsx +9 -0
- package/index.server.ts +5 -1
- package/package.json +1 -1
- package/server/agent-hooks.ts +37 -0
- package/server/agent-runs.ts +15 -2
- package/server/agent-tools.ts +240 -0
- package/server/init.ts +1 -0
- package/server/live.ts +27 -1
- package/server/mcp-http.ts +152 -0
- package/server/stores.ts +8 -1
- package/server/subagent-logs.ts +146 -0
- package/shared/activity.ts +5 -1
- package/shared/agent-hooks.ts +68 -0
- package/shared/agent-tools.ts +130 -0
- package/shared/custom-icon.ts +148 -0
- package/shared/graph-camera.ts +93 -0
- package/shared/live.ts +43 -0
- package/shared/prompts.ts +49 -1
- package/shared/settings.ts +34 -0
|
@@ -5,6 +5,7 @@ import { type ReactNode, useCallback, useMemo, useState } from "react";
|
|
|
5
5
|
import { Text, View } from "react-native";
|
|
6
6
|
import { TEMPLATE_VARIABLES } from "../shared/prompts";
|
|
7
7
|
import type { LinearSettings, linearSettings } from "../shared/settings";
|
|
8
|
+
import { MONO } from "./live-issues";
|
|
8
9
|
import type { Theme } from "./ui";
|
|
9
10
|
|
|
10
11
|
export type ReadySettings = Extract<
|
|
@@ -19,10 +20,14 @@ export function MultilineField(props: {
|
|
|
19
20
|
value: string;
|
|
20
21
|
placeholder?: string;
|
|
21
22
|
minHeight?: number;
|
|
23
|
+
error?: string | null;
|
|
24
|
+
/** Markup, not prose: a monospace font and no autocorrect. */
|
|
25
|
+
code?: boolean;
|
|
22
26
|
onChange(value: string): void;
|
|
23
27
|
}) {
|
|
24
28
|
const { colors } = props.theme;
|
|
25
29
|
const minHeight = props.minHeight ?? 96;
|
|
30
|
+
const { code } = props;
|
|
26
31
|
const style = useMemo(
|
|
27
32
|
() => ({
|
|
28
33
|
marginTop: 8,
|
|
@@ -36,11 +41,12 @@ export function MultilineField(props: {
|
|
|
36
41
|
fontSize: 13,
|
|
37
42
|
lineHeight: 19,
|
|
38
43
|
textAlignVertical: "top" as const,
|
|
44
|
+
...(code ? { fontFamily: MONO, fontSize: 12 } : {}),
|
|
39
45
|
}),
|
|
40
|
-
[colors, minHeight],
|
|
46
|
+
[colors, minHeight, code],
|
|
41
47
|
);
|
|
42
48
|
return (
|
|
43
|
-
<SettingsRow label={props.label} hint={props.hint}>
|
|
49
|
+
<SettingsRow label={props.label} hint={props.hint} error={props.error}>
|
|
44
50
|
<TextInput
|
|
45
51
|
value={props.value}
|
|
46
52
|
onChangeText={props.onChange}
|
|
@@ -48,7 +54,9 @@ export function MultilineField(props: {
|
|
|
48
54
|
placeholder={props.placeholder}
|
|
49
55
|
placeholderTextColor={colors.foregroundMuted}
|
|
50
56
|
accessibilityLabel={props.label}
|
|
51
|
-
autoCapitalize="sentences"
|
|
57
|
+
autoCapitalize={code ? "none" : "sentences"}
|
|
58
|
+
autoCorrect={!code}
|
|
59
|
+
spellCheck={!code}
|
|
52
60
|
style={style}
|
|
53
61
|
/>
|
|
54
62
|
</SettingsRow>
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { type PluginSurfaceProps, useSettings } from "@getpaseo/plugin/client";
|
|
2
|
+
import {
|
|
3
|
+
SettingsAction,
|
|
4
|
+
SettingsCard,
|
|
5
|
+
SettingsInput,
|
|
6
|
+
SettingsRow,
|
|
7
|
+
SettingsSection,
|
|
8
|
+
SettingsSelect,
|
|
9
|
+
SettingsSwitch,
|
|
10
|
+
} from "@getpaseo/plugin/client/ui";
|
|
11
|
+
import { useCallback, useMemo, useState } from "react";
|
|
12
|
+
import { Platform, Text, View } from "react-native";
|
|
13
|
+
import {
|
|
14
|
+
HEX_COLOR,
|
|
15
|
+
type IconPaint,
|
|
16
|
+
type IconSettings,
|
|
17
|
+
MAX_SVG_LENGTH,
|
|
18
|
+
parseIconSvg,
|
|
19
|
+
} from "../shared/custom-icon";
|
|
20
|
+
import { linearSettings } from "../shared/settings";
|
|
21
|
+
import { IconGlyph } from "./plugin-icon";
|
|
22
|
+
import { MultilineField, type ReadySettings, SaveBar, SettingsGate, useSettingsDraft } from "./settings-fields";
|
|
23
|
+
import type { Theme } from "./ui";
|
|
24
|
+
import { canPickFiles, pickTextFile } from "./web";
|
|
25
|
+
|
|
26
|
+
const PAINT_OPTIONS: readonly { label: string; value: IconPaint }[] = [
|
|
27
|
+
{ label: "Original colors", value: "original" },
|
|
28
|
+
{ label: "Theme color, like Paseo's icons", value: "theme" },
|
|
29
|
+
{ label: "Theme accent", value: "accent" },
|
|
30
|
+
{ label: "Linear indigo, the plugin color", value: "linear" },
|
|
31
|
+
{ label: "Custom color", value: "custom" },
|
|
32
|
+
];
|
|
33
|
+
const PREVIEW_SIZES = [14, 16, 22, 28] as const;
|
|
34
|
+
const SVG_FILES = ".svg,image/svg+xml";
|
|
35
|
+
/** Exports often carry comments and metadata, so the file may be larger than the saved SVG. */
|
|
36
|
+
const MAX_FILE_BYTES = MAX_SVG_LENGTH * 4;
|
|
37
|
+
|
|
38
|
+
export function IconSettingsScreen({ theme }: PluginSurfaceProps) {
|
|
39
|
+
const settings = useSettings(linearSettings);
|
|
40
|
+
const render = useCallback(
|
|
41
|
+
(ready: ReadySettings) => <IconEditor theme={theme} settings={ready} />,
|
|
42
|
+
[theme],
|
|
43
|
+
);
|
|
44
|
+
return (
|
|
45
|
+
<SettingsGate theme={theme} title="Icon" settings={settings}>
|
|
46
|
+
{render}
|
|
47
|
+
</SettingsGate>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function IconPreview({ theme, icon }: { theme: Theme; icon: IconSettings }) {
|
|
52
|
+
const { colors } = theme;
|
|
53
|
+
const styles = useMemo(
|
|
54
|
+
() => ({
|
|
55
|
+
row: { flexDirection: "row" as const, flexWrap: "wrap" as const, alignItems: "center" as const, gap: 14, paddingTop: 8 },
|
|
56
|
+
pill: {
|
|
57
|
+
flexDirection: "row" as const,
|
|
58
|
+
alignItems: "center" as const,
|
|
59
|
+
gap: 6,
|
|
60
|
+
height: 26,
|
|
61
|
+
paddingHorizontal: 10,
|
|
62
|
+
borderRadius: 13,
|
|
63
|
+
borderWidth: 1,
|
|
64
|
+
borderColor: colors.border,
|
|
65
|
+
backgroundColor: colors.surface1,
|
|
66
|
+
},
|
|
67
|
+
label: { color: colors.foreground, fontSize: 12 },
|
|
68
|
+
}),
|
|
69
|
+
[colors],
|
|
70
|
+
);
|
|
71
|
+
return (
|
|
72
|
+
<View style={styles.row}>
|
|
73
|
+
{PREVIEW_SIZES.map((size) => (
|
|
74
|
+
<IconGlyph key={size} icon={icon} size={size} color={colors.foregroundMuted} theme={theme} />
|
|
75
|
+
))}
|
|
76
|
+
<View style={styles.pill}>
|
|
77
|
+
<IconGlyph icon={icon} size={14} color={colors.foregroundMuted} theme={theme} />
|
|
78
|
+
<Text style={styles.label}>ENG-123</Text>
|
|
79
|
+
</View>
|
|
80
|
+
<View style={styles.pill}>
|
|
81
|
+
<IconGlyph icon={icon} size={14} color={colors.foreground} theme={theme} />
|
|
82
|
+
<Text style={styles.label}>Linear</Text>
|
|
83
|
+
</View>
|
|
84
|
+
</View>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function IconEditor({ theme, settings }: { theme: Theme; settings: ReadySettings }) {
|
|
89
|
+
const { values, dirty, edit, save, discard } = useSettingsDraft(settings);
|
|
90
|
+
const icon = values.icon;
|
|
91
|
+
const [markup, setMarkup] = useState(icon.svg);
|
|
92
|
+
const [markupError, setMarkupError] = useState<string | null>(null);
|
|
93
|
+
const [colorError, setColorError] = useState<string | null>(null);
|
|
94
|
+
const [fileName, setFileName] = useState<string | null>(null);
|
|
95
|
+
const change = useCallback(
|
|
96
|
+
(patch: Partial<IconSettings>) => edit((current) => ({ ...current, icon: { ...current.icon, ...patch } })),
|
|
97
|
+
[edit],
|
|
98
|
+
);
|
|
99
|
+
// The draft keeps the last SVG that passed the checks, so the preview never breaks.
|
|
100
|
+
const applyMarkup = useCallback(
|
|
101
|
+
(text: string) => {
|
|
102
|
+
setMarkup(text);
|
|
103
|
+
if (!text.trim()) {
|
|
104
|
+
setMarkupError(null);
|
|
105
|
+
change({ svg: "" });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const parsed = parseIconSvg(text);
|
|
109
|
+
setMarkupError("error" in parsed ? parsed.error : null);
|
|
110
|
+
if ("svg" in parsed) change({ svg: parsed.svg });
|
|
111
|
+
},
|
|
112
|
+
[change],
|
|
113
|
+
);
|
|
114
|
+
const choose = useCallback(
|
|
115
|
+
() =>
|
|
116
|
+
pickTextFile(SVG_FILES, MAX_FILE_BYTES, (file) => {
|
|
117
|
+
if ("error" in file) {
|
|
118
|
+
setMarkupError(file.error);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
setFileName(file.name);
|
|
122
|
+
applyMarkup(file.text);
|
|
123
|
+
}),
|
|
124
|
+
[applyMarkup],
|
|
125
|
+
);
|
|
126
|
+
const useBuiltIn = useCallback(() => {
|
|
127
|
+
setFileName(null);
|
|
128
|
+
applyMarkup("");
|
|
129
|
+
}, [applyMarkup]);
|
|
130
|
+
const changeColor = useCallback(
|
|
131
|
+
(text: string) => {
|
|
132
|
+
const color = text.trim();
|
|
133
|
+
const valid = HEX_COLOR.test(color);
|
|
134
|
+
setColorError(valid ? null : "Use a hex color, such as #5E6AD2.");
|
|
135
|
+
if (valid) change({ color });
|
|
136
|
+
},
|
|
137
|
+
[change],
|
|
138
|
+
);
|
|
139
|
+
const reset = useCallback(() => {
|
|
140
|
+
discard();
|
|
141
|
+
setMarkup(settings.values.icon.svg);
|
|
142
|
+
setMarkupError(null);
|
|
143
|
+
setColorError(null);
|
|
144
|
+
setFileName(null);
|
|
145
|
+
}, [discard, settings.values.icon.svg]);
|
|
146
|
+
const native = Platform.OS !== "web";
|
|
147
|
+
const custom = icon.svg !== "";
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<View>
|
|
151
|
+
<SettingsSection title="Plugin icon">
|
|
152
|
+
<SettingsCard>
|
|
153
|
+
<SettingsRow
|
|
154
|
+
label="Preview"
|
|
155
|
+
hint={
|
|
156
|
+
native
|
|
157
|
+
? "The iOS and Android apps cannot draw plugin SVG images, so they show the built-in icon in the color you choose."
|
|
158
|
+
: "Composer pills, the Linear sidebar row, and Linear screens use this icon. Paseo menus and panel tabs keep the built-in icon."
|
|
159
|
+
}
|
|
160
|
+
>
|
|
161
|
+
<IconPreview theme={theme} icon={icon} />
|
|
162
|
+
</SettingsRow>
|
|
163
|
+
{canPickFiles ? (
|
|
164
|
+
<SettingsAction
|
|
165
|
+
label="SVG file"
|
|
166
|
+
hint={fileName ? `Loaded ${fileName}. Save to use it.` : "Choose an .svg file. The plugin saves a copy in its settings."}
|
|
167
|
+
actionLabel="Choose file"
|
|
168
|
+
onPress={choose}
|
|
169
|
+
/>
|
|
170
|
+
) : null}
|
|
171
|
+
<MultilineField
|
|
172
|
+
theme={theme}
|
|
173
|
+
label="SVG markup"
|
|
174
|
+
hint="Paste SVG markup, or edit it here. Scripts, links to other files, and bitmap images are not allowed. Clear it to use the built-in icon."
|
|
175
|
+
value={markup}
|
|
176
|
+
placeholder={'<svg viewBox="0 0 24 24">…</svg>'}
|
|
177
|
+
error={markupError}
|
|
178
|
+
code
|
|
179
|
+
onChange={applyMarkup}
|
|
180
|
+
/>
|
|
181
|
+
<SettingsAction
|
|
182
|
+
label="Built-in icon"
|
|
183
|
+
hint="Remove your SVG and use the Linear board icon."
|
|
184
|
+
actionLabel="Use built-in"
|
|
185
|
+
disabled={!custom && markup === ""}
|
|
186
|
+
onPress={useBuiltIn}
|
|
187
|
+
/>
|
|
188
|
+
</SettingsCard>
|
|
189
|
+
</SettingsSection>
|
|
190
|
+
<SettingsSection title="Color and fill">
|
|
191
|
+
<SettingsCard>
|
|
192
|
+
<SettingsSelect
|
|
193
|
+
label="Color"
|
|
194
|
+
hint="Original colors keep the SVG as drawn, and currentColor follows Paseo. The other choices paint the whole icon one color."
|
|
195
|
+
value={icon.paint}
|
|
196
|
+
options={PAINT_OPTIONS}
|
|
197
|
+
onValueChange={(paint) => change({ paint })}
|
|
198
|
+
/>
|
|
199
|
+
{icon.paint === "custom" ? (
|
|
200
|
+
<SettingsInput
|
|
201
|
+
label="Custom color"
|
|
202
|
+
hint="A hex color, such as #5E6AD2 or #5E6AD2CC with transparency."
|
|
203
|
+
initialValue={icon.color}
|
|
204
|
+
placeholder="#5E6AD2"
|
|
205
|
+
error={colorError}
|
|
206
|
+
onChangeText={changeColor}
|
|
207
|
+
/>
|
|
208
|
+
) : null}
|
|
209
|
+
<SettingsSwitch
|
|
210
|
+
label="Solid"
|
|
211
|
+
hint={custom ? "Fill shapes that only have an outline, so line icons show as solid shapes." : "Add your SVG to use this. The built-in icon stays as drawn."}
|
|
212
|
+
value={icon.solid}
|
|
213
|
+
disabled={!custom}
|
|
214
|
+
onValueChange={(solid) => change({ solid })}
|
|
215
|
+
/>
|
|
216
|
+
</SettingsCard>
|
|
217
|
+
</SettingsSection>
|
|
218
|
+
<SaveBar
|
|
219
|
+
theme={theme}
|
|
220
|
+
dirty={dirty}
|
|
221
|
+
saving={settings.saving}
|
|
222
|
+
error={settings.saveError}
|
|
223
|
+
onSave={() => void save()}
|
|
224
|
+
onDiscard={reset}
|
|
225
|
+
/>
|
|
226
|
+
</View>
|
|
227
|
+
);
|
|
228
|
+
}
|
package/client/settings-live.tsx
CHANGED
|
@@ -26,6 +26,7 @@ const VIEW_OPTIONS: readonly { label: string; value: LiveView }[] = [
|
|
|
26
26
|
{ label: "Graph: issue, files, and agents as connected nodes", value: "graph" },
|
|
27
27
|
];
|
|
28
28
|
const SAME_AGENT = "";
|
|
29
|
+
const MODEL_DEFAULT = "";
|
|
29
30
|
|
|
30
31
|
export function LiveSettings({ theme }: PluginSurfaceProps) {
|
|
31
32
|
const settings = useSettings(linearSettings);
|
|
@@ -62,6 +63,20 @@ function LiveEditor({ theme, settings }: { theme: Theme; settings: ReadySettings
|
|
|
62
63
|
}
|
|
63
64
|
return options;
|
|
64
65
|
}, [providers.data, live.exploreProvider]);
|
|
66
|
+
// Efforts of the chosen model, such as Low to Max, with its default first.
|
|
67
|
+
const effortOptions = useMemo(() => {
|
|
68
|
+
const [agentId, ...rest] = live.exploreProvider.split("/");
|
|
69
|
+
const modelId = rest.join("/");
|
|
70
|
+
const model = providers.data
|
|
71
|
+
?.find((agent) => agent.id === agentId)
|
|
72
|
+
?.models.find((entry) => entry.id === modelId);
|
|
73
|
+
if (!model || model.efforts.length === 0) return null;
|
|
74
|
+
const fallback = model.efforts.find((effort) => effort.id === model.defaultEffortId);
|
|
75
|
+
return [
|
|
76
|
+
{ label: fallback ? `Model default: ${fallback.label}` : "Model default", value: MODEL_DEFAULT },
|
|
77
|
+
...model.efforts.map((effort) => ({ label: effort.label, value: effort.id })),
|
|
78
|
+
];
|
|
79
|
+
}, [providers.data, live.exploreProvider]);
|
|
65
80
|
|
|
66
81
|
return (
|
|
67
82
|
<View>
|
|
@@ -94,8 +109,17 @@ function LiveEditor({ theme, settings }: { theme: Theme; settings: ReadySettings
|
|
|
94
109
|
hint="It may only read and search the project folder. A faster, cheaper model is usually enough."
|
|
95
110
|
value={live.exploreProvider}
|
|
96
111
|
options={agentOptions}
|
|
97
|
-
onValueChange={(exploreProvider) => change({ exploreProvider })}
|
|
112
|
+
onValueChange={(exploreProvider) => change({ exploreProvider, exploreEffort: MODEL_DEFAULT })}
|
|
98
113
|
/>
|
|
114
|
+
{effortOptions ? (
|
|
115
|
+
<SettingsSelect
|
|
116
|
+
label="Explore and setup effort"
|
|
117
|
+
hint="How much the model thinks before it answers. Lower is faster and uses less of your plan."
|
|
118
|
+
value={effortOptions.some((option) => option.value === live.exploreEffort) ? live.exploreEffort : MODEL_DEFAULT}
|
|
119
|
+
options={effortOptions}
|
|
120
|
+
onValueChange={(exploreEffort) => change({ exploreEffort })}
|
|
121
|
+
/>
|
|
122
|
+
) : null}
|
|
99
123
|
</SettingsCard>
|
|
100
124
|
</SettingsSection>
|
|
101
125
|
<SettingsSection title="Linear status sync">
|
package/client/timeline-card.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import { useCallback, useMemo } from "react";
|
|
|
4
4
|
import { Pressable, Text, View } from "react-native";
|
|
5
5
|
import type { IssueCard } from "../shared/linear";
|
|
6
6
|
import { ACTION_LABELS } from "../shared/prompts";
|
|
7
|
+
import { PluginIcon } from "./plugin-icon";
|
|
7
8
|
import { usePressableStyle } from "./ui";
|
|
8
9
|
|
|
9
10
|
export function IssueCardRow({ item, theme }: PluginTimelineItemProps<IssueCard>) {
|
|
@@ -44,7 +45,7 @@ export function IssueCardRow({ item, theme }: PluginTimelineItemProps<IssueCard>
|
|
|
44
45
|
onPress={open}
|
|
45
46
|
style={style}
|
|
46
47
|
>
|
|
47
|
-
<
|
|
48
|
+
<PluginIcon size={16} color={colors.foregroundMuted} theme={theme} />
|
|
48
49
|
<View style={styles.body}>
|
|
49
50
|
<Text numberOfLines={1} style={styles.title}>
|
|
50
51
|
<Text style={styles.identifier}>{`${card.identifier} `}</Text>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
2
|
+
import { type ReactNode, useMemo } from "react";
|
|
3
|
+
import { ActivityIndicator, Pressable, View } from "react-native";
|
|
4
|
+
import { type Theme, usePressableStyle } from "./ui";
|
|
5
|
+
|
|
6
|
+
// Small icon buttons for card toolbars: map actions and the graph's camera. A group draws them
|
|
7
|
+
// in one bordered track, like the Map and Graph switch.
|
|
8
|
+
|
|
9
|
+
export function ToolGroup({ theme, children }: { theme: Theme; children: ReactNode }) {
|
|
10
|
+
const { colors } = theme;
|
|
11
|
+
const style = useMemo(
|
|
12
|
+
() =>
|
|
13
|
+
({
|
|
14
|
+
flexDirection: "row",
|
|
15
|
+
alignItems: "center",
|
|
16
|
+
padding: 2,
|
|
17
|
+
gap: 2,
|
|
18
|
+
borderRadius: 8,
|
|
19
|
+
borderWidth: 1,
|
|
20
|
+
borderColor: colors.border,
|
|
21
|
+
backgroundColor: colors.surface1,
|
|
22
|
+
}) as const,
|
|
23
|
+
[colors],
|
|
24
|
+
);
|
|
25
|
+
return <View style={style}>{children}</View>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function ToolButton(props: {
|
|
29
|
+
theme: Theme;
|
|
30
|
+
icon: string;
|
|
31
|
+
/** Read by screen readers and shown as the tooltip on web. */
|
|
32
|
+
label: string;
|
|
33
|
+
onPress(): void;
|
|
34
|
+
/** A toggle that is on. */
|
|
35
|
+
active?: boolean;
|
|
36
|
+
disabled?: boolean;
|
|
37
|
+
busy?: boolean;
|
|
38
|
+
}) {
|
|
39
|
+
const { colors } = props.theme;
|
|
40
|
+
const styles = useMemo(() => {
|
|
41
|
+
const idle = {
|
|
42
|
+
width: 26,
|
|
43
|
+
height: 24,
|
|
44
|
+
borderRadius: 6,
|
|
45
|
+
alignItems: "center",
|
|
46
|
+
justifyContent: "center",
|
|
47
|
+
backgroundColor: props.active ? colors.surface2 : "transparent",
|
|
48
|
+
opacity: props.disabled ? 0.4 : 1,
|
|
49
|
+
} as const;
|
|
50
|
+
return { idle, hover: { ...idle, backgroundColor: colors.surface2 } };
|
|
51
|
+
}, [colors, props.active, props.disabled]);
|
|
52
|
+
const pressStyle = usePressableStyle(styles.idle, styles.hover);
|
|
53
|
+
const color = props.active ? colors.foreground : colors.foregroundMuted;
|
|
54
|
+
return (
|
|
55
|
+
<Pressable
|
|
56
|
+
accessibilityRole="button"
|
|
57
|
+
accessibilityLabel={props.label}
|
|
58
|
+
accessibilityState={{ selected: props.active === true, disabled: props.disabled === true }}
|
|
59
|
+
disabled={props.disabled || props.busy}
|
|
60
|
+
onPress={props.onPress}
|
|
61
|
+
hitSlop={4}
|
|
62
|
+
style={pressStyle}
|
|
63
|
+
{...({ title: props.label } as object)}
|
|
64
|
+
>
|
|
65
|
+
{props.busy ? <ActivityIndicator size="small" color={color} /> : <Icon name={props.icon} size={14} color={color} />}
|
|
66
|
+
</Pressable>
|
|
67
|
+
);
|
|
68
|
+
}
|
package/client/ui.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
|
|
2
2
|
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
3
|
-
import { type ReactNode, useCallback, useMemo } from "react";
|
|
3
|
+
import { type ComponentType, type ReactNode, useCallback, useMemo } from "react";
|
|
4
4
|
import {
|
|
5
5
|
ActivityIndicator,
|
|
6
6
|
Pressable,
|
|
@@ -15,6 +15,15 @@ export { Avatar, LabelPill, PriorityIcon, priorityMeta, StateIcon } from "./glyp
|
|
|
15
15
|
|
|
16
16
|
export type Theme = PluginSurfaceProps["theme"];
|
|
17
17
|
|
|
18
|
+
/** A Lucide icon name, or a component such as the plugin icon. */
|
|
19
|
+
export type Glyph = string | ComponentType<{ size: number; color: string; theme: Theme }>;
|
|
20
|
+
|
|
21
|
+
export function GlyphIcon(props: { glyph: Glyph; size: number; color: string; theme: Theme }) {
|
|
22
|
+
const { glyph: Custom, size, color, theme } = props;
|
|
23
|
+
if (typeof Custom === "string") return <Icon name={Custom} size={size} color={color} />;
|
|
24
|
+
return <Custom size={size} color={color} theme={theme} />;
|
|
25
|
+
}
|
|
26
|
+
|
|
18
27
|
type InteractionState = PressableStateCallbackType & { hovered?: boolean };
|
|
19
28
|
|
|
20
29
|
export function interactive(state: PressableStateCallbackType): boolean {
|
|
@@ -43,7 +52,7 @@ function buttonFill(variant: ButtonVariant, colors: Theme["colors"]): string {
|
|
|
43
52
|
export interface ButtonProps {
|
|
44
53
|
theme: Theme;
|
|
45
54
|
label: string;
|
|
46
|
-
icon?:
|
|
55
|
+
icon?: Glyph;
|
|
47
56
|
variant?: ButtonVariant;
|
|
48
57
|
size?: keyof typeof HEIGHT;
|
|
49
58
|
disabled?: boolean;
|
|
@@ -52,10 +61,10 @@ export interface ButtonProps {
|
|
|
52
61
|
onPress(): void;
|
|
53
62
|
}
|
|
54
63
|
|
|
55
|
-
function ButtonGlyph(props: { busy: boolean; icon?:
|
|
64
|
+
function ButtonGlyph(props: { busy: boolean; icon?: Glyph; size: number; color: string; theme: Theme }) {
|
|
56
65
|
if (props.busy) return <ActivityIndicator size="small" color={props.color} />;
|
|
57
66
|
if (!props.icon) return null;
|
|
58
|
-
return <
|
|
67
|
+
return <GlyphIcon glyph={props.icon} size={props.size} color={props.color} theme={props.theme} />;
|
|
59
68
|
}
|
|
60
69
|
|
|
61
70
|
export function Button({
|
|
@@ -103,7 +112,7 @@ export function Button({
|
|
|
103
112
|
onPress={onPress}
|
|
104
113
|
style={pressStyle}
|
|
105
114
|
>
|
|
106
|
-
<ButtonGlyph busy={busy === true} icon={icon} size={size === "xs" ? 14 : 16} color={ink} />
|
|
115
|
+
<ButtonGlyph busy={busy === true} icon={icon} size={size === "xs" ? 14 : 16} color={ink} theme={theme} />
|
|
107
116
|
<Text numberOfLines={1} style={styles.label}>
|
|
108
117
|
{label}
|
|
109
118
|
</Text>
|
|
@@ -276,7 +285,7 @@ export function OptionRow(props: {
|
|
|
276
285
|
|
|
277
286
|
export function EmptyState(props: {
|
|
278
287
|
theme: Theme;
|
|
279
|
-
icon:
|
|
288
|
+
icon: Glyph;
|
|
280
289
|
title: string;
|
|
281
290
|
detail?: string;
|
|
282
291
|
tone?: "default" | "danger";
|
|
@@ -309,7 +318,7 @@ export function EmptyState(props: {
|
|
|
309
318
|
);
|
|
310
319
|
return (
|
|
311
320
|
<View style={styles.root}>
|
|
312
|
-
<
|
|
321
|
+
<GlyphIcon glyph={props.icon} size={28} color={colors.foregroundMuted} theme={props.theme} />
|
|
313
322
|
<Text style={styles.title}>{props.title}</Text>
|
|
314
323
|
{props.detail ? <Text style={styles.detail}>{props.detail}</Text> : null}
|
|
315
324
|
{props.children}
|
package/client/web.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Platform } from "react-native";
|
|
2
|
+
|
|
3
|
+
// The only module that uses the DOM. React wheel listeners are passive, so they cannot keep
|
|
4
|
+
// the page from scrolling while the wheel zooms the graph. Off the web each export does nothing.
|
|
5
|
+
// The file picker is a hidden file input, because plugins cannot use native document pickers.
|
|
6
|
+
|
|
7
|
+
interface WheelEventLike {
|
|
8
|
+
deltaY: number;
|
|
9
|
+
deltaMode: number;
|
|
10
|
+
clientX: number;
|
|
11
|
+
clientY: number;
|
|
12
|
+
preventDefault(): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type WheelListener = (event: WheelEventLike) => void;
|
|
16
|
+
|
|
17
|
+
interface ElementLike {
|
|
18
|
+
addEventListener(type: "wheel", listener: WheelListener, options: { passive: boolean }): void;
|
|
19
|
+
removeEventListener(type: "wheel", listener: WheelListener): void;
|
|
20
|
+
getBoundingClientRect(): { left: number; top: number };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isElement(node: unknown): node is ElementLike {
|
|
24
|
+
if (typeof node !== "object" || node === null) return false;
|
|
25
|
+
const element = node as Partial<ElementLike>;
|
|
26
|
+
return typeof element.addEventListener === "function" && typeof element.getBoundingClientRect === "function";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const LINE = 16;
|
|
30
|
+
const PAGE = 400;
|
|
31
|
+
|
|
32
|
+
/** Calls onWheel with the point under the pointer and a zoom factor. Returns the cleanup. */
|
|
33
|
+
export function listenWheel(
|
|
34
|
+
node: unknown,
|
|
35
|
+
onWheel: (point: { x: number; y: number }, factor: number) => void,
|
|
36
|
+
): () => void {
|
|
37
|
+
if (Platform.OS !== "web" || !isElement(node)) return () => {};
|
|
38
|
+
const listener: WheelListener = (event) => {
|
|
39
|
+
event.preventDefault();
|
|
40
|
+
const rect = node.getBoundingClientRect();
|
|
41
|
+
const pixels = event.deltaY * (event.deltaMode === 1 ? LINE : event.deltaMode === 2 ? PAGE : 1);
|
|
42
|
+
onWheel({ x: event.clientX - rect.left, y: event.clientY - rect.top }, Math.exp(-pixels * 0.002));
|
|
43
|
+
};
|
|
44
|
+
node.addEventListener("wheel", listener, { passive: false });
|
|
45
|
+
return () => node.removeEventListener("wheel", listener);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface FileLike {
|
|
49
|
+
name: string;
|
|
50
|
+
size: number;
|
|
51
|
+
text(): Promise<string>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface FileInputLike {
|
|
55
|
+
type: string;
|
|
56
|
+
accept: string;
|
|
57
|
+
files: ArrayLike<FileLike> | null;
|
|
58
|
+
onchange: (() => void) | null;
|
|
59
|
+
click(): void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface DocumentLike {
|
|
63
|
+
createElement(tag: "input"): FileInputLike;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** True where pickTextFile can open a file picker. */
|
|
67
|
+
export const canPickFiles = Platform.OS === "web";
|
|
68
|
+
|
|
69
|
+
export type PickedFile = { name: string; text: string } | { error: string };
|
|
70
|
+
|
|
71
|
+
/** Opens the browser file picker and calls onPick with the chosen file. Nothing on cancel. */
|
|
72
|
+
export function pickTextFile(accept: string, maxBytes: number, onPick: (file: PickedFile) => void): void {
|
|
73
|
+
const document = (globalThis as { document?: DocumentLike }).document;
|
|
74
|
+
if (!canPickFiles || !document) return;
|
|
75
|
+
const input = document.createElement("input");
|
|
76
|
+
input.type = "file";
|
|
77
|
+
input.accept = accept;
|
|
78
|
+
input.onchange = () => {
|
|
79
|
+
const file = input.files?.[0];
|
|
80
|
+
if (!file) return;
|
|
81
|
+
if (file.size > maxBytes) {
|
|
82
|
+
onPick({ error: `${file.name} is too large. Use a file under ${Math.round(maxBytes / 1000)} KB.` });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
file.text().then(
|
|
86
|
+
(text) => onPick({ name: file.name, text }),
|
|
87
|
+
() => onPick({ error: `Paseo could not read ${file.name}.` }),
|
|
88
|
+
);
|
|
89
|
+
};
|
|
90
|
+
input.click();
|
|
91
|
+
}
|
package/index.client.tsx
CHANGED
|
@@ -4,7 +4,9 @@ import { openPluginScreen, registerScreen } from "./client/compat";
|
|
|
4
4
|
import { gateAgentPanel } from "./client/access";
|
|
5
5
|
import { LivePanel } from "./client/live-panel";
|
|
6
6
|
import { contributeComposerPills } from "./client/pill";
|
|
7
|
+
import { PluginIcon } from "./client/plugin-icon";
|
|
7
8
|
import { ConnectionSettings } from "./client/settings-connection";
|
|
9
|
+
import { IconSettingsScreen } from "./client/settings-icon";
|
|
8
10
|
import { LiveSettings } from "./client/settings-live";
|
|
9
11
|
import { ProjectSettings } from "./client/settings-projects";
|
|
10
12
|
import { PromptSettings } from "./client/settings-prompts";
|
|
@@ -23,6 +25,7 @@ export default function contribute(client: PluginClientContext) {
|
|
|
23
25
|
id: SCREEN_ID,
|
|
24
26
|
title: "Linear",
|
|
25
27
|
icon: "SquareKanban",
|
|
28
|
+
RowIcon: PluginIcon,
|
|
26
29
|
Component: LinearScreen,
|
|
27
30
|
});
|
|
28
31
|
client.addWorkspacePanel({
|
|
@@ -58,6 +61,12 @@ export default function contribute(client: PluginClientContext) {
|
|
|
58
61
|
icon: "Radar",
|
|
59
62
|
Component: LiveSettings,
|
|
60
63
|
});
|
|
64
|
+
client.addSettingsScreen({
|
|
65
|
+
id: "icon",
|
|
66
|
+
title: "Icon",
|
|
67
|
+
icon: "Shapes",
|
|
68
|
+
Component: IconSettingsScreen,
|
|
69
|
+
});
|
|
61
70
|
client.addSettingsScreen({
|
|
62
71
|
id: "projects",
|
|
63
72
|
title: "Projects",
|
package/index.server.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
3
|
+
import { registerAgentHooks } from "./server/agent-hooks";
|
|
4
|
+
import { registerAgentTools } from "./server/agent-tools";
|
|
3
5
|
import { createCredentialStore, defaultCredentialPath } from "./server/credentials";
|
|
4
6
|
import { registerHandlers } from "./server/handlers";
|
|
5
7
|
import { registerInit } from "./server/init";
|
|
@@ -24,9 +26,11 @@ export default function contribute(server: PluginServerContext) {
|
|
|
24
26
|
registerLive(server, { access, maps, launches, readSettings });
|
|
25
27
|
registerSync(server, { access, readSettings });
|
|
26
28
|
registerInit(server, { readSettings });
|
|
29
|
+
registerAgentHooks(server, dataDirectory);
|
|
30
|
+
const closeTools = registerAgentTools(server, { access, dataDirectory });
|
|
27
31
|
server.handle(accessRpc, async () => {
|
|
28
32
|
const values = await readSettings();
|
|
29
33
|
return values?.access ?? linearSettings.schema.parse({}).access;
|
|
30
34
|
});
|
|
31
|
-
return
|
|
35
|
+
return closeTools;
|
|
32
36
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
5
|
+
import { agentHooksRpc, claudeHooks, hookPluginName, IDENTIFIER } from "../shared/agent-hooks";
|
|
6
|
+
|
|
7
|
+
// Writes one Claude Code plugin folder per issue under the plugin's data folder. A launch passes
|
|
8
|
+
// the folder to Claude with --plugin-dir. The name holds a hash of the hooks, so a later launch
|
|
9
|
+
// never changes the hooks of a running agent. Resumed agents keep using it, so it stays.
|
|
10
|
+
|
|
11
|
+
async function writeAtomic(file: string, text: string) {
|
|
12
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
13
|
+
await writeFile(temporary, text, { mode: 0o600 });
|
|
14
|
+
await rename(temporary, file);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function registerAgentHooks(server: PluginServerContext, dataDirectory: string) {
|
|
18
|
+
server.handle(agentHooksRpc, async ({ issue, guidance }) => {
|
|
19
|
+
// The hooks run printf in a POSIX shell, which Windows hosts do not have.
|
|
20
|
+
if (process.platform === "win32" || !IDENTIFIER.test(issue.identifier)) return { pluginDir: null };
|
|
21
|
+
const hooks = claudeHooks(issue, guidance);
|
|
22
|
+
if (!hooks) return { pluginDir: null };
|
|
23
|
+
const text = `${JSON.stringify(hooks, null, 2)}\n`;
|
|
24
|
+
const hash = createHash("sha256").update(text).digest("hex").slice(0, 12);
|
|
25
|
+
const root = path.join(dataDirectory, "claude-hooks", `${issue.identifier.toUpperCase()}-${hash}`);
|
|
26
|
+
await mkdir(path.join(root, ".claude-plugin"), { recursive: true, mode: 0o700 });
|
|
27
|
+
await mkdir(path.join(root, "hooks"), { recursive: true, mode: 0o700 });
|
|
28
|
+
const manifest = {
|
|
29
|
+
name: hookPluginName(issue.identifier),
|
|
30
|
+
version: "1.0.0",
|
|
31
|
+
description: `Linear context for ${issue.identifier} from the Paseo Linear plugin`,
|
|
32
|
+
};
|
|
33
|
+
await writeAtomic(path.join(root, ".claude-plugin", "plugin.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
34
|
+
await writeAtomic(path.join(root, "hooks", "hooks.json"), text);
|
|
35
|
+
return { pluginDir: root };
|
|
36
|
+
});
|
|
37
|
+
}
|