From ae80ec42529c388167f16581dc61fd9de682f1ba Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 6 Sep 2026 10:22:26 +0200 Subject: [PATCH] Let plugins contribute settings screens (#4357) * feat(plugins): contribute settings screens and shared controls * fix(plugins): localize settings and verify shared UI consumers * test(composer): unmount draft test roots after each case * test(app): keep launch terminals alive until cleanup The fixed ten-second profile lifetime could expire before a busy CI browser attached or finished revisiting the composer. Keep the real process waiting for input until project teardown terminates it. * test(server): await the initial snapshot before a buffered ref event Watcher setup can read checkout facts before the initial refresh publishes a snapshot. Wait for publication before injecting the event so the test consistently exercises a second refresh. --- docs/design.md | 2 +- docs/expo-router.md | 3 + docs/forms.md | 8 + docs/plugins.md | 14 +- .../new-workspace-launch-memory.spec.ts | 6 +- .../new-workspace-launch-terminal.spec.ts | 11 +- .../app/e2e/browser/plugin-settings.spec.ts | 62 ++++++ .../app/e2e/support/helpers/new-workspace.ts | 2 + .../e2e/support/helpers/plugin-settings.ts | 108 +++++++++++ .../settings/agent-profiles-section.tsx | 2 +- packages/app/src/agent-skills/index.tsx | 2 +- .../app/src/agent-skills/selection-sheet.tsx | 2 +- packages/app/src/app/_layout.tsx | 1 + .../plugins/[pluginId]/[screenId].tsx | 22 +++ .../settings/headings}/settings-group.tsx | 2 +- .../settings/headings}/settings-info-tip.tsx | 0 .../settings/headings}/settings-section.tsx | 2 +- .../app/src/components/settings/index.tsx | 182 ++++++++++++++++++ .../composer/draft/input-draft.live.test.tsx | 75 ++++---- .../browser/settings/browser-data-section.tsx | 2 +- .../desktop-notifications-section.tsx | 2 +- .../desktop-permissions-section.tsx | 2 +- .../components/desktop-updates-section.tsx | 2 +- .../components/integrations-section.tsx | 2 +- packages/app/src/i18n/resources/en.ts | 6 + .../app/src/i18n/resources/plugin-settings.ts | 48 +++++ .../app/src/navigation/settings-navigation.ts | 6 +- packages/app/src/plugins/actions.ts | 6 + packages/app/src/plugins/catalog-sync.tsx | 10 + .../app/src/plugins/client-runtime.test.ts | 1 + .../command-center/contributions.test.ts | 4 + packages/app/src/plugins/evaluate.test.ts | 17 +- packages/app/src/plugins/evaluate.ts | 22 ++- packages/app/src/plugins/navigation.ts | 4 + packages/app/src/plugins/react-native/ui.ts | 10 + packages/app/src/plugins/registry.ts | 1 + packages/app/src/plugins/settings/index.tsx | 128 ++++++++++++ packages/app/src/plugins/settings/routes.ts | 6 + .../app/src/plugins/settings/use-settings.ts | 83 ++++++++ .../app/src/plugins/sidebar-groups.test.ts | 1 + .../src/plugins/surface-contribution.test.ts | 1 + .../src/plugins/surface-error-boundary.tsx | 2 + packages/app/src/plugins/theme.test.ts | 1 + .../app/src/plugins/timeline/model.test.ts | 1 + .../app/src/plugins/timeline/view.test.tsx | 10 - packages/app/src/plugins/types.ts | 3 + .../workspace-panels/resolution.test.ts | 1 + .../src/provider-usage/settings-section.tsx | 2 +- .../src/screens/project-settings-screen.tsx | 4 +- .../app/src/screens/projects-screen.test.tsx | 38 +--- packages/app/src/screens/settings-screen.tsx | 31 ++- .../appearance/appearance-section.tsx | 46 ++--- .../appearance/sidebar-nav-section.tsx | 2 +- .../src/screens/settings/editor-section.tsx | 2 +- .../settings/host-appearance-section.tsx | 2 +- .../app/src/screens/settings/host-page.tsx | 2 +- .../settings/keyboard-shortcuts-section.tsx | 2 +- .../settings/layout/layout-section.tsx | 119 +++--------- .../settings/metadata-generation-page.tsx | 2 +- .../screens/settings/plugins-page.test.tsx | 5 + .../app/src/screens/settings/plugins-page.tsx | 70 ++++--- .../settings/providers-section.test.tsx | 2 +- .../screens/settings/providers-section.tsx | 2 +- packages/app/vitest.setup.ts | 17 ++ packages/plugin/package.json | 5 + packages/plugin/src/contracts.ts | 12 ++ packages/plugin/src/host.ts | 1 + packages/plugin/src/index.ts | 8 + packages/plugin/src/settings.ts | 65 +++++++ packages/plugin/src/ui.ts | 56 ++++++ packages/protocol/src/messages.ts | 8 + packages/server/src/server/bootstrap.ts | 1 + packages/server/src/server/plugins/index.ts | 25 ++- .../server/plugins/plugin-process-protocol.ts | 11 +- .../src/server/plugins/plugin-process.ts | 30 ++- .../server/plugins/plugin-sdk-specifiers.ts | 1 + packages/server/src/server/plugins/runtime.ts | 11 +- .../src/server/plugins/settings.e2e.test.ts | 73 +++++++ .../src/server/plugins/settings/index.test.ts | 131 +++++++++++++ .../src/server/plugins/settings/index.ts | 133 +++++++++++++ packages/server/src/server/session.ts | 13 +- .../server/src/server/websocket-server.ts | 1 + .../workspace-git-service.observation.test.ts | 6 +- plugin-examples/settings/README.md | 14 ++ .../settings/client/display-settings.tsx | 148 ++++++++++++++ plugin-examples/settings/index.client.tsx | 21 ++ plugin-examples/settings/index.server.ts | 7 + plugin-examples/settings/paseo-plugin.json | 1 + .../settings/screenshots/android.png | Bin 0 -> 75487 bytes .../settings/screenshots/electron.png | Bin 0 -> 47456 bytes .../settings/screenshots/web-compact.png | Bin 0 -> 20680 bytes .../settings/shared/preferences.ts | 13 ++ public-docs/plugins/v0.8/migration.md | 33 ++-- public-docs/plugins/v0.8/reference.md | 121 +++++++++++- 94 files changed, 1890 insertions(+), 295 deletions(-) create mode 100644 packages/app/e2e/browser/plugin-settings.spec.ts create mode 100644 packages/app/e2e/support/helpers/plugin-settings.ts create mode 100644 packages/app/src/app/settings/hosts/[serverId]/plugins/[pluginId]/[screenId].tsx rename packages/app/src/{screens/settings => components/settings/headings}/settings-group.tsx (96%) rename packages/app/src/{screens/settings => components/settings/headings}/settings-info-tip.tsx (100%) rename packages/app/src/{screens/settings => components/settings/headings}/settings-section.tsx (96%) create mode 100644 packages/app/src/components/settings/index.tsx create mode 100644 packages/app/src/plugins/react-native/ui.ts create mode 100644 packages/app/src/plugins/settings/index.tsx create mode 100644 packages/app/src/plugins/settings/routes.ts create mode 100644 packages/app/src/plugins/settings/use-settings.ts create mode 100644 packages/plugin/src/settings.ts create mode 100644 packages/plugin/src/ui.ts create mode 100644 packages/server/src/server/plugins/settings.e2e.test.ts create mode 100644 packages/server/src/server/plugins/settings/index.test.ts create mode 100644 packages/server/src/server/plugins/settings/index.ts create mode 100644 plugin-examples/settings/README.md create mode 100644 plugin-examples/settings/client/display-settings.tsx create mode 100644 plugin-examples/settings/index.client.tsx create mode 100644 plugin-examples/settings/index.server.ts create mode 100644 plugin-examples/settings/paseo-plugin.json create mode 100644 plugin-examples/settings/screenshots/android.png create mode 100644 plugin-examples/settings/screenshots/electron.png create mode 100644 plugin-examples/settings/screenshots/web-compact.png create mode 100644 plugin-examples/settings/shared/preferences.ts diff --git a/docs/design.md b/docs/design.md index b98e06cb72..0b54be46a3 100644 --- a/docs/design.md +++ b/docs/design.md @@ -120,7 +120,7 @@ Workspace and chat surfaces use the full width — these are working surfaces, n Sections sit apart. `` owns its own bottom margin; the next thing is wrapped in another ``. The agent-list `sectionHeading` carries the same `marginTop`/`marginBottom` rhythm (`packages/app/src/components/agent-list.tsx:511-517`). Adding `marginBottom` to a section is wrong. -A section or group explains itself through the `info` prop on `` or `` — an info icon beside the header that opens a tooltip (`packages/app/src/screens/settings/settings-info-tip.tsx`). A muted paragraph between the header and the card is wrong: it sits in the section's own gap, so it reads as a second heading rather than as prose belonging to the header. Explanatory copy that describes one row belongs to that row, as `settingsStyles.rowHint` inside the card. +A section or group explains itself through the `info` prop on `` or `` — an info icon beside the header that opens a tooltip (`packages/app/src/components/settings/headings/settings-info-tip.tsx`). A muted paragraph between the header and the card is wrong: it sits in the section's own gap, so it reads as a second heading rather than as prose belonging to the header. Explanatory copy that describes one row belongs to that row, as `settingsStyles.rowHint` inside the card. Cards inside a section sit closer than sections. Rows inside a card touch — only the divider separates them. The rhythm is page → spacious; section → spacious; card → tight. diff --git a/docs/expo-router.md b/docs/expo-router.md index 835798426a..d6eaf00374 100644 --- a/docs/expo-router.md +++ b/docs/expo-router.md @@ -77,6 +77,9 @@ only use local param fallback during cold mount (`/` or empty pathname), or a 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. + Settings detail routes are separate siblings on purpose. Keep `settings/[section]`, the host routes, the projects index, and project detail as distinct route names. `router.dismissTo()` ultimately matches stack entries by diff --git a/docs/forms.md b/docs/forms.md index cbb1c2d951..073dde368f 100644 --- a/docs/forms.md +++ b/docs/forms.md @@ -78,6 +78,14 @@ complete`), keyed off the opened snapshot's serverId. Waiting for data is a timezone) once — never in a preview line AND a helper line. - `useUnistyles` is banned (see docs/unistyles.md); lint enforces. +## Settings rows + +Use the named components from `@/components/settings` for settings cards and controls. +`SettingsCard` owns row separators; `SettingsRow` accepts custom content. Heading-only screens +can import `SettingsGroup` and `SettingsSection` directly from their modules in +`@/components/settings/headings/`. +The plugin UI entry exposes the same components; keep them independent of storage and routing. + ## Data gating Aggregate hooks return a discriminated load state: diff --git a/docs/plugins.md b/docs/plugins.md index ae8a241c88..1a101e5c5c 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1,7 +1,7 @@ # Plugins Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items, -client slash commands, timeline items, composer pills, app themes, and composer attachment sources. +client slash commands, timeline items, composer pills, app themes, composer attachment sources, and settings screens. Paseo executes `index.server.ts` in a subprocess and `index.client.tsx` in every connected app. > **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Plugins are unsandboxed: server code and Git preparation commands run with the daemon user's access on the daemon host, and client contributions run inside Paseo. The repository's dependencies and future updates are part of that trust decision. With `--host`, preparation runs on that remote daemon host. @@ -440,6 +440,18 @@ on several hosts are not coalesced. The selected snapshot submits as a text atta external-resource presentation, so it remains readable if the plugin is removed or an older peer drops the optional presentation fields. +## Contribute settings + +Register ordinary components with `client.addSettingsScreen` and open them with `openSettings`. +The host settings shell owns navigation and layout; plugin content must not add another page +scroll view or header. See the [author contract](../public-docs/plugins/v0.8/reference.md#settings-screens) +and `plugin-examples/settings` for the named UI components and persistence API. + +Settings storage is scoped to the runtime installation ID, never the source path or manifest ID. +Its writer lives with the plugin subprocess, while its directory lives outside managed sources, +so updates and reloads retain values. Settings-change notifications must not enter the catalog +reload path: that path disposes the plugin and would destroy open drafts after every save. + ## Contribute a theme `addTheme` takes a small light or dark palette and a display name. Paseo expands it through the diff --git a/packages/app/e2e/browser/new-workspace-launch-memory.spec.ts b/packages/app/e2e/browser/new-workspace-launch-memory.spec.ts index 39b734d4f9..f2ee89c12f 100644 --- a/packages/app/e2e/browser/new-workspace-launch-memory.spec.ts +++ b/packages/app/e2e/browser/new-workspace-launch-memory.spec.ts @@ -21,13 +21,13 @@ import { type TerminalProfileSeed, } from "../support/helpers/new-workspace-launch"; -// `sleep` keeps the terminal alive long enough for the UI to attach and -// render output before the process exits — see new-workspace-launch-terminal.spec.ts. +// Wait for input until project cleanup kills the terminal. A fixed lifetime +// can expire before a busy browser attaches or finishes revisiting the composer. const PROMPT_PROFILE: TerminalProfile = { id: "e2e-memory-profile", name: "Memory Profile", command: "/bin/sh", - args: ["-c", 'echo remembered: "$0"; sleep 10', "{{{prompt}}}"], + args: ["-c", 'echo remembered: "$0"; exec cat', "{{{prompt}}}"], }; function hasLeftNewWorkspaceRoute(url: URL): boolean { diff --git a/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts b/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts index dd249cb03a..16b277b537 100644 --- a/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts +++ b/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts @@ -25,14 +25,13 @@ import { // // The sentinel becomes `$0` (a positional shell parameter, not string // interpolation into the script source), so the prompt reaches the process -// exactly as typed with no injection risk. `sleep` keeps the terminal alive -// long enough for the UI to attach and render the output before it exits — -// a bare `echo` exits before the frontend subscribes to its output. +// exactly as typed with no injection risk. `cat` waits for input until project +// cleanup kills the terminal, so attachment does not race a fixed process lifetime. const PROMPT_PROFILE: TerminalProfile = { id: "e2e-echo-prompt", name: "Echo Prompt", command: "/bin/sh", - args: ["-c", 'echo captured: "$0"; sleep 10', "{{{prompt}}}"], + args: ["-c", 'echo captured: "$0"; exec cat', "{{{prompt}}}"], }; // No sentinel anywhere: takes no prompt, composer goes read-only. @@ -40,7 +39,7 @@ const BARE_PROFILE: TerminalProfile = { id: "e2e-echo-bare", name: "Bare Echo", command: "/bin/sh", - args: ["-c", "echo bare-launch-static-line; sleep 10"], + args: ["-c", "echo bare-launch-static-line; exec cat"], }; test.describe("New workspace: launching a terminal", () => { @@ -88,7 +87,7 @@ test.describe("New workspace: launching a terminal", () => { }); await selectLaunchOption(page, BARE_PROFILE.id); await expect(terminalPromptInput(page)).toHaveCount(0); - await expectTerminalPreviewCommand(page, "/bin/sh -c echo bare-launch-static-line; sleep 10"); + await expectTerminalPreviewCommand(page, "/bin/sh -c echo bare-launch-static-line; exec cat"); await expect(terminalLaunchSubmit(page)).toHaveText("Launch"); }); diff --git a/packages/app/e2e/browser/plugin-settings.spec.ts b/packages/app/e2e/browser/plugin-settings.spec.ts new file mode 100644 index 0000000000..29f42988ab --- /dev/null +++ b/packages/app/e2e/browser/plugin-settings.spec.ts @@ -0,0 +1,62 @@ +import { + test, + openDisplaySettings, + openCompactDisplaySettings, + groupByWorkspace, + hideMetadata, + reloadSavedDisplaySettings, + editTitle, + replaceTitle, + saveTitle, + expectTitleRequired, + expectSavedTitle, + changeMetadataFromAnotherClient, + expectConflictPreservesDraft, + discardTitle, + returnAndReopenSettings, + expectSettingsUnavailable, +} from "../support/helpers/plugin-settings"; + +test("display preferences survive a reload", async ({ page }) => { + await openDisplaySettings(page); + await groupByWorkspace(page); + await hideMetadata(page); + await reloadSavedDisplaySettings(page); +}); + +test("invalid titles stay editable until a valid title is saved", async ({ page }) => { + await openDisplaySettings(page); + await editTitle(page, ""); + await saveTitle(page); + await expectTitleRequired(page); + await replaceTitle(page, "My monitor"); + await saveTitle(page); + await expectSavedTitle(page, "My monitor"); +}); + +test("another client's save preserves a conflicting draft", async ({ page }) => { + await openDisplaySettings(page); + await editTitle(page, "Unsaved title"); + await changeMetadataFromAnotherClient(page); + await saveTitle(page); + await expectConflictPreservesDraft(page, "Unsaved title"); + await discardTitle(page); +}); + +test.describe("compact plugin settings", () => { + test.use({ viewport: { width: 390, height: 844 } }); + + test("Back and plugin re-enable restore the settings screen", async ({ + page, + settingsPlugin, + }) => { + await openCompactDisplaySettings(page); + await groupByWorkspace(page); + await hideMetadata(page); + await returnAndReopenSettings(page); + await settingsPlugin.disablePlugin("settings-example"); + await expectSettingsUnavailable(page); + await settingsPlugin.enablePlugin("settings-example"); + await reloadSavedDisplaySettings(page); + }); +}); diff --git a/packages/app/e2e/support/helpers/new-workspace.ts b/packages/app/e2e/support/helpers/new-workspace.ts index 6ac5d89871..05e8cddc9a 100644 --- a/packages/app/e2e/support/helpers/new-workspace.ts +++ b/packages/app/e2e/support/helpers/new-workspace.ts @@ -20,6 +20,8 @@ type NewWorkspaceDaemonClient = Pick< | "getPaseoWorktreeList" | "getDaemonConfig" | "installDirectoryPlugin" + | "disablePlugin" + | "enablePlugin" | "inspectWorkspaceRecovery" | "listProjects" | "on" diff --git a/packages/app/e2e/support/helpers/plugin-settings.ts b/packages/app/e2e/support/helpers/plugin-settings.ts new file mode 100644 index 0000000000..d8dfb020e4 --- /dev/null +++ b/packages/app/e2e/support/helpers/plugin-settings.ts @@ -0,0 +1,108 @@ +import path from "node:path"; +import { expect, test as base, type Page } from "../fixtures"; +import { gotoAppShell, openSettings } from "./app"; +import { goBackInSettings, openCompactSettings, openHostSection } from "./settings"; +import { getServerId } from "./server-id"; +import { connectNewWorkspaceDaemonClient } from "./new-workspace"; + +const directory = path.resolve(__dirname, "../../../../../plugin-examples/settings"); +type SettingsClient = Awaited>; + +export const test = base.extend<{ settingsPlugin: SettingsClient }>({ + settingsPlugin: [ + async ({ e2eWorker, page }, provide, testInfo) => { + void e2eWorker; + const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const previous = await client.getDaemonConfig(); + try { + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory); + await provide(client); + await page.screenshot({ path: testInfo.outputPath("settings.png") }); + } finally { + await client.removePlugin("settings-example"); + await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled }); + await client.close(); + } + }, + { auto: true }, + ], +}); + +export async function openDisplaySettings(page: Page) { + await gotoAppShell(page); + await openSettings(page); + await openPluginScreen(page); +} +export async function openCompactDisplaySettings(page: Page) { + await gotoAppShell(page); + await openCompactSettings(page, new URL(page.url()).pathname); + await openPluginScreen(page); +} +async function openPluginScreen(page: Page) { + await openHostSection(page, getServerId(), "plugins"); + await page.getByRole("button", { name: "Open", exact: true }).click(); + await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible(); +} +export async function groupByWorkspace(page: Page) { + await page.getByRole("button", { name: "Group agents by", exact: true }).click(); + await page.getByRole("menuitem", { name: "Workspace", exact: true }).click(); + await expect(page.getByText("Grouped by workspace", { exact: true })).toBeVisible(); +} +export async function hideMetadata(page: Page) { + await page.getByRole("switch", { name: "Show metadata" }).click(); + await expect(page.getByText("Metadata hidden", { exact: true })).toBeVisible(); +} +export async function reloadSavedDisplaySettings(page: Page) { + await page.reload(); + await expect(page.getByText("Grouped by workspace", { exact: true })).toBeVisible(); + await expect(page.getByRole("switch", { name: "Show metadata" })).not.toBeChecked(); +} +export async function editTitle(page: Page, title: string) { + await page.getByRole("button", { name: "Edit title", exact: true }).click(); + await replaceTitle(page, title); +} +export async function replaceTitle(page: Page, title: string) { + await page.getByRole("textbox", { name: "Monitor title", exact: true }).fill(title); +} +export async function saveTitle(page: Page) { + await page.getByRole("button", { name: "Save title", exact: true }).click(); +} +export async function expectTitleRequired(page: Page) { + await expect(page.getByText(/Enter a title/).first()).toBeVisible(); +} +export async function expectSavedTitle(page: Page, title: string) { + await expect(page.getByText(title, { exact: true })).toBeVisible(); + await expect(page.getByRole("textbox", { name: "Monitor title", exact: true })).toHaveCount(0); +} +export async function changeMetadataFromAnotherClient(page: Page) { + const peer = await page.context().newPage(); + try { + await peer.goto(page.url()); + await hideMetadata(peer); + await expect(page.getByText("Metadata hidden", { exact: true })).toBeVisible(); + } finally { + await peer.close(); + } +} +export async function expectConflictPreservesDraft(page: Page, title: string) { + await expect(page.getByText(/Settings changed on another client/)).toBeVisible(); + await expect(page.getByRole("textbox", { name: "Monitor title", exact: true })).toHaveValue( + title, + ); +} +export async function discardTitle(page: Page) { + await page.getByRole("button", { name: "Discard", exact: true }).click(); + await expectSavedTitle(page, "Agent monitor"); +} +export async function returnAndReopenSettings(page: Page) { + await goBackInSettings(page); + await expect(page.getByRole("button", { name: "Open", exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Open", exact: true }).click(); + await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible(); +} +export async function expectSettingsUnavailable(page: Page) { + await expect( + page.getByText("This plugin settings screen is unavailable.", { exact: true }), + ).toBeVisible(); +} diff --git a/packages/app/src/agent-profiles/settings/agent-profiles-section.tsx b/packages/app/src/agent-profiles/settings/agent-profiles-section.tsx index 37f94bfc0c..0cbee5d4d6 100644 --- a/packages/app/src/agent-profiles/settings/agent-profiles-section.tsx +++ b/packages/app/src/agent-profiles/settings/agent-profiles-section.tsx @@ -7,7 +7,7 @@ import type { AgentProfile } from "@getpaseo/protocol/messages"; import { Button } from "@/components/ui/button"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { settingsStyles } from "@/styles/settings"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import { confirmDialog } from "@/utils/confirm-dialog"; diff --git a/packages/app/src/agent-skills/index.tsx b/packages/app/src/agent-skills/index.tsx index 044c6acd1d..04fdbd2294 100644 --- a/packages/app/src/agent-skills/index.tsx +++ b/packages/app/src/agent-skills/index.tsx @@ -7,7 +7,7 @@ import { ArrowUpRight, Blocks, Check, Settings2 } from "lucide-react-native"; import type { AgentSkillOperation, AgentSkillsStatus } from "@getpaseo/protocol/messages"; import type { TFunction } from "i18next"; import { Button } from "@/components/ui/button"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { settingsStyles } from "@/styles/settings"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import { confirmDialog } from "@/utils/confirm-dialog"; diff --git a/packages/app/src/agent-skills/selection-sheet.tsx b/packages/app/src/agent-skills/selection-sheet.tsx index 923d098cee..c161747c4c 100644 --- a/packages/app/src/agent-skills/selection-sheet.tsx +++ b/packages/app/src/agent-skills/selection-sheet.tsx @@ -6,7 +6,7 @@ import { Check } from "lucide-react-native"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { settingsStyles } from "@/styles/settings"; import type { Theme } from "@/styles/theme"; import { confirmDialog } from "@/utils/confirm-dialog"; diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 148d431921..f2b0755122 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -902,6 +902,7 @@ function RootStack() { + diff --git a/packages/app/src/app/settings/hosts/[serverId]/plugins/[pluginId]/[screenId].tsx b/packages/app/src/app/settings/hosts/[serverId]/plugins/[pluginId]/[screenId].tsx new file mode 100644 index 0000000000..1fa5ad7f85 --- /dev/null +++ b/packages/app/src/app/settings/hosts/[serverId]/plugins/[pluginId]/[screenId].tsx @@ -0,0 +1,22 @@ +import { useMemo } from "react"; + +import { useLocalSearchParams } from "expo-router"; +import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; +import SettingsScreen from "@/screens/settings-screen"; + +export default function PluginSettingsRoute() { + const { serverId, pluginId, screenId } = useLocalSearchParams<{ + serverId: string; + pluginId: string; + screenId: string; + }>(); + const view = useMemo( + () => ({ kind: "plugin" as const, serverId, pluginId, screenId }), + [serverId, pluginId, screenId], + ); + return ( + + + + ); +} diff --git a/packages/app/src/screens/settings/settings-group.tsx b/packages/app/src/components/settings/headings/settings-group.tsx similarity index 96% rename from packages/app/src/screens/settings/settings-group.tsx rename to packages/app/src/components/settings/headings/settings-group.tsx index 25e4087292..e13bc6017a 100644 --- a/packages/app/src/screens/settings/settings-group.tsx +++ b/packages/app/src/components/settings/headings/settings-group.tsx @@ -1,7 +1,7 @@ import { useMemo, type ReactNode } from "react"; import { Text, View, type StyleProp, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; -import { SettingsInfoTip } from "@/screens/settings/settings-info-tip"; +import { SettingsInfoTip } from "./settings-info-tip"; interface SettingsGroupProps { title: string; diff --git a/packages/app/src/screens/settings/settings-info-tip.tsx b/packages/app/src/components/settings/headings/settings-info-tip.tsx similarity index 100% rename from packages/app/src/screens/settings/settings-info-tip.tsx rename to packages/app/src/components/settings/headings/settings-info-tip.tsx diff --git a/packages/app/src/screens/settings/settings-section.tsx b/packages/app/src/components/settings/headings/settings-section.tsx similarity index 96% rename from packages/app/src/screens/settings/settings-section.tsx rename to packages/app/src/components/settings/headings/settings-section.tsx index d3a4a4b01c..28b19f3589 100644 --- a/packages/app/src/screens/settings/settings-section.tsx +++ b/packages/app/src/components/settings/headings/settings-section.tsx @@ -1,7 +1,7 @@ import { useMemo, type ReactNode } from "react"; import { Text, View, type StyleProp, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; -import { SettingsInfoTip } from "@/screens/settings/settings-info-tip"; +import { SettingsInfoTip } from "./settings-info-tip"; import { settingsStyles } from "@/styles/settings"; interface SettingsSectionProps { diff --git a/packages/app/src/components/settings/index.tsx b/packages/app/src/components/settings/index.tsx new file mode 100644 index 0000000000..371709f60f --- /dev/null +++ b/packages/app/src/components/settings/index.tsx @@ -0,0 +1,182 @@ +import { + Children, + isValidElement, + useCallback, + useImperativeHandle, + useMemo, + useRef, + type ReactNode, +} from "react"; +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import type { + SettingsRowProps, + SettingsSwitchProps, + SettingsSelectProps, + SettingsInputProps, + SettingsActionProps, +} from "@getpaseo/plugin/ui"; +import type { EditingTextInputHandle } from "@/components/ui/text-input"; +import { Switch } from "@/components/ui/switch"; +import { Button } from "@/components/ui/button"; +import { FormTextInput } from "@/components/ui/form-field"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { DropdownTrigger } from "@/components/ui/dropdown-trigger"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { settingsStyles } from "@/styles/settings"; +export { SettingsGroup } from "./headings/settings-group"; +export { SettingsSection } from "./headings/settings-section"; + +export function SettingsCard({ children, testID }: { children: ReactNode; testID?: string }) { + return ( + + {Children.toArray(children).map((child, index) => ( + + {child} + + ))} + + ); +} + +export function SettingsRow({ label, hint, error, children, testID }: SettingsRowProps) { + const compact = useIsCompactFormFactor(); + const rowStyle = useMemo(() => [settingsStyles.row, compact && styles.compactRow], [compact]); + return ( + + + {label} + {hint ? {hint} : null} + {error ? ( + + {error} + + ) : null} + + {children ? {children} : null} + + ); +} + +export function SettingsSwitch({ value, onValueChange, disabled, ...row }: SettingsSwitchProps) { + return ( + + + + ); +} + +function SettingsOption({ + option, + selected, + onValueChange, +}: { + option: { label: string; value: Value }; + selected: boolean; + onValueChange(value: Value): void; +}) { + const select = useCallback(() => onValueChange(option.value), [onValueChange, option.value]); + return ( + + {option.label} + + ); +} + +export function SettingsSelect({ + value, + options, + onValueChange, + disabled, + ...row +}: SettingsSelectProps) { + return ( + + + + + {options.find((option) => option.value === value)?.label ?? value} + + + + {options.map((option) => ( + + ))} + + + + ); +} + +export function SettingsInput({ + initialValue, + onChangeText, + placeholder, + disabled, + secureTextEntry, + ref, + ...row +}: SettingsInputProps) { + const compact = useIsCompactFormFactor(); + const input = useRef(null); + useImperativeHandle( + ref, + () => ({ + focus: () => input.current?.focus(), + blur: () => input.current?.blur(), + getText: () => input.current?.getText() ?? "", + replaceText: (text) => input.current?.replaceText(text), + }), + [], + ); + return ( + + + + ); +} + +export function SettingsAction({ actionLabel, onPress, disabled, ...row }: SettingsActionProps) { + return ( + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + compactRow: { flexWrap: "wrap", gap: theme.spacing[3] }, + label: { flexGrow: 1, flexShrink: 1, flexBasis: 160, marginRight: theme.spacing[3] }, + control: { flexShrink: 1, maxWidth: "100%" }, + value: { color: theme.colors.foreground, fontSize: theme.fontSize.base }, + input: { minWidth: 180 }, +})); diff --git a/packages/app/src/composer/draft/input-draft.live.test.tsx b/packages/app/src/composer/draft/input-draft.live.test.tsx index 65180dce35..3442703d48 100644 --- a/packages/app/src/composer/draft/input-draft.live.test.tsx +++ b/packages/app/src/composer/draft/input-draft.live.test.tsx @@ -1,8 +1,8 @@ +/** @vitest-environment jsdom */ import React, { act } from "react"; import { createRoot, type Root } from "react-dom/client"; -import { JSDOM } from "jsdom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { useDraftStore } from "@/stores/draft-store"; import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types"; import { createWorkspaceFileAttachment } from "@/attachments/workspace-file"; @@ -116,26 +116,24 @@ vi.mock("@/hooks/use-agent-form-state", () => ({ }), })); +const mountedRoots = new Set(); +function createTestRoot(container: HTMLElement): Root { + const root = createRoot(container); + mountedRoots.add(root); + return root; +} + +afterEach(async () => { + await act(async () => { + for (const root of mountedRoots) root.unmount(); + mountedRoots.clear(); + }); +}); + let useAgentInputDraft: typeof import("./input-draft").useAgentInputDraft; type DraftRecordForTest = ReturnType["drafts"][string]; beforeAll(async () => { - const storage = new Map(); - - Object.defineProperty(globalThis, "window", { - value: { - localStorage: { - getItem: (key: string) => storage.get(key) ?? null, - setItem: (key: string, value: string) => { - storage.set(key, value); - }, - removeItem: (key: string) => { - storage.delete(key); - }, - }, - }, - configurable: true, - }); Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { value: true, configurable: true, @@ -147,18 +145,8 @@ beforeAll(async () => { describe("useAgentInputDraft live contract", () => { beforeEach(() => { asyncStorage.clear(); - const dom = new JSDOM("
", { - url: "http://localhost", - }); - - Object.defineProperty(globalThis, "document", { - value: dom.window.document, - configurable: true, - }); - Object.defineProperty(globalThis, "navigator", { - value: dom.window.navigator, - configurable: true, - }); + document.body.innerHTML = "
"; + localStorage.clear(); useDraftStore.setState({ drafts: {}, @@ -206,7 +194,7 @@ describe("useAgentInputDraft live contract", () => { throw new Error("Missing root container"); } - let root: Root | null = createRoot(container); + let root: Root | null = createTestRoot(container); await act(async () => { root!.render( @@ -248,7 +236,7 @@ describe("useAgentInputDraft live contract", () => { root!.unmount(); }); - root = createRoot(container); + root = createTestRoot(container); await act(async () => { root.render( @@ -306,7 +294,7 @@ describe("useAgentInputDraft live contract", () => { throw new Error("Missing root container"); } - const root = createRoot(container); + const root = createTestRoot(container); await act(async () => { root.render( @@ -372,7 +360,7 @@ describe("useAgentInputDraft live contract", () => { throw new Error("Missing root container"); } - const root = createRoot(container); + const root = createTestRoot(container); await act(async () => { root.render( @@ -423,7 +411,7 @@ describe("useAgentInputDraft live contract", () => { throw new Error("Missing root container"); } - const root = createRoot(container); + const root = createTestRoot(container); await act(async () => { root.render( @@ -438,9 +426,16 @@ describe("useAgentInputDraft live contract", () => { }); expect(getLatest().attachments).toEqual([{ kind: "image", metadata: image }]); - expect(useDraftStore.getState().drafts["draft:attachments"]?.input).toEqual({ - text: "with attachment", - attachments: [{ kind: "image", metadata: image }], + const readPersistedInput = () => useDraftStore.getState().drafts["draft:attachments"]?.input; + await act(async () => { + // Web text publication occurs after paint; attachments save immediately. + await expect.poll(readPersistedInput).toEqual({ + text: "with attachment", + attachments: [{ kind: "image", metadata: image }], + }); + }); + await act(async () => { + root.unmount(); }); }); @@ -505,7 +500,7 @@ describe("useAgentInputDraft live contract", () => { throw new Error("Missing root container"); } - const root = createRoot(container); + const root = createTestRoot(container); await act(async () => { root.render( @@ -559,7 +554,7 @@ describe("useAgentInputDraft live contract", () => { throw new Error("Missing root container"); } - const root = createRoot(container); + const root = createTestRoot(container); await act(async () => { root.render( diff --git a/packages/app/src/desktop/browser/settings/browser-data-section.tsx b/packages/app/src/desktop/browser/settings/browser-data-section.tsx index 0efc3ebf8e..743bd2a737 100644 --- a/packages/app/src/desktop/browser/settings/browser-data-section.tsx +++ b/packages/app/src/desktop/browser/settings/browser-data-section.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { useToast } from "@/contexts/toast-context"; import { getDesktopHost } from "@/desktop/host"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { useBrowserStore } from "@/desktop/browser/store"; import { settingsStyles } from "@/styles/settings"; import { confirmDialog } from "@/utils/confirm-dialog"; diff --git a/packages/app/src/desktop/components/desktop-notifications-section.tsx b/packages/app/src/desktop/components/desktop-notifications-section.tsx index a45d64744d..f3916f8c1e 100644 --- a/packages/app/src/desktop/components/desktop-notifications-section.tsx +++ b/packages/app/src/desktop/components/desktop-notifications-section.tsx @@ -9,7 +9,7 @@ import { Switch } from "@/components/ui/switch"; import { DesktopPermissionRow } from "@/desktop/components/desktop-permission-row"; import { useDesktopPermissions } from "@/desktop/permissions/use-desktop-permissions"; import { useDesktopSettings } from "@/desktop/settings/desktop-settings"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { settingsStyles } from "@/styles/settings"; const ThemedRotateCw = withUnistyles(RotateCw, (theme) => ({ diff --git a/packages/app/src/desktop/components/desktop-permissions-section.tsx b/packages/app/src/desktop/components/desktop-permissions-section.tsx index 41a1da3431..74cc71dc4a 100644 --- a/packages/app/src/desktop/components/desktop-permissions-section.tsx +++ b/packages/app/src/desktop/components/desktop-permissions-section.tsx @@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button"; import { DesktopPermissionRow } from "@/desktop/components/desktop-permission-row"; import { useDesktopPermissions } from "@/desktop/permissions/use-desktop-permissions"; import { settingsStyles } from "@/styles/settings"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; const ThemedRotateCw = withUnistyles(RotateCw, (theme) => ({ size: theme.iconSize.md, diff --git a/packages/app/src/desktop/components/desktop-updates-section.tsx b/packages/app/src/desktop/components/desktop-updates-section.tsx index b904c628e8..1dec26d5dd 100644 --- a/packages/app/src/desktop/components/desktop-updates-section.tsx +++ b/packages/app/src/desktop/components/desktop-updates-section.tsx @@ -5,7 +5,7 @@ import * as Clipboard from "expo-clipboard"; import { useTranslation } from "react-i18next"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { settingsStyles } from "@/styles/settings"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { ArrowUpRight, Copy, FileText, Activity } from "lucide-react-native"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; diff --git a/packages/app/src/desktop/components/integrations-section.tsx b/packages/app/src/desktop/components/integrations-section.tsx index 7d859beef4..609aa9add4 100644 --- a/packages/app/src/desktop/components/integrations-section.tsx +++ b/packages/app/src/desktop/components/integrations-section.tsx @@ -6,7 +6,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ArrowUpRight, Check, Terminal } from "lucide-react-native"; import { Button } from "@/components/ui/button"; import { useCliInstall } from "@/desktop/hooks/use-install-status"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { settingsStyles } from "@/styles/settings"; import { openExternalUrl } from "@/utils/open-external-url"; import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 2516a3f064..cc1f983db2 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1999,6 +1999,12 @@ export const en = { }, plugins: { title: "Plugins", + screens: { + open: "Open", + offline: "Connect to this host to open plugin settings.", + update: "Update this host to use plugin settings.", + unavailable: "This plugin settings screen is unavailable.", + }, trustedTitle: "Plugins are trusted code", trustedDescription: "They run on this host and inside the app without sandboxing. Install only code you trust.", diff --git a/packages/app/src/i18n/resources/plugin-settings.ts b/packages/app/src/i18n/resources/plugin-settings.ts index 7390beac36..26c26fb733 100644 --- a/packages/app/src/i18n/resources/plugin-settings.ts +++ b/packages/app/src/i18n/resources/plugin-settings.ts @@ -14,6 +14,12 @@ const shared = { export const pluginSettings = { ar: { ...shared, + screens: { + open: "فتح", + offline: "اتصل بهذا المضيف لفتح إعدادات الإضافة.", + update: "حدّث هذا المضيف لاستخدام إعدادات الإضافة.", + unavailable: "شاشة إعدادات الإضافة هذه غير متاحة.", + }, title: "الإضافات", trustedTitle: "الإضافات تعليمات برمجية موثوقة", trustedDescription: @@ -61,6 +67,12 @@ export const pluginSettings = { }, es: { ...shared, + screens: { + open: "Abrir", + offline: "Conéctate a este host para abrir los ajustes del plugin.", + update: "Actualiza este host para usar los ajustes del plugin.", + unavailable: "Esta pantalla de ajustes del plugin no está disponible.", + }, title: "Plugins", trustedTitle: "Los plugins son código de confianza", trustedDescription: @@ -108,6 +120,12 @@ export const pluginSettings = { }, fr: { ...shared, + screens: { + open: "Ouvrir", + offline: "Connectez-vous à cet hôte pour ouvrir les paramètres du plugin.", + update: "Mettez à jour cet hôte pour utiliser les paramètres du plugin.", + unavailable: "Cet écran de paramètres du plugin est indisponible.", + }, title: "Plugins", trustedTitle: "Les plugins sont du code de confiance", trustedDescription: @@ -155,6 +173,12 @@ export const pluginSettings = { }, ja: { ...shared, + screens: { + open: "開く", + offline: "このホストに接続してプラグイン設定を開いてください。", + update: "プラグイン設定を使用するには、このホストを更新してください。", + unavailable: "このプラグイン設定画面は利用できません。", + }, title: "プラグイン", trustedTitle: "プラグインは信頼済みコードです", trustedDescription: @@ -202,6 +226,12 @@ export const pluginSettings = { }, ko: { ...shared, + screens: { + open: "열기", + offline: "플러그인 설정을 열려면 이 호스트에 연결하세요.", + update: "플러그인 설정을 사용하려면 이 호스트를 업데이트하세요.", + unavailable: "이 플러그인 설정 화면을 사용할 수 없습니다.", + }, title: "플러그인", trustedTitle: "플러그인은 신뢰하는 코드입니다", trustedDescription: "호스트와 앱 안에서 격리 없이 실행됩니다. 신뢰하는 코드만 설치하세요.", @@ -248,6 +278,12 @@ export const pluginSettings = { }, "pt-BR": { ...shared, + screens: { + open: "Abrir", + offline: "Conecte-se a este host para abrir as configurações do plugin.", + update: "Atualize este host para usar as configurações do plugin.", + unavailable: "Esta tela de configurações do plugin está indisponível.", + }, title: "Plugins", trustedTitle: "Plugins são código confiável", trustedDescription: @@ -295,6 +331,12 @@ export const pluginSettings = { }, ru: { ...shared, + screens: { + open: "Открыть", + offline: "Подключитесь к этому хосту, чтобы открыть настройки плагина.", + update: "Обновите этот хост, чтобы использовать настройки плагина.", + unavailable: "Этот экран настроек плагина недоступен.", + }, title: "Плагины", trustedTitle: "Плагины — доверенный код", trustedDescription: @@ -342,6 +384,12 @@ export const pluginSettings = { }, "zh-CN": { ...shared, + screens: { + open: "打开", + offline: "连接到此主机以打开插件设置。", + update: "更新此主机以使用插件设置。", + unavailable: "此插件设置页面不可用。", + }, title: "插件", trustedTitle: "插件是受信任的代码", trustedDescription: "它们会在此主机和应用内运行,且没有隔离。请仅安装你信任的代码。", diff --git a/packages/app/src/navigation/settings-navigation.ts b/packages/app/src/navigation/settings-navigation.ts index ae7eb6efdd..123b23077a 100644 --- a/packages/app/src/navigation/settings-navigation.ts +++ b/packages/app/src/navigation/settings-navigation.ts @@ -11,6 +11,7 @@ import { } from "@/utils/host-routes"; export type SettingsView = + | { kind: "plugin"; serverId: string; pluginId: string; screenId: string } | { kind: "root" } | { kind: "section"; section: SettingsSectionSlug } | { kind: "host"; serverId: string; section: HostSectionSlug } @@ -32,7 +33,8 @@ export function returnFromSettings(view: SettingsView): void { return; } - const parent = - view.kind === "project" ? buildProjectsSettingsRoute(view.serverId) : buildSettingsRoute(); + let parent: Href = buildSettingsRoute(); + if (view.kind === "plugin") parent = buildSettingsHostSectionRoute(view.serverId, "plugins"); + if (view.kind === "project") parent = buildProjectsSettingsRoute(view.serverId); router.dismissTo(parent as Href); } diff --git a/packages/app/src/plugins/actions.ts b/packages/app/src/plugins/actions.ts index 0d9278a702..7f8678add2 100644 --- a/packages/app/src/plugins/actions.ts +++ b/packages/app/src/plugins/actions.ts @@ -11,6 +11,7 @@ import type { PluginSurfaceRuntime } from "./surface-runtime"; import type { InstalledPlugin } from "./types"; export interface PluginNavigation { + openSettings(pluginId: string, screenId: string): void; openSurface(pluginId: string, surfaceId: string): void; openWorkspacePanel(pluginId: string, panelId: string, location: PluginPanelLocation): void; openAgentPanel( @@ -29,6 +30,11 @@ export function createPluginCapabilities( return { paseo: runtime.paseo, rpc: (contract, input) => callPluginRpc(contract, runtime.invoke, input), + openSettings(screenId) { + if (!plugin.settingsScreens.some((screen) => screen.id === screenId)) + throw new Error(`Plugin settings screen is unavailable: ${screenId}`); + navigation.openSettings(plugin.id, screenId); + }, openSurface(surfaceId) { if (!plugin.surfaces.some((surface) => surface.id === surfaceId)) { throw new Error(`Plugin surface is unavailable: ${surfaceId}`); diff --git a/packages/app/src/plugins/catalog-sync.tsx b/packages/app/src/plugins/catalog-sync.tsx index 3647334f73..a7300d75c2 100644 --- a/packages/app/src/plugins/catalog-sync.tsx +++ b/packages/app/src/plugins/catalog-sync.tsx @@ -1,3 +1,4 @@ +import { pluginSettingsKey } from "./settings/use-settings"; import { useEffect } from "react"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { useHostFeature } from "@/runtime/host-features"; @@ -49,6 +50,15 @@ export function PluginCatalogSync({ }; void refresh(); const unsubscribe = client.on("status", (message) => { + if (message.payload.status === "plugin_settings_changed") { + const { pluginId, settingsId } = message.payload; + if (typeof settingsId === "string") { + const plugin = pluginRegistry + .getSnapshot() + .find((item) => item.serverId === serverId && item.id === pluginId); + void plugin?.queryClient.invalidateQueries({ queryKey: pluginSettingsKey(settingsId) }); + } + } if (message.payload.status === "plugin_catalog_changed") { const pluginId = message.payload.pluginId; if (typeof pluginId === "string") void refresh(pluginId); diff --git a/packages/app/src/plugins/client-runtime.test.ts b/packages/app/src/plugins/client-runtime.test.ts index 2be61bbc2e..34fe7d706e 100644 --- a/packages/app/src/plugins/client-runtime.test.ts +++ b/packages/app/src/plugins/client-runtime.test.ts @@ -17,6 +17,7 @@ function installation(): InstalledPlugin { clientBundle: "bundle", queryClient: new QueryClient(), cleanup: () => undefined, + settingsScreens: [], surfaces: [], sidebarItems: [], workspacePanels: [], diff --git a/packages/app/src/plugins/command-center/contributions.test.ts b/packages/app/src/plugins/command-center/contributions.test.ts index 51a6df5029..575dbdb99d 100644 --- a/packages/app/src/plugins/command-center/contributions.test.ts +++ b/packages/app/src/plugins/command-center/contributions.test.ts @@ -62,6 +62,7 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin { clientBundle: "bundle", queryClient: new QueryClient(), cleanup: () => {}, + settingsScreens: [], surfaces: [{ id: "main", Component: () => null }], sidebarItems: [], workspacePanels: [ @@ -143,6 +144,7 @@ describe("plugin Command Center contributions", () => { runtime: createRuntime, state: stateSource(), navigation: { + openSettings() {}, openSurface() {}, openWorkspacePanel() {}, openAgentPanel() {}, @@ -193,6 +195,7 @@ describe("plugin Command Center contributions", () => { workspaceId: workspace.id, agentId: agent.id, navigation: { + openSettings() {}, openSurface(pluginId, surfaceId) { opened.push(`${pluginId}/surface/${surfaceId}`); }, @@ -224,6 +227,7 @@ describe("plugin Command Center contributions", () => { workspaceId: workspace.id, agentId: agent.id, navigation: { + openSettings() {}, openSurface() {}, openWorkspacePanel() {}, openAgentPanel() {}, diff --git a/packages/app/src/plugins/evaluate.test.ts b/packages/app/src/plugins/evaluate.test.ts index 698053102a..1d042284aa 100644 --- a/packages/app/src/plugins/evaluate.test.ts +++ b/packages/app/src/plugins/evaluate.test.ts @@ -4,6 +4,7 @@ import { runPluginClientBundle, type PluginClientRuntime } from "./evaluate"; const runtime = { paseo: {}, async rpc() {}, + openSettings() {}, openSurface() {}, openPanel() {}, addComposerPill() { @@ -16,7 +17,7 @@ function evaluatePluginClientBundle(id: string, source: string) { } function bundle(body: string): string { - return `(function() { + return `(function(require) { const module = { exports: {} }; module.exports.default = function(plugin) { ${body}; return function() {}; }; return module.exports; @@ -24,6 +25,17 @@ function bundle(body: string): string { } describe("evaluatePluginClientBundle", () => { + it("accepts memoized settings screens", () => { + const plugin = evaluatePluginClientBundle( + "settings", + bundle(` + const Component = require("react").memo(function Settings() { return null; }); + plugin.addSettingsScreen({ id: "display", title: "Display", icon: "Settings", Component }); + `), + ); + expect(plugin.settingsScreens.map((screen) => screen.id)).toEqual(["display"]); + }); + it("returns idempotent removers for every client registration", () => { let pillCount = 0; const plugin = runPluginClientBundle( @@ -33,6 +45,7 @@ describe("evaluatePluginClientBundle", () => { const schema = { safeParse(value) { return { success: true, data: value }; } }; globalThis.__pluginRemovals = [ plugin.addSurface("main", Component), + plugin.addSettingsScreen({ id: "display", title: "Display", icon: "Settings", Component }), plugin.addSidebarItem({ id: "main", title: "Main", icon: "Blocks", surface: "main" }), plugin.addWorkspacePanel({ id: "panel", title: "Panel", icon: "Blocks", context: "workspace", Component }), plugin.addCommandCenterItem({ id: "command", title: "Command", icon: "Blocks", context: "global", onSelect() {} }), @@ -59,6 +72,7 @@ describe("evaluatePluginClientBundle", () => { expect( [ plugin.surfaces, + plugin.settingsScreens, plugin.sidebarItems, plugin.workspacePanels, plugin.commandCenterItems, @@ -77,6 +91,7 @@ describe("evaluatePluginClientBundle", () => { expect( [ plugin.surfaces, + plugin.settingsScreens, plugin.sidebarItems, plugin.workspacePanels, plugin.commandCenterItems, diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index 14ace0607a..e989d44966 100644 --- a/packages/app/src/plugins/evaluate.ts +++ b/packages/app/src/plugins/evaluate.ts @@ -1,3 +1,6 @@ +import * as pluginUiRuntime from "./react-native/ui"; +import { useSettings } from "./settings/use-settings"; +import { defineSettings } from "@getpaseo/plugin"; import * as React from "react"; import * as ReactJsxRuntime from "react/jsx-runtime"; // eslint-disable-next-line no-restricted-imports -- plugin client runtime injects host ReactNative. @@ -70,7 +73,7 @@ function requireId(value: string, label: string): string { export type PluginClientRuntime = Pick< PluginClientContext, - "paseo" | "rpc" | "openSurface" | "openPanel" | "addComposerPill" + "paseo" | "rpc" | "openSettings" | "openSurface" | "openPanel" | "addComposerPill" >; export function runPluginClientBundle( @@ -81,6 +84,7 @@ export function runPluginClientBundle( ): EvaluatedPlugin { const collector: Omit = { surfaces: [], + settingsScreens: [], sidebarItems: [], workspacePanels: [], commandCenterItems: [], @@ -91,6 +95,7 @@ export function runPluginClientBundle( timelineRenderers: [], }; const surfaceIds = new Set(); + const settingsScreenIds = new Set(); const sidebarItemIds = new Set(); const workspacePanelIds = new Set(); const commandCenterItemIds = new Set(); @@ -122,6 +127,17 @@ export function runPluginClientBundle( } const pluginContext: PluginClientContext = { ...runtime, + addSettingsScreen(contribution) { + const screenId = requireId(contribution.id, "settings screen id"); + if (settingsScreenIds.has(screenId)) + throw new Error(`Duplicate settings screen: ${screenId}`); + if (!contribution.title.trim()) throw new Error(`Invalid settings screen: ${screenId}`); + resolvePluginIcon(contribution.icon); + settingsScreenIds.add(screenId); + return register(collector.settingsScreens, { ...contribution, id: screenId }, () => + settingsScreenIds.delete(screenId), + ); + }, addSurface(surfaceId: string, Component: ComponentType) { const normalizedId = requireId(surfaceId, "surface id"); if (surfaceIds.has(normalizedId)) throw new Error(`Duplicate surface: ${normalizedId}`); @@ -336,6 +352,7 @@ export function runPluginClientBundle( }, }; const runtimeRequire = (name: string): unknown => { + if (name === "@getpaseo/plugin/ui") return pluginUiRuntime; if (name === "react") return React; if (name === "react/jsx-runtime") return ReactJsxRuntime; if (name === "react-native") return ReactNative; @@ -343,6 +360,8 @@ export function runPluginClientBundle( return { defineAttachmentSource, defineRpc, + defineSettings, + useSettings, Icon, usePaseo, useAgent, @@ -406,6 +425,7 @@ export function runPluginClientBundle( id, cleanup, surfaces: collector.surfaces, + settingsScreens: collector.settingsScreens, sidebarItems: collector.sidebarItems, workspacePanels: collector.workspacePanels as EvaluatedPlugin["workspacePanels"], commandCenterItems: collector.commandCenterItems, diff --git a/packages/app/src/plugins/navigation.ts b/packages/app/src/plugins/navigation.ts index cd1f9c129b..e155384d52 100644 --- a/packages/app/src/plugins/navigation.ts +++ b/packages/app/src/plugins/navigation.ts @@ -1,3 +1,4 @@ +import { buildPluginSettingsRoute } from "./settings/routes"; import { router } from "expo-router"; import type { PluginPanelLocation } from "@getpaseo/plugin"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; @@ -19,6 +20,9 @@ export function createPluginNavigation(input: { return { mode: "pane" as const, paneId }; } return { + openSettings(pluginId, screenId) { + router.push(buildPluginSettingsRoute(serverId, pluginId, screenId)); + }, openSurface(pluginId, surfaceId) { router.push(buildPluginSurfaceRoute(serverId, pluginId, { kind: "surface", id: surfaceId })); }, diff --git a/packages/app/src/plugins/react-native/ui.ts b/packages/app/src/plugins/react-native/ui.ts new file mode 100644 index 0000000000..644cdf7632 --- /dev/null +++ b/packages/app/src/plugins/react-native/ui.ts @@ -0,0 +1,10 @@ +export { + SettingsGroup, + SettingsSection, + SettingsCard, + SettingsRow, + SettingsSwitch, + SettingsSelect, + SettingsInput, + SettingsAction, +} from "@/components/settings"; diff --git a/packages/app/src/plugins/registry.ts b/packages/app/src/plugins/registry.ts index d7f5845e5a..8f6981e9ee 100644 --- a/packages/app/src/plugins/registry.ts +++ b/packages/app/src/plugins/registry.ts @@ -73,6 +73,7 @@ class PluginRegistry { queryClient: new QueryClient(), cleanup: () => undefined, surfaces: [], + settingsScreens: [], sidebarItems: [], workspacePanels: [], commandCenterItems: [], diff --git a/packages/app/src/plugins/settings/index.tsx b/packages/app/src/plugins/settings/index.tsx new file mode 100644 index 0000000000..175daa994d --- /dev/null +++ b/packages/app/src/plugins/settings/index.tsx @@ -0,0 +1,128 @@ +import { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Platform, Text, View } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { router } from "expo-router"; +import type { PluginHostProps } from "@getpaseo/plugin"; +import { SettingsAction } from "@/components/settings"; +import { Button } from "@/components/ui/button"; +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 { useInstalledPlugin } from "../registry"; +import { PluginRuntimeBoundary } from "../runtime-boundary"; +import { SurfaceErrorBoundary } from "../surface-error-boundary"; +import { createPluginSurfaceRuntime } from "../surface-runtime"; +import { toPluginTheme } from "../theme"; +import { buildPluginSettingsRoute } from "./routes"; + +interface SettingsIdentity { + serverId: string; + pluginId: string; + screenId: string; +} + +function SettingsLink({ + serverId, + pluginId, + screenId, + title, +}: SettingsIdentity & { title: string }) { + const { t } = useTranslation(); + const open = useCallback( + () => router.push(buildPluginSettingsRoute(serverId, pluginId, screenId)), + [serverId, pluginId, screenId], + ); + return ( + + ); +} + +export function PluginSettingsLinks({ serverId, pluginId }: Omit) { + const plugin = useInstalledPlugin(serverId, pluginId); + const supported = useHostFeature(serverId, "pluginSettings"); + if (!supported || !plugin) return null; + return ( + <> + {plugin.settingsScreens.map((screen) => ( + + ))} + + ); +} + +function SettingsContent({ + serverId, + pluginId, + screenId, + theme, +}: SettingsIdentity & { theme: PluginHostProps["theme"] }) { + const { t } = useTranslation(); + const plugin = useInstalledPlugin(serverId, pluginId); + const client = useHostRuntimeClient(serverId); + const connected = useHostRuntimeIsConnected(serverId); + const supported = useHostFeature(serverId, "pluginSettings"); + const compact = useIsCompactFormFactor(); + const hosts = useHosts(); + const [attempt, setAttempt] = useState(0); + const screen = plugin?.settingsScreens.find((item) => item.id === screenId); + const retry = useCallback(() => setAttempt((value) => value + 1), []); + const renderError = useCallback( + (error: string) => ( + + {error} + + + ), + [retry, t], + ); + const host = useMemo( + () => ({ + id: serverId, + label: hosts.find((candidate) => candidate.serverId === serverId)?.label ?? serverId, + }), + [hosts, serverId], + ); + const platform = Platform.OS === "ios" || Platform.OS === "android" ? Platform.OS : "web"; + const layout = useMemo( + () => ({ compact, platform }), + [compact, platform], + ); + if (!connected) + return {t("settings.plugins.screens.offline")}; + // COMPAT(pluginSettings): added in v0.8, remove after 2027-03-05. + if (!supported) return {t("settings.plugins.screens.update")}; + const runtime = createPluginSurfaceRuntime(client, pluginId); + if (!plugin || !screen || !runtime) + return {t("settings.plugins.screens.unavailable")}; + const Component = screen.Component; + return ( + + + + + + + + ); +} +const ThemedSettingsContent = withUnistyles(SettingsContent); +const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) }); +export function PluginSettingsContent(props: SettingsIdentity) { + return ; +} +const styles = StyleSheet.create((theme) => ({ message: { color: theme.colors.foregroundMuted } })); diff --git a/packages/app/src/plugins/settings/routes.ts b/packages/app/src/plugins/settings/routes.ts new file mode 100644 index 0000000000..be2fcfe897 --- /dev/null +++ b/packages/app/src/plugins/settings/routes.ts @@ -0,0 +1,6 @@ +export function buildPluginSettingsRoute(serverId: string, pluginId: string, screenId: string) { + return { + pathname: "/settings/hosts/[serverId]/plugins/[pluginId]/[screenId]" as const, + params: { serverId, pluginId, screenId }, + }; +} diff --git a/packages/app/src/plugins/settings/use-settings.ts b/packages/app/src/plugins/settings/use-settings.ts new file mode 100644 index 0000000000..8c19a4f7f2 --- /dev/null +++ b/packages/app/src/plugins/settings/use-settings.ts @@ -0,0 +1,83 @@ +import { useMemo } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useRpc, type SettingsDefinition, type SettingsState } from "@getpaseo/plugin"; +import { settingsRpc } from "@getpaseo/plugin/host"; +import { useReplicaQuery } from "@/data/query"; +import { z, type ZodType } from "zod"; + +export const pluginSettingsKey = (id: string) => ["plugin-settings", id] as const; +function message(error: unknown): string { + if (error instanceof z.ZodError) return error.issues.map((issue) => issue.message).join("\n"); + return error instanceof Error ? error.message : String(error); +} + +export function useSettings( + definition: SettingsDefinition, +): SettingsState { + const rpc = useMemo(() => settingsRpc(definition.id), [definition.id]); + const read = useRpc(rpc.read); + const write = useRpc(rpc.write); + const reset = useRpc(rpc.reset); + const client = useQueryClient(); + const key = pluginSettingsKey(definition.id); + const query = useReplicaQuery({ + queryKey: key, + pushEvent: "plugin_settings_changed", + queryFn: () => read({}), + retry: false, + }); + const mutation = useMutation({ + mutationFn: async ( + input: { revision: string; values: z.output } | { revision: string }, + ) => { + const result = + "values" in input + ? await write({ + revision: input.revision, + values: z.json().parse(await definition.schema.parseAsync(input.values)), + }) + : await reset(input); + if (result.status !== "saved") throw new Error(result.error); + client.setQueryData(key, { ...result, status: "ready" }); + }, + }); + async function save(values: z.output, revision: string) { + try { + await mutation.mutateAsync({ values, revision }); + return true; + } catch { + return false; + } + } + async function resetValues() { + if (!query.data) return false; + try { + await mutation.mutateAsync({ revision: query.data.revision }); + return true; + } catch { + return false; + } + } + const actions = { + saving: mutation.isPending, + saveError: mutation.error ? message(mutation.error) : null, + save, + reset: resetValues, + reload: async () => { + mutation.reset(); + await query.refetch(); + }, + }; + if (query.isPending) return { ...actions, status: "loading" }; + if (query.isError) return { ...actions, status: "error", error: message(query.error) }; + if (query.data.status === "invalid") return { ...actions, ...query.data }; + const parsed = definition.schema.safeParse(query.data.values); + if (!parsed.success) + return { + ...actions, + status: "invalid", + revision: query.data.revision, + error: parsed.error.message, + }; + return { ...actions, ...query.data, values: parsed.data }; +} diff --git a/packages/app/src/plugins/sidebar-groups.test.ts b/packages/app/src/plugins/sidebar-groups.test.ts index dbecea7662..eb9ff4fa45 100644 --- a/packages/app/src/plugins/sidebar-groups.test.ts +++ b/packages/app/src/plugins/sidebar-groups.test.ts @@ -10,6 +10,7 @@ function installed(serverId: string, contributionId = "main"): InstalledPlugin { serverId, clientBundle: serverId, queryClient: new QueryClient(), + settingsScreens: [], surfaces: [{ id: "surface", Component: () => null }], sidebarItems: [ { diff --git a/packages/app/src/plugins/surface-contribution.test.ts b/packages/app/src/plugins/surface-contribution.test.ts index 653bac17bd..685b57d3c2 100644 --- a/packages/app/src/plugins/surface-contribution.test.ts +++ b/packages/app/src/plugins/surface-contribution.test.ts @@ -18,6 +18,7 @@ function installation( clientBundle: serverId, queryClient: new QueryClient(), cleanup: () => undefined, + settingsScreens: [], surfaces: surfaces.map((id) => ({ id, Component: () => null })), sidebarItems: sidebarItems.map((item) => ({ ...item, diff --git a/packages/app/src/plugins/surface-error-boundary.tsx b/packages/app/src/plugins/surface-error-boundary.tsx index 7a3db639ea..89c6f6b468 100644 --- a/packages/app/src/plugins/surface-error-boundary.tsx +++ b/packages/app/src/plugins/surface-error-boundary.tsx @@ -7,6 +7,7 @@ interface SurfaceErrorBoundaryProps { installation: object; resetKey?: unknown; Surface: unknown; + renderError?(error: string): ReactNode; } interface SurfaceErrorBoundaryState { @@ -40,6 +41,7 @@ export class SurfaceErrorBoundary extends Component< render() { if (this.state.error) { + if (this.props.renderError) return this.props.renderError(this.state.error); return Plugin failed: {this.state.error}; } return this.props.children; diff --git a/packages/app/src/plugins/theme.test.ts b/packages/app/src/plugins/theme.test.ts index 6081a33e86..4272f8e95e 100644 --- a/packages/app/src/plugins/theme.test.ts +++ b/packages/app/src/plugins/theme.test.ts @@ -50,6 +50,7 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install serverId, clientBundle: serverId, queryClient: new QueryClient(), + settingsScreens: [], surfaces: [], sidebarItems: [], workspacePanels: [], diff --git a/packages/app/src/plugins/timeline/model.test.ts b/packages/app/src/plugins/timeline/model.test.ts index 27e734d40d..5db3d3b327 100644 --- a/packages/app/src/plugins/timeline/model.test.ts +++ b/packages/app/src/plugins/timeline/model.test.ts @@ -13,6 +13,7 @@ function plugin(input: { clientBundle: "bundle", queryClient: new QueryClient(), cleanup: () => {}, + settingsScreens: [], surfaces: [], sidebarItems: [], workspacePanels: [], diff --git a/packages/app/src/plugins/timeline/view.test.tsx b/packages/app/src/plugins/timeline/view.test.tsx index 870a51742b..5f8485b8c0 100644 --- a/packages/app/src/plugins/timeline/view.test.tsx +++ b/packages/app/src/plugins/timeline/view.test.tsx @@ -30,16 +30,6 @@ vi.mock("../icons", () => ({ Icon: () => null, resolvePluginIcon: () => () => null, })); -vi.mock("react-native-unistyles", () => ({ - StyleSheet: { - create: (factory: (theme: object) => object) => - factory({ - colors: { foregroundMuted: "gray", statusDanger: "red" }, - spacing: [0, 4, 8, 12, 16], - }), - }, - withUnistyles: (Component: React.ComponentType) => Component, -})); import { pluginRegistry } from "../registry"; import { PluginTimelineItemView } from "./view"; diff --git a/packages/app/src/plugins/types.ts b/packages/app/src/plugins/types.ts index 0006fe553a..9334520ed0 100644 --- a/packages/app/src/plugins/types.ts +++ b/packages/app/src/plugins/types.ts @@ -7,6 +7,7 @@ import type { PluginComposerPillContribution, PluginSidebarContribution, PluginSurfaceContribution, + PluginSettingsScreenContribution, PluginThemeContribution, PluginTimelineRendererContribution, PluginTimelineTransformerContribution, @@ -22,6 +23,7 @@ export interface EvaluatedPlugin { id: string; cleanup: PluginCleanup; surfaces: PluginSurfaceContribution[]; + settingsScreens: PluginSettingsScreenContribution[]; sidebarItems: PluginSidebarContribution[]; workspacePanels: EvaluatedPluginWorkspacePanelContribution[]; commandCenterItems: PluginCommandCenterItemContribution[]; @@ -45,6 +47,7 @@ export type { PluginComposerPillContribution, PluginSidebarContribution, PluginSurfaceContribution, + PluginSettingsScreenContribution, PluginThemeContribution, PluginTimelineRendererContribution, PluginTimelineTransformerContribution, diff --git a/packages/app/src/plugins/workspace-panels/resolution.test.ts b/packages/app/src/plugins/workspace-panels/resolution.test.ts index 8ca204b8ba..36f1e35282 100644 --- a/packages/app/src/plugins/workspace-panels/resolution.test.ts +++ b/packages/app/src/plugins/workspace-panels/resolution.test.ts @@ -10,6 +10,7 @@ function installed(): InstalledPlugin { clientBundle: "bundle", queryClient: new QueryClient(), cleanup: () => {}, + settingsScreens: [], surfaces: [], sidebarItems: [], workspacePanels: [ diff --git a/packages/app/src/provider-usage/settings-section.tsx b/packages/app/src/provider-usage/settings-section.tsx index cb8e8f6b55..0b3c35ec3e 100644 --- a/packages/app/src/provider-usage/settings-section.tsx +++ b/packages/app/src/provider-usage/settings-section.tsx @@ -5,7 +5,7 @@ import { StyleSheet } from "react-native-unistyles"; import { Alert } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { settingsStyles } from "@/styles/settings"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { providerUsageCopy } from "./copy"; import { ProviderUsageList } from "./list"; import type { ProviderUsageView } from "./types"; diff --git a/packages/app/src/screens/project-settings-screen.tsx b/packages/app/src/screens/project-settings-screen.tsx index 591c955269..2c177568cc 100644 --- a/packages/app/src/screens/project-settings-screen.tsx +++ b/packages/app/src/screens/project-settings-screen.tsx @@ -28,8 +28,8 @@ import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-moda import { ProjectEditSheet } from "@/components/project-edit-sheet"; import { EditingTextInput as TextInput } from "@/components/ui/text-input"; import { SettingsTextAreaCard } from "@/components/settings-textarea"; -import { SettingsGroup } from "@/screens/settings/settings-group"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsGroup } from "@/components/settings/headings/settings-group"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { settingsStyles } from "@/styles/settings"; import { useProjects } from "@/hooks/use-projects"; import type { ProjectEditFormSnapshot } from "@/projects/edit-form"; diff --git a/packages/app/src/screens/projects-screen.test.tsx b/packages/app/src/screens/projects-screen.test.tsx index 0533d7c810..17c22e906f 100644 --- a/packages/app/src/screens/projects-screen.test.tsx +++ b/packages/app/src/screens/projects-screen.test.tsx @@ -7,27 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ProjectHostEntry, ProjectSummary, WorkspaceSummary } from "@/utils/projects"; import type { ProjectHostError, UseProjectsResult } from "@/hooks/use-projects"; -const { theme, projectsState, push } = vi.hoisted(() => ({ - theme: { - spacing: { 0: 0, 1: 4, "1.5": 6, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 }, - iconSize: { sm: 14, md: 20 }, - fontSize: { xs: 11, sm: 13, base: 15 }, - fontWeight: { normal: "400" as const, medium: "500" as const }, - borderRadius: { sm: 4, md: 6, lg: 8, full: 999 }, - opacity: { 50: 0.5 }, - colors: { - surface0: "#000", - surface1: "#111", - surface2: "#222", - surface3: "#333", - surfaceSidebarHover: "#1a1a1a", - foreground: "#fff", - foregroundMuted: "#aaa", - border: "#444", - accent: "#0a84ff", - palette: { red: { 300: "#ff6b6b" } }, - }, - }, +const { projectsState, push } = vi.hoisted(() => ({ projectsState: { current: { projects: [], @@ -40,7 +20,8 @@ const { theme, projectsState, push } = vi.hoisted(() => ({ push: vi.fn(), })); -vi.mock("react-native", () => { +vi.mock("react-native", async (importOriginal) => { + const actual = await importOriginal(); const passthrough = ({ children, testID, @@ -87,6 +68,7 @@ vi.mock("react-native", () => { }; return { + ...actual, View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) => React.createElement("div", { "data-testid": testID }, children), Text: ({ children }: { children?: React.ReactNode }) => @@ -101,21 +83,15 @@ vi.mock("react-native", () => { }; }); -vi.mock("react-native-unistyles", () => ({ - StyleSheet: { - create: (factory: unknown) => - typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory, - }, - useUnistyles: () => ({ theme }), -})); - -vi.mock("lucide-react-native", () => { +vi.mock("lucide-react-native", async (importOriginal) => { + const actual = await importOriginal(); const icon = (name: string) => { const Icon = () => React.createElement("span", { "data-icon": name }); Icon.displayName = name; return Icon; }; return { + ...actual, ChevronRight: icon("ChevronRight"), MoreVertical: icon("MoreVertical"), ExternalLink: icon("ExternalLink"), diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 5e5e9be474..eff81ebd37 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -48,7 +48,7 @@ import { HostPicker as SharedHostPicker } from "@/components/hosts/host-picker"; import { HostStatusDot } from "@/components/host-status-dot"; import { ScreenTitle } from "@/components/headers/screen-title"; import { HeaderIconBadge } from "@/components/headers/header-icon-badge"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { AppearanceSection } from "@/screens/settings/appearance/appearance-section"; import { LayoutSection } from "@/screens/settings/layout/layout-section"; import { @@ -112,6 +112,9 @@ import { HostWorkspacesPage, HostTerminalsPage, } from "@/screens/settings/host-page"; +import { resolvePluginIcon } from "@/plugins/icons"; +import { PluginSettingsContent } from "@/plugins/settings"; +import { useInstalledPlugins } from "@/plugins/registry"; import { HostPluginsPage } from "@/screens/settings/plugins-page"; import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-page"; import ProjectsScreen from "@/screens/projects-screen"; @@ -1066,6 +1069,7 @@ function SettingsSidebar({ let selectedHostSection: HostSectionSlug | null = null; if (view.kind === "host") selectedHostSection = view.section; if (view.kind === "project") selectedHostSection = "projects"; + if (view.kind === "plugin") selectedHostSection = "plugins"; const sidebarBody = ( <> @@ -1204,7 +1208,9 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti const sortedHosts = useSortedHosts(hosts, localServerId); const lastWorkspaceSelection = useLastWorkspaceSelection(); const routedSettingsHostServerId = - view.kind === "host" || view.kind === "project" ? view.serverId : null; + view.kind === "host" || view.kind === "project" || view.kind === "plugin" + ? view.serverId + : null; const [selectedSettingsHostServerId, setSelectedSettingsHostServerId] = useState( routedSettingsHostServerId ?? lastWorkspaceSelection?.serverId ?? null, ); @@ -1219,7 +1225,8 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti // The host the four sections scope to: the host on the active view, otherwise // the picker choice, otherwise the connected local daemon, otherwise the first host. const activeHostServerId = useMemo(() => { - if (view.kind === "host" || view.kind === "project") return view.serverId; + if (view.kind === "host" || view.kind === "project" || view.kind === "plugin") + return view.serverId; return resolveActiveHostServerId({ selectedServerId: selectedSettingsHostServerId, localServerId, @@ -1425,11 +1432,21 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti returnFromSettings({ kind: "root" }); }, []); + const installedPlugins = useInstalledPlugins(); const detailHeader = ((): { title: string; Icon: ComponentType<{ size: number; color: string }>; titleAccessory?: ReactNode; } | null => { + if (view.kind === "plugin") { + const screen = installedPlugins + .find((plugin) => plugin.serverId === view.serverId && plugin.id === view.pluginId) + ?.settingsScreens.find((candidate) => candidate.id === view.screenId); + return { + title: `${view.pluginId} · ${screen?.title ?? t("settings.title")}`, + Icon: screen ? resolvePluginIcon(screen.icon) : Blocks, + }; + } if (view.kind === "host") { const item = HOST_SECTION_ITEMS.find((s) => s.id === view.section); if (!item) return null; @@ -1451,6 +1468,14 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti content = isDesktopApp ? : null; } else { content = (() => { + if (view.kind === "plugin") + return ( + + ); if (view.kind === "host") { return renderHostSettingsContent(view, handleHostRemoved); } diff --git a/packages/app/src/screens/settings/appearance/appearance-section.tsx b/packages/app/src/screens/settings/appearance/appearance-section.tsx index 5e9d3d1597..e92a861f78 100644 --- a/packages/app/src/screens/settings/appearance/appearance-section.tsx +++ b/packages/app/src/screens/settings/appearance/appearance-section.tsx @@ -16,8 +16,8 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Switch } from "@/components/ui/switch"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsCard, SettingsSwitch } from "@/components/settings"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { useContributedThemes } from "@/appearance/provider"; import { EditingTextInput as TextInput } from "@/components/ui/text-input"; import { @@ -234,17 +234,12 @@ interface AutoExpandReasoningRowProps { function AutoExpandReasoningRow({ value, onChange }: AutoExpandReasoningRowProps) { const { t } = useTranslation(); return ( - - - - {t("settings.general.autoExpandReasoning.label")} - - - {t("settings.general.autoExpandReasoning.description")} - - - - + ); } @@ -256,19 +251,12 @@ interface ChatOutlineRowProps { function ChatOutlineRow({ value, onChange }: ChatOutlineRowProps) { const { t } = useTranslation(); return ( - - - {t("settings.appearance.chatOutline.title")} - - {t("settings.appearance.chatOutline.description")} - - - - + ); } @@ -309,7 +297,7 @@ function ToolCallDetailRow({ value, onChange }: ToolCallDetailRowProps) { const { t } = useTranslation(); const selectedLabel = getToolCallDetailLevelLabel(t, value); return ( - + {t("settings.general.toolCallDetail.label")} @@ -693,7 +681,7 @@ export function AppearanceSection() {
- + ) : null} - + diff --git a/packages/app/src/screens/settings/appearance/sidebar-nav-section.tsx b/packages/app/src/screens/settings/appearance/sidebar-nav-section.tsx index 1733d9ade9..90d6e02344 100644 --- a/packages/app/src/screens/settings/appearance/sidebar-nav-section.tsx +++ b/packages/app/src/screens/settings/appearance/sidebar-nav-section.tsx @@ -17,7 +17,7 @@ import { Shortcut } from "@/components/ui/shortcut"; import { Switch } from "@/components/ui/switch"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { resolvePluginIcon } from "@/plugins/icons"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { builtinSidebarNavLabelKey, builtinSidebarNavShortcutAction, diff --git a/packages/app/src/screens/settings/editor-section.tsx b/packages/app/src/screens/settings/editor-section.tsx index ec926640ee..b9367d81f9 100644 --- a/packages/app/src/screens/settings/editor-section.tsx +++ b/packages/app/src/screens/settings/editor-section.tsx @@ -4,7 +4,7 @@ import { Text, View } from "react-native"; import { Switch } from "@/components/ui/switch"; import { useAppSettings } from "@/hooks/use-settings"; import { settingsStyles } from "@/styles/settings"; -import { SettingsSection } from "./settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; export function EditorSection() { const { t } = useTranslation(); diff --git a/packages/app/src/screens/settings/host-appearance-section.tsx b/packages/app/src/screens/settings/host-appearance-section.tsx index 037be657d4..4b40392f6b 100644 --- a/packages/app/src/screens/settings/host-appearance-section.tsx +++ b/packages/app/src/screens/settings/host-appearance-section.tsx @@ -13,7 +13,7 @@ import { import { AdaptiveRenameModal } from "@/components/rename-modal"; import { WorkspaceMetaRow } from "@/components/sidebar/workspace-meta-row"; import { useToast } from "@/contexts/toast-context"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { HOST_BADGE_DISPLAYS, HOST_COLORS, diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index 40eeb4fc77..ffe07c308e 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -59,7 +59,7 @@ import { ProvidersSection } from "@/screens/settings/providers-section"; import { ProviderUsageSettingsSection } from "@/provider-usage/settings-section"; import { useProviderUsage } from "@/provider-usage/use-provider-usage"; import { HostAppearanceSection } from "@/screens/settings/host-appearance-section"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { useSessionStore } from "@/stores/session-store"; import { settingsStyles } from "@/styles/settings"; import type { HostConnection, HostProfile } from "@/types/host-connection"; diff --git a/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx index bba32cfa9d..8bba11a1c4 100644 --- a/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx +++ b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx @@ -6,7 +6,7 @@ import { MoreHorizontal, Pencil, Undo2, X } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import type { Theme } from "@/styles/theme"; import { settingsStyles } from "@/styles/settings"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { Button } from "@/components/ui/button"; import { DropdownMenu, diff --git a/packages/app/src/screens/settings/layout/layout-section.tsx b/packages/app/src/screens/settings/layout/layout-section.tsx index f15c451fbc..5c0080bd1d 100644 --- a/packages/app/src/screens/settings/layout/layout-section.tsx +++ b/packages/app/src/screens/settings/layout/layout-section.tsx @@ -1,16 +1,11 @@ -import { Fragment, useCallback } from "react"; -import { Text, View, type PressableStateCallbackType } from "react-native"; +import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { StyleSheet } from "react-native-unistyles"; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu"; -import { DropdownTrigger } from "@/components/ui/dropdown-trigger"; +import { SettingsSection, SettingsCard, SettingsSelect } from "@/components/settings"; import { useAppSettings, type OpenInSidePanePreferences, type PullRequestOpenLocation, } from "@/hooks/use-settings"; -import { SettingsSection } from "@/screens/settings/settings-section"; -import { settingsStyles } from "@/styles/settings"; const SOURCES = [ "explorerFiles", @@ -22,72 +17,38 @@ const SOURCES = [ type LayoutPreferenceSource = keyof OpenInSidePanePreferences | "pullRequests"; -function destinationTriggerStyle({ - pressed, - open, -}: PressableStateCallbackType & { open?: boolean }) { - return [styles.destinationTrigger, (pressed || open) && styles.destinationTriggerActive]; -} - function LayoutPreferenceRow({ source, destination, - first, allowExplorer, onDestinationChange, }: { source: LayoutPreferenceSource; destination: PullRequestOpenLocation; - first: boolean; allowExplorer?: boolean; - onDestinationChange: ( - source: LayoutPreferenceSource, - destination: PullRequestOpenLocation, - ) => void; + onDestinationChange(source: LayoutPreferenceSource, destination: PullRequestOpenLocation): void; }) { const { t } = useTranslation(); - const destinationLabel = t(`settings.layout.openInSidePane.destinations.${destination}`); - const selectMain = useCallback( - () => onDestinationChange(source, "main"), - [onDestinationChange, source], + const options = useMemo(() => { + const destinations = allowExplorer + ? (["main", "side", "explorer"] as const) + : (["main", "side"] as const); + return destinations.map((value) => ({ + value, + label: t(`settings.layout.openInSidePane.destinations.${value}`), + })); + }, [allowExplorer, t]); + const change = useCallback( + (value: PullRequestOpenLocation) => onDestinationChange(source, value), + [source, onDestinationChange], ); - const selectSide = useCallback( - () => onDestinationChange(source, "side"), - [onDestinationChange, source], - ); - const selectExplorer = useCallback( - () => onDestinationChange(source, "explorer"), - [onDestinationChange, source], - ); - const label = t(`settings.layout.openInSidePane.sources.${source}.label`); return ( - - - {label} - - - - {destinationLabel} - - - - {t("settings.layout.openInSidePane.destinations.main")} - - - {t("settings.layout.openInSidePane.destinations.side")} - - {allowExplorer ? ( - - {t("settings.layout.openInSidePane.destinations.explorer")} - - ) : null} - - - + ); } @@ -108,42 +69,22 @@ export function LayoutSection() { ); return ( - - {SOURCES.map((source, index) => ( - - - + + {SOURCES.map((source) => ( + ))} - + ); } - -const styles = StyleSheet.create((theme) => ({ - destinationTrigger: { - paddingVertical: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius.md, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - }, - destinationTriggerActive: { - backgroundColor: theme.colors.interactionHighlight, - }, - destinationLabel: { - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - }, -})); diff --git a/packages/app/src/screens/settings/metadata-generation-page.tsx b/packages/app/src/screens/settings/metadata-generation-page.tsx index decf4bf6ee..be137726ba 100644 --- a/packages/app/src/screens/settings/metadata-generation-page.tsx +++ b/packages/app/src/screens/settings/metadata-generation-page.tsx @@ -10,7 +10,7 @@ import { SegmentedControl } from "@/components/ui/segmented-control"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { buildSelectableProviderSelectorProviders } from "@/provider-selection/provider-selection"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { settingsStyles } from "@/styles/settings"; const METADATA_GENERATION_DOCS_URL = "https://paseo.sh/docs/metadata-generation"; diff --git a/packages/app/src/screens/settings/plugins-page.test.tsx b/packages/app/src/screens/settings/plugins-page.test.tsx index 65992960da..8ec1e4df6b 100644 --- a/packages/app/src/screens/settings/plugins-page.test.tsx +++ b/packages/app/src/screens/settings/plugins-page.test.tsx @@ -58,6 +58,11 @@ vi.mock("@/components/adaptive-modal-sheet", async () => { vi.mock("react-native-reanimated", () => ({ default: { View: "div" }, + Keyframe: class { + duration() { + return this; + } + }, Easing: { ease: "ease", inOut: (value: unknown) => value }, interpolateColor: (value: number, _input: number[], output: string[]) => value >= 1 ? output[1] : output[0], diff --git a/packages/app/src/screens/settings/plugins-page.tsx b/packages/app/src/screens/settings/plugins-page.tsx index 8ff4ccc27e..086b1f1a53 100644 --- a/packages/app/src/screens/settings/plugins-page.tsx +++ b/packages/app/src/screens/settings/plugins-page.tsx @@ -1,3 +1,4 @@ +import { PluginSettingsLinks } from "@/plugins/settings"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Text, View } from "react-native"; @@ -14,7 +15,7 @@ import { useFetchQuery } from "@/data/query"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { useHostFeature } from "@/runtime/host-features"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { resolvePluginPageState } from "@/screens/settings/plugins-page-state"; import { pluginRegistry, useInstalledPlugins } from "@/plugins/registry"; import { settingsStyles } from "@/styles/settings"; @@ -36,6 +37,7 @@ function pluginRowAction(action: string | undefined): PluginRowAction | undefine function PluginRow({ plugin, + serverId, clientError, pending, pendingAction, @@ -44,6 +46,7 @@ function PluginRow({ supportsLogs, }: { plugin: PluginListItem; + serverId: string; clientError?: string; pending: boolean; pendingAction?: PluginRowAction; @@ -70,37 +73,45 @@ function PluginRow({ if (pendingAction === "enable") toggleLabel = t("settings.plugins.actions.enabling"); else if (pendingAction === "disable") toggleLabel = t("settings.plugins.actions.disabling"); return ( - - - - {plugin.id} - + + + + + {plugin.id} + + + {plugin.path} + {clientError || plugin.error ? ( + {clientError ?? plugin.error} + ) : null} - {plugin.path} - {clientError || plugin.error ? ( - {clientError ?? plugin.error} - ) : null} - - - {supportsLogs ? ( - + ) : null} + - ) : null} - - - + + + + ); } @@ -347,6 +358,7 @@ export function HostPluginsPage({ serverId }: { serverId: string }) { return ( ({ LoadingSpinner: () => React.createElement("span", { "data-testid": "loading-spinner" }), })); -vi.mock("@/screens/settings/settings-info-tip", () => ({ +vi.mock("@/components/settings/headings/settings-info-tip", () => ({ SettingsInfoTip: () => null, })); diff --git a/packages/app/src/screens/settings/providers-section.tsx b/packages/app/src/screens/settings/providers-section.tsx index ae1ea5e855..69e5caadac 100644 --- a/packages/app/src/screens/settings/providers-section.tsx +++ b/packages/app/src/screens/settings/providers-section.tsx @@ -31,7 +31,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { useProviderSettingsStore } from "@/stores/provider-settings-store"; import { confirmDialog } from "@/utils/confirm-dialog"; import { filterSelectableModels } from "@/provider-selection/model-catalog"; diff --git a/packages/app/vitest.setup.ts b/packages/app/vitest.setup.ts index feb490d91e..8834ab649f 100644 --- a/packages/app/vitest.setup.ts +++ b/packages/app/vitest.setup.ts @@ -6,6 +6,23 @@ const globalWithTestShims = globalThis as typeof globalThis & Record + Object.assign(new window.EventTarget(), { + media, + matches: false, + onchange: null, + addListener(listener: EventListener) { + this.addEventListener("change", listener); + }, + removeListener(listener: EventListener) { + this.removeEventListener("change", listener); + }, + }); +} + if (typeof globalThis.self === "undefined") { globalWithTestShims.self = globalThis; } diff --git a/packages/plugin/package.json b/packages/plugin/package.json index c71e2c7db6..710bce8339 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -37,6 +37,11 @@ "types": "./dist/host.d.ts", "source": "./src/host.ts", "default": "./dist/host.js" + }, + "./ui": { + "types": "./dist/ui.d.ts", + "source": "./src/ui.ts", + "default": "./dist/ui.js" } }, "publishConfig": { diff --git a/packages/plugin/src/contracts.ts b/packages/plugin/src/contracts.ts index 96152aa590..628d8ee355 100644 --- a/packages/plugin/src/contracts.ts +++ b/packages/plugin/src/contracts.ts @@ -128,6 +128,7 @@ export interface PluginClientOpenPanelOptions extends PluginOpenPanelOptions { } export interface PluginClientContext extends PluginCommandCapabilities { + addSettingsScreen(contribution: PluginSettingsScreenContribution): PluginCleanup; addSurface(id: string, Component: ComponentType): PluginCleanup; addSidebarItem(contribution: PluginSidebarContribution): PluginCleanup; addWorkspacePanel(contribution: PluginWorkspacePanelContribution): PluginCleanup; @@ -157,6 +158,13 @@ export type PluginWorkspacePanelContribution = Component: ComponentType; }); +export interface PluginSettingsScreenContribution { + id: string; + title: string; + icon: string; + Component: ComponentType; +} + export interface PluginSurfaceContribution { id: string; Component: ComponentType; @@ -250,6 +258,7 @@ export interface PluginCommandCapabilities { input: ZodInput, ): Promise>; openSurface(id: string): void; + openSettings(id: string): void; } export interface PluginGlobalCommandContext extends PluginCommandCapabilities { @@ -311,6 +320,9 @@ export interface PluginHandlerContext { } export interface PluginServerContext { + registerSettings( + definition: import("./settings.js").SettingsDefinition, + ): void; handle( contract: PluginRpcContract, handler: ( diff --git a/packages/plugin/src/host.ts b/packages/plugin/src/host.ts index 6fd4d7aaac..59e1d84e6d 100644 --- a/packages/plugin/src/host.ts +++ b/packages/plugin/src/host.ts @@ -1,3 +1,4 @@ +export { settingsRpc } from "./settings.js"; import { PluginAttachmentSearchPayloadSchema } from "./attachments.js"; export { PluginClientStateProvider, type PluginClientStateSource } from "./client-state.js"; export { diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index e54e24e85b..2f0229acc8 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -1,3 +1,10 @@ +import type { SettingsDefinition, SettingsState } from "./settings.js"; +import type { ZodType } from "zod"; +export { defineSettings, type SettingsDefinition, type SettingsState } from "./settings.js"; +export declare function useSettings( + definition: SettingsDefinition, +): SettingsState; + import type { ComponentType } from "react"; import type { PluginAttachmentSourceContribution, PluginIconProps } from "./contracts.js"; @@ -41,6 +48,7 @@ export type { PluginTheme, PluginSidebarContribution, PluginSurfaceContribution, + PluginSettingsScreenContribution, PluginSurfaceProps, PluginThemeColors, PluginThemeContribution, diff --git a/packages/plugin/src/settings.ts b/packages/plugin/src/settings.ts new file mode 100644 index 0000000000..dca51d8245 --- /dev/null +++ b/packages/plugin/src/settings.ts @@ -0,0 +1,65 @@ +import { z } from "zod"; +import type { output, ZodType } from "zod"; +import { defineRpc } from "./rpc.js"; + +export interface SettingsDefinition { + id: string; + scope: "host"; + version: number; + schema: Schema; + /** Convert a previous stored version to the current schema. Runs on the host. */ + migrate?: (values: unknown, fromVersion: number) => unknown | Promise; +} + +export function defineSettings( + definition: SettingsDefinition, +): SettingsDefinition { + if (!/^[a-z][a-z0-9_-]*$/.test(definition.id)) throw new Error("Invalid settings ID"); + if (definition.scope !== "host") throw new Error("Only host-scoped settings are supported"); + if (!Number.isSafeInteger(definition.version) || definition.version < 1) + throw new Error("Invalid settings version"); + return definition; +} + +export const SettingsReadResultSchema = z.discriminatedUnion("status", [ + z.object({ status: z.literal("ready"), revision: z.string(), values: z.json() }), + z.object({ status: z.literal("invalid"), revision: z.string(), error: z.string() }), +]); +export const SettingsWriteResultSchema = z.discriminatedUnion("status", [ + z.object({ status: z.literal("saved"), revision: z.string(), values: z.json() }), + z.object({ status: z.literal("conflict"), error: z.string() }), + z.object({ status: z.literal("invalid"), error: z.string() }), +]); +export function settingsRpc(id: string) { + return { + read: defineRpc({ + name: `settings.${id}.read`, + input: z.object({}), + output: SettingsReadResultSchema, + }), + write: defineRpc({ + name: `settings.${id}.write`, + input: z.object({ revision: z.string(), values: z.json() }), + output: SettingsWriteResultSchema, + }), + reset: defineRpc({ + name: `settings.${id}.reset`, + input: z.object({ revision: z.string() }), + output: SettingsWriteResultSchema, + }), + }; +} + +export type SettingsState = ( + | { status: "loading" } + | { status: "error"; error: string } + | { status: "invalid"; error: string; revision: string } + | { status: "ready"; values: output; revision: string } +) & { + saving: boolean; + saveError: string | null; + /** Save an entire document against the revision currently displayed. Never throws. */ + save(values: output, revision: string): Promise; + reset(): Promise; + reload(): Promise; +}; diff --git a/packages/plugin/src/ui.ts b/packages/plugin/src/ui.ts new file mode 100644 index 0000000000..608ed43b12 --- /dev/null +++ b/packages/plugin/src/ui.ts @@ -0,0 +1,56 @@ +import type { ComponentType, ReactNode, Ref } from "react"; + +export interface SettingsSectionProps { + title: string; + info?: ReactNode; + trailing?: ReactNode; + children: ReactNode; + testID?: string; +} +export interface SettingsRowProps { + label: string; + hint?: string; + error?: string | null; + children?: ReactNode; + testID?: string; +} +export interface SettingsSwitchProps extends SettingsRowProps { + value: boolean; + onValueChange(value: boolean): void; + disabled?: boolean; +} +export interface SettingsSelectProps extends SettingsRowProps { + value: Value; + options: readonly { label: string; value: Value }[]; + onValueChange(value: Value): void; + disabled?: boolean; +} +export interface SettingsInputHandle { + focus(): void; + blur(): void; + getText(): string; + replaceText(text: string): void; +} +export interface SettingsInputProps extends SettingsRowProps { + initialValue?: string; + onChangeText(text: string): void; + placeholder?: string; + disabled?: boolean; + secureTextEntry?: boolean; + ref?: Ref; +} +export interface SettingsActionProps extends SettingsRowProps { + actionLabel: string; + onPress(): void; + disabled?: boolean; +} +export declare const SettingsGroup: ComponentType; +export declare const SettingsSection: ComponentType; +export declare const SettingsCard: ComponentType<{ children: ReactNode; testID?: string }>; +export declare const SettingsRow: ComponentType; +export declare const SettingsSwitch: ComponentType; +export declare function SettingsSelect( + props: SettingsSelectProps, +): ReactNode; +export declare const SettingsInput: ComponentType; +export declare const SettingsAction: ComponentType; diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 797cab3908..3ad434ce14 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -3456,6 +3456,7 @@ export const ServerInfoStatusPayloadSchema = z // A daemon that predates this flag keeps `addTheme` in the server bundle it compiles, // so a theme plugin cannot start there at all. pluginThemes: z.boolean().optional(), + pluginSettings: z.boolean().optional(), pluginTimelineItems: z.boolean().optional(), // COMPAT(skillManagement): added in v0.4.0, remove gate after 2027-08-16. skillManagement: z.boolean().optional(), @@ -3655,6 +3656,12 @@ export const PluginCatalogChangedStatusPayloadSchema = z.object({ pluginId: PluginIdSchema, }); +export const PluginSettingsChangedStatusPayloadSchema = z.object({ + status: z.literal("plugin_settings_changed"), + pluginId: PluginIdSchema, + settingsId: z.string(), +}); + export const KnownStatusPayloadSchema = z.discriminatedUnion("status", [ AgentCreatedStatusPayloadSchema, AgentCreateFailedStatusPayloadSchema, @@ -3664,6 +3671,7 @@ export const KnownStatusPayloadSchema = z.discriminatedUnion("status", [ RestartRequestedStatusPayloadSchema, DaemonConfigChangedStatusPayloadSchema, PluginCatalogChangedStatusPayloadSchema, + PluginSettingsChangedStatusPayloadSchema, ]); export type KnownStatusPayload = z.infer; diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 1cfea1c99d..42121b6404 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -607,6 +607,7 @@ export async function createPaseoDaemon( const browserToolsBroker = new BrowserToolsBroker({}); const pluginRuntime = new PluginService(logger, daemonConfigStore, daemonVersion, { managedSources: new ManagedPluginSources(config.paseoHome), + settingsDirectory: path.join(config.paseoHome, "plugin-settings"), }); const serverId = getOrCreateServerId(config.paseoHome, { logger }); diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index e12b8d7386..bb6f59ecdd 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { stat } from "node:fs/promises"; +import { stat, rm } from "node:fs/promises"; import type pino from "pino"; import type { ProviderRegistration } from "@getpaseo/plugin/provider"; import { @@ -38,6 +38,7 @@ interface PluginRuntimePort { } interface PluginServiceDependencies { + settingsDirectory?: string; runtime?: PluginRuntimePort; managedSources?: ManagedPluginSources; } @@ -63,15 +64,23 @@ export class PluginService { private lifecycle = Promise.resolve(); private globalStartsBlocked = true; private started = false; + private readonly settingsListeners = new Set<(pluginId: string, settingsId: string) => void>(); constructor( logger: pino.Logger, private readonly configStore: DaemonConfigStore, daemonVersion: string, - dependencies: PluginServiceDependencies = {}, + private readonly dependencies: PluginServiceDependencies = {}, ) { this.logger = logger.child({ module: "plugin-service" }); - this.runtime = dependencies.runtime ?? new PluginRuntime(logger, daemonVersion); + this.runtime = + dependencies.runtime ?? + new PluginRuntime(logger, daemonVersion, { + settingsDirectory: dependencies.settingsDirectory, + onSettingsChanged: (pluginId, settingsId) => { + for (const listener of this.settingsListeners) listener(pluginId, settingsId); + }, + }); this.managedSources = dependencies.managedSources ?? null; this.runtime.subscribe((pluginId, error) => { this.removeProviderRegistrations(pluginId); @@ -80,6 +89,11 @@ export class PluginService { }); } + subscribeSettings(listener: (pluginId: string, settingsId: string) => void): () => void { + this.settingsListeners.add(listener); + return () => this.settingsListeners.delete(listener); + } + subscribe(listener: (pluginId: string) => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); @@ -318,6 +332,11 @@ export class PluginService { this.errors.delete(pluginId); this.notify(pluginId); await this.managedSources?.remove(pluginId); + if (this.dependencies.settingsDirectory) + await rm(path.join(this.dependencies.settingsDirectory, pluginId), { + recursive: true, + force: true, + }); }); } diff --git a/packages/server/src/server/plugins/plugin-process-protocol.ts b/packages/server/src/server/plugins/plugin-process-protocol.ts index 463eec8631..1a63845d63 100644 --- a/packages/server/src/server/plugins/plugin-process-protocol.ts +++ b/packages/server/src/server/plugins/plugin-process-protocol.ts @@ -14,7 +14,13 @@ export interface PluginProviderMetadata { } export type PluginProcessRequest = - | { type: "initialize"; pluginId: string; bundle: string; appVersion: string } + | { + type: "initialize"; + pluginId: string; + bundle: string; + appVersion: string; + settingsDirectory?: string; + } | { type: "invoke"; requestId: string; method: string; input: unknown } | { type: "provider.connect"; @@ -34,6 +40,7 @@ export type PluginProcessRequest = | { type: "paseo_close" }; export type PluginProcessMessage = + | { type: "settings.changed"; settingsId: string } | { type: "ready"; methods: string[]; providers: PluginProviderMetadata[] } | { type: "result"; requestId: string; output: unknown } | { type: "error"; requestId: string; error: string } @@ -85,6 +92,7 @@ export const PluginProcessRequestSchema: z.ZodType = z.dis pluginId: z.string().min(1), bundle: z.string(), appVersion: z.string(), + settingsDirectory: z.string().optional(), }) .strict(), z @@ -121,6 +129,7 @@ export const PluginProcessRequestSchema: z.ZodType = z.dis export const PluginProcessMessageSchema: z.ZodType = z.discriminatedUnion( "type", [ + z.object({ type: z.literal("settings.changed"), settingsId: z.string() }).strict(), z .object({ type: z.literal("ready"), diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index c03db034b6..b048ee1135 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -4,7 +4,13 @@ import { type PluginProcessRequest, } from "./plugin-process-protocol.js"; import { createRequire } from "node:module"; -import { defineAttachmentSource, defineRpc, type PluginRpcContract } from "@getpaseo/plugin"; +import { + defineSettings, + type SettingsDefinition, + defineAttachmentSource, + defineRpc, + type PluginRpcContract, +} from "@getpaseo/plugin"; import { ProviderEventSchema, type ProviderConnection, @@ -21,6 +27,20 @@ import { } from "./plugin-sdk-specifiers.js"; import { createPluginClientId } from "./plugin-session-identity.js"; +import { PluginSettingsStore } from "./settings/index.js"; +let settingsStore: PluginSettingsStore | null = null; +function registerSettings(definition: SettingsDefinition) { + if (!settingsStore) throw new Error("Plugin settings storage is unavailable"); + const handlers = settingsStore.register(definition); + register(handlers.read.contract, handlers.read.handle); + register(handlers.write.contract, (input) => + handlers.write.handle(handlers.write.contract.input.parse(input)), + ); + register(handlers.reset.contract, (input) => + handlers.reset.handle(handlers.reset.contract.input.parse(input)), + ); +} + type RpcHandler = (input: unknown, context: PluginHandlerContext) => unknown | Promise; interface RegisteredRpc { @@ -186,6 +206,7 @@ async function closeProviderConnection(connectionId: string): Promise { const pluginAuthorRuntime = { defineAttachmentSource, + defineSettings, defineRpc, Icon() { throw new Error("Icon is available only in plugin client code"); @@ -211,7 +232,7 @@ function evaluateBundle(bundle: string): void { if (typeof setup !== "function") { throw new Error("Plugin server bundle must default export a function"); } - const contributedCleanup = setup({ handle: register, registerProvider }); + const contributedCleanup = setup({ handle: register, registerProvider, registerSettings }); if (typeof contributedCleanup !== "function") { throw new Error("Plugin contribution must return a cleanup function"); } @@ -237,6 +258,11 @@ async function initialize(message: Extract + send({ type: "settings.changed", settingsId }), + ) + : null; evaluateBundle(message.bundle); send({ type: "ready", diff --git a/packages/server/src/server/plugins/plugin-sdk-specifiers.ts b/packages/server/src/server/plugins/plugin-sdk-specifiers.ts index f4ae72e55b..9961aafc8c 100644 --- a/packages/server/src/server/plugins/plugin-sdk-specifiers.ts +++ b/packages/server/src/server/plugins/plugin-sdk-specifiers.ts @@ -6,6 +6,7 @@ // published — that scope is not ours. Plugins scaffolded against that name still import it, so // both spellings resolve. Remove the @paseo/* entries after 2026-11-19. export const PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS = [ + "@getpaseo/plugin/ui", "@getpaseo/plugin/react-native", "@paseo/plugin/react-native", ] as const; diff --git a/packages/server/src/server/plugins/runtime.ts b/packages/server/src/server/plugins/runtime.ts index 922a529324..102d7b3812 100644 --- a/packages/server/src/server/plugins/runtime.ts +++ b/packages/server/src/server/plugins/runtime.ts @@ -89,6 +89,8 @@ interface RemoteProviderConnection { } interface PluginRuntimeDependencies { + settingsDirectory?: string; + onSettingsChanged?: (pluginId: string, settingsId: string) => void; spawnChild?: () => PluginChild; sessionHost?: PluginPaseoSessionHost; } @@ -270,7 +272,7 @@ export class PluginRuntime { constructor( logger: pino.Logger, private readonly daemonVersion: string, - dependencies: PluginRuntimeDependencies = {}, + private readonly dependencies: PluginRuntimeDependencies = {}, ) { this.logger = logger.child({ module: "plugins" }); this.spawnChild = dependencies.spawnChild ?? spawnPluginChild; @@ -532,6 +534,9 @@ export class PluginRuntime { pluginId, appVersion: this.daemonVersion, bundle: serverBundle, + settingsDirectory: this.dependencies.settingsDirectory + ? path.join(this.dependencies.settingsDirectory, pluginId) + : undefined, }).catch(fail); }, ); @@ -562,6 +567,10 @@ export class PluginRuntime { } private handleChildMessage(loaded: LoadedPlugin, message: PluginProcessMessage): void { + if (message.type === "settings.changed") { + this.dependencies.onSettingsChanged?.(loaded.id, message.settingsId); + return; + } if (message.type.startsWith("provider.")) { this.handleProviderMessage(loaded, message); return; diff --git a/packages/server/src/server/plugins/settings.e2e.test.ts b/packages/server/src/server/plugins/settings.e2e.test.ts new file mode 100644 index 0000000000..baff17d0c3 --- /dev/null +++ b/packages/server/src/server/plugins/settings.e2e.test.ts @@ -0,0 +1,73 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test } from "vitest"; +import { z } from "zod"; +import { settingsRpc } from "@getpaseo/plugin/host"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +test("two clients share settings, observe changes, and preserve values through plugin lifecycle", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "settings-plugin-")); + const daemon = await createTestPaseoDaemon(); + const first = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.7.2" }); + const second = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.7.2" }); + const rpc = settingsRpc("display"); + const read = async (client: DaemonClient, pluginId = "settings-test") => + rpc.read.output.parse(await client.invokePluginRpc(pluginId, rpc.read.name, {})); + const changed: string[] = []; + try { + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "settings-test" }), + ); + await writeFile( + path.join(directory, "index.server.ts"), + `import { defineSettings } from "@getpaseo/plugin"; +import { z } from "zod"; +export default function(server) { server.registerSettings(defineSettings({ id: "display", scope: "host", version: 1, schema: z.object({ enabled: z.boolean().default(true) }) })); return () => {}; }`, + ); + await first.connect(); + await second.connect(); + second.on("status", (message) => { + if (message.payload.status === "plugin_settings_changed") + changed.push(z.string().parse(message.payload.settingsId)); + }); + await first.patchDaemonConfig({ pluginsEnabled: true }); + await first.installDirectoryPlugin(directory); + const initial = await read(first); + expect(initial).toMatchObject({ status: "ready", values: { enabled: true } }); + expect( + await second.invokePluginRpc("settings-test", rpc.write.name, { + revision: initial.revision, + values: { enabled: false }, + }), + ).toMatchObject({ status: "saved" }); + await expect.poll(() => changed).toEqual(["display"]); + expect(await read(first)).toMatchObject({ values: { enabled: false } }); + expect( + await first.invokePluginRpc("settings-test", rpc.write.name, { + revision: initial.revision, + values: { enabled: true }, + }), + ).toMatchObject({ status: "conflict" }); + await first.reloadPlugin("settings-test"); + expect(await read(first)).toMatchObject({ values: { enabled: false } }); + await first.disablePlugin("settings-test"); + await first.enablePlugin("settings-test"); + expect(await read(first)).toMatchObject({ values: { enabled: false } }); + await first.installDirectoryPlugin(directory, "other-installation"); + expect(await read(first, "other-installation")).toMatchObject({ values: { enabled: true } }); + await first.removePlugin("settings-test"); + await first.installDirectoryPlugin(directory); + expect(await read(first)).toMatchObject({ values: { enabled: true } }); + } catch (error) { + console.error(await first.getPluginLogs("settings-test")); + throw error; + } finally { + await first.close(); + await second.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } +}, 60_000); diff --git a/packages/server/src/server/plugins/settings/index.test.ts b/packages/server/src/server/plugins/settings/index.test.ts new file mode 100644 index 0000000000..c367c93be3 --- /dev/null +++ b/packages/server/src/server/plugins/settings/index.test.ts @@ -0,0 +1,131 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, expect, test } from "vitest"; +import { defineSettings } from "@getpaseo/plugin"; +import { z } from "zod"; +import { PluginSettingsStore } from "./index.js"; + +const roots: string[] = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); +const definition = defineSettings({ + id: "display", + scope: "host", + version: 1, + schema: z.object({ + enabled: z.boolean().default(true), + count: z.number().int().min(1).default(5), + }), +}); +async function setup() { + const directory = await mkdtemp(path.join(tmpdir(), "plugin-settings-")); + roots.push(directory); + const changes: string[] = []; + const store = new PluginSettingsStore(directory, (id) => changes.push(id)); + return { directory, changes, handlers: store.register(definition) }; +} + +test("defaults, atomic saves, concurrent revisions, and restart persistence", async () => { + const { directory, changes, handlers } = await setup(); + expect(await handlers.read.handle()).toEqual({ + status: "ready", + revision: "missing", + values: { enabled: true, count: 5 }, + }); + const outcomes = await Promise.all([ + handlers.write.handle({ revision: "missing", values: { enabled: false, count: 10 } }), + handlers.write.handle({ revision: "missing", values: { enabled: true, count: 20 } }), + ]); + expect(outcomes.map((result) => result.status)).toEqual(["saved", "conflict"]); + const reopened = new PluginSettingsStore(directory, () => {}).register(definition); + expect(await reopened.read.handle()).toMatchObject({ + status: "ready", + values: { enabled: false, count: 10 }, + }); + expect(changes).toEqual(["display"]); +}); + +test("validation failure preserves disk and leaves the writer usable", async () => { + const { handlers, changes } = await setup(); + expect(await handlers.write.handle({ revision: "missing", values: { count: -1 } })).toMatchObject( + { status: "invalid" }, + ); + expect(await handlers.read.handle()).toMatchObject({ revision: "missing" }); + expect(changes).toEqual([]); + expect(await handlers.write.handle({ revision: "missing", values: { count: 2 } })).toMatchObject({ + status: "saved", + values: { enabled: true, count: 2 }, + }); +}); + +test("migrates once, persists the validated version, and rejects old clients", async () => { + const { directory, handlers } = await setup(); + const saved = await handlers.write.handle({ revision: "missing", values: { count: 7 } }); + if (saved.status !== "saved") throw new Error("save failed"); + let migrations = 0; + const upgraded = new PluginSettingsStore(directory, () => {}).register({ + ...definition, + version: 2, + schema: z.object({ total: z.number().default(0) }), + migrate(values, version) { + migrations++; + expect(version).toBe(1); + return { total: z.object({ count: z.number() }).parse(values).count }; + }, + }); + expect(await upgraded.read.handle()).toMatchObject({ status: "ready", values: { total: 7 } }); + await upgraded.read.handle(); + expect(migrations).toBe(1); + expect( + await handlers.write.handle({ revision: saved.revision, values: { count: 9 } }), + ).toMatchObject({ status: "conflict" }); + const newer = await handlers.read.handle(); + expect(newer).toMatchObject({ + status: "invalid", + error: expect.stringContaining("newer plugin"), + }); + expect( + await handlers.write.handle({ revision: newer.revision, values: { count: 9 } }), + ).toMatchObject({ status: "invalid" }); + expect(await upgraded.read.handle()).toMatchObject({ values: { total: 7 } }); +}); + +test("failed migrations and corrupt data survive reads until an explicit reset", async () => { + const { directory, handlers } = await setup(); + await handlers.write.handle({ revision: "missing", values: { count: 7 } }); + const file = path.join(directory, "display.json"); + const before = await readFile(file, "utf8"); + const upgraded = new PluginSettingsStore(directory, () => {}).register({ + ...definition, + version: 2, + migrate() { + throw new Error("migration failed"); + }, + }); + expect(await upgraded.read.handle()).toMatchObject({ + status: "invalid", + error: "migration failed", + }); + expect(await readFile(file, "utf8")).toBe(before); + await writeFile(file, "broken JSON"); + const invalid = await handlers.read.handle(); + expect(invalid.status).toBe("invalid"); + expect(await readFile(file, "utf8")).toBe("broken JSON"); + expect(await handlers.reset.handle({ revision: invalid.revision })).toMatchObject({ + status: "saved", + values: { enabled: true, count: 5 }, + }); +}); + +test("installation namespaces and definition IDs remain separate", async () => { + const first = await setup(); + const second = await setup(); + await first.handlers.write.handle({ revision: "missing", values: { enabled: false } }); + expect(await second.handlers.read.handle()).toMatchObject({ values: { enabled: true } }); + const store = new PluginSettingsStore(first.directory, () => {}); + store.register(definition); + expect(() => store.register(definition)).toThrow("Duplicate settings"); + expect(() => store.register({ ...definition, id: "../escape" })).toThrow("Invalid settings ID"); +}); diff --git a/packages/server/src/server/plugins/settings/index.ts b/packages/server/src/server/plugins/settings/index.ts new file mode 100644 index 0000000000..8ac418330a --- /dev/null +++ b/packages/server/src/server/plugins/settings/index.ts @@ -0,0 +1,133 @@ +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; +import { defineSettings, type SettingsDefinition } from "@getpaseo/plugin"; +import { settingsRpc } from "@getpaseo/plugin/host"; + +const envelopeSchema = z.object({ version: z.number().int().positive(), values: z.json() }); +function message(error: unknown): string { + if (error instanceof z.ZodError) return error.issues.map((issue) => issue.message).join("\n"); + return error instanceof Error ? error.message : String(error); +} +const revisionOf = (raw: string) => createHash("sha256").update(raw).digest("hex"); + +/** One instance per installation. The subprocess lifetime gives writes a single owner. */ +export class PluginSettingsStore { + private readonly definitions = new Map(); + private queue: Promise = Promise.resolve(); + + private readonly directory: string; + private readonly changed: (id: string) => void; + constructor(directory: string, changed: (id: string) => void) { + this.directory = directory; + this.changed = changed; + } + + register(definition: SettingsDefinition) { + defineSettings(definition); + if (this.definitions.has(definition.id)) + throw new Error(`Duplicate settings: ${definition.id}`); + this.definitions.set(definition.id, definition); + const rpc = settingsRpc(definition.id); + return { + read: { contract: rpc.read, handle: () => this.serial(() => this.read(definition)) }, + write: { + contract: rpc.write, + handle: (input: z.output) => + this.serial(() => this.write(definition, input.revision, input.values, "save")), + }, + reset: { + contract: rpc.reset, + handle: (input: z.output) => + this.serial(() => this.write(definition, input.revision, {}, "reset")), + }, + }; + } + + private serial(work: () => Promise): Promise { + const pending = this.queue.then(work); + this.queue = pending.catch(() => undefined); + return pending; + } + + private async stored(id: string) { + try { + const raw = await readFile(path.join(this.directory, `${id}.json`), "utf8"); + return { raw, revision: revisionOf(raw) }; + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") + return { raw: null, revision: "missing" }; + throw error; + } + } + + private async read( + definition: SettingsDefinition, + ): Promise["read"]["output"]>> { + const stored = await this.stored(definition.id); + try { + const envelope = stored.raw === null ? null : envelopeSchema.parse(JSON.parse(stored.raw)); + let values: unknown = envelope?.values ?? {}; + if (envelope && envelope.version !== definition.version) { + if (envelope.version > definition.version) + throw new Error("Settings were saved by a newer plugin version"); + if (!definition.migrate) + throw new Error(`Settings version ${envelope.version} requires a migration`); + values = await definition.migrate(values, envelope.version); + } + const parsed = z.json().parse(await definition.schema.parseAsync(values)); + const revision = + envelope && envelope.version !== definition.version + ? await this.persist(definition, parsed) + : stored.revision; + return { status: "ready", values: parsed, revision }; + } catch (error) { + return { status: "invalid", revision: stored.revision, error: message(error) }; + } + } + + private async write( + definition: SettingsDefinition, + revision: string, + values: unknown, + intent: "save" | "reset", + ): Promise["write"]["output"]>> { + const stored = await this.stored(definition.id); + if (stored.revision !== revision) + return { + status: "conflict", + error: "Settings changed on another client. Reload before saving again.", + }; + let parsed: z.output>; + try { + if (intent === "save" && stored.raw !== null) { + const envelope = envelopeSchema.parse(JSON.parse(stored.raw)); + if (envelope.version !== definition.version) + throw new Error("Reload or reset settings before saving a different schema version"); + } + parsed = z.json().parse(await definition.schema.parseAsync(values)); + } catch (error) { + return { status: "invalid", error: message(error) }; + } + return { status: "saved", values: parsed, revision: await this.persist(definition, parsed) }; + } + + private async persist( + definition: SettingsDefinition, + values: z.output>, + ) { + await mkdir(this.directory, { recursive: true }); + const target = path.join(this.directory, `${definition.id}.json`); + const temporary = `${target}.${randomUUID()}.tmp`; + const raw = JSON.stringify({ version: definition.version, values }); + try { + await writeFile(temporary, raw, { mode: 0o600 }); + await rename(temporary, target); + } finally { + await rm(temporary, { force: true }); + } + this.changed(definition.id); + return revisionOf(raw); + } +} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 957c2ef36b..433c6db141 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -502,6 +502,7 @@ export interface SessionOptions { disablePlugin(pluginId: string): Promise; removePlugin(pluginId: string): Promise; subscribe(listener: (pluginId: string) => void): () => void; + subscribeSettings?(listener: (pluginId: string, settingsId: string) => void): () => void; catalog(): Array<{ id: string; clientBundle: string }>; invokePluginRpc(pluginId: string, method: string, input: unknown): Promise; }; @@ -2219,9 +2220,19 @@ export class Session { pluginRuntime: SessionOptions["pluginRuntime"], ): (() => void) | null { if (!pluginRuntime) return null; - return pluginRuntime.subscribe((pluginId) => { + const catalog = pluginRuntime.subscribe((pluginId) => { this.emit({ type: "status", payload: { status: "plugin_catalog_changed", pluginId } }); }); + const settings = pluginRuntime.subscribeSettings?.((pluginId, settingsId) => { + this.emit({ + type: "status", + payload: { status: "plugin_settings_changed", pluginId, settingsId }, + }); + }); + return () => { + catalog(); + settings?.(); + }; } private dispatchVoiceAndControlMessage(msg: SessionInboundMessage): Promise | undefined { diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index ac5f19f66b..8fba3c27ac 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1679,6 +1679,7 @@ export class VoiceAssistantWebSocketServer { pluginLogs: true, // COMPAT(pluginThemes): added in v0.5.0, remove gate after 2027-08-20. pluginThemes: true, + pluginSettings: true, pluginTimelineItems: true, // COMPAT(skillManagement): added in v0.4.0, remove gate after 2027-08-16. skillManagement: true, diff --git a/packages/server/src/server/workspace-git-service.observation.test.ts b/packages/server/src/server/workspace-git-service.observation.test.ts index 8469d300c9..c55faa2bb5 100644 --- a/packages/server/src/server/workspace-git-service.observation.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.test.ts @@ -889,9 +889,13 @@ describe("WorkspaceGitService checkout observation", () => { }); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); await fetchSnapshotRead.promise; + // Observation setup also reads facts. Wait for the initial snapshot to be + // published before injecting an event that must produce a second refresh. await vi.waitFor(() => { - expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1); + expect(service.peekSnapshot(REPO_CWD)?.git.currentBranch).toBe("feature"); + expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0); }); + expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1); await vi.waitFor(() => { expect(getWatcherRecordsForDirectory(watcher, GIT_DIR)).toHaveLength(1); diff --git a/plugin-examples/settings/README.md b/plugin-examples/settings/README.md new file mode 100644 index 0000000000..45a896d63e --- /dev/null +++ b/plugin-examples/settings/README.md @@ -0,0 +1,14 @@ +# 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. + +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 +the editor to discard the draft and use the latest settings. + +Every UI component is a plain named import. The custom preview shares the same values without +using a form wrapper. Values are shared by clients connected to the same host. + +The same screen on [Android](screenshots/android.png), [Electron](screenshots/electron.png), +and [compact web](screenshots/web-compact.png). diff --git a/plugin-examples/settings/client/display-settings.tsx b/plugin-examples/settings/client/display-settings.tsx new file mode 100644 index 0000000000..f3f20199cc --- /dev/null +++ b/plugin-examples/settings/client/display-settings.tsx @@ -0,0 +1,148 @@ +import { useCallback, useMemo, useState } from "react"; +import { Text, View } from "react-native"; +import { useSettings, type PluginSurfaceProps, type SettingsState } from "@getpaseo/plugin"; +import { + SettingsAction, + SettingsCard, + SettingsInput, + SettingsRow, + SettingsSection, + SettingsSelect, + SettingsSwitch, +} from "@getpaseo/plugin/ui"; +import { preferences } from "../shared/preferences"; + +const grouping = [ + { label: "Project", value: "project" }, + { label: "Workspace", value: "workspace" }, + { label: "None", value: "none" }, +] as const; +type Preferences = Extract, { status: "ready" }>; + +function TitleEditor({ settings, onClose }: { settings: Preferences; onClose(): void }) { + // Keep the draft's revision so another client's save produces a conflict. + const [draft, setDraft] = useState(() => ({ + values: settings.values, + revision: settings.revision, + })); + const changeTitle = useCallback( + (title: string) => + setDraft((current) => ({ ...current, values: { ...current.values, title } })), + [], + ); + const saveTitle = useCallback(async () => { + if (await settings.save(draft.values, draft.revision)) onClose(); + }, [settings, draft, onClose]); + return ( + + + + + + ); +} + +function DisplayControls({ + settings, + theme, +}: { + settings: Preferences; + theme: PluginSurfaceProps["theme"]; +}) { + const style = useMemo(() => ({ color: theme.colors.foreground }), [theme]); + const [editingTitle, setEditingTitle] = useState(false); + const closeTitle = useCallback(() => { + setEditingTitle(false); + void settings.reload(); + }, [settings]); + const toggleTitle = useCallback(() => setEditingTitle((value) => !value), []); + const changeGrouping = useCallback( + (groupBy: Preferences["values"]["groupBy"]) => { + void settings.save({ ...settings.values, groupBy }, settings.revision); + }, + [settings], + ); + const changeMetadata = useCallback( + (showMetadata: boolean) => { + void settings.save({ ...settings.values, showMetadata }, settings.revision); + }, + [settings], + ); + return ( + + + + + + + {editingTitle ? : null} + {settings.saveError && !editingTitle ? ( + + {settings.saveError} + + ) : null} + + + {settings.values.title} + Grouped by {settings.values.groupBy} + + {settings.values.showMetadata ? "Metadata visible" : "Metadata hidden"} + + + + + ); +} + +export function DisplaySettings({ theme }: PluginSurfaceProps) { + const settings = useSettings(preferences); + const style = useMemo(() => ({ color: theme.colors.foreground }), [theme]); + if (settings.status === "loading") return Loading settings…; + if (settings.status !== "ready") + return ( + + {settings.error} + + {settings.status === "invalid" ? ( + + ) : null} + + ); + return ; +} diff --git a/plugin-examples/settings/index.client.tsx b/plugin-examples/settings/index.client.tsx new file mode 100644 index 0000000000..09583a6024 --- /dev/null +++ b/plugin-examples/settings/index.client.tsx @@ -0,0 +1,21 @@ +import type { PluginClientContext } from "@getpaseo/plugin"; +import { DisplaySettings } from "./client/display-settings"; + +export default function contribute(client: PluginClientContext) { + client.addSettingsScreen({ + id: "display", + title: "Display", + icon: "SlidersHorizontal", + Component: DisplaySettings, + }); + client.addCommandCenterItem({ + id: "settings", + title: "Configure agent monitor", + icon: "Settings", + context: "global", + onSelect({ openSettings }) { + openSettings("display"); + }, + }); + return () => {}; +} diff --git a/plugin-examples/settings/index.server.ts b/plugin-examples/settings/index.server.ts new file mode 100644 index 0000000000..13b136c58e --- /dev/null +++ b/plugin-examples/settings/index.server.ts @@ -0,0 +1,7 @@ +import type { PluginServerContext } from "@getpaseo/plugin"; +import { preferences } from "./shared/preferences"; + +export default function contribute(server: PluginServerContext) { + server.registerSettings(preferences); + return () => {}; +} diff --git a/plugin-examples/settings/paseo-plugin.json b/plugin-examples/settings/paseo-plugin.json new file mode 100644 index 0000000000..a654e069da --- /dev/null +++ b/plugin-examples/settings/paseo-plugin.json @@ -0,0 +1 @@ +{ "id": "settings-example" } diff --git a/plugin-examples/settings/screenshots/android.png b/plugin-examples/settings/screenshots/android.png new file mode 100644 index 0000000000000000000000000000000000000000..41e09458c73471f32d99ab3ec2bfc6593606819c GIT binary patch literal 75487 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 z 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 literal 0 HcmV?d00001 diff --git a/plugin-examples/settings/screenshots/electron.png b/plugin-examples/settings/screenshots/electron.png new file mode 100644 index 0000000000000000000000000000000000000000..0a22ed26b42966f69330a4dd728dd2e9d5b6b913 GIT binary patch literal 47456 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 KUvTc)o&N&2^~wMM literal 0 HcmV?d00001 diff --git a/plugin-examples/settings/screenshots/web-compact.png b/plugin-examples/settings/screenshots/web-compact.png new file mode 100644 index 0000000000000000000000000000000000000000..d0b5174d66c0015992695e00c968929d28b71153 GIT binary patch literal 20680 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 literal 0 HcmV?d00001 diff --git a/plugin-examples/settings/shared/preferences.ts b/plugin-examples/settings/shared/preferences.ts new file mode 100644 index 0000000000..f001cae934 --- /dev/null +++ b/plugin-examples/settings/shared/preferences.ts @@ -0,0 +1,13 @@ +import { defineSettings } from "@getpaseo/plugin"; +import { z } from "zod"; + +export const preferences = defineSettings({ + id: "display", + scope: "host", + version: 1, + schema: z.object({ + groupBy: z.enum(["project", "workspace", "none"]).default("project"), + showMetadata: z.boolean().default(true), + title: z.string().trim().min(1, "Enter a title").max(40).default("Agent monitor"), + }), +}); diff --git a/public-docs/plugins/v0.8/migration.md b/public-docs/plugins/v0.8/migration.md index bfe844877d..cad91d5537 100644 --- a/public-docs/plugins/v0.8/migration.md +++ b/public-docs/plugins/v0.8/migration.md @@ -67,22 +67,23 @@ is a compile error. Use this table as the complete registration checklist. -| Old registration and location | New registration and location | -| --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | -| `plugin.handle(contract, handler)` in the old root entry | `server.handle(contract, handler)` in `index.server.ts` | -| `plugin.addSurface(id, Component)` in the old root entry | `client.addSurface(id, Component)` in `index.client.tsx` | -| `plugin.addSidebarItem(item)` in the old root entry | `client.addSidebarItem(item)` in `index.client.tsx` | -| `plugin.addWorkspacePanel(panel)` in the old root entry | `client.addWorkspacePanel(panel)` in `index.client.tsx` | -| `plugin.addCommandCenterItem(item)` in the old root entry | `client.addCommandCenterItem(item)` in `index.client.tsx` | -| `plugin.addClientSlashCommand(command)` in the old root entry | `client.addSlashCommand(command)` in `index.client.tsx` | -| `plugin.addClientSide(fn)` in the old root entry | Delete the wrapper and move the body of `fn` into the default client entry function | -| `client.addComposerPill(pill)` inside the old client callback | `client.addComposerPill(pill)` inside `index.client.tsx` or an imported `client/` function | -| `plugin.addAttachmentSource(source)` in the old root entry | `client.addAttachmentSource(source)` in `index.client.tsx` | -| `plugin.addTheme(theme)` in the old root entry | `client.addTheme(theme)` in `index.client.tsx` | -| `plugin.addTimelineTransformer(transformer)` in the old root entry | `client.addTimelineTransformer(transformer)` in `index.client.tsx` | -| `plugin.addTimelineRenderer(renderer)` in the old root entry | `client.addTimelineRenderer(renderer)` in `index.client.tsx` | -| `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin/server"` in shared files | `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin"` | -| `ZodOutput` handler parameter types | `RpcInput` from `@getpaseo/plugin`; `RpcOutput` for return types | +| Old registration and location | New registration and location | +| --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `plugin.handle(contract, handler)` in the old root entry | `server.handle(contract, handler)` in `index.server.ts` | +| `plugin.addSurface(id, Component)` in the old root entry | `client.addSurface(id, Component)` in `index.client.tsx` | +| `plugin.addSidebarItem(item)` in the old root entry | `client.addSidebarItem(item)` in `index.client.tsx` | +| `plugin.addWorkspacePanel(panel)` in the old root entry | `client.addWorkspacePanel(panel)` in `index.client.tsx` | +| `plugin.addCommandCenterItem(item)` in the old root entry | `client.addCommandCenterItem(item)` in `index.client.tsx` | +| `plugin.addClientSlashCommand(command)` in the old root entry | `client.addSlashCommand(command)` in `index.client.tsx` | +| `plugin.addClientSide(fn)` in the old root entry | Delete the wrapper and move the body of `fn` into the default client entry function | +| `client.addComposerPill(pill)` inside the old client callback | `client.addComposerPill(pill)` inside `index.client.tsx` or an imported `client/` function | +| `plugin.addAttachmentSource(source)` in the old root entry | `client.addAttachmentSource(source)` in `index.client.tsx` | +| New settings screen contribution | `client.addSettingsScreen(screen)` in `index.client.tsx`; see [settings screens](reference#settings-screens) | +| `plugin.addTheme(theme)` in the old root entry | `client.addTheme(theme)` in `index.client.tsx` | +| `plugin.addTimelineTransformer(transformer)` in the old root entry | `client.addTimelineTransformer(transformer)` in `index.client.tsx` | +| `plugin.addTimelineRenderer(renderer)` in the old root entry | `client.addTimelineRenderer(renderer)` in `index.client.tsx` | +| `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin/server"` in shared files | `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin"` | +| `ZodOutput` handler parameter types | `RpcInput` from `@getpaseo/plugin`; `RpcOutput` for return types | Import `PluginClientContext` in the client entry and `PluginServerContext` in the server entry. Remove imports of the old context type. `@getpaseo/plugin/server` now exports only handler-side diff --git a/public-docs/plugins/v0.8/reference.md b/public-docs/plugins/v0.8/reference.md index db73adc131..cf3bdf5040 100644 --- a/public-docs/plugins/v0.8/reference.md +++ b/public-docs/plugins/v0.8/reference.md @@ -85,6 +85,7 @@ Paseo provides these modules to client code: | Module | Use it for | | ------------------------------- | ------------------------------------------------------------------------------------------------------ | | `@getpaseo/plugin` | Contribution contracts, `defineRpc`, `defineAttachmentSource`, `RpcInput`, `RpcOutput`, and data hooks | +| `@getpaseo/plugin/ui` | Named, composable settings components | | `@getpaseo/plugin/react-native` | Paseo UI components and UI hooks | | `@getpaseo/plugin/server` | Handler-only types such as `PluginHandlerContext` | | `@tanstack/react-query` | Request state and caching | @@ -136,9 +137,8 @@ for the whole project and hides the next mistake. Components import `openExterna `window` themselves. `layout.platform` on surface and panel props carries the same value as `Platform.OS` for rendering decisions. -There is no plugin storage API. Browser storage does not persist settings across Paseo clients. -There is also no general host navigation API: plugin code cannot open native Paseo routes. Command -Center callbacks can only open surfaces and panels registered by the same plugin. +Use the [settings API](#settings-screens) for typed host-scoped persistence across clients. +Use `openSettings`, `openSurface`, and `openPanel` for your own registered contributions. ### Server runtime @@ -528,6 +528,121 @@ unpainted. Themes need a host that supports them. A client released before `addTheme` cannot evaluate that client entry and reports `client.addTheme is not a function`. Update the client. +## 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 a Lucide name. 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 +of your own screens. Each installation has its own values and route, even when several hosts +install the same plugin. + +The component receives `PluginSurfaceProps`. Paseo owns the header, back navigation, safe areas, +scrolling, and the centered settings column. Compact windows push a full-screen detail; wide +windows keep the settings sidebar. Render content inside that frame using React Native components. +A disabled or removed plugin leaves an unavailable screen with working Back navigation. + +### Named UI components + +Import settings components from `@getpaseo/plugin/ui`. They work with your own state and RPCs; +no form wrapper or storage binding is required. + +```tsx +import { useState } from "react"; +import { SettingsCard, SettingsSection, SettingsSwitch } from "@getpaseo/plugin/ui"; + +export function DisplaySettings() { + const [visible, setVisible] = useState(true); + return ( + + + + + + ); +} +``` + +| Component | Props and behavior | +| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `SettingsGroup`, `SettingsSection` | Required `title`, `children`; optional `info` tooltip, `trailing` content, `testID`. Own section spacing and headings. | +| `SettingsCard` | `children`, optional `testID`. Owns the card surface and dividers between direct children. Give mapped rows stable React keys. | +| `SettingsRow` | Required `label`; optional `hint`, `error`, `children`, `testID`. Wrap any custom control or content. | +| `SettingsSwitch` | Row props plus required `value: boolean`, `onValueChange`; optional `disabled`. | +| `SettingsSelect` | Row props plus required string `value`, `options: { label, value }[]`, `onValueChange`; optional `disabled`. Uses Paseo's adaptive menu. | +| `SettingsInput` | Row props plus required `onChangeText`; optional `initialValue`, `placeholder`, `disabled`, `secureTextEntry`, `ref`. | +| `SettingsAction` | Row props plus required `actionLabel`, `onPress`; optional `disabled`. | + +`SettingsInput` owns in-progress text. `initialValue` seeds it when mounted. Its ref exposes +`focus()`, `blur()`, `getText()`, and `replaceText(text)` for explicit programmatic changes. +Keep draft text outside persisted values until the user saves. Custom previews and controls +can sit beside or inside these components. + +### Persisted values + +Define a settings document in `shared/`: + +```ts +import { defineSettings } from "@getpaseo/plugin"; +import { z } from "zod"; + +export const preferences = defineSettings({ + id: "display", + scope: "host", + version: 1, + schema: z.object({ showMetadata: z.boolean().default(true) }), +}); +``` + +Register it with `server.registerSettings(preferences)` in `index.server.ts` before returning +cleanup. This server entry is required for built-in persistence; a screen using its own data +can remain client-only. + +| Definition field | Contract | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | +| `id` | Lowercase identifier matching `[a-z][a-z0-9_-]*`; unique within the installation. | +| `scope` | Required `"host"`. All authorized clients of that host share the document. No per-user, device-local, or cross-host synchronization. | +| `version` | Required positive integer describing the schema, independent of the write revision. | +| `schema` | Zod schema for JSON values. Supply defaults so parsing `{}` produces a complete document. | +| `migrate(values, fromVersion)` | Optional synchronous or asynchronous conversion from an older stored version. Its output must pass the current schema. | + +Call `useSettings(preferences)` in any contributed component. It returns a discriminated state: + +| `status` | Available data | +| --------- | ------------------------------------------------------------------------ | +| `loading` | Read is pending; do not render default values as though they were saved. | +| `ready` | Typed `values` and an opaque `revision`. | +| `invalid` | `error` and `revision`; stored data is preserved. | +| `error` | `error` from the read/connection. | + +Every state also exposes `saving`, `saveError`, and these actions: + +- `save(values, revision): Promise` validates and saves a complete document. Returns + `false` and sets `saveError` on validation, conflict, or transport failure; it does not throw. +- `reset(): Promise` explicitly replaces the document with schema defaults, using the + revision loaded by the hook. It can recover invalid stored data. +- `reload(): Promise` clears the save error and reads again. Your component owns its draft; + reloading does not discard that draft automatically. + +For an immediate toggle, pass `{ ...settings.values, showMetadata }` and `settings.revision` +to `save`. For a draft editor, capture both the values and revision when opening it. Keep that +revision until a save succeeds or the user discards the draft. A stale revision rejects the +save, preserving both the draft and the newer saved values. + +Writes are atomic and validated on the host. Connected clients receive updates without reloading +the plugin. Values survive daemon restart, plugin reload, disable, and updates. Removing an +installation deletes its settings. Reinstalling that ID starts from defaults. + +A missing document uses schema defaults. Invalid data, failed migrations, and unsupported newer +versions produce `invalid` without silently resetting the file. Successful migrations persist +the new version once. These documents are ordinary host-side JSON, not a credential vault. +Settings RPCs use the existing `daemon.manage` permission for plugin execution. + +See the complete [settings example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/settings) +for immediate controls, a draft editor with validation, custom content, and Command Center navigation. + ## Workspace panels Register one panel for workspace or agent context: