feat(app): open plugin settings from a cog with a tab per screen

The plugin row shows a settings button next to its switch when the plugin
registers settings screens. The actions menu keeps Logs, Reload and Remove.
The settings page shows one tab per registered screen; a plugin with one
screen shows no tabs. Tabs swap screenId in place, so existing
plugins/[pluginId]/[screenId] links and openSettings keep working.
This commit is contained in:
Mohamed Boudra
2026-11-03 13:12:45 +01:00
parent 51b9ad7695
commit c8376d9c67
11 changed files with 194 additions and 56 deletions
+2 -1
View File
@@ -96,7 +96,8 @@ hidden workspace can overwrite the remembered workspace before Settings or
History returns.
Plugin settings use the distinct `settings/hosts/[serverId]/plugins/[pluginId]/[screenId]` leaf;
Back returns to that host's Plugins page.
Back returns to that host's Plugins page. Its screen tabs change `screenId` with `router.setParams`,
so switching tabs adds no stack entries.
Settings detail routes are separate siblings on purpose. Keep
`settings/[section]`, the host routes, the projects index, and project detail as
@@ -16,6 +16,12 @@ import {
returnAndReopenSettings,
returnToPluginsPage,
expectSettingsUnavailable,
openPluginsPage,
expectActionsMenuWithoutSettings,
expectNoSettingsCogWithoutScreens,
openTabbedSettings,
selectSettingsTab,
openTabbedSettingsLink,
} from "../support/helpers/plugin-settings";
test("display preferences survive a reload", async ({ page }) => {
@@ -49,6 +55,26 @@ test("Back to plugins returns from a plugin settings screen", async ({ page }) =
await returnToPluginsPage(page);
});
test("plugin settings open from the cog and show a tab per screen", async ({ page }) => {
await openPluginsPage(page);
await test.step("the actions menu keeps only plugin actions", async () => {
await expectActionsMenuWithoutSettings(page);
});
await test.step("tabs switch between the plugin's settings screens", async () => {
await openTabbedSettings(page);
await selectSettingsTab(page, "Advanced");
await selectSettingsTab(page, "General");
await returnToPluginsPage(page);
});
await test.step("a plugin without settings screens shows no cog", async () => {
await expectNoSettingsCogWithoutScreens(page);
});
});
test("a settings screen link opens its tab", async ({ page }) => {
await openTabbedSettingsLink(page, "Advanced");
});
test.describe("compact plugin settings", () => {
test.use({ viewport: { width: 390, height: 844 } });
@@ -1,4 +1,7 @@
import { copyPluginExample } from "./plugin-fixture";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
import { expect, test as base, type Page } from "../fixtures";
import { gotoAppShell, openSettings } from "./app";
import { goBackInSettings, openCompactSettings, openHostSection } from "./settings";
@@ -9,6 +12,36 @@ import { connectNewWorkspaceDaemonClient } from "./new-workspace";
type SettingsClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const TABBED_PLUGIN_ID = "settings-tabs";
/** Two settings screens, so the settings page shows a tab per screen. */
const TABBED_SOURCE = `import React from "react";
import { Text } from "react-native";
function General({ theme }) {
return <Text style={{ color: theme.colors.foreground }}>General settings body</Text>;
}
function Advanced({ theme }) {
return <Text style={{ color: theme.colors.foreground }}>Advanced settings body</Text>;
}
export default function contribute(client) {
client.addSettingsScreen({ id: "general", title: "General", icon: "Settings", Component: General });
client.addSettingsScreen({ id: "advanced", title: "Advanced", icon: "Wrench", Component: Advanced });
return () => {};
}
`;
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 }),
);
await writeFile(path.join(directory, "index.client.tsx"), TABBED_SOURCE);
return { directory, cleanup: () => rm(directory, { recursive: true, force: true }) };
}
export const test = base.extend<{ settingsPlugin: SettingsClient }>({
settingsPlugin: [
async ({ e2eWorker, page }, provide, testInfo) => {
@@ -16,16 +49,20 @@ export const test = base.extend<{ settingsPlugin: SettingsClient }>({
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const example = await copyPluginExample("settings");
const tabbed = await writeTabbedPlugin();
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(example.directory);
await client.installDirectoryPlugin(tabbed.directory);
await provide(client);
await page.screenshot({ path: testInfo.outputPath("settings.png") });
} finally {
await client.removePlugin("settings-example");
await client.removePlugin(TABBED_PLUGIN_ID);
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await client.close();
await example.cleanup();
await tabbed.cleanup();
}
},
{ auto: true },
@@ -44,9 +81,63 @@ export async function openCompactDisplaySettings(page: Page) {
}
async function openPluginScreen(page: Page) {
await openHostSection(page, getServerId(), "plugins");
await page.getByRole("button", { name: "Actions for settings-example", exact: true }).click();
await page.getByRole("menuitem", { name: "Display", exact: true }).click();
await openSettingsFromCog(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.
await expect(page.getByTestId("plugin-settings-tabs")).toHaveCount(0);
}
export async function openPluginsPage(page: Page) {
await gotoAppShell(page);
await openSettings(page);
await openHostSection(page, getServerId(), "plugins");
}
export async function expectActionsMenuWithoutSettings(page: Page) {
await page.getByRole("button", { name: "Actions for settings-example", exact: true }).click();
await expect(page.getByRole("menuitem", { name: "Reload", exact: true })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "Display", exact: true })).toHaveCount(0);
await page.keyboard.press("Escape");
}
export async function expectNoSettingsCogWithoutScreens(page: Page) {
await page.getByRole("switch", { name: "settings-example: Disable" }).click();
await expect(page.getByRole("switch", { name: "settings-example: Enable" })).toBeVisible();
await expect(
page.getByRole("button", { name: "Settings for settings-example", exact: true }),
).toHaveCount(0);
await page.getByRole("switch", { name: "settings-example: Enable" }).click();
await expect(
page.getByRole("button", { name: "Settings for settings-example", exact: true }),
).toBeVisible();
}
export async function openTabbedSettings(page: Page) {
await page.getByRole("button", { name: `Settings for ${TABBED_PLUGIN_ID}`, exact: true }).click();
await expectSettingsTab(page, "General");
}
function settingsTab(page: Page, title: string) {
return page.getByTestId("plugin-settings-tabs").getByRole("button", { name: title, exact: true });
}
export async function selectSettingsTab(page: Page, title: "General" | "Advanced") {
await settingsTab(page, title).click();
await expectSettingsTab(page, title);
}
export async function expectSettingsTab(page: Page, title: "General" | "Advanced") {
const other = title === "General" ? "Advanced" : "General";
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);
await expectAppRoute(
page,
`${buildSettingsHostSectionRoute(getServerId(), "plugins")}/${TABBED_PLUGIN_ID}/${title.toLowerCase()}`,
);
}
export async function openTabbedSettingsLink(page: Page, title: "General" | "Advanced") {
await gotoAppShell(page);
await page.goto(
`${buildSettingsHostSectionRoute(getServerId(), "plugins")}/${TABBED_PLUGIN_ID}/${title.toLowerCase()}`,
);
await expectSettingsTab(page, title);
}
export async function groupByWorkspace(page: Page) {
await page.getByRole("button", { name: /^Group agents by:/ }).click();
@@ -101,11 +192,7 @@ export async function discardTitle(page: Page) {
}
export async function returnAndReopenSettings(page: Page) {
await goBackInSettings(page);
const actions = page.getByRole("button", { name: "Actions for settings-example", exact: true });
await expect(actions).toBeVisible();
await actions.click();
await page.getByRole("menuitem", { name: "Display", exact: true }).click();
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
await openSettingsFromCog(page);
}
export async function returnToPluginsPage(page: Page) {
await page.getByRole("button", { name: "Back to plugins", exact: true }).click();
+1
View File
@@ -2394,6 +2394,7 @@ export const en = {
status: { running: "running", disabled: "disabled", failed: "failed" },
actions: {
menu: "Actions for {{id}}",
settings: "Settings for {{id}}",
reload: "Reload",
reloading: "Reloading…",
enable: "Enable",
@@ -34,6 +34,7 @@ export const pluginSettings = {
status: { running: "قيد التشغيل", disabled: "معطّلة", failed: "فشلت" },
actions: {
menu: "إجراءات {{id}}",
settings: "إعدادات {{id}}",
reload: "إعادة تحميل",
reloading: "جارٍ إعادة التحميل…",
enable: "تفعيل",
@@ -89,6 +90,7 @@ export const pluginSettings = {
status: { running: "en ejecución", disabled: "desactivado", failed: "fallido" },
actions: {
menu: "Acciones para {{id}}",
settings: "Configuración de {{id}}",
reload: "Recargar",
reloading: "Recargando…",
enable: "Activar",
@@ -144,6 +146,7 @@ export const pluginSettings = {
status: { running: "actif", disabled: "désactivé", failed: "échec" },
actions: {
menu: "Actions pour {{id}}",
settings: "Paramètres de {{id}}",
reload: "Recharger",
reloading: "Rechargement…",
enable: "Activer",
@@ -199,6 +202,7 @@ export const pluginSettings = {
status: { running: "実行中", disabled: "無効", failed: "失敗" },
actions: {
menu: "{{id}} のアクション",
settings: "{{id}} の設定",
reload: "再読み込み",
reloading: "再読み込み中…",
enable: "有効化",
@@ -253,6 +257,7 @@ export const pluginSettings = {
status: { running: "실행 중", disabled: "비활성화", failed: "실패" },
actions: {
menu: "{{id}} 작업",
settings: "{{id}} 설정",
reload: "다시 로드",
reloading: "다시 로드 중…",
enable: "활성화",
@@ -308,6 +313,7 @@ export const pluginSettings = {
status: { running: "em execução", disabled: "desativado", failed: "falhou" },
actions: {
menu: "Ações para {{id}}",
settings: "Configurações de {{id}}",
reload: "Recarregar",
reloading: "Recarregando…",
enable: "Ativar",
@@ -363,6 +369,7 @@ export const pluginSettings = {
status: { running: "работает", disabled: "отключён", failed: "ошибка" },
actions: {
menu: "Действия для {{id}}",
settings: "Настройки {{id}}",
reload: "Перезагрузить",
reloading: "Перезагрузка…",
enable: "Включить",
@@ -417,6 +424,7 @@ export const pluginSettings = {
status: { running: "运行中", disabled: "已禁用", failed: "失败" },
actions: {
menu: "{{id}} 的操作",
settings: "{{id}} 的设置",
reload: "重新加载",
reloading: "正在重新加载…",
enable: "启用",
+52 -38
View File
@@ -1,17 +1,19 @@
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, Text, View } from "react-native";
import { Platform, ScrollView, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { router } from "expo-router";
import { ArrowLeft } from "lucide-react-native";
import { ArrowLeft, Settings } from "lucide-react-native";
import type { PluginHostProps } from "@getpaseo/plugin/client";
import { Button } from "@/components/ui/button";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useHostFeature } from "@/runtime/host-features";
import { useHostRuntimeClient, useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
import type { Theme } from "@/styles/theme";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { PluginIconView } from "../icons";
import { useInstalledPlugin } from "../registry";
import type { InstalledPlugin } from "../types";
import { PluginInstallationProvider } from "../installation-provider";
import { SurfaceErrorBoundary } from "../surface-error-boundary";
import { toPluginTheme } from "../theme";
@@ -23,45 +25,55 @@ interface SettingsIdentity {
screenId: string;
}
function PluginSettingsMenuItem({
serverId,
pluginId,
screenId,
title,
disabled,
}: SettingsIdentity & { title: string; disabled?: boolean }) {
const open = useCallback(
() => router.push(buildPluginSettingsRoute(serverId, pluginId, screenId)),
[serverId, pluginId, screenId],
);
const ThemedSettingsIcon = withUnistyles(Settings);
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const settingsIcon = <ThemedSettingsIcon size={ICON_SIZE.md} uniProps={mutedIconMapping} />;
/** Opens the plugin's settings at its first screen. Renders nothing when it has none. */
export function PluginSettingsButton({ serverId, pluginId }: Omit<SettingsIdentity, "screenId">) {
const { t } = useTranslation();
const plugin = useInstalledPlugin(serverId, pluginId);
const supported = useHostFeature(serverId, "pluginSettings");
const firstScreenId = plugin?.settingsScreens[0]?.id;
const open = useCallback(() => {
if (firstScreenId) router.push(buildPluginSettingsRoute(serverId, pluginId, firstScreenId));
}, [serverId, pluginId, firstScreenId]);
if (!supported || !firstScreenId) return null;
return (
<DropdownMenuItem onSelect={open} disabled={disabled}>
{title}
</DropdownMenuItem>
<Button
variant="ghost"
leftIcon={settingsIcon}
onPress={open}
accessibilityLabel={t("settings.plugins.actions.settings", { id: pluginId })}
testID={`plugin-settings-${pluginId}`}
/>
);
}
export function PluginSettingsMenuItems({
serverId,
pluginId,
disabled,
}: Omit<SettingsIdentity, "screenId"> & { disabled?: boolean }) {
const plugin = useInstalledPlugin(serverId, pluginId);
const supported = useHostFeature(serverId, "pluginSettings");
if (!supported || !plugin) return null;
/** One tab per registered settings screen. A plugin with a single screen shows no tabs. */
function SettingsTabs({ plugin, screenId }: { plugin: InstalledPlugin; screenId: string }) {
const options = useMemo<SegmentedControlOption<string>[]>(
() =>
plugin.settingsScreens.map((screen) => ({
value: screen.id,
label: plugin.i18n.resolve(screen.title, plugin.locale),
icon: ({ color, size }) => <PluginIconView icon={screen.icon} size={size} color={color} />,
testID: `plugin-settings-tab-${screen.id}`,
})),
[plugin],
);
// Tabs swap the screen in place, so Back still returns to the Plugins page.
const select = useCallback((id: string) => router.setParams({ screenId: id }), []);
if (options.length < 2) return null;
return (
<>
{plugin.settingsScreens.map((screen) => (
<PluginSettingsMenuItem
key={screen.id}
serverId={serverId}
pluginId={pluginId}
screenId={screen.id}
title={plugin.i18n.resolve(screen.title, plugin.locale)}
disabled={disabled}
/>
))}
</>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.tabs}
testID="plugin-settings-tabs"
>
<SegmentedControl options={options} value={screenId} onValueChange={select} />
</ScrollView>
);
}
@@ -113,6 +125,7 @@ function SettingsContent({
const Component = screen.Component;
return (
<View>
<SettingsTabs plugin={plugin} screenId={screen.id} />
<SurfaceErrorBoundary
installation={plugin}
Surface={Component}
@@ -153,5 +166,6 @@ export function PluginSettingsContent({
}
const styles = StyleSheet.create((theme) => ({
message: { color: theme.colors.foregroundMuted },
tabs: { flexGrow: 0, marginBottom: theme.spacing[4] },
backButton: { alignSelf: "flex-start", paddingHorizontal: 0, marginBottom: theme.spacing[4] },
}));
@@ -1273,6 +1273,8 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
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")}`;
}
@@ -1,5 +1,5 @@
import { formatPluginInstallation } from "@getpaseo/protocol/plugin-source-reference";
import { PluginSettingsMenuItems } from "@/plugins/settings";
import { PluginSettingsButton } from "@/plugins/settings";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
@@ -49,7 +49,6 @@ function pluginRowAction(action: string | undefined): PluginRowAction | undefine
function PluginActionsMenu({
plugin,
serverId,
pending,
pendingAction,
onAction,
@@ -57,7 +56,6 @@ function PluginActionsMenu({
supportsLogs,
}: {
plugin: PluginListItem;
serverId: string;
pending: boolean;
pendingAction?: PluginRowAction;
onAction(action: PluginRowAction, plugin: PluginListItem): void;
@@ -83,7 +81,6 @@ function PluginActionsMenu({
<MoreHorizontal size={18} color={styles.menuIcon.color} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220} sheetTitle={menuLabel}>
<PluginSettingsMenuItems serverId={serverId} pluginId={plugin.id} disabled={pending} />
{supportsLogs ? (
<DropdownMenuItem onSelect={openLogs} disabled={pending}>
{t("settings.plugins.logs.action")}
@@ -171,6 +168,7 @@ function PluginRow({
testID={`plugin-row-${plugin.id}`}
>
<View style={styles.pluginControls} accessibilityLabel={`${plugin.id} ${statusLabel}`}>
<PluginSettingsButton serverId={serverId} pluginId={plugin.id} />
<Switch
value={plugin.enabled}
onValueChange={toggle}
@@ -179,7 +177,6 @@ function PluginRow({
/>
<PluginActionsMenu
plugin={plugin}
serverId={serverId}
pending={pending}
pendingAction={pendingAction}
onAction={onAction}
+1 -1
View File
@@ -1,7 +1,7 @@
# Settings example
Install this directory with `paseo plugin install /absolute/path/to/plugin-examples/settings`.
Open Settings → Plugins → settings-example → Display, or run **Configure agent monitor** in Command Center.
Open Settings → Plugins and press the settings button (cog) on the settings-example row, or run **Configure agent monitor** in Command Center.
The select and switch save immediately. The title editor keeps a draft and its original revision,
so another client's save produces a conflict instead of overwriting its changes. Close and reopen
+4 -2
View File
@@ -1302,8 +1302,10 @@ Themes need a host that supports them. A client released before `addTheme` canno
## Settings screens
Register a component with `client.addSettingsScreen({ id, title, icon, Component })` in
`index.client.tsx`. It appears under **Settings → Plugins → your plugin** on that host.
`id` is unique within the installation; `icon` is any [icon](#icons) form. Registration returns an
`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
idempotent remover, and plugin teardown removes remaining screens.
Call `client.openSettings(id)` or a Command Center callback's `openSettings(id)` to open one
+1 -1
View File
@@ -1087,7 +1087,7 @@ export default function contribute(client: PluginClientContext) {
## 设置界面 {#settings-screens}
在 `index.client.tsx` 中用 `client.addSettingsScreen({ id, title, icon, Component })` 注册一个组件。它会出现在该主机的 **设置 → 插件 → 你的插件** 下。`id` 在同一次安装中唯一;`icon` 可以是任意一种[图标](#icons)形式。注册返回一个幂等的移除函数,插件销毁时会移除剩余的界面。
在 `index.client.tsx` 中用 `client.addSettingsScreen({ id, title, icon, Component })` 注册一个组件。之后,该主机 **设置 → 插件** 中你的插件行会显示一个设置按钮(齿轮),点击后打开你的第一个界面。注册多个界面时,设置页面会按注册顺序为每个界面显示一个标签页,使用其 `title` 和 `icon`;只有一个界面时不显示标签页。`id` 在同一次安装中唯一;`icon` 可以是任意一种[图标](#icons)形式。注册返回一个幂等的移除函数,插件销毁时会移除剩余的界面。
调用 `client.openSettings(id)` 或 Command Center 回调中的 `openSettings(id)` 可以打开你自己的某个界面。即使多台主机安装了同一个插件,每次安装也有各自的值和路由。