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 0000000000..41e09458c7 Binary files /dev/null and b/plugin-examples/settings/screenshots/android.png differ diff --git a/plugin-examples/settings/screenshots/electron.png b/plugin-examples/settings/screenshots/electron.png new file mode 100644 index 0000000000..0a22ed26b4 Binary files /dev/null and b/plugin-examples/settings/screenshots/electron.png differ diff --git a/plugin-examples/settings/screenshots/web-compact.png b/plugin-examples/settings/screenshots/web-compact.png new file mode 100644 index 0000000000..d0b5174d66 Binary files /dev/null and b/plugin-examples/settings/screenshots/web-compact.png differ 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: