@yannelli/paseo-linear-plugin 1.0.1 → 1.1.0-alpha.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/OVERVIEW.md +32 -2
- package/README.md +134 -8
- package/client/access.tsx +67 -0
- package/client/browser.tsx +12 -9
- package/client/compat.ts +23 -5
- package/client/connect.tsx +3 -2
- package/client/launch-assignments.tsx +182 -0
- package/client/launch-choices.ts +21 -6
- package/client/launch-guidance.tsx +103 -0
- package/client/launch-plan.ts +114 -17
- package/client/launch.tsx +55 -9
- package/client/live-agents.tsx +446 -0
- package/client/live-feed.tsx +151 -0
- package/client/live-header.tsx +310 -0
- package/client/live-issues.tsx +345 -0
- package/client/live-map.tsx +351 -0
- package/client/live-panel.tsx +392 -0
- package/client/live-timeline.ts +379 -0
- package/client/pill.tsx +394 -0
- package/client/plugin-icon.tsx +75 -0
- package/client/project-init.tsx +192 -0
- package/client/project-knowledge.tsx +87 -0
- package/client/settings-access.tsx +56 -0
- package/client/settings-agents.tsx +113 -0
- package/client/settings-fields.tsx +11 -3
- package/client/settings-icon.tsx +277 -0
- package/client/settings-live.tsx +128 -0
- package/client/settings-overrides.tsx +148 -0
- package/client/settings-projects.tsx +26 -4
- package/client/settings-prompts.tsx +3 -3
- package/client/timeline-card.tsx +2 -1
- package/client/tool-buttons.tsx +68 -0
- package/client/ui.tsx +16 -7
- package/client/web.ts +81 -0
- package/index.client.tsx +160 -51
- package/index.server.ts +32 -5
- package/package.json +1 -1
- package/server/agent-hooks.ts +37 -0
- package/server/agent-runs.ts +312 -0
- package/server/agent-tools.ts +276 -0
- package/server/handlers.ts +12 -1
- package/server/init.ts +89 -0
- package/server/inspect.ts +392 -0
- package/server/knowledge.ts +157 -0
- package/server/live.ts +371 -0
- package/server/mcp-http.ts +156 -0
- package/server/stores.ts +126 -0
- package/server/subagent-logs.ts +146 -0
- package/server/sync.ts +133 -0
- package/shared/activity.ts +268 -0
- package/shared/agent-hooks.ts +68 -0
- package/shared/agent-tools.ts +157 -0
- package/shared/custom-icon.ts +188 -0
- package/shared/knowledge.ts +255 -0
- package/shared/live.ts +248 -0
- package/shared/map-model.ts +240 -0
- package/shared/project-setup.ts +56 -0
- package/shared/prompts.ts +109 -2
- package/shared/settings.ts +145 -3
- package/shared/shell-activity.ts +309 -0
- package/shared/shell-lexer.ts +180 -0
- package/shared/subagents.ts +106 -0
- package/shared/todo-sync.ts +158 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { type PluginSurfaceProps, useSettings } from "@getpaseo/plugin/client";
|
|
2
|
+
import {
|
|
3
|
+
SettingsCard,
|
|
4
|
+
SettingsSection,
|
|
5
|
+
SettingsSelect,
|
|
6
|
+
SettingsSwitch,
|
|
7
|
+
} from "@getpaseo/plugin/client/ui";
|
|
8
|
+
import { useCallback, useMemo } from "react";
|
|
9
|
+
import { View } from "react-native";
|
|
10
|
+
import { type LinearSettings, linearSettings, type MappingMode } from "../shared/settings";
|
|
11
|
+
import { useProviders } from "./queries";
|
|
12
|
+
import { type ReadySettings, SaveBar, SettingsGate, useSettingsDraft } from "./settings-fields";
|
|
13
|
+
import type { Theme } from "./ui";
|
|
14
|
+
|
|
15
|
+
const MAPPING_OPTIONS: readonly { label: string; value: MappingMode }[] = [
|
|
16
|
+
{ label: "Ticket text: free, rough", value: "semantic" },
|
|
17
|
+
{ label: "Explore agent: reads the repo first", value: "explore" },
|
|
18
|
+
];
|
|
19
|
+
const SAME_AGENT = "";
|
|
20
|
+
const MODEL_DEFAULT = "";
|
|
21
|
+
|
|
22
|
+
export function LiveSettings({ theme }: PluginSurfaceProps) {
|
|
23
|
+
const settings = useSettings(linearSettings);
|
|
24
|
+
const render = useCallback(
|
|
25
|
+
(ready: ReadySettings) => <LiveEditor theme={theme} settings={ready} />,
|
|
26
|
+
[theme],
|
|
27
|
+
);
|
|
28
|
+
return (
|
|
29
|
+
<SettingsGate theme={theme} title="Live" settings={settings}>
|
|
30
|
+
{render}
|
|
31
|
+
</SettingsGate>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type Live = LinearSettings["live"];
|
|
36
|
+
|
|
37
|
+
function LiveEditor({ theme, settings }: { theme: Theme; settings: ReadySettings }) {
|
|
38
|
+
const { values, dirty, edit, save, discard } = useSettingsDraft(settings);
|
|
39
|
+
const providers = useProviders();
|
|
40
|
+
const live = values.live;
|
|
41
|
+
const change = useCallback(
|
|
42
|
+
(patch: Partial<Live>) => edit((current) => ({ ...current, live: { ...current.live, ...patch } })),
|
|
43
|
+
[edit],
|
|
44
|
+
);
|
|
45
|
+
const agentOptions = useMemo(() => {
|
|
46
|
+
const options = [{ label: "Same agent as the implementer", value: SAME_AGENT }];
|
|
47
|
+
for (const agent of providers.data ?? []) {
|
|
48
|
+
for (const model of agent.models) {
|
|
49
|
+
options.push({ label: `${agent.label} · ${model.label}`, value: `${agent.id}/${model.id}` });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (live.exploreProvider && !options.some((option) => option.value === live.exploreProvider)) {
|
|
53
|
+
options.push({ label: `${live.exploreProvider} · not on this host`, value: live.exploreProvider });
|
|
54
|
+
}
|
|
55
|
+
return options;
|
|
56
|
+
}, [providers.data, live.exploreProvider]);
|
|
57
|
+
// Efforts of the chosen model, such as Low to Max, with its default first.
|
|
58
|
+
const effortOptions = useMemo(() => {
|
|
59
|
+
const [agentId, ...rest] = live.exploreProvider.split("/");
|
|
60
|
+
const modelId = rest.join("/");
|
|
61
|
+
const model = providers.data
|
|
62
|
+
?.find((agent) => agent.id === agentId)
|
|
63
|
+
?.models.find((entry) => entry.id === modelId);
|
|
64
|
+
if (!model || model.efforts.length === 0) return null;
|
|
65
|
+
const fallback = model.efforts.find((effort) => effort.id === model.defaultEffortId);
|
|
66
|
+
return [
|
|
67
|
+
{ label: fallback ? `Model default: ${fallback.label}` : "Model default", value: MODEL_DEFAULT },
|
|
68
|
+
...model.efforts.map((effort) => ({ label: effort.label, value: effort.id })),
|
|
69
|
+
];
|
|
70
|
+
}, [providers.data, live.exploreProvider]);
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<View>
|
|
74
|
+
<SettingsSection title="Linear Live">
|
|
75
|
+
<SettingsCard>
|
|
76
|
+
<SettingsSwitch
|
|
77
|
+
label="Show Linear Live"
|
|
78
|
+
hint="Track agents started from an issue: sub-issues, todos, and the files they touch."
|
|
79
|
+
value={live.enabled}
|
|
80
|
+
onValueChange={(enabled) => change({ enabled })}
|
|
81
|
+
/>
|
|
82
|
+
<SettingsSelect
|
|
83
|
+
label="Map files with"
|
|
84
|
+
hint="Ticket text finds paths named in the issue. The explore agent reads the repository first, and the implement agent starts when the map is ready. It costs provider usage."
|
|
85
|
+
value={live.mapping}
|
|
86
|
+
options={MAPPING_OPTIONS}
|
|
87
|
+
onValueChange={(mapping) => change({ mapping })}
|
|
88
|
+
disabled={!live.enabled}
|
|
89
|
+
/>
|
|
90
|
+
<SettingsSelect
|
|
91
|
+
label="Explore and setup agent"
|
|
92
|
+
hint="It may only read and search the project folder. A faster, cheaper model is usually enough."
|
|
93
|
+
value={live.exploreProvider}
|
|
94
|
+
options={agentOptions}
|
|
95
|
+
onValueChange={(exploreProvider) => change({ exploreProvider, exploreEffort: MODEL_DEFAULT })}
|
|
96
|
+
/>
|
|
97
|
+
{effortOptions ? (
|
|
98
|
+
<SettingsSelect
|
|
99
|
+
label="Explore and setup effort"
|
|
100
|
+
hint="How much the model thinks before it answers. Lower is faster and uses less of your plan."
|
|
101
|
+
value={effortOptions.some((option) => option.value === live.exploreEffort) ? live.exploreEffort : MODEL_DEFAULT}
|
|
102
|
+
options={effortOptions}
|
|
103
|
+
onValueChange={(exploreEffort) => change({ exploreEffort })}
|
|
104
|
+
/>
|
|
105
|
+
) : null}
|
|
106
|
+
</SettingsCard>
|
|
107
|
+
</SettingsSection>
|
|
108
|
+
<SettingsSection title="Linear status sync">
|
|
109
|
+
<SettingsCard>
|
|
110
|
+
<SettingsSwitch
|
|
111
|
+
label="Update Linear from agent todos"
|
|
112
|
+
hint="After each turn, a todo that starts with a sub-issue key moves that sub-issue forward: In Progress, then Done. The parent moves to In Review when every sub-issue is Done. Statuses never move back. A project can turn this on or off for itself, under Projects."
|
|
113
|
+
value={live.syncTodos}
|
|
114
|
+
onValueChange={(syncTodos) => change({ syncTodos })}
|
|
115
|
+
/>
|
|
116
|
+
</SettingsCard>
|
|
117
|
+
</SettingsSection>
|
|
118
|
+
<SaveBar
|
|
119
|
+
theme={theme}
|
|
120
|
+
dirty={dirty}
|
|
121
|
+
saving={settings.saving}
|
|
122
|
+
error={settings.saveError}
|
|
123
|
+
onSave={() => void save()}
|
|
124
|
+
onDiscard={discard}
|
|
125
|
+
/>
|
|
126
|
+
</View>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { SettingsCard, SettingsSection, SettingsSelect } from "@getpaseo/plugin/client/ui";
|
|
2
|
+
import { useCallback, useMemo } from "react";
|
|
3
|
+
import { View } from "react-native";
|
|
4
|
+
import {
|
|
5
|
+
type LinearSettings,
|
|
6
|
+
type ProjectConfig,
|
|
7
|
+
type ProjectOverrides,
|
|
8
|
+
projectDefaults,
|
|
9
|
+
} from "../shared/settings";
|
|
10
|
+
import { useProviders } from "./queries";
|
|
11
|
+
import { COPY, LIMIT_OPTIONS, limitLabel } from "./settings-agents";
|
|
12
|
+
import { ISOLATION_OPTIONS } from "./settings-prompts";
|
|
13
|
+
|
|
14
|
+
// A project's own values for settings that all projects share. "Same as all projects" removes
|
|
15
|
+
// the project's value, so the project follows the setting for all projects again.
|
|
16
|
+
|
|
17
|
+
const INHERIT = "__all__";
|
|
18
|
+
const AUTO_MODEL = "__auto__";
|
|
19
|
+
const ON_OFF = [
|
|
20
|
+
{ label: "On", value: "on" },
|
|
21
|
+
{ label: "Off", value: "off" },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
type BooleanKey = {
|
|
25
|
+
[Key in keyof ProjectOverrides]-?: NonNullable<ProjectOverrides[Key]> extends boolean ? Key : never;
|
|
26
|
+
}[keyof ProjectOverrides];
|
|
27
|
+
|
|
28
|
+
interface Option {
|
|
29
|
+
label: string;
|
|
30
|
+
value: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const onOff = (value: boolean) => (value ? "On" : "Off");
|
|
34
|
+
const labelOf = (options: readonly Option[], value: string) =>
|
|
35
|
+
options.find((option) => option.value === value)?.label ?? value;
|
|
36
|
+
|
|
37
|
+
function OverrideSelect(props: {
|
|
38
|
+
label: string;
|
|
39
|
+
hint?: string;
|
|
40
|
+
/** The project's own value, or undefined when it follows all projects. */
|
|
41
|
+
value: string | undefined;
|
|
42
|
+
/** How the setting for all projects reads, such as "On". */
|
|
43
|
+
inherited: string;
|
|
44
|
+
options: readonly Option[];
|
|
45
|
+
disabled?: boolean;
|
|
46
|
+
onChange(value: string | undefined): void;
|
|
47
|
+
}) {
|
|
48
|
+
const { onChange } = props;
|
|
49
|
+
const options = useMemo(
|
|
50
|
+
() => [{ label: `Same as all projects: ${props.inherited}`, value: INHERIT }, ...props.options],
|
|
51
|
+
[props.inherited, props.options],
|
|
52
|
+
);
|
|
53
|
+
const select = useCallback((value: string) => onChange(value === INHERIT ? undefined : value), [onChange]);
|
|
54
|
+
return (
|
|
55
|
+
<SettingsSelect
|
|
56
|
+
label={props.label}
|
|
57
|
+
hint={props.hint}
|
|
58
|
+
value={props.value ?? INHERIT}
|
|
59
|
+
options={options}
|
|
60
|
+
disabled={props.disabled}
|
|
61
|
+
onValueChange={select}
|
|
62
|
+
/>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function ProjectOverridesSection(props: {
|
|
67
|
+
values: LinearSettings;
|
|
68
|
+
config: ProjectConfig;
|
|
69
|
+
change(patch: Partial<ProjectConfig>): void;
|
|
70
|
+
}) {
|
|
71
|
+
const { values, config, change } = props;
|
|
72
|
+
const own = config.overrides;
|
|
73
|
+
const providers = useProviders();
|
|
74
|
+
const set = useCallback(
|
|
75
|
+
<Key extends keyof ProjectOverrides>(key: Key, value: ProjectOverrides[Key] | undefined) => {
|
|
76
|
+
const { [key]: _previous, ...others } = config.overrides;
|
|
77
|
+
change({ overrides: value === undefined ? others : { ...others, [key]: value } });
|
|
78
|
+
},
|
|
79
|
+
[change, config.overrides],
|
|
80
|
+
);
|
|
81
|
+
const modelOptions = useMemo(() => {
|
|
82
|
+
const options: Option[] = [{ label: "Provider default", value: AUTO_MODEL }];
|
|
83
|
+
for (const agent of providers.data ?? []) {
|
|
84
|
+
for (const model of agent.models) options.push({ label: `${agent.label} · ${model.label}`, value: `${agent.id}/${model.id}` });
|
|
85
|
+
}
|
|
86
|
+
if (own.provider && !options.some((option) => option.value === own.provider)) {
|
|
87
|
+
options.push({ label: `${own.provider} · not on this host`, value: own.provider });
|
|
88
|
+
}
|
|
89
|
+
return options;
|
|
90
|
+
}, [providers.data, own.provider]);
|
|
91
|
+
const tools = projectDefaults(values, config.projectId).tools;
|
|
92
|
+
const toggle = (key: BooleanKey, label: string, global: boolean, extra: { hint?: string; disabled?: boolean } = {}) => (
|
|
93
|
+
<OverrideSelect
|
|
94
|
+
label={label}
|
|
95
|
+
hint={extra.hint}
|
|
96
|
+
value={own[key] === undefined ? undefined : own[key] ? "on" : "off"}
|
|
97
|
+
inherited={onOff(global)}
|
|
98
|
+
options={ON_OFF}
|
|
99
|
+
disabled={extra.disabled}
|
|
100
|
+
onChange={(value) => set(key, value === undefined ? undefined : value === "on")}
|
|
101
|
+
/>
|
|
102
|
+
);
|
|
103
|
+
const globalModel = values.launch.provider || AUTO_MODEL;
|
|
104
|
+
return (
|
|
105
|
+
<View>
|
|
106
|
+
<SettingsSection title="Agent defaults for this project">
|
|
107
|
+
<SettingsCard>
|
|
108
|
+
<OverrideSelect
|
|
109
|
+
label="Model"
|
|
110
|
+
hint="Preselected when you start an agent from an issue in this project."
|
|
111
|
+
value={own.provider === undefined ? undefined : own.provider || AUTO_MODEL}
|
|
112
|
+
inherited={labelOf(modelOptions, globalModel)}
|
|
113
|
+
options={modelOptions}
|
|
114
|
+
onChange={(value) => set("provider", value === undefined ? undefined : value === AUTO_MODEL ? "" : value)}
|
|
115
|
+
/>
|
|
116
|
+
<OverrideSelect
|
|
117
|
+
label="Run agents in"
|
|
118
|
+
value={own.isolation}
|
|
119
|
+
inherited={labelOf(ISOLATION_OPTIONS, values.launch.isolation)}
|
|
120
|
+
options={ISOLATION_OPTIONS}
|
|
121
|
+
onChange={(value) => set("isolation", value as ProjectOverrides["isolation"])}
|
|
122
|
+
/>
|
|
123
|
+
{toggle("includeComments", "Include issue comments", values.launch.includeComments)}
|
|
124
|
+
{toggle("moveToStarted", "Move the issue to In Progress", values.launch.moveToStarted)}
|
|
125
|
+
{toggle("assignToMe", "Assign the issue to me", values.launch.assignToMe)}
|
|
126
|
+
</SettingsCard>
|
|
127
|
+
</SettingsSection>
|
|
128
|
+
<SettingsSection title="Linear tools and sync for this project">
|
|
129
|
+
<SettingsCard>
|
|
130
|
+
{toggle("syncTodos", "Update Linear from agent todos", values.live.syncTodos, {
|
|
131
|
+
hint: "After each turn, todos that start with a sub-issue key move that sub-issue forward.",
|
|
132
|
+
})}
|
|
133
|
+
{toggle("tools", COPY.enabled.label, values.tools.enabled, { hint: COPY.enabled.hint })}
|
|
134
|
+
{toggle("allowEdits", COPY.allowEdits.label, values.tools.allowEdits, { disabled: !tools.enabled })}
|
|
135
|
+
{toggle("assignAgents", COPY.assignAgents.label, values.tools.assignAgents, { disabled: !tools.enabled })}
|
|
136
|
+
<OverrideSelect
|
|
137
|
+
label={COPY.maxAgents.label}
|
|
138
|
+
value={own.maxAgents === undefined ? undefined : String(own.maxAgents)}
|
|
139
|
+
inherited={limitLabel(values.tools.maxAgents)}
|
|
140
|
+
options={LIMIT_OPTIONS}
|
|
141
|
+
disabled={!tools.enabled}
|
|
142
|
+
onChange={(value) => set("maxAgents", value === undefined ? undefined : Number(value))}
|
|
143
|
+
/>
|
|
144
|
+
</SettingsCard>
|
|
145
|
+
</SettingsSection>
|
|
146
|
+
</View>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
@@ -21,6 +21,10 @@ import {
|
|
|
21
21
|
upsertProject,
|
|
22
22
|
} from "../shared/settings";
|
|
23
23
|
import { effectiveKeyScope, KeyScopeProvider } from "./key-scope";
|
|
24
|
+
import { ProjectSetup } from "./project-init";
|
|
25
|
+
import { ProjectKnowledgeSection } from "./project-knowledge";
|
|
26
|
+
import { AccessSection } from "./settings-access";
|
|
27
|
+
import { ProjectOverridesSection } from "./settings-overrides";
|
|
24
28
|
import { type PaseoProjectOption, useAuthStatus, useCatalog, useProjects } from "./queries";
|
|
25
29
|
import {
|
|
26
30
|
MultilineField,
|
|
@@ -40,11 +44,15 @@ const STEPS_PLACEHOLDER = "Run the type checker\nUpdate the changelog\nOpen a dr
|
|
|
40
44
|
|
|
41
45
|
type Change = (patch: Partial<ProjectConfig>) => void;
|
|
42
46
|
|
|
43
|
-
export function ProjectSettings({ theme }: PluginSurfaceProps) {
|
|
47
|
+
export function ProjectSettings({ theme, navigation }: PluginSurfaceProps) {
|
|
44
48
|
const settings = useSettings(linearSettings);
|
|
49
|
+
const openAgent = useMemo(
|
|
50
|
+
() => (navigation ? (agentId: string) => navigation.openAgent({ agentId }) : undefined),
|
|
51
|
+
[navigation],
|
|
52
|
+
);
|
|
45
53
|
const render = useCallback(
|
|
46
|
-
(ready: ReadySettings) => <ProjectEditor theme={theme} settings={ready} />,
|
|
47
|
-
[theme],
|
|
54
|
+
(ready: ReadySettings) => <ProjectEditor theme={theme} settings={ready} openAgent={openAgent} />,
|
|
55
|
+
[theme, openAgent],
|
|
48
56
|
);
|
|
49
57
|
return (
|
|
50
58
|
<SettingsGate theme={theme} title="Projects" settings={settings}>
|
|
@@ -70,7 +78,12 @@ function projectOptions(
|
|
|
70
78
|
return [...known, ...missing];
|
|
71
79
|
}
|
|
72
80
|
|
|
73
|
-
function ProjectEditor(
|
|
81
|
+
function ProjectEditor(props: {
|
|
82
|
+
theme: Theme;
|
|
83
|
+
settings: ReadySettings;
|
|
84
|
+
openAgent?: (agentId: string) => void;
|
|
85
|
+
}) {
|
|
86
|
+
const { theme, settings } = props;
|
|
74
87
|
const { values, dirty, edit, save, discard } = useSettingsDraft(settings);
|
|
75
88
|
const projects = useProjects();
|
|
76
89
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
@@ -120,6 +133,12 @@ function ProjectEditor({ theme, settings }: { theme: Theme; settings: ReadySetti
|
|
|
120
133
|
}
|
|
121
134
|
return (
|
|
122
135
|
<View>
|
|
136
|
+
<AccessSection
|
|
137
|
+
access={values.access}
|
|
138
|
+
saved={settings.values.access}
|
|
139
|
+
projects={projects.data}
|
|
140
|
+
edit={edit}
|
|
141
|
+
/>
|
|
123
142
|
<SettingsSection title="Project">
|
|
124
143
|
<SettingsCard>
|
|
125
144
|
<SettingsSelect
|
|
@@ -131,7 +150,10 @@ function ProjectEditor({ theme, settings }: { theme: Theme; settings: ReadySetti
|
|
|
131
150
|
<SettingsRow label="Folder" hint={config.rootPath || "Unknown"} />
|
|
132
151
|
</SettingsCard>
|
|
133
152
|
</SettingsSection>
|
|
153
|
+
<ProjectOverridesSection values={values} config={config} change={change} />
|
|
134
154
|
<TeamsSection config={config} change={change} />
|
|
155
|
+
{paseoProject ? <ProjectKnowledgeSection projectId={paseoProject.projectId} /> : null}
|
|
156
|
+
<ProjectSetup theme={theme} config={config} change={change} openAgent={props.openAgent} />
|
|
135
157
|
<InstructionsSection theme={theme} config={config} change={change} />
|
|
136
158
|
{AGENT_ACTIONS.map((action) => (
|
|
137
159
|
<ActionSection key={action} theme={theme} action={action} config={config} change={change} />
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
} from "./settings-fields";
|
|
29
29
|
import type { Theme } from "./ui";
|
|
30
30
|
|
|
31
|
-
const ISOLATION_OPTIONS: readonly { label: string; value: Isolation }[] = [
|
|
31
|
+
export const ISOLATION_OPTIONS: readonly { label: string; value: Isolation }[] = [
|
|
32
32
|
{ label: "New worktree on the issue branch", value: "worktree" },
|
|
33
33
|
{ label: "The project or current workspace", value: "workspace" },
|
|
34
34
|
];
|
|
@@ -89,11 +89,11 @@ function LaunchDefaults({ launch, edit }: { launch: Launch; edit: Edit }) {
|
|
|
89
89
|
? launch.provider
|
|
90
90
|
: AUTO_MODEL;
|
|
91
91
|
return (
|
|
92
|
-
<SettingsSection title="Agent defaults">
|
|
92
|
+
<SettingsSection title="Agent defaults for all projects">
|
|
93
93
|
<SettingsCard>
|
|
94
94
|
<SettingsSelect
|
|
95
95
|
label="Model"
|
|
96
|
-
hint="Preselected when you start an agent from an issue."
|
|
96
|
+
hint="Preselected when you start an agent from an issue. A project can use its own defaults in Linear settings, under Projects."
|
|
97
97
|
value={model}
|
|
98
98
|
options={modelOptions}
|
|
99
99
|
onValueChange={chooseModel}
|
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, such as the map actions. A group draws them in one
|
|
7
|
+
// bordered track.
|
|
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,81 @@
|
|
|
1
|
+
import { Platform } from "react-native";
|
|
2
|
+
|
|
3
|
+
// The only module that uses the DOM. Off the web each export does nothing.
|
|
4
|
+
// The file picker is a hidden file input, because plugins cannot use native document pickers.
|
|
5
|
+
// Local storage keeps small values for the next start; it can be missing or blocked.
|
|
6
|
+
|
|
7
|
+
interface FileLike {
|
|
8
|
+
name: string;
|
|
9
|
+
size: number;
|
|
10
|
+
text(): Promise<string>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface FileInputLike {
|
|
14
|
+
type: string;
|
|
15
|
+
accept: string;
|
|
16
|
+
files: ArrayLike<FileLike> | null;
|
|
17
|
+
onchange: (() => void) | null;
|
|
18
|
+
click(): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface DocumentLike {
|
|
22
|
+
createElement(tag: "input"): FileInputLike;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** True where pickTextFile can open a file picker. */
|
|
26
|
+
export const canPickFiles = Platform.OS === "web";
|
|
27
|
+
|
|
28
|
+
export type PickedFile = { name: string; text: string } | { error: string };
|
|
29
|
+
|
|
30
|
+
/** Opens the browser file picker and calls onPick with the chosen file. Nothing on cancel. */
|
|
31
|
+
export function pickTextFile(accept: string, maxBytes: number, onPick: (file: PickedFile) => void): void {
|
|
32
|
+
const document = (globalThis as { document?: DocumentLike }).document;
|
|
33
|
+
if (!canPickFiles || !document) return;
|
|
34
|
+
const input = document.createElement("input");
|
|
35
|
+
input.type = "file";
|
|
36
|
+
input.accept = accept;
|
|
37
|
+
input.onchange = () => {
|
|
38
|
+
const file = input.files?.[0];
|
|
39
|
+
if (!file) return;
|
|
40
|
+
if (file.size > maxBytes) {
|
|
41
|
+
onPick({ error: `${file.name} is too large. Use a file under ${Math.round(maxBytes / 1000)} KB.` });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
file.text().then(
|
|
45
|
+
(text) => onPick({ name: file.name, text }),
|
|
46
|
+
() => onPick({ error: `Paseo could not read ${file.name}.` }),
|
|
47
|
+
);
|
|
48
|
+
};
|
|
49
|
+
input.click();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface StorageLike {
|
|
53
|
+
getItem(key: string): string | null;
|
|
54
|
+
setItem(key: string, value: string): void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function storage(): StorageLike | null {
|
|
58
|
+
if (Platform.OS !== "web") return null;
|
|
59
|
+
try {
|
|
60
|
+
return (globalThis as { localStorage?: StorageLike }).localStorage ?? null;
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A value saved on this device, or null off the web or when storage is blocked. */
|
|
67
|
+
export function readStored(key: string): string | null {
|
|
68
|
+
try {
|
|
69
|
+
return storage()?.getItem(key) ?? null;
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function writeStored(key: string, value: string): void {
|
|
76
|
+
try {
|
|
77
|
+
storage()?.setItem(key, value);
|
|
78
|
+
} catch {
|
|
79
|
+
// Blocked storage only means the next start uses the built-in icon first.
|
|
80
|
+
}
|
|
81
|
+
}
|