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

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.
@@ -1,16 +1,50 @@
1
1
  import { useSettings } from "@getpaseo/plugin/client";
2
2
  import { Icon } from "@getpaseo/plugin/client/react-native";
3
- import { useMemo } from "react";
3
+ import { useEffect, useMemo } from "react";
4
4
  import { Image, Platform } from "react-native";
5
- import { type IconSettings, iconMarkup, paintColor } from "../shared/custom-icon";
5
+ import { BUILT_IN_ICON, type IconSettings, iconMarkup, paintColor } from "../shared/custom-icon";
6
6
  import { linearSettings } from "../shared/settings";
7
7
  import { svgDataUri } from "./provider-icon";
8
8
  import type { Theme } from "./ui";
9
+ import { readStored, writeStored } from "./web";
9
10
 
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.
11
+ // The plugin's icon in pills, the sidebar, and plugin screens. Without an SVG, and in native
12
+ // apps, which cannot draw SVG images from plugins, it is the chosen built-in icon.
12
13
 
13
- export const BUILT_IN_ICON = "SquareKanban";
14
+ // Panels and Command Center items take a Lucide name when they register, outside React.
15
+ // Components that read the settings share the chosen name here. Paseo keeps the icon an open
16
+ // tab got, so the web app remembers the name and registers with it on the next start.
17
+ const MENU_ICON_KEY = "paseo-linear-plugin:menu-icon";
18
+ let menuIcon = readStored(MENU_ICON_KEY) ?? BUILT_IN_ICON;
19
+
20
+ /** The menu icon to register with first: the last one chosen on this device. */
21
+ export function startMenuIcon(): string {
22
+ return menuIcon;
23
+ }
24
+ const menuListeners = new Set<(name: string) => void>();
25
+
26
+ export function publishMenuIcon(name: string): void {
27
+ if (name === menuIcon) return;
28
+ menuIcon = name;
29
+ writeStored(MENU_ICON_KEY, name);
30
+ for (const listener of menuListeners) listener(name);
31
+ }
32
+
33
+ export function onMenuIconChange(listener: (name: string) => void): () => void {
34
+ menuListeners.add(listener);
35
+ return () => menuListeners.delete(listener);
36
+ }
37
+
38
+ /** The saved icon settings, or null while they load. Shares the menu icon on the way. */
39
+ export function useIconSettings(): IconSettings | null {
40
+ const settings = useSettings(linearSettings);
41
+ const icon = settings.status === "ready" ? settings.values.icon : null;
42
+ const menu = icon?.menu;
43
+ useEffect(() => {
44
+ if (menu) publishMenuIcon(menu);
45
+ }, [menu]);
46
+ return icon;
47
+ }
14
48
 
15
49
  export interface PluginIconProps {
16
50
  size: number;
@@ -31,13 +65,11 @@ export function IconGlyph(props: PluginIconProps & { icon: IconSettings | null }
31
65
  return markup ? { uri: svgDataUri(markup, color) } : null;
32
66
  }, [svg, color, paint, solid]);
33
67
  const style = useMemo(() => ({ width: size, height: size }), [size]);
34
- if (!source) return <Icon name={BUILT_IN_ICON} size={size} color={paint ?? color} />;
68
+ if (!source) return <Icon name={icon?.menu ?? BUILT_IN_ICON} size={size} color={paint ?? color} />;
35
69
  return <Image source={source} style={style} resizeMode="contain" accessibilityIgnoresInvertColors />;
36
70
  }
37
71
 
38
72
  /** The plugin icon from the saved settings. The built-in icon shows while they load. */
39
73
  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} />;
74
+ return <IconGlyph {...props} icon={useIconSettings()} />;
43
75
  }
@@ -8,6 +8,7 @@ import {
8
8
  SettingsSelect,
9
9
  SettingsSwitch,
10
10
  } from "@getpaseo/plugin/client/ui";
11
+ import { Icon } from "@getpaseo/plugin/client/react-native";
11
12
  import { useCallback, useMemo, useState } from "react";
12
13
  import { Platform, Text, View } from "react-native";
13
14
  import {
@@ -15,10 +16,11 @@ import {
15
16
  type IconPaint,
16
17
  type IconSettings,
17
18
  MAX_SVG_LENGTH,
19
+ MENU_ICONS,
18
20
  parseIconSvg,
19
21
  } from "../shared/custom-icon";
20
22
  import { linearSettings } from "../shared/settings";
21
- import { IconGlyph } from "./plugin-icon";
23
+ import { IconGlyph, useIconSettings } from "./plugin-icon";
22
24
  import { MultilineField, type ReadySettings, SaveBar, SettingsGate, useSettingsDraft } from "./settings-fields";
23
25
  import type { Theme } from "./ui";
24
26
  import { canPickFiles, pickTextFile } from "./web";
@@ -37,6 +39,7 @@ const MAX_FILE_BYTES = MAX_SVG_LENGTH * 4;
37
39
 
38
40
  export function IconSettingsScreen({ theme }: PluginSurfaceProps) {
39
41
  const settings = useSettings(linearSettings);
42
+ useIconSettings();
40
43
  const render = useCallback(
41
44
  (ready: ReadySettings) => <IconEditor theme={theme} settings={ready} />,
42
45
  [theme],
@@ -85,6 +88,33 @@ function IconPreview({ theme, icon }: { theme: Theme; icon: IconSettings }) {
85
88
  );
86
89
  }
87
90
 
91
+ function MenuPreview({ theme, name }: { theme: Theme; name: string }) {
92
+ const { colors } = theme;
93
+ const styles = useMemo(
94
+ () => ({
95
+ tab: {
96
+ flexDirection: "row" as const,
97
+ alignSelf: "flex-start" as const,
98
+ alignItems: "center" as const,
99
+ gap: 6,
100
+ marginTop: 8,
101
+ paddingHorizontal: 10,
102
+ paddingVertical: 5,
103
+ borderRadius: 6,
104
+ backgroundColor: colors.surface2,
105
+ },
106
+ label: { color: colors.foreground, fontSize: 12 },
107
+ }),
108
+ [colors],
109
+ );
110
+ return (
111
+ <View style={styles.tab}>
112
+ <Icon name={name} size={14} color={colors.foregroundMuted} />
113
+ <Text style={styles.label}>Linear</Text>
114
+ </View>
115
+ );
116
+ }
117
+
88
118
  function IconEditor({ theme, settings }: { theme: Theme; settings: ReadySettings }) {
89
119
  const { values, dirty, edit, save, discard } = useSettingsDraft(settings);
90
120
  const icon = values.icon;
@@ -123,7 +153,7 @@ function IconEditor({ theme, settings }: { theme: Theme; settings: ReadySettings
123
153
  }),
124
154
  [applyMarkup],
125
155
  );
126
- const useBuiltIn = useCallback(() => {
156
+ const removeSvg = useCallback(() => {
127
157
  setFileName(null);
128
158
  applyMarkup("");
129
159
  }, [applyMarkup]);
@@ -145,6 +175,11 @@ function IconEditor({ theme, settings }: { theme: Theme; settings: ReadySettings
145
175
  }, [discard, settings.values.icon.svg]);
146
176
  const native = Platform.OS !== "web";
147
177
  const custom = icon.svg !== "";
178
+ const menuOptions = useMemo(() => {
179
+ const options = MENU_ICONS.map((entry) => ({ label: entry.label, value: entry.name }));
180
+ if (!options.some((option) => option.value === icon.menu)) options.push({ label: icon.menu, value: icon.menu });
181
+ return options;
182
+ }, [icon.menu]);
148
183
 
149
184
  return (
150
185
  <View>
@@ -155,7 +190,7 @@ function IconEditor({ theme, settings }: { theme: Theme; settings: ReadySettings
155
190
  hint={
156
191
  native
157
192
  ? "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."
193
+ : "Composer pills, the Linear sidebar row, and Linear screens use this icon. Panel tabs and menus use the built-in icon below."
159
194
  }
160
195
  >
161
196
  <IconPreview theme={theme} icon={icon} />
@@ -179,11 +214,11 @@ function IconEditor({ theme, settings }: { theme: Theme; settings: ReadySettings
179
214
  onChange={applyMarkup}
180
215
  />
181
216
  <SettingsAction
182
- label="Built-in icon"
183
- hint="Remove your SVG and use the Linear board icon."
184
- actionLabel="Use built-in"
217
+ label="Remove SVG"
218
+ hint="Use the built-in icon everywhere."
219
+ actionLabel="Remove"
185
220
  disabled={!custom && markup === ""}
186
- onPress={useBuiltIn}
221
+ onPress={removeSvg}
187
222
  />
188
223
  </SettingsCard>
189
224
  </SettingsSection>
@@ -208,13 +243,27 @@ function IconEditor({ theme, settings }: { theme: Theme; settings: ReadySettings
208
243
  ) : null}
209
244
  <SettingsSwitch
210
245
  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."}
246
+ hint={custom ? "Fill shapes that only have an outline, so line icons show as solid shapes." : "Add an SVG to use this. Built-in icons stay as drawn."}
212
247
  value={icon.solid}
213
248
  disabled={!custom}
214
249
  onValueChange={(solid) => change({ solid })}
215
250
  />
216
251
  </SettingsCard>
217
252
  </SettingsSection>
253
+ <SettingsSection title="Built-in icon">
254
+ <SettingsCard>
255
+ <SettingsSelect
256
+ label="Built-in icon"
257
+ hint="Panel tabs, the new tab menu, and Command Center take built-in icons only, so they use this one. Without an SVG, every place uses it. Tabs that are open change when you reload Paseo or open them again."
258
+ value={icon.menu}
259
+ options={menuOptions}
260
+ onValueChange={(menu) => change({ menu })}
261
+ />
262
+ <SettingsRow label="Tab preview">
263
+ <MenuPreview theme={theme} name={icon.menu} />
264
+ </SettingsRow>
265
+ </SettingsCard>
266
+ </SettingsSection>
218
267
  <SaveBar
219
268
  theme={theme}
220
269
  dirty={dirty}
package/client/web.ts CHANGED
@@ -3,6 +3,7 @@ import { Platform } from "react-native";
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
5
  // The file picker is a hidden file input, because plugins cannot use native document pickers.
6
+ // Local storage keeps small values for the next start; it can be missing or blocked.
6
7
 
7
8
  interface WheelEventLike {
8
9
  deltaY: number;
@@ -89,3 +90,34 @@ export function pickTextFile(accept: string, maxBytes: number, onPick: (file: Pi
89
90
  };
90
91
  input.click();
91
92
  }
93
+
94
+ interface StorageLike {
95
+ getItem(key: string): string | null;
96
+ setItem(key: string, value: string): void;
97
+ }
98
+
99
+ function storage(): StorageLike | null {
100
+ if (Platform.OS !== "web") return null;
101
+ try {
102
+ return (globalThis as { localStorage?: StorageLike }).localStorage ?? null;
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ /** A value saved on this device, or null off the web or when storage is blocked. */
109
+ export function readStored(key: string): string | null {
110
+ try {
111
+ return storage()?.getItem(key) ?? null;
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ export function writeStored(key: string, value: string): void {
118
+ try {
119
+ storage()?.setItem(key, value);
120
+ } catch {
121
+ // Blocked storage only means the next start uses the built-in icon first.
122
+ }
123
+ }
package/index.client.tsx CHANGED
@@ -4,7 +4,7 @@ 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
+ import { onMenuIconChange, PluginIcon, startMenuIcon } from "./client/plugin-icon";
8
8
  import { ConnectionSettings } from "./client/settings-connection";
9
9
  import { IconSettingsScreen } from "./client/settings-icon";
10
10
  import { LiveSettings } from "./client/settings-live";
@@ -12,6 +12,7 @@ import { ProjectSettings } from "./client/settings-projects";
12
12
  import { PromptSettings } from "./client/settings-prompts";
13
13
  import { focusIssue, panelScope, SCREEN_SCOPE, updateBrowser } from "./client/store";
14
14
  import { IssueCardRow } from "./client/timeline-card";
15
+ import { BUILT_IN_ICON } from "./shared/custom-icon";
15
16
  import { issueAttachments } from "./shared/issues";
16
17
  import { ISSUE_CARD_KIND, IssueCardSchema, LINEAR_IDENTIFIER } from "./shared/linear";
17
18
 
@@ -19,23 +20,140 @@ const SCREEN_ID = "linear";
19
20
  const PANEL_ID = "issues";
20
21
  const LIVE_PANEL_ID = "live";
21
22
 
23
+ type Cleanup = () => void;
24
+
25
+ // Panels and Command Center items take the menu icon when they register, so a new choice
26
+ // registers the whole group again. One group keeps their order the same.
27
+ function contributeMenus(client: PluginClientContext, icon: string): Cleanup {
28
+ const cleanups: Cleanup[] = [];
29
+ const removeAll = () => {
30
+ for (const cleanup of cleanups.splice(0).reverse()) cleanup();
31
+ };
32
+ try {
33
+ cleanups.push(
34
+ client.addWorkspacePanel({
35
+ id: PANEL_ID,
36
+ title: "Linear",
37
+ icon,
38
+ context: "workspace",
39
+ locations: ["workspace", "explorer"],
40
+ Component: LinearPanel,
41
+ }),
42
+ client.addWorkspacePanel({
43
+ id: LIVE_PANEL_ID,
44
+ title: "Linear Live",
45
+ icon: "Radar",
46
+ context: "agent",
47
+ Component: gateAgentPanel(LivePanel),
48
+ }),
49
+ client.addCommandCenterItem({
50
+ id: "open-issues",
51
+ title: "Linear: Open issues",
52
+ icon,
53
+ keywords: ["linear", "issues", "tickets"],
54
+ context: "global",
55
+ onSelect(context) {
56
+ openPluginScreen(context, SCREEN_ID);
57
+ },
58
+ }),
59
+ client.addCommandCenterItem({
60
+ id: "create-issue",
61
+ title: "Linear: Create issue",
62
+ icon: "Plus",
63
+ keywords: ["linear", "new", "ticket"],
64
+ context: "global",
65
+ onSelect(context) {
66
+ updateBrowser(SCREEN_SCOPE, { creating: true });
67
+ openPluginScreen(context, SCREEN_ID);
68
+ },
69
+ }),
70
+ client.addCommandCenterItem({
71
+ id: "workspace-issues",
72
+ title: "Linear: Show issues in this workspace",
73
+ icon,
74
+ keywords: ["linear", "panel"],
75
+ context: "workspace",
76
+ onSelect({ openPanel }) {
77
+ openPanel(PANEL_ID);
78
+ },
79
+ }),
80
+ client.addCommandCenterItem({
81
+ id: "open-live",
82
+ title: "Linear: Open Linear Live",
83
+ icon: "Radar",
84
+ keywords: ["linear", "live", "progress", "map"],
85
+ context: "agent",
86
+ onSelect({ openPanel }) {
87
+ openPanel(LIVE_PANEL_ID);
88
+ },
89
+ }),
90
+ client.addCommandCenterItem({
91
+ id: "setup-project",
92
+ title: "Linear: Set up project prompts",
93
+ icon: "Sparkles",
94
+ keywords: ["linear", "init", "setup", "agents.md", "guidelines"],
95
+ context: "global",
96
+ onSelect({ openSettings }) {
97
+ openSettings("projects");
98
+ },
99
+ }),
100
+ client.addCommandCenterItem({
101
+ id: "settings",
102
+ title: "Linear: Settings",
103
+ icon: "Settings",
104
+ keywords: ["linear", "api key", "prompts", "projects"],
105
+ context: "global",
106
+ onSelect({ openSettings }) {
107
+ openSettings("connection");
108
+ },
109
+ }),
110
+ );
111
+ } catch (error) {
112
+ removeAll();
113
+ throw error;
114
+ }
115
+ return removeAll;
116
+ }
117
+
118
+ /** Registers the menus with the chosen icon, and again when it changes. */
119
+ function contributeMenusWithIcon(client: PluginClientContext): Cleanup {
120
+ let icon = startMenuIcon();
121
+ let removeMenus: Cleanup;
122
+ try {
123
+ removeMenus = contributeMenus(client, icon);
124
+ } catch (error) {
125
+ console.error("Linear menu icon", error);
126
+ icon = BUILT_IN_ICON;
127
+ removeMenus = contributeMenus(client, icon);
128
+ }
129
+ const stop = onMenuIconChange((next) => {
130
+ if (next === icon) return;
131
+ removeMenus();
132
+ try {
133
+ removeMenus = contributeMenus(client, next);
134
+ icon = next;
135
+ } catch (error) {
136
+ // An icon this Paseo does not have: keep the built-in one.
137
+ console.error("Linear menu icon", error);
138
+ removeMenus = contributeMenus(client, BUILT_IN_ICON);
139
+ icon = BUILT_IN_ICON;
140
+ }
141
+ });
142
+ return () => {
143
+ stop();
144
+ removeMenus();
145
+ };
146
+ }
147
+
22
148
  export default function contribute(client: PluginClientContext) {
23
149
  client.addAttachmentSource(issueAttachments);
24
150
  registerScreen(client, {
25
151
  id: SCREEN_ID,
26
152
  title: "Linear",
27
- icon: "SquareKanban",
153
+ icon: BUILT_IN_ICON,
28
154
  RowIcon: PluginIcon,
29
155
  Component: LinearScreen,
30
156
  });
31
- client.addWorkspacePanel({
32
- id: PANEL_ID,
33
- title: "Linear",
34
- icon: "SquareKanban",
35
- context: "workspace",
36
- locations: ["workspace", "explorer"],
37
- Component: LinearPanel,
38
- });
39
157
  client.addSettingsScreen({
40
158
  id: "connection",
41
159
  title: "Account",
@@ -48,13 +166,6 @@ export default function contribute(client: PluginClientContext) {
48
166
  icon: "MessageSquare",
49
167
  Component: PromptSettings,
50
168
  });
51
- client.addWorkspacePanel({
52
- id: LIVE_PANEL_ID,
53
- title: "Linear Live",
54
- icon: "Radar",
55
- context: "agent",
56
- Component: gateAgentPanel(LivePanel),
57
- });
58
169
  client.addSettingsScreen({
59
170
  id: "live",
60
171
  title: "Live",
@@ -73,67 +184,7 @@ export default function contribute(client: PluginClientContext) {
73
184
  icon: "FolderGit2",
74
185
  Component: ProjectSettings,
75
186
  });
76
- client.addCommandCenterItem({
77
- id: "open-issues",
78
- title: "Linear: Open issues",
79
- icon: "SquareKanban",
80
- keywords: ["linear", "issues", "tickets"],
81
- context: "global",
82
- onSelect(context) {
83
- openPluginScreen(context, SCREEN_ID);
84
- },
85
- });
86
- client.addCommandCenterItem({
87
- id: "create-issue",
88
- title: "Linear: Create issue",
89
- icon: "Plus",
90
- keywords: ["linear", "new", "ticket"],
91
- context: "global",
92
- onSelect(context) {
93
- updateBrowser(SCREEN_SCOPE, { creating: true });
94
- openPluginScreen(context, SCREEN_ID);
95
- },
96
- });
97
- client.addCommandCenterItem({
98
- id: "workspace-issues",
99
- title: "Linear: Show issues in this workspace",
100
- icon: "SquareKanban",
101
- keywords: ["linear", "panel"],
102
- context: "workspace",
103
- onSelect({ openPanel }) {
104
- openPanel(PANEL_ID);
105
- },
106
- });
107
- client.addCommandCenterItem({
108
- id: "open-live",
109
- title: "Linear: Open Linear Live",
110
- icon: "Radar",
111
- keywords: ["linear", "live", "progress", "map"],
112
- context: "agent",
113
- onSelect({ openPanel }) {
114
- openPanel(LIVE_PANEL_ID);
115
- },
116
- });
117
- client.addCommandCenterItem({
118
- id: "setup-project",
119
- title: "Linear: Set up project prompts",
120
- icon: "Sparkles",
121
- keywords: ["linear", "init", "setup", "agents.md", "guidelines"],
122
- context: "global",
123
- onSelect({ openSettings }) {
124
- openSettings("projects");
125
- },
126
- });
127
- client.addCommandCenterItem({
128
- id: "settings",
129
- title: "Linear: Settings",
130
- icon: "Settings",
131
- keywords: ["linear", "api key", "prompts", "projects"],
132
- context: "global",
133
- onSelect({ openSettings }) {
134
- openSettings("connection");
135
- },
136
- });
187
+ const removeMenus = contributeMenusWithIcon(client);
137
188
  client.addSlashCommand({
138
189
  name: "linear",
139
190
  description: "Open Linear issues, or jump to one by key",
@@ -154,5 +205,9 @@ export default function contribute(client: PluginClientContext) {
154
205
  schema: IssueCardSchema,
155
206
  Component: IssueCardRow,
156
207
  });
157
- return contributeComposerPills(client);
208
+ const removePills = contributeComposerPills(client);
209
+ return () => {
210
+ removeMenus();
211
+ removePills();
212
+ };
158
213
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yannelli/paseo-linear-plugin",
3
- "version": "1.1.0-beta.3",
3
+ "version": "1.1.0-beta.4",
4
4
  "description": "Browse and triage Linear issues in Paseo and start agents from them",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -12,6 +12,44 @@ export const LINEAR_INDIGO = "#5E6AD2";
12
12
  export const MAX_SVG_LENGTH = 64_000;
13
13
  export const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
14
14
 
15
+ /** Paseo draws panel tabs, the new tab menu, and Command Center with Lucide names only. */
16
+ export const BUILT_IN_ICON = "SquareKanban";
17
+ export const MENU_ICONS: readonly { name: string; label: string }[] = [
18
+ { name: BUILT_IN_ICON, label: "Board (default)" },
19
+ ...[
20
+ ["Kanban", "Kanban"],
21
+ ["LayoutGrid", "Grid"],
22
+ ["ListTodo", "To-do list"],
23
+ ["ClipboardList", "Clipboard"],
24
+ ["CircleDot", "Circle dot"],
25
+ ["Target", "Target"],
26
+ ["Zap", "Lightning"],
27
+ ["Rocket", "Rocket"],
28
+ ["Star", "Star"],
29
+ ["Hexagon", "Hexagon"],
30
+ ["Triangle", "Triangle"],
31
+ ["Diamond", "Diamond"],
32
+ ["Box", "Box"],
33
+ ["Layers", "Layers"],
34
+ ["Compass", "Compass"],
35
+ ["Flag", "Flag"],
36
+ ["Bookmark", "Bookmark"],
37
+ ["Ticket", "Ticket"],
38
+ ["Bug", "Bug"],
39
+ ["Sparkles", "Sparkles"],
40
+ ["Bot", "Bot"],
41
+ ["Gem", "Gem"],
42
+ ["Flame", "Flame"],
43
+ ["Shapes", "Shapes"],
44
+ ["Orbit", "Orbit"],
45
+ ["Workflow", "Workflow"],
46
+ ["Puzzle", "Puzzle"],
47
+ ["Atom", "Atom"],
48
+ ["Command", "Command"],
49
+ ].map(([name = "", label = ""]) => ({ name, label })),
50
+ ];
51
+ const LUCIDE_NAME = /^[A-Z][A-Za-z0-9]*$/;
52
+
15
53
  export const IconSettingsSchema = z.object({
16
54
  /** Checked SVG markup. Empty uses the built-in icon. */
17
55
  svg: z.string().max(MAX_SVG_LENGTH).default(""),
@@ -20,6 +58,8 @@ export const IconSettingsSchema = z.object({
20
58
  color: z.string().regex(HEX_COLOR).default(LINEAR_INDIGO),
21
59
  /** Fill shapes that only have an outline. */
22
60
  solid: z.boolean().default(false),
61
+ /** The Lucide icon for panel tabs, the new tab menu, and Command Center. */
62
+ menu: z.string().regex(LUCIDE_NAME).default(BUILT_IN_ICON),
23
63
  });
24
64
  export type IconSettings = z.infer<typeof IconSettingsSchema>;
25
65
 
@@ -17,6 +17,11 @@ export interface Box {
17
17
  bottom: number;
18
18
  }
19
19
 
20
+ /** A box with the point the view centers on when the box still fits around it. */
21
+ export interface Focus extends Box {
22
+ center: Point;
23
+ }
24
+
20
25
  export const FIT: Camera = { x: 0, y: 0, zoom: 1 };
21
26
  export const MIN_ZOOM = 1;
22
27
  export const MAX_ZOOM = 4;
@@ -27,6 +32,11 @@ const PAD_Y = 56;
27
32
 
28
33
  const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));
29
34
 
35
+ /** How much nodes, labels, and cursors grow on screen: less than the zoom, so they stay readable. */
36
+ export function growth(zoom: number): number {
37
+ return Math.sqrt(zoom);
38
+ }
39
+
30
40
  /** Where a canvas point draws in the view. */
31
41
  export function project(camera: Camera, at: Point, width: number, height: number): Point {
32
42
  return {
@@ -61,21 +71,31 @@ export function zoomAt(camera: Camera, at: Point, factor: number, width: number,
61
71
  return clampCamera({ zoom, x: at.x - cx - zoom * (px - cx), y: at.y - cy - zoom * (py - cy) }, width, height);
62
72
  }
63
73
 
64
- /** Shows the box as large as fits in the view, up to the auto zoom. */
65
- export function frameBox(box: Box, width: number, height: number): Camera {
74
+ /** The center nearest `wanted` that keeps the span from low to high in a view of this size. */
75
+ function centerFor(wanted: number, low: number, high: number, half: number): number {
76
+ return high - low >= half * 2 ? (low + high) / 2 : clamp(wanted, high - half, low + half);
77
+ }
78
+
79
+ /** Shows the box as large as fits in the view, up to the auto zoom, centered on its focus. */
80
+ export function frameBox(box: Box | Focus, width: number, height: number): Camera {
66
81
  const fit = Math.min(width / Math.max(1, box.right - box.left), height / Math.max(1, box.bottom - box.top));
67
82
  const zoom = clamp(fit, MIN_ZOOM, AUTO_ZOOM);
68
- const x = zoom * (width / 2 - (box.left + box.right) / 2);
69
- const y = zoom * (height / 2 - (box.top + box.bottom) / 2);
70
- return clampCamera({ zoom, x, y }, width, height);
83
+ const wanted = "center" in box ? box.center : { x: (box.left + box.right) / 2, y: (box.top + box.bottom) / 2 };
84
+ const middleX = centerFor(wanted.x, box.left, box.right, width / 2 / zoom);
85
+ const middleY = centerFor(wanted.y, box.top, box.bottom, height / 2 / zoom);
86
+ return clampCamera({ zoom, x: zoom * (width / 2 - middleX), y: zoom * (height / 2 - middleY) }, width, height);
71
87
  }
72
88
 
73
89
  // The area auto mode shows: the nodes the running agents work on and their neighbors, with
74
90
  // room for labels and cursors. Null when no agent works on a node other than the issue.
75
- export function focusBox(graph: Graph, positions: ReadonlyMap<string, Point>, targets: Iterable<string>): Box | null {
91
+ export function focusBox(graph: Graph, positions: ReadonlyMap<string, Point>, targets: Iterable<string>): Focus | null {
76
92
  const ids = new Set<string>();
77
93
  for (const target of targets) if (target !== graph.root) ids.add(target);
78
94
  if (ids.size === 0) return null;
95
+ const spots = [...ids].flatMap((id) => positions.get(id) ?? []);
96
+ const center = spots.length
97
+ ? { x: spots.reduce((sum, at) => sum + at.x, 0) / spots.length, y: spots.reduce((sum, at) => sum + at.y, 0) / spots.length }
98
+ : null;
79
99
  for (const edge of graph.edges) {
80
100
  if (ids.has(edge.from) && edge.to !== graph.root) ids.add(edge.to);
81
101
  else if (ids.has(edge.to) && edge.from !== graph.root) ids.add(edge.from);
@@ -88,6 +108,6 @@ export function focusBox(graph: Graph, positions: ReadonlyMap<string, Point>, ta
88
108
  ? { left: Math.min(box.left, at.x), top: Math.min(box.top, at.y), right: Math.max(box.right, at.x), bottom: Math.max(box.bottom, at.y) }
89
109
  : { left: at.x, top: at.y, right: at.x, bottom: at.y };
90
110
  }
91
- if (!box) return null;
92
- return { left: box.left - PAD_X, top: box.top - PAD_Y, right: box.right + PAD_X, bottom: box.bottom + PAD_Y };
111
+ if (!box || !center) return null;
112
+ return { left: box.left - PAD_X, top: box.top - PAD_Y, right: box.right + PAD_X, bottom: box.bottom + PAD_Y, center };
93
113
  }