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.
This commit is contained in:
Mohamed Boudra
2026-11-03 13:47:14 +01:00
parent 51fd60d4c6
commit ac099f4ff8
6 changed files with 61 additions and 29 deletions
@@ -13,6 +13,7 @@ import { connectNewWorkspaceDaemonClient } from "./new-workspace";
type SettingsClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
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 }),
+36
View File
@@ -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<PluginListItem, "id" | "name">): 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;
}
+4 -11
View File
@@ -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;
@@ -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;
+3 -2
View File
@@ -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
+2 -2
View File
@@ -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)` 可以打开你自己的某个界面。即使多台主机安装了同一个插件,每次安装也有各自的值和路由。