@yannelli/paseo-linear-plugin 1.1.0-beta.1 → 1.1.0-beta.3

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.
@@ -0,0 +1,43 @@
1
+ import { useSettings } from "@getpaseo/plugin/client";
2
+ import { Icon } from "@getpaseo/plugin/client/react-native";
3
+ import { useMemo } from "react";
4
+ import { Image, Platform } from "react-native";
5
+ import { type IconSettings, iconMarkup, paintColor } from "../shared/custom-icon";
6
+ import { linearSettings } from "../shared/settings";
7
+ import { svgDataUri } from "./provider-icon";
8
+ import type { Theme } from "./ui";
9
+
10
+ // The plugin's icon in pills, the sidebar, and plugin screens. Native apps cannot draw SVG
11
+ // images from plugins, so they show the built-in icon in the chosen color.
12
+
13
+ export const BUILT_IN_ICON = "SquareKanban";
14
+
15
+ export interface PluginIconProps {
16
+ size: number;
17
+ /** The color Paseo gives the icon in this place. */
18
+ color: string;
19
+ theme?: Theme | null;
20
+ }
21
+
22
+ /** Draws icon settings, saved or not yet saved. */
23
+ export function IconGlyph(props: PluginIconProps & { icon: IconSettings | null }) {
24
+ const { icon, size, color } = props;
25
+ const accent = props.theme?.colors.accent ?? color;
26
+ const paint = icon ? paintColor(icon, { ink: color, accent }) : null;
27
+ const svg = Platform.OS === "web" ? (icon?.svg ?? "") : "";
28
+ const solid = icon?.solid === true;
29
+ const source = useMemo(() => {
30
+ const markup = svg ? iconMarkup(svg, { ink: color, paint, solid }) : null;
31
+ return markup ? { uri: svgDataUri(markup, color) } : null;
32
+ }, [svg, color, paint, solid]);
33
+ const style = useMemo(() => ({ width: size, height: size }), [size]);
34
+ if (!source) return <Icon name={BUILT_IN_ICON} size={size} color={paint ?? color} />;
35
+ return <Image source={source} style={style} resizeMode="contain" accessibilityIgnoresInvertColors />;
36
+ }
37
+
38
+ /** The plugin icon from the saved settings. The built-in icon shows while they load. */
39
+ export function PluginIcon(props: PluginIconProps) {
40
+ const settings = useSettings(linearSettings);
41
+ const icon = settings.status === "ready" ? settings.values.icon : null;
42
+ return <IconGlyph {...props} icon={icon} />;
43
+ }
@@ -6,7 +6,7 @@ import {
6
6
  SettingsSection,
7
7
  SettingsSwitch,
8
8
  } from "@getpaseo/plugin/client/ui";
9
- import { useMutation, useQuery } from "@tanstack/react-query";
9
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
10
10
  import { useCallback, useEffect, useMemo, useState } from "react";
11
11
  import { Text, View } from "react-native";
12
12
  import { initStartRpc, initStatusRpc } from "../shared/live";
@@ -17,6 +17,7 @@ import {
17
17
  proposalDiffs,
18
18
  } from "../shared/project-setup";
19
19
  import type { ProjectConfig } from "../shared/settings";
20
+ import { knowledgeKey } from "./project-knowledge";
20
21
  import { Button, errorMessage, type Theme } from "./ui";
21
22
 
22
23
  // Setup runs on the daemon. Remember the job per project so leaving the screen keeps it.
@@ -26,8 +27,10 @@ export function ProjectSetup(props: {
26
27
  theme: Theme;
27
28
  config: ProjectConfig;
28
29
  change(patch: Partial<ProjectConfig>): void;
30
+ /** Opens the setup agent while it runs; absent on hosts without navigation. */
31
+ openAgent?: (agentId: string) => void;
29
32
  }) {
30
- const { theme, config, change } = props;
33
+ const { theme, config, change, openAgent } = props;
31
34
  const start = useRpc(initStartRpc);
32
35
  const status = useRpc(initStatusRpc);
33
36
  const [jobId, setJobId] = useState(() => jobsByProject.get(config.projectId) ?? null);
@@ -47,6 +50,12 @@ export function ProjectSetup(props: {
47
50
  });
48
51
  const state = job.data?.job ?? null;
49
52
  const proposal = job.data?.proposal ?? null;
53
+ // The daemon saved the agent's area summaries; show them in Project knowledge.
54
+ const queries = useQueryClient();
55
+ const finished = state?.status === "done";
56
+ useEffect(() => {
57
+ if (finished) void queries.invalidateQueries({ queryKey: knowledgeKey(config.projectId) });
58
+ }, [finished, queries, config.projectId]);
50
59
  const diffs = useMemo(
51
60
  () => (proposal ? proposalDiffs(config, proposal) : []),
52
61
  [config, proposal],
@@ -76,12 +85,27 @@ export function ProjectSetup(props: {
76
85
  <SettingsCard>
77
86
  <SettingsAction
78
87
  label="Read this repository's guidelines"
79
- hint="An agent reads AGENTS.md, CONTRIBUTING, scripts, and CI, then proposes instructions and steps. It is told not to edit. You review each change before it is used."
88
+ hint="The daemon inspects the project first, and the agent starts from that project map. The agent reads AGENTS.md, CONTRIBUTING, scripts, and CI, then proposes instructions and steps, and describes each area for Project knowledge. It is told not to edit. You review each prompt change before it is used."
80
89
  actionLabel={running ? "Reading…" : proposal ? "Run again" : "Start"}
81
90
  disabled={running || !config.rootPath}
82
91
  onPress={() => begin.mutate()}
83
92
  />
84
- {running ? <SettingsRow label="The setup agent is reading the repository." /> : null}
93
+ {running ? (
94
+ <SettingsRow
95
+ label={state?.agentId ? "The setup agent is reading the repository." : "Inspecting the project…"}
96
+ >
97
+ {state?.agentId && openAgent ? (
98
+ <View style={ACTIONS_STYLE}>
99
+ <Button
100
+ theme={theme}
101
+ icon="Bot"
102
+ label="Open agent"
103
+ onPress={() => openAgent(state.agentId ?? "")}
104
+ />
105
+ </View>
106
+ ) : null}
107
+ </SettingsRow>
108
+ ) : null}
85
109
  {failure ? <SettingsRow label="Setup did not finish" error={failure} /> : null}
86
110
  {state?.status === "done" && diffs.length === 0 ? (
87
111
  <SettingsAction
@@ -0,0 +1,87 @@
1
+ import { useRpc } from "@getpaseo/plugin/client";
2
+ import {
3
+ SettingsAction,
4
+ SettingsCard,
5
+ SettingsRow,
6
+ SettingsSection,
7
+ } from "@getpaseo/plugin/client/ui";
8
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
9
+ import {
10
+ areaLabel,
11
+ knowledgeGetRpc,
12
+ knowledgeInspectRpc,
13
+ MAX_KNOWLEDGE_FILES,
14
+ type ProjectBrief,
15
+ } from "../shared/knowledge";
16
+ import { errorMessage, relativeTime } from "./ui";
17
+
18
+ const AREAS_SHOWN = 24;
19
+
20
+ export const knowledgeKey = (projectId: string) => ["linear", "knowledge", projectId] as const;
21
+
22
+ function countText(entries: readonly { name: string; files: number }[]): string {
23
+ return entries.map((entry) => `${entry.name} ${entry.files}`).join(", ");
24
+ }
25
+
26
+ function overview(brief: ProjectBrief): string {
27
+ const source = brief.source === "git" ? "files git tracks" : "files found in the folder";
28
+ const truncated = brief.truncated ? `; the first ${MAX_KNOWLEDGE_FILES.toLocaleString()} are kept` : "";
29
+ return `${brief.fileCount} ${source}${truncated}. Inspected ${relativeTime(brief.inspectedAt)}.`;
30
+ }
31
+
32
+ /** What the daemon knows about the project's files, and a button to inspect it again. */
33
+ export function ProjectKnowledgeSection(props: { projectId: string }) {
34
+ const { projectId } = props;
35
+ const get = useRpc(knowledgeGetRpc);
36
+ const inspect = useRpc(knowledgeInspectRpc);
37
+ const queries = useQueryClient();
38
+ const query = useQuery({
39
+ queryKey: knowledgeKey(projectId),
40
+ queryFn: () => get({ projectId }),
41
+ staleTime: 60_000,
42
+ retry: false,
43
+ });
44
+ const again = useMutation({
45
+ mutationFn: () => inspect({ projectId }),
46
+ onSuccess: (data) => queries.setQueryData(knowledgeKey(projectId), data),
47
+ });
48
+ const brief = query.data?.knowledge ?? null;
49
+ const busy = query.isPending || again.isPending;
50
+ const failure = again.error ?? query.error;
51
+ const areas = brief?.areas.filter((area) => area.path !== "") ?? [];
52
+ return (
53
+ <SettingsSection title="Project knowledge">
54
+ <SettingsCard>
55
+ <SettingsAction
56
+ label="Inspect the project"
57
+ hint="The daemon lists the project's files and reads its manifests to find its services, packages, and main folders. No agent runs. Explore and setup agents, ticket-text maps, and Linear Live use what it finds. It inspects again when the knowledge is 6 hours old."
58
+ actionLabel={busy ? "Inspecting…" : "Inspect again"}
59
+ disabled={busy}
60
+ onPress={() => again.mutate()}
61
+ />
62
+ {failure ? <SettingsRow label="Inspection did not finish" error={errorMessage(failure)} /> : null}
63
+ {brief ? (
64
+ <>
65
+ <SettingsRow label="Files" hint={overview(brief)} />
66
+ {brief.summary ? <SettingsRow label="Project" hint={brief.summary} /> : null}
67
+ {brief.languages.length > 0 ? (
68
+ <SettingsRow label="Languages" hint={countText(brief.languages)} />
69
+ ) : null}
70
+ {brief.commands.length > 0 ? (
71
+ <SettingsRow label="Commands" hint={brief.commands.join(", ")} />
72
+ ) : null}
73
+ {brief.guides.length > 0 ? <SettingsRow label="Guides" hint={brief.guides.join(", ")} /> : null}
74
+ {areas.slice(0, AREAS_SHOWN).map((area) => {
75
+ const files = `${area.files} ${area.files === 1 ? "file" : "files"}`;
76
+ const parts = [areaLabel(area), files, area.languages.join("/"), area.summary];
77
+ return <SettingsRow key={area.path} label={area.path} hint={parts.filter(Boolean).join(" · ")} />;
78
+ })}
79
+ {areas.length > AREAS_SHOWN ? (
80
+ <SettingsRow label={`${areas.length - AREAS_SHOWN} more areas`} />
81
+ ) : null}
82
+ </>
83
+ ) : null}
84
+ </SettingsCard>
85
+ </SettingsSection>
86
+ );
87
+ }
@@ -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
+ }
@@ -22,6 +22,7 @@ import {
22
22
  } from "../shared/settings";
23
23
  import { effectiveKeyScope, KeyScopeProvider } from "./key-scope";
24
24
  import { ProjectSetup } from "./project-init";
25
+ import { ProjectKnowledgeSection } from "./project-knowledge";
25
26
  import { AccessSection } from "./settings-access";
26
27
  import { type PaseoProjectOption, useAuthStatus, useCatalog, useProjects } from "./queries";
27
28
  import {
@@ -42,11 +43,15 @@ const STEPS_PLACEHOLDER = "Run the type checker\nUpdate the changelog\nOpen a dr
42
43
 
43
44
  type Change = (patch: Partial<ProjectConfig>) => void;
44
45
 
45
- export function ProjectSettings({ theme }: PluginSurfaceProps) {
46
+ export function ProjectSettings({ theme, navigation }: PluginSurfaceProps) {
46
47
  const settings = useSettings(linearSettings);
48
+ const openAgent = useMemo(
49
+ () => (navigation ? (agentId: string) => navigation.openAgent({ agentId }) : undefined),
50
+ [navigation],
51
+ );
47
52
  const render = useCallback(
48
- (ready: ReadySettings) => <ProjectEditor theme={theme} settings={ready} />,
49
- [theme],
53
+ (ready: ReadySettings) => <ProjectEditor theme={theme} settings={ready} openAgent={openAgent} />,
54
+ [theme, openAgent],
50
55
  );
51
56
  return (
52
57
  <SettingsGate theme={theme} title="Projects" settings={settings}>
@@ -72,7 +77,12 @@ function projectOptions(
72
77
  return [...known, ...missing];
73
78
  }
74
79
 
75
- function ProjectEditor({ theme, settings }: { theme: Theme; settings: ReadySettings }) {
80
+ function ProjectEditor(props: {
81
+ theme: Theme;
82
+ settings: ReadySettings;
83
+ openAgent?: (agentId: string) => void;
84
+ }) {
85
+ const { theme, settings } = props;
76
86
  const { values, dirty, edit, save, discard } = useSettingsDraft(settings);
77
87
  const projects = useProjects();
78
88
  const [selectedId, setSelectedId] = useState<string | null>(null);
@@ -140,7 +150,8 @@ function ProjectEditor({ theme, settings }: { theme: Theme; settings: ReadySetti
140
150
  </SettingsCard>
141
151
  </SettingsSection>
142
152
  <TeamsSection config={config} change={change} />
143
- <ProjectSetup theme={theme} config={config} change={change} />
153
+ {paseoProject ? <ProjectKnowledgeSection projectId={paseoProject.projectId} /> : null}
154
+ <ProjectSetup theme={theme} config={config} change={change} openAgent={props.openAgent} />
144
155
  <InstructionsSection theme={theme} config={config} change={change} />
145
156
  {AGENT_ACTIONS.map((action) => (
146
157
  <ActionSection key={action} theme={theme} action={action} config={config} change={change} />
@@ -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
- <Icon name="SquareKanban" size={16} color={colors.foregroundMuted} />
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>
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?: string;
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?: string; size: number; color: string }) {
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 <Icon name={props.icon} size={props.size} color={props.color} />;
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: string;
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
- <Icon name={props.icon} size={28} color={colors.foregroundMuted} />
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 CHANGED
@@ -2,6 +2,7 @@ import { Platform } from "react-native";
2
2
 
3
3
  // The only module that uses the DOM. React wheel listeners are passive, so they cannot keep
4
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.
5
6
 
6
7
  interface WheelEventLike {
7
8
  deltaY: number;
@@ -43,3 +44,48 @@ export function listenWheel(
43
44
  node.addEventListener("wheel", listener, { passive: false });
44
45
  return () => node.removeEventListener("wheel", listener);
45
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
+ }