From ac099f4ff88a1ea66799a6439eab38fec762879e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 3 Nov 2026 13:47:14 +0100 Subject: [PATCH] feat(app): name only the plugin in its settings page header The header shows the plugin's manifest name, or its id when it has none, for single- and multi-screen plugins alike. The selected tab names the screen. The Plugins page and the header share one plugin list query. --- .../e2e/support/helpers/plugin-settings.ts | 16 +++++++-- packages/app/src/plugins/plugin-list.ts | 36 +++++++++++++++++++ packages/app/src/screens/settings-screen.tsx | 15 +++----- .../app/src/screens/settings/plugins-page.tsx | 14 ++------ public-docs/plugins/reference.md | 5 +-- public-docs/plugins/reference.zh-CN.md | 4 +-- 6 files changed, 61 insertions(+), 29 deletions(-) create mode 100644 packages/app/src/plugins/plugin-list.ts diff --git a/packages/app/e2e/support/helpers/plugin-settings.ts b/packages/app/e2e/support/helpers/plugin-settings.ts index cc22bd1586..921e39cd94 100644 --- a/packages/app/e2e/support/helpers/plugin-settings.ts +++ b/packages/app/e2e/support/helpers/plugin-settings.ts @@ -13,6 +13,7 @@ import { connectNewWorkspaceDaemonClient } from "./new-workspace"; type SettingsClient = Awaited>; const TABBED_PLUGIN_ID = "settings-tabs"; +const TABBED_PLUGIN_NAME = "Tabbed settings"; /** Two settings screens, so the settings page shows a tab per screen. */ const TABBED_SOURCE = `import React from "react"; import { Text, View } from "react-native"; @@ -49,7 +50,11 @@ async function writeTabbedPlugin() { const directory = await mkdtemp(path.join(tmpdir(), `paseo-${TABBED_PLUGIN_ID}-`)); await writeFile( path.join(directory, "paseo-plugin.json"), - JSON.stringify({ id: TABBED_PLUGIN_ID, requirements: pluginRequirements }), + JSON.stringify({ + id: TABBED_PLUGIN_ID, + name: TABBED_PLUGIN_NAME, + requirements: pluginRequirements, + }), ); await writeFile(path.join(directory, "index.client.tsx"), TABBED_SOURCE); return { directory, cleanup: () => rm(directory, { recursive: true, force: true }) }; @@ -99,8 +104,9 @@ async function openPluginScreen(page: Page) { async function openSettingsFromCog(page: Page) { await page.getByRole("button", { name: "Settings for settings-example", exact: true }).click(); await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible(); - // A plugin with one settings screen shows no tabs. + // A plugin with one settings screen shows no tabs, and the header names only the plugin. await expect(page.getByTestId("plugin-settings-tabs")).toHaveCount(0); + await expectSettingsHeader(page, "settings-example"); } export async function openPluginsPage(page: Page) { await gotoAppShell(page); @@ -142,6 +148,8 @@ export async function expectSettingsTab(page: Page, title: "General" | "Advanced await expect(settingsTab(page, title)).toHaveAttribute("aria-selected", "true"); await expect(page.getByText(`${title} settings body`, { exact: true })).toBeVisible(); await expect(page.getByText(`${other} settings body`, { exact: true })).toHaveCount(0); + // The header names the plugin by its manifest name; the tab names the screen. + await expectSettingsHeader(page, TABBED_PLUGIN_NAME); await expectAppRoute( page, `${buildSettingsHostSectionRoute(getServerId(), "plugins")}/${TABBED_PLUGIN_ID}/${title.toLowerCase()}`, @@ -216,6 +224,10 @@ export async function returnToPluginsPage(page: Page) { page.getByRole("button", { name: "Actions for settings-example", exact: true }), ).toBeVisible(); } +async function expectSettingsHeader(page: Page, title: string) { + // Settings keeps the Plugins page mounted beneath, so read the visible title only. + await expect(page.getByText(title, { exact: true }).filter({ visible: true })).toHaveCount(1); +} export async function expectSettingsUnavailable(page: Page) { await expect( page.getByText("This plugin settings screen is unavailable.", { exact: true }), diff --git a/packages/app/src/plugins/plugin-list.ts b/packages/app/src/plugins/plugin-list.ts new file mode 100644 index 0000000000..387e0c6b56 --- /dev/null +++ b/packages/app/src/plugins/plugin-list.ts @@ -0,0 +1,36 @@ +import type { PluginListItem } from "@getpaseo/protocol/messages"; +import { useFetchQuery } from "@/data/query"; +import { useHostFeature } from "@/runtime/host-features"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; + +/** A host's configured plugins, as listed on Settings → Plugins. One cache entry per host. */ +export function usePluginList(serverId: string, options: { enabled?: boolean } = {}) { + const client = useHostRuntimeClient(serverId); + const connected = useHostRuntimeIsConnected(serverId); + const supported = useHostFeature(serverId, "pluginManagement"); + return useFetchQuery({ + queryKey: ["plugins", serverId] as const, + queryFn: async () => { + if (!client) throw new Error("Plugin host is offline"); + return client.listPlugins(); + }, + enabled: (options.enabled ?? true) && Boolean(client && connected && supported), + dataShape: "list", + staleTimeMs: 1_000, + }); +} + +/** The manifest's display name, or the plugin id when it has none. */ +export function pluginDisplayName(plugin: Pick): string { + return plugin.name ?? plugin.id; +} + +/** The display name of one configured plugin; its id until the list loads. */ +export function usePluginDisplayName( + target: { serverId: string; pluginId: string } | null, +): string | undefined { + const plugins = usePluginList(target?.serverId ?? "", { enabled: target !== null }); + if (!target) return undefined; + const plugin = plugins.data?.find((candidate) => candidate.id === target.pluginId); + return plugin ? pluginDisplayName(plugin) : target.pluginId; +} diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 3832c037cc..8bce0c4dd1 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -114,7 +114,7 @@ import { HostTerminalsPage, } from "@/screens/settings/host-page"; import { PluginSettingsContent } from "@/plugins/settings"; -import { useInstalledPlugins } from "@/plugins/registry"; +import { usePluginDisplayName } from "@/plugins/plugin-list"; import { HostPluginsPage } from "@/screens/settings/plugins-page"; import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-page"; import { HostVoicePage } from "@/screens/settings/voice/voice-page"; @@ -1267,17 +1267,10 @@ export default function SettingsScreen({ view }: SettingsScreenProps) { returnFromSettings({ kind: "root" }); }, []); - const installedPlugins = useInstalledPlugins(); + // The header names the plugin; its tabs name the open settings screen. + const pluginDisplayName = usePluginDisplayName(view.kind === "plugin" ? view : null); const detailTitle = ((): string | undefined => { - if (view.kind === "plugin") { - const plugin = installedPlugins.find( - (candidate) => candidate.serverId === view.serverId && candidate.id === view.pluginId, - ); - // Several screens show tabs, which already name the open screen. - if (plugin && plugin.settingsScreens.length > 1) return view.pluginId; - const screen = plugin?.settingsScreens.find((candidate) => candidate.id === view.screenId); - return `${view.pluginId} · ${plugin && screen ? plugin.i18n.resolve(screen.title, plugin.locale) : t("settings.title")}`; - } + if (view.kind === "plugin") return pluginDisplayName; if (view.kind === "host") { const item = HOST_SECTION_ITEMS.find((s) => s.id === view.section); return item ? t(item.labelKey) : undefined; diff --git a/packages/app/src/screens/settings/plugins-page.tsx b/packages/app/src/screens/settings/plugins-page.tsx index 907364ba45..0b45644035 100644 --- a/packages/app/src/screens/settings/plugins-page.tsx +++ b/packages/app/src/screens/settings/plugins-page.tsx @@ -29,10 +29,10 @@ import { SettingsSection } from "@/components/settings/headings/settings-section import { resolvePluginPageState } from "@/screens/settings/plugins-page-state"; import { openPluginInstallForm } from "@/screens/settings/plugin-install-form-model"; import { pluginRegistry, useInstalledPlugins } from "@/plugins/registry"; +import { usePluginList } from "@/plugins/plugin-list"; import { settingsStyles } from "@/styles/settings"; import { confirmDialog } from "@/utils/confirm-dialog"; -const pluginQueryKey = (serverId: string) => ["plugins", serverId] as const; const PLUGIN_SOURCE_DOCS_URL = "https://paseo.sh/docs/plugins/reference#plugin-sources"; type PluginRowAction = "reload" | "enable" | "disable" | "remove"; @@ -273,7 +273,6 @@ export function HostPluginsPage({ serverId }: { serverId: string }) { const { config, patchConfig } = useDaemonConfig(serverId); const refreshQueue = useRef(Promise.resolve()); useInstalledPlugins(); - const queryKey = useMemo(() => pluginQueryKey(serverId), [serverId]); const [installForm] = useState(openPluginInstallForm); const installState = useSyncExternalStore( installForm.subscribe, @@ -294,16 +293,7 @@ export function HostPluginsPage({ serverId }: { serverId: string }) { const [feedback, setFeedback] = useState<{ kind: "success" | "error"; message: string } | null>( null, ); - const plugins = useFetchQuery({ - queryKey, - queryFn: async () => { - if (!client) throw new Error("Plugin host is offline"); - return client.listPlugins(); - }, - enabled: Boolean(client && connected && supported), - dataShape: "list", - staleTimeMs: 1_000, - }); + const plugins = usePluginList(serverId); const refetchPlugins = plugins.refetch; const refresh = useCallback(async () => { if (!client) return; diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index ff9b52e2b2..a4bcfebde7 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -76,7 +76,7 @@ The required root manifest is `paseo-plugin.json`: | Field | Required | Behavior | | -------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | Yes | Default installation ID. | -| `name` | No | Non-empty display name for the registry and website; independent of the installation ID. | +| `name` | No | Non-empty display name for the registry, the website, and your plugin settings header; independent of the installation ID. | | `icon` | No | Relative path to a PNG inside the plugin package. | | `media` | No | Array of image or video paths inside the package, or HTTPS URLs. An empty array is valid. | | `description` | No | Non-empty summary shown below the plugin ID in **Settings → Plugins**. | @@ -1305,7 +1305,8 @@ Register a component with `client.addSettingsScreen({ id, title, icon, Component `index.client.tsx`. Your plugin's row in **Settings → Plugins** on that host then shows a settings button (cog) that opens your first screen. When you register several screens, the settings page shows one tab per screen, labeled with its `title` and `icon`, in registration order. With one screen there -are no tabs. `id` is unique within the installation; `icon` is any [icon](#icons) form. Registration returns an +are no tabs. The page header shows your manifest `name`, or your plugin ID when you leave `name` +out; it never includes a screen title. `id` is unique within the installation; `icon` is any [icon](#icons) form. Registration returns an idempotent remover, and plugin teardown removes remaining screens. Call `client.openSettings(id)` or a Command Center callback's `openSettings(id)` to open one diff --git a/public-docs/plugins/reference.zh-CN.md b/public-docs/plugins/reference.zh-CN.md index c08e4d1a31..54b6332fbe 100644 --- a/public-docs/plugins/reference.zh-CN.md +++ b/public-docs/plugins/reference.zh-CN.md @@ -73,7 +73,7 @@ my-plugin/ | 字段 | 是否必填 | 行为 | | -------------- | -------- | ---------------------------------------------------------------------------------------------------------- | | `id` | 是 | 默认的安装 ID。 | -| `name` | 否 | 注册表和网站使用的非空显示名称;与安装 ID 无关。 | +| `name` | 否 | 注册表、网站和插件设置页标题使用的非空显示名称;与安装 ID 无关。 | | `icon` | 否 | 插件包内 PNG 文件的相对路径。 | | `media` | 否 | 包内图片或视频路径的数组,或 HTTPS URL。空数组是有效的。 | | `description` | 否 | 在 **设置 → 插件** 中显示在插件 ID 下方的非空摘要。 | @@ -1087,7 +1087,7 @@ export default function contribute(client: PluginClientContext) { ## 设置界面 {#settings-screens} -在 `index.client.tsx` 中用 `client.addSettingsScreen({ id, title, icon, Component })` 注册一个组件。之后,该主机 **设置 → 插件** 中你的插件行会显示一个设置按钮(齿轮),点击后打开你的第一个界面。注册多个界面时,设置页面会按注册顺序为每个界面显示一个标签页,使用其 `title` 和 `icon`;只有一个界面时不显示标签页。`id` 在同一次安装中唯一;`icon` 可以是任意一种[图标](#icons)形式。注册返回一个幂等的移除函数,插件销毁时会移除剩余的界面。 +在 `index.client.tsx` 中用 `client.addSettingsScreen({ id, title, icon, Component })` 注册一个组件。之后,该主机 **设置 → 插件** 中你的插件行会显示一个设置按钮(齿轮),点击后打开你的第一个界面。注册多个界面时,设置页面会按注册顺序为每个界面显示一个标签页,使用其 `title` 和 `icon`;只有一个界面时不显示标签页。页面标题显示 manifest 中的 `name`;没有 `name` 时显示插件 ID,且从不包含界面标题。`id` 在同一次安装中唯一;`icon` 可以是任意一种[图标](#icons)形式。注册返回一个幂等的移除函数,插件销毁时会移除剩余的界面。 调用 `client.openSettings(id)` 或 Command Center 回调中的 `openSettings(id)` 可以打开你自己的某个界面。即使多台主机安装了同一个插件,每次安装也有各自的值和路由。