@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.
Files changed (63) hide show
  1. package/OVERVIEW.md +32 -2
  2. package/README.md +134 -8
  3. package/client/access.tsx +67 -0
  4. package/client/browser.tsx +12 -9
  5. package/client/compat.ts +23 -5
  6. package/client/connect.tsx +3 -2
  7. package/client/launch-assignments.tsx +182 -0
  8. package/client/launch-choices.ts +21 -6
  9. package/client/launch-guidance.tsx +103 -0
  10. package/client/launch-plan.ts +114 -17
  11. package/client/launch.tsx +55 -9
  12. package/client/live-agents.tsx +446 -0
  13. package/client/live-feed.tsx +151 -0
  14. package/client/live-header.tsx +310 -0
  15. package/client/live-issues.tsx +345 -0
  16. package/client/live-map.tsx +351 -0
  17. package/client/live-panel.tsx +392 -0
  18. package/client/live-timeline.ts +379 -0
  19. package/client/pill.tsx +394 -0
  20. package/client/plugin-icon.tsx +75 -0
  21. package/client/project-init.tsx +192 -0
  22. package/client/project-knowledge.tsx +87 -0
  23. package/client/settings-access.tsx +56 -0
  24. package/client/settings-agents.tsx +113 -0
  25. package/client/settings-fields.tsx +11 -3
  26. package/client/settings-icon.tsx +277 -0
  27. package/client/settings-live.tsx +128 -0
  28. package/client/settings-overrides.tsx +148 -0
  29. package/client/settings-projects.tsx +26 -4
  30. package/client/settings-prompts.tsx +3 -3
  31. package/client/timeline-card.tsx +2 -1
  32. package/client/tool-buttons.tsx +68 -0
  33. package/client/ui.tsx +16 -7
  34. package/client/web.ts +81 -0
  35. package/index.client.tsx +160 -51
  36. package/index.server.ts +32 -5
  37. package/package.json +1 -1
  38. package/server/agent-hooks.ts +37 -0
  39. package/server/agent-runs.ts +312 -0
  40. package/server/agent-tools.ts +276 -0
  41. package/server/handlers.ts +12 -1
  42. package/server/init.ts +89 -0
  43. package/server/inspect.ts +392 -0
  44. package/server/knowledge.ts +157 -0
  45. package/server/live.ts +371 -0
  46. package/server/mcp-http.ts +156 -0
  47. package/server/stores.ts +126 -0
  48. package/server/subagent-logs.ts +146 -0
  49. package/server/sync.ts +133 -0
  50. package/shared/activity.ts +268 -0
  51. package/shared/agent-hooks.ts +68 -0
  52. package/shared/agent-tools.ts +157 -0
  53. package/shared/custom-icon.ts +188 -0
  54. package/shared/knowledge.ts +255 -0
  55. package/shared/live.ts +248 -0
  56. package/shared/map-model.ts +240 -0
  57. package/shared/project-setup.ts +56 -0
  58. package/shared/prompts.ts +109 -2
  59. package/shared/settings.ts +145 -3
  60. package/shared/shell-activity.ts +309 -0
  61. package/shared/shell-lexer.ts +180 -0
  62. package/shared/subagents.ts +106 -0
  63. 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({ theme, settings }: { theme: Theme; settings: ReadySettings }) {
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}
@@ -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>
@@ -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?: 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 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
+ }