From 87dfe270653d23f32e793c789a51efdf07cef263 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 20 Oct 2026 08:43:16 +0200 Subject: [PATCH] feat(plugins): translate plugin UI in each app language Keep translation resources and subscriptions inside each installation. Resolve host labels against the presentation locale so live language changes preserve mounted drafts and RPC state. Existing descriptor types retain string defaults for source compatibility. --- docs/plugins.md | 6 + package-lock.json | 1 + .../app/e2e/browser/plugin-buttons.spec.ts | 8 + .../app/e2e/browser/plugin-host-ui.spec.ts | 8 + .../support/helpers/plugin-translations.ts | 162 ++++++++++++++ .../app/src/agent-stream/presentation.test.ts | 3 + packages/app/src/composer/index.tsx | 7 + .../app/src/plugins/attachments/picker.tsx | 16 +- packages/app/src/plugins/attachments/pill.tsx | 16 +- .../app/src/plugins/buttons/model.test.ts | 81 +++++++ packages/app/src/plugins/buttons/model.ts | 29 ++- .../app/src/plugins/buttons/validation.ts | 49 ++++- packages/app/src/plugins/buttons/view.tsx | 9 +- packages/app/src/plugins/client-runtime.ts | 1 + .../plugins/client-slash-commands/index.ts | 4 +- .../command-center/contributions.test.ts | 3 + .../plugins/command-center/contributions.ts | 2 +- packages/app/src/plugins/evaluate.ts | 65 +++--- .../plugins/installation-provider.test.tsx | 3 + .../app/src/plugins/installation-provider.tsx | 10 +- .../src/plugins/registry-lifecycle.test.ts | 64 +++++- .../src/plugins/registry-requirements.test.ts | 22 ++ packages/app/src/plugins/registry.ts | 23 ++ packages/app/src/plugins/settings/index.tsx | 2 +- .../app/src/plugins/sidebar-groups.test.ts | 3 + packages/app/src/plugins/sidebar-groups.ts | 4 +- .../app/src/plugins/sidebar-items/index.tsx | 6 +- .../src/plugins/surface-contribution.test.ts | 25 ++- .../app/src/plugins/surface-contribution.ts | 7 +- packages/app/src/plugins/surface-screen.tsx | 15 +- packages/app/src/plugins/theme.test.ts | 3 + packages/app/src/plugins/themes/index.ts | 11 +- .../app/src/plugins/timeline/model.test.ts | 3 + packages/app/src/plugins/types.ts | 35 ++- .../src/plugins/workspace-panels/locations.ts | 6 +- .../src/plugins/workspace-panels/panel.tsx | 4 +- .../workspace-panels/resolution.test.ts | 3 + .../plugins/workspace-panels/resolution.ts | 2 +- packages/app/src/screens/settings-screen.tsx | 9 +- .../app/src/workspace-tabs/launcher/index.tsx | 2 +- packages/plugin/package.json | 1 + packages/plugin/src/attachments.ts | 7 +- packages/plugin/src/boundaries.test.ts | 3 +- packages/plugin/src/client/buttons.ts | 38 ++-- packages/plugin/src/client/contracts.ts | 86 ++++---- packages/plugin/src/client/host.ts | 12 +- packages/plugin/src/client/index.ts | 4 + .../src/client/runtime-context-bridge.tsx | 8 +- .../plugin/src/client/translations.test.ts | 104 +++++++++ packages/plugin/src/client/translations.tsx | 202 ++++++++++++++++++ packages/plugin/src/contracts.ts | 13 +- packages/plugin/src/index.ts | 2 + packages/plugin/src/skill-examples.test.ts | 74 +++++++ packages/plugin/src/text.ts | 67 ++++++ packages/plugin/tsconfig.examples.json | 3 +- plugin-examples/translations/OVERVIEW.md | 7 + .../translations/OVERVIEW.zh-CN.md | 7 + .../translations/client/locales/en.json | 23 ++ .../translations/client/locales/zh-CN.json | 21 ++ .../translations/client/messages.ts | 5 + plugin-examples/translations/client/notes.tsx | 151 +++++++++++++ plugin-examples/translations/index.client.tsx | 101 +++++++++ plugin-examples/translations/package.json | 16 ++ .../translations/paseo-plugin.json | 12 ++ plugin-examples/translations/tsconfig.json | 15 ++ public-docs/plugins/reference.md | 83 ++++++- public-docs/plugins/reference.zh-CN.md | 77 ++++++- 67 files changed, 1680 insertions(+), 194 deletions(-) create mode 100644 packages/app/e2e/support/helpers/plugin-translations.ts create mode 100644 packages/plugin/src/client/translations.test.ts create mode 100644 packages/plugin/src/client/translations.tsx create mode 100644 packages/plugin/src/text.ts create mode 100644 plugin-examples/translations/OVERVIEW.md create mode 100644 plugin-examples/translations/OVERVIEW.zh-CN.md create mode 100644 plugin-examples/translations/client/locales/en.json create mode 100644 plugin-examples/translations/client/locales/zh-CN.json create mode 100644 plugin-examples/translations/client/messages.ts create mode 100644 plugin-examples/translations/client/notes.tsx create mode 100644 plugin-examples/translations/index.client.tsx create mode 100644 plugin-examples/translations/package.json create mode 100644 plugin-examples/translations/paseo-plugin.json create mode 100644 plugin-examples/translations/tsconfig.json diff --git a/docs/plugins.md b/docs/plugins.md index 4e1985ea81..283a10e230 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -86,6 +86,12 @@ plugin before compiling and starting from disk. A failed reload stays failed; Pa the old code. Use `enable`, `disable`, and `remove` to manage one plugin. Removing a directory source never deletes it. The global `pluginsEnabled` switch remains available. +## Client translations + +Plugin runtime translations follow the connected app, so never send a daemon locale through RPCs or add plugin resources to the app catalog. The installation owns the translation engine; native portal bridges must carry it alongside RPC and client-state contexts. Keep host label resolution at presentation time so language changes preserve the installation lifetime. + +The author contract is in [runtime translations](../public-docs/plugins/reference.md#runtime-translations). + ## Built-in plugins Built-in plugins ship from `plugins//`, with `paseo-plugin.json`, `index.server.ts`, diff --git a/package-lock.json b/package-lock.json index 9c5e60d609..87af0a3577 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38907,6 +38907,7 @@ "version": "0.12.0-beta.3", "dependencies": { "@agentclientprotocol/sdk": "^1.4.0", + "i18next": "^26.3.0", "use-sync-external-store": "^1.6.0" }, "devDependencies": { diff --git a/packages/app/e2e/browser/plugin-buttons.spec.ts b/packages/app/e2e/browser/plugin-buttons.spec.ts index 22eb61910b..d3503f56ed 100644 --- a/packages/app/e2e/browser/plugin-buttons.spec.ts +++ b/packages/app/e2e/browser/plugin-buttons.spec.ts @@ -1,3 +1,4 @@ +import { verifyPluginToolTranslations } from "../support/helpers/plugin-translations"; import { test } from "../support/fixtures"; import { withButtonShowcase } from "../support/helpers/plugin-buttons"; @@ -13,3 +14,10 @@ test("plugin header and composer buttons share actions, menus, content, and upda await buttons.hideAndUnloadOpenButtons(); }); }); + +test("plugin tooltips and menu labels follow app language with a panel draft open", async ({ + page, +}, info) => { + test.setTimeout(180_000); + await verifyPluginToolTranslations(page, info); +}); diff --git a/packages/app/e2e/browser/plugin-host-ui.spec.ts b/packages/app/e2e/browser/plugin-host-ui.spec.ts index 84d786033a..3c6547f01c 100644 --- a/packages/app/e2e/browser/plugin-host-ui.spec.ts +++ b/packages/app/e2e/browser/plugin-host-ui.spec.ts @@ -1,3 +1,4 @@ +import { verifyPluginTranslations } from "../support/helpers/plugin-translations"; import { pluginRequirements } from "../support/helpers/plugin-fixture"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -177,3 +178,10 @@ async function playPluginAudio(page: Page, button: string, result: string) { await page.getByRole("button", { name: button, exact: true }).click(); await expect(page.getByText(result, { exact: true })).toBeVisible(); } + +test("plugin content and host labels follow app language without losing an open draft", async ({ + page, +}, info) => { + test.setTimeout(180_000); + await verifyPluginTranslations(page, info); +}); diff --git a/packages/app/e2e/support/helpers/plugin-translations.ts b/packages/app/e2e/support/helpers/plugin-translations.ts new file mode 100644 index 0000000000..a2551dfdd1 --- /dev/null +++ b/packages/app/e2e/support/helpers/plugin-translations.ts @@ -0,0 +1,162 @@ +import { seedWorkspace } from "./seed-client"; +import { buildAgentRoute } from "./mock-agent"; +import { openCommandCenter } from "./command-center"; +import type { TestInfo, BrowserContext } from "@playwright/test"; +import { expect, type Page } from "../fixtures"; +import { gotoAppShell, openSettings } from "./app"; +import { connectNewWorkspaceDaemonClient } from "./new-workspace"; +import { copyPluginExample } from "./plugin-fixture"; + +async function chooseChinese(page: Page) { + await openSettings(page); + await page.getByRole("button", { name: "System", exact: true }).click(); + await page.getByRole("menuitem", { name: "简体中文 - Simplified Chinese", exact: true }).click(); + await page.goBack(); +} + +async function chooseEnglish(page: Page) { + await page.getByTestId("sidebar-settings").click(); + await page.getByRole("button", { name: "简体中文", exact: true }).click(); + await page.getByRole("menuitem", { name: "English - 英语", exact: true }).click(); + await page.goBack(); +} + +async function capture(page: Page, info: TestInfo, name: string) { + const file = info.outputPath(`${name}.png`); + await page.screenshot({ path: file, animations: "disabled" }); + await info.attach(name, { path: file, contentType: "image/png" }); +} + +export async function verifyPluginTranslations(page: Page, info: TestInfo) { + const example = await copyPluginExample("translations"); + const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const config = await client.getDaemonConfig(); + const browser = page.context().browser(); + if (!browser) throw new Error("Translation isolation requires a browser"); + let secondContext: BrowserContext | undefined; + try { + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(example.directory); + await page.setViewportSize({ width: 1440, height: 1000 }); + await gotoAppShell(page); + await page.getByRole("button", { name: "Workspace notes", exact: true }).click(); + await expect(page.getByText("App language: en", { exact: true })).toBeVisible(); + await page + .getByRole("textbox", { name: "Your draft", exact: true }) + .fill("Remember the language switch"); + await capture(page, info, "01-english-draft"); + await page.getByRole("button", { name: "Preview draft", exact: true }).click(); + await expect(page.getByRole("button", { name: "Close preview", exact: true })).toBeVisible(); + await capture(page, info, "02-english-preview"); + await page.getByRole("button", { name: "Close preview", exact: true }).click(); + secondContext = await browser.newContext({ + storageState: await page.context().storageState(), + viewport: { width: 1440, height: 1000 }, + }); + const second = await secondContext.newPage(); + await second.goto(page.url()); + await expect(second.getByText("App language: en", { exact: true })).toBeVisible(); + await chooseChinese(second); + await expect(second.getByText("应用语言:zh-CN", { exact: true })).toBeVisible(); + await second + .getByRole("textbox", { name: "你的草稿", exact: true }) + .fill("A separate client draft"); + await expect(page.getByText("App language: en", { exact: true })).toBeVisible(); + await expect(page.getByTestId("plugin-surface-title")).toHaveText("Workspace notes"); + await capture(second, info, "03-independent-chinese-client"); + await chooseChinese(page); + await expect(page.getByRole("textbox", { name: "你的草稿", exact: true })).toHaveValue( + "Remember the language switch", + ); + await expect(page.getByTestId("plugin-surface-title")).toHaveText("工作区笔记"); + await expect(page.getByText("应用语言:zh-CN", { exact: true })).toBeVisible(); + await expect( + page.getByText("This English-only message demonstrates fallback.", { exact: true }), + ).toBeVisible(); + await capture(page, info, "04-chinese-draft-preserved"); + await page.getByRole("button", { name: "预览草稿", exact: true }).click(); + await expect(page.getByText("草稿预览", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "关闭预览", exact: true })).toBeVisible(); + await capture(page, info, "05-chinese-preview"); + await page.getByRole("button", { name: "关闭预览", exact: true }).click(); + await chooseEnglish(page); + await expect(page.getByRole("textbox", { name: "Your draft", exact: true })).toHaveValue( + "Remember the language switch", + ); + await expect(page.getByTestId("plugin-surface-title")).toHaveText("Workspace notes"); + await expect(second.getByRole("textbox", { name: "你的草稿", exact: true })).toHaveValue( + "A separate client draft", + ); + await capture(page, info, "06-return-to-english-draft-preserved"); + await page.getByRole("button", { name: "Add note", exact: true }).click(); + await expect(page.getByText("1 note", { exact: true })).toBeVisible(); + await page.getByRole("textbox", { name: "Your draft", exact: true }).fill("Second note"); + await page.getByRole("button", { name: "Add note", exact: true }).click(); + await expect(page.getByText("2 notes", { exact: true })).toBeVisible(); + } finally { + await secondContext?.close(); + await client.removePlugin("translation-notes").catch(() => undefined); + await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled }); + await client.close(); + await example.cleanup(); + } +} + +export async function verifyPluginToolTranslations(page: Page, info: TestInfo) { + const workspace = await seedWorkspace({ + repoPrefix: "translation-tools-", + title: "Notes translation QA", + }); + const agent = await workspace.client.createAgent({ + provider: "mock", + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: "Notes", + model: "ten-second-stream", + modeId: "load-test", + }); + const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const config = await client.getDaemonConfig(); + const example = await copyPluginExample("translations"); + try { + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(example.directory); + await page.setViewportSize({ width: 1440, height: 1000 }); + await gotoAppShell(page); + await page.goto(buildAgentRoute(workspace.workspaceId, agent.id)); + const commandCenter = await openCommandCenter(page); + await commandCenter.getByTestId("command-center-input").fill("Show notes tools"); + await commandCenter.getByRole("button", { name: "Show notes tools", exact: true }).click(); + const tools = page + .getByRole("toolbar", { name: "Workspace actions", exact: true }) + .getByRole("button", { name: "Notes tools", exact: true }); + await tools.hover(); + await expect(page.getByRole("tooltip")).toContainText("Notes tools"); + await capture(page, info, "07-english-tooltip"); + await tools.click(); + await page.getByRole("menuitem", { name: "Open notes panel", exact: true }).click(); + await page + .getByRole("textbox", { name: "Your draft", exact: true }) + .fill("A workspace panel draft"); + await chooseChinese(page); + await expect(page.getByRole("textbox", { name: "你的草稿", exact: true })).toHaveValue( + "A workspace panel draft", + ); + const translatedTools = page + .getByRole("toolbar") + .getByRole("button", { name: "笔记工具", exact: true }); + await translatedTools.hover(); + await expect(page.getByRole("tooltip")).toContainText("笔记工具"); + await capture(page, info, "08-chinese-tooltip-and-panel-draft"); + await translatedTools.click(); + await expect(page.getByRole("menuitem", { name: "打开笔记面板", exact: true })).toBeVisible(); + await capture(page, info, "09-chinese-menu"); + await page.keyboard.press("Escape"); + } finally { + await client.removePlugin("translation-notes").catch(() => undefined); + await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled }); + await client.close(); + await example.cleanup(); + await workspace.cleanup(); + } +} diff --git a/packages/app/src/agent-stream/presentation.test.ts b/packages/app/src/agent-stream/presentation.test.ts index dcb52fd24b..ef3a30e5c5 100644 --- a/packages/app/src/agent-stream/presentation.test.ts +++ b/packages/app/src/agent-stream/presentation.test.ts @@ -1,3 +1,4 @@ +import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host"; import { runPluginClientBundle, type PluginClientRuntime } from "@/plugins/evaluate"; import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/timeline/model"; import type { InstalledPlugin } from "@/plugins/types"; @@ -61,6 +62,8 @@ function installProbe( serverId: "host-1", clientBundle, lifetime: new AbortController(), + locale: "en", + i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }), queryClient: new QueryClient(), paseo: {} as InstalledPlugin["paseo"], invoke: async () => undefined, diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 10d670fcae..ed9ccca4f7 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -315,6 +315,7 @@ interface PendingFileAttachment { } interface RenderAttachmentTrayArgs { + serverId: string; selectedAttachments: ComposerAttachment[]; pendingFiles: PendingFileAttachment[]; isComposerLocked: boolean; @@ -331,6 +332,7 @@ interface RenderAttachmentTrayArgs { function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | null { const { + serverId, selectedAttachments, pendingFiles, isComposerLocked, @@ -343,6 +345,7 @@ function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | nu {selectedAttachments.map((attachment, index) => renderComposerAttachmentPill({ + serverId, attachment, index, disabled: isComposerLocked, @@ -393,6 +396,7 @@ function renderQueueTrack(args: RenderQueueTrackArgs): ReactElement | null { } interface RenderComposerAttachmentPillArgs { + serverId: string; attachment: ComposerAttachment; index: number; disabled: boolean; @@ -453,6 +457,7 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R if (attachment.kind === "plugin_resource") { return ( renderAttachmentTray({ + serverId, selectedAttachments, pendingFiles, isComposerLocked, @@ -2314,6 +2320,7 @@ function ComposerContentImpl({ handleOpenAttachment, handleRemoveAttachment, isComposerLocked, + serverId, selectedAttachments, pendingFiles, t, diff --git a/packages/app/src/plugins/attachments/picker.tsx b/packages/app/src/plugins/attachments/picker.tsx index a63c1eb4e0..db5d32293f 100644 --- a/packages/app/src/plugins/attachments/picker.tsx +++ b/packages/app/src/plugins/attachments/picker.tsx @@ -2,7 +2,8 @@ import { useCallback, useMemo, useState, type ReactElement, type RefObject } fro import { View } from "react-native"; import { withUnistyles } from "react-native-unistyles"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import type { PluginAttachmentItem, PluginAttachmentSourceContribution } from "@getpaseo/plugin"; +import type { PluginAttachmentItem } from "@getpaseo/plugin"; +import type { PluginAttachmentSourceContribution } from "../types"; import { searchPluginAttachments } from "@getpaseo/plugin/client/host"; import type { LucideIcon } from "lucide-react-native"; import type { UserComposerAttachment } from "@/attachments/types"; @@ -133,7 +134,7 @@ export function usePluginAttachmentPicker( { pluginId: active.plugin.id, sourceId: active.source.id, - sourceTitle: active.source.title, + sourceTitle: active.plugin.i18n.resolve(active.source.title, active.plugin.locale), sourceIcon: active.source.icon, }, item, @@ -145,11 +146,11 @@ export function usePluginAttachmentPicker( ); const menuItems = useMemo( () => - sources.map(({ key, source }) => { + sources.map(({ key, source, plugin }) => { const Icon = resolvePluginIcon(source.icon); return { id: `plugin:${key}`, - label: `Attach ${source.title}`, + label: `Attach ${plugin.i18n.resolve(source.title, plugin.locale)}`, icon: , onSelect: () => setActiveKey(key), }; @@ -165,8 +166,11 @@ export function usePluginAttachmentPicker( value="" onSelect={handleSelect} searchable - searchPlaceholder={active.source.searchPlaceholder} - title={active.source.pickerTitle} + searchPlaceholder={active.plugin.i18n.resolve( + active.source.searchPlaceholder, + active.plugin.locale, + )} + title={active.plugin.i18n.resolve(active.source.pickerTitle, active.plugin.locale)} open onOpenChange={handleOpenChange} onSearchQueryChange={setQuery} diff --git a/packages/app/src/plugins/attachments/pill.tsx b/packages/app/src/plugins/attachments/pill.tsx index 18fb926b59..99f46f2d11 100644 --- a/packages/app/src/plugins/attachments/pill.tsx +++ b/packages/app/src/plugins/attachments/pill.tsx @@ -1,3 +1,4 @@ +import { useInstalledPlugin } from "../registry"; import { useCallback, useMemo } from "react"; import { withUnistyles } from "react-native-unistyles"; import { AttachmentLabel, AttachmentPill } from "@/components/attachment-pill"; @@ -21,6 +22,7 @@ function ResourceIcon({ const ThemedResourceIcon = withUnistyles(ResourceIcon); interface PluginResourceAttachmentPillProps { + serverId: string; attachment: PluginResourceComposerAttachment; index: number; disabled: boolean; @@ -31,6 +33,7 @@ interface PluginResourceAttachmentPillProps { } export function PluginResourceAttachmentPill({ + serverId, attachment, index, disabled, @@ -39,6 +42,13 @@ export function PluginResourceAttachmentPill({ openLabel, removeLabel, }: PluginResourceAttachmentPillProps) { + const plugin = useInstalledPlugin(serverId, attachment.pluginId); + const source = plugin?.attachmentSources.find( + (candidate) => candidate.id === attachment.sourceId, + ); + // Keep the saved label when the source is no longer installed. + const sourceTitle = + plugin && source ? plugin.i18n.resolve(source.title, plugin.locale) : attachment.sourceTitle; const Icon = resolvePluginIcon(attachment.sourceIcon); const handleOpen = useCallback(() => onOpen(attachment), [attachment, onOpen]); const handleRemove = useCallback(() => onRemove(index), [index, onRemove]); @@ -51,14 +61,14 @@ export function PluginResourceAttachmentPill({ testID="composer-plugin-resource-attachment-pill" onOpen={handleOpen} onRemove={handleRemove} - openAccessibilityLabel={openLabel(attachment.sourceTitle, attachment.item.identifier)} - removeAccessibilityLabel={removeLabel(attachment.sourceTitle, attachment.item.identifier)} + openAccessibilityLabel={openLabel(sourceTitle, attachment.item.identifier)} + removeAccessibilityLabel={removeLabel(sourceTitle, attachment.item.identifier)} disabled={disabled} > ); diff --git a/packages/app/src/plugins/buttons/model.test.ts b/packages/app/src/plugins/buttons/model.test.ts index 4a77a81311..20bdcb3887 100644 --- a/packages/app/src/plugins/buttons/model.test.ts +++ b/packages/app/src/plugins/buttons/model.test.ts @@ -1,3 +1,5 @@ +import { defineTranslations } from "@getpaseo/plugin/client"; +import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host"; import { QueryClient } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; import type { PluginButton } from "@getpaseo/plugin/client"; @@ -7,6 +9,8 @@ import { PluginButtonStore, buttonMatches } from "./model"; function installation(): InstalledPlugin { return { lifetime: new AbortController(), + locale: "en", + i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }), id: "review", serverId: "host-a", clientBundle: "bundle", @@ -171,3 +175,80 @@ describe("plugin buttons", () => { expect(calls).toBe(2); }); }); + +it("updates translated tooltips and nested menu labels without closing open content", () => { + let locale = "en"; + const listeners = new Set<() => void>(); + const plugin = installation(); + plugin.i18n.dispose(); + plugin.i18n = new PluginTranslationRuntime({ + getLocale: () => locale, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }); + const translations = defineTranslations({ + resources: { + en: { title: "Notes", menu: "Open notes" }, + "zh-CN": { title: "笔记", menu: "打开笔记" }, + }, + }); + const buttons = store(); + const registration = buttons.addHeaderButton(plugin, { + id: "notes", + workspaceId: "workspace", + button: { + title: translations.message("title"), + icon: "Scan", + behavior: { + kind: "menu", + items: [ + { + kind: "item", + id: "open", + title: translations.message("menu"), + behavior: { kind: "action", onPress() {} }, + }, + ], + }, + }, + }); + const original = buttons.getSnapshot()[0]; + buttons.setOpen(original.key, true); + locale = "zh-CN"; + for (const listener of listeners) listener(); + const translated = buttons.getSnapshot()[0]; + expect(translated.key).toBe(original.key); + expect(translated.open).toBe(true); + expect(translated.button.title).toBe("笔记"); + expect(translated.button.behavior).toMatchObject({ + kind: "menu", + items: [{ title: "打开笔记" }], + }); + registration.update({ disabled: false }); + locale = "en"; + for (const listener of listeners) listener(); + expect(buttons.getSnapshot()[0].button.title).toBe("Notes"); + registration.remove(); + plugin.i18n.dispose(); + expect(listeners.size).toBe(0); + expect(buttons.getSnapshot()).toEqual([]); +}); + +it("keeps the previous descriptor after rejecting an invalid update", () => { + const buttons = store(); + const plugin = installation(); + const registration = buttons.addHeaderButton(plugin, { + id: "review", + workspaceId: "workspace", + button: button(), + }); + expect(() => registration.update({ title: "" })).toThrow("Plugin button needs title"); + expect(() => registration.update({ disabled: true })).not.toThrow(); + expect(buttons.getSnapshot()[0].button.title).toBe("Review"); + registration.remove(); + plugin.i18n.dispose(); +}); diff --git a/packages/app/src/plugins/buttons/model.ts b/packages/app/src/plugins/buttons/model.ts index 25e7490b33..4b81f95a84 100644 --- a/packages/app/src/plugins/buttons/model.ts +++ b/packages/app/src/plugins/buttons/model.ts @@ -1,5 +1,7 @@ import type { + PluginText, PluginButtonBehavior, + PluginButton, PluginButtonContext, PluginButtonRegistration, PluginHeaderButtonContribution, @@ -27,7 +29,7 @@ export interface RegisteredPluginButton { } function resolveAction( - behavior: PluginButtonBehavior, + behavior: PluginButtonBehavior, path: readonly string[], ): (() => void | Promise) | null { if (path.length === 0) return behavior.kind === "action" ? behavior.onPress : null; @@ -55,8 +57,8 @@ export class PluginButtonStore { addHeaderButton( installation: InstalledPlugin, - input: PluginHeaderButtonContribution, - ): PluginButtonRegistration { + input: PluginHeaderButtonContribution, + ): PluginButtonRegistration { return this.add(installation, input, "header", { context: "workspace", workspaceId: input.workspaceId.trim(), @@ -65,8 +67,8 @@ export class PluginButtonStore { addComposerPill( installation: InstalledPlugin, - input: PluginComposerPillContribution, - ): PluginButtonRegistration { + input: PluginComposerPillContribution, + ): PluginButtonRegistration { if (!input.agentId.trim()) throw new Error("Plugin composer pill needs an agent"); return this.add(installation, input, "composer", { context: "agent", @@ -77,10 +79,10 @@ export class PluginButtonStore { private add( installation: InstalledPlugin, - input: PluginHeaderButtonContribution, + input: PluginHeaderButtonContribution, placement: ButtonPlacement, context: PluginButtonContext, - ): PluginButtonRegistration { + ): PluginButtonRegistration { const id = requireButtonId(input.id); if (!context.workspaceId.trim()) throw new Error("Plugin button needs a workspace"); const duplicate = this.entries.some( @@ -94,7 +96,8 @@ export class PluginButtonStore { ); if (duplicate) throw new Error(`Duplicate plugin button: ${id}`); const key = this.nextKey++; - const initialButton = validateButton(input.button, this.validation); + let sourceButton: PluginButton = input.button; + const initialButton = validateButton(sourceButton, this.validation, installation.i18n.resolve); this.publish([ ...this.entries, { @@ -108,15 +111,23 @@ export class PluginButtonStore { open: false, }, ]); + const unsubscribe = installation.i18n.subscribe(() => { + this.replace(key, { + button: validateButton(sourceButton, this.validation, installation.i18n.resolve), + }); + }); return { update: (patch) => { const entry = this.entries.find((candidate) => candidate.key === key); if (!entry) return; - const button = validateButton({ ...entry.button, ...patch }, this.validation); + const nextSource = { ...sourceButton, ...patch }; + const button = validateButton(nextSource, this.validation, installation.i18n.resolve); + sourceButton = nextSource; const close = !button.visible || button.disabled || patch.behavior !== undefined; this.replace(key, { button, open: close ? false : entry.open }); }, remove: () => { + unsubscribe(); const next = this.entries.filter((entry) => entry.key !== key); if (next.length !== this.entries.length) this.publish(next); }, diff --git a/packages/app/src/plugins/buttons/validation.ts b/packages/app/src/plugins/buttons/validation.ts index 650c21fac2..ae4c68822d 100644 --- a/packages/app/src/plugins/buttons/validation.ts +++ b/packages/app/src/plugins/buttons/validation.ts @@ -1,4 +1,12 @@ -import type { PluginButton, PluginButtonBehavior, PluginButtonIcon } from "@getpaseo/plugin/client"; +import type { PluginText } from "@getpaseo/plugin/client"; +import type { + PluginButton as SDKPluginButton, + PluginButtonBehavior as SDKPluginButtonBehavior, + PluginButtonIcon, +} from "@getpaseo/plugin/client"; + +type PluginButton = SDKPluginButton; +type PluginButtonBehavior = SDKPluginButtonBehavior; export interface ButtonValidation { validateIconName(name: string): void; @@ -17,7 +25,12 @@ function optionalBoolean(value: boolean | undefined, fallback: boolean): boolean return value; } -function requireText(value: string, field: string): string { +function requireText( + value: PluginText, + field: string, + resolve: (text: PluginText) => string, +): string { + value = resolve(value); if (typeof value !== "string" || !value.trim()) throw new Error(`Plugin button needs ${field}`); return value.trim(); } @@ -38,7 +51,8 @@ function validateIcon(icon: PluginButtonIcon, validation: ButtonValidation): voi function validateBehavior( behavior: PluginButtonBehavior, validation: ButtonValidation, -): PluginButtonBehavior { + resolve: (text: PluginText) => string, +): ResolvedPluginButtonBehavior { switch (behavior.kind) { case "action": if (typeof behavior.onPress !== "function") @@ -60,10 +74,10 @@ function validateBehavior( if (item.icon !== undefined) validateIcon(item.icon, validation); return { ...item, - title: requireText(item.title, "menu item title"), + title: requireText(item.title, "menu item title", resolve), visible: optionalBoolean(item.visible, true), disabled: optionalBoolean(item.disabled, false), - behavior: validateBehavior(item.behavior, validation), + behavior: validateBehavior(item.behavior, validation, resolve), }; }), }; @@ -73,23 +87,40 @@ function validateBehavior( } } -export type ResolvedPluginButton = PluginButton & { +export type ResolvedPluginButtonBehavior = + | Exclude + | { kind: "menu"; items: readonly ResolvedPluginButtonMenuEntry[] }; +export type ResolvedPluginButtonMenuEntry = + | { kind: "separator"; id: string } + | { + kind: "item"; + id: string; + title: string; + icon?: PluginButtonIcon; + visible?: boolean; + disabled?: boolean; + behavior: ResolvedPluginButtonBehavior; + }; +export type ResolvedPluginButton = Omit & { + title: string; visible: boolean; disabled: boolean; label: string | undefined; + behavior: ResolvedPluginButtonBehavior; }; export function validateButton( button: PluginButton, validation: ButtonValidation, + resolve: (text: PluginText) => string, ): ResolvedPluginButton { validateIcon(button.icon, validation); return { ...button, - title: requireText(button.title, "title"), - label: button.label === undefined ? undefined : requireText(button.label, "label"), + title: requireText(button.title, "title", resolve), + label: button.label === undefined ? undefined : requireText(button.label, "label", resolve), visible: optionalBoolean(button.visible, true), disabled: optionalBoolean(button.disabled, false), - behavior: validateBehavior(button.behavior, validation), + behavior: validateBehavior(button.behavior, validation, resolve), }; } diff --git a/packages/app/src/plugins/buttons/view.tsx b/packages/app/src/plugins/buttons/view.tsx index 7eae9a046d..5024726153 100644 --- a/packages/app/src/plugins/buttons/view.tsx +++ b/packages/app/src/plugins/buttons/view.tsx @@ -1,9 +1,8 @@ import type { - PluginButtonBehavior, - PluginButtonIcon, - PluginButtonMenuEntry, - PluginHostProps, -} from "@getpaseo/plugin/client"; + ResolvedPluginButtonBehavior as PluginButtonBehavior, + ResolvedPluginButtonMenuEntry as PluginButtonMenuEntry, +} from "./validation"; +import type { PluginButtonIcon, PluginHostProps } from "@getpaseo/plugin/client"; import type { PluginTheme } from "@getpaseo/plugin"; import { useCallback, useMemo, useSyncExternalStore, type ReactNode } from "react"; import { Pressable, Text, View, useWindowDimensions } from "react-native"; diff --git a/packages/app/src/plugins/client-runtime.ts b/packages/app/src/plugins/client-runtime.ts index f9074383bd..61e921bd0f 100644 --- a/packages/app/src/plugins/client-runtime.ts +++ b/packages/app/src/plugins/client-runtime.ts @@ -24,6 +24,7 @@ export function createPluginClientRuntime( ); return { ...capabilities, + i18n: installation.i18n, playAudio: createPlayAudio(audio, installation.lifetime.signal), hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal), addComposerPill(contribution) { diff --git a/packages/app/src/plugins/client-slash-commands/index.ts b/packages/app/src/plugins/client-slash-commands/index.ts index 9ab66ac195..7938ff7c18 100644 --- a/packages/app/src/plugins/client-slash-commands/index.ts +++ b/packages/app/src/plugins/client-slash-commands/index.ts @@ -36,8 +36,8 @@ export function usePluginClientSlashCommands(input: { { pluginId: plugin.id, name: contribution.name, - description: contribution.description, - argumentHint: contribution.argumentHint, + description: plugin.i18n.resolve(contribution.description, plugin.locale), + argumentHint: plugin.i18n.resolve(contribution.argumentHint, plugin.locale), async run(args: string) { const context = contribution.context === "agent" diff --git a/packages/app/src/plugins/command-center/contributions.test.ts b/packages/app/src/plugins/command-center/contributions.test.ts index a2544bd034..b7383f6737 100644 --- a/packages/app/src/plugins/command-center/contributions.test.ts +++ b/packages/app/src/plugins/command-center/contributions.test.ts @@ -1,3 +1,4 @@ +import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host"; import { QueryClient } from "@tanstack/react-query"; import { createPaseoApi, type PaseoApi } from "@getpaseo/client"; import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; @@ -61,6 +62,8 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin { serverId: "host-1", clientBundle: "bundle", lifetime: new AbortController(), + locale: "en", + i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }), paseo: createPaseoApi( new DaemonClient({ url: "ws://127.0.0.1:1", diff --git a/packages/app/src/plugins/command-center/contributions.ts b/packages/app/src/plugins/command-center/contributions.ts index be4cb8068a..b79ef27824 100644 --- a/packages/app/src/plugins/command-center/contributions.ts +++ b/packages/app/src/plugins/command-center/contributions.ts @@ -81,7 +81,7 @@ export function buildPluginCommandCenterContributions( visibility: "always", presentation: { kind: "action", - title: item.title, + title: plugin.i18n.resolve(item.title, plugin.locale), sectionTitle: plugin.id, icon: getCommandCenterIcon(resolvePluginIcon(item.icon)), }, diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index a2703b6ad2..32eb8d2c18 100644 --- a/packages/app/src/plugins/evaluate.ts +++ b/packages/app/src/plugins/evaluate.ts @@ -1,3 +1,4 @@ +import { requirePluginText } from "@getpaseo/plugin/client/host"; import type { createPluginHosts } from "./hosts"; import { openExternalUrl } from "@/utils/open-external-url"; import * as pluginUiRuntime from "./react-native/ui"; @@ -11,27 +12,29 @@ import * as ReactNative from "react-native"; // eslint-disable-next-line no-restricted-imports -- plugin bundles receive TanStack's real runtime, not Paseo's query wrappers. import * as ReactQuery from "@tanstack/react-query"; import * as Zod from "zod"; +import { type PluginCleanup } from "@getpaseo/plugin"; import { - type PluginAttachmentSourceContribution, - type PluginCleanup, - type PluginThemeContribution, -} from "@getpaseo/plugin"; -import { - type PluginCommandCenterItemContribution, type PluginClientContext, - type PluginClientSlashCommandContribution, - type PluginSidebarContribution, - type PluginScreenContribution, type PluginScreenProps, type PluginScreenTitle, - type PluginSidebarItemContribution, + type PluginText, type PluginSurfaceProps, type PluginTimelineRendererContribution, type PluginTimelineTransformerContribution, - type PluginWorkspacePanelContribution, type PluginButtonRegistration, } from "@getpaseo/plugin/client"; -import type { EvaluatedPlugin, PluginSidebarSection } from "./types"; +import type { + EvaluatedPlugin, + PluginSidebarSection, + PluginAttachmentSourceContribution, + PluginThemeContribution, + PluginCommandCenterItemContribution, + PluginClientSlashCommandContribution, + PluginSidebarContribution, + PluginScreenContribution, + PluginSidebarItemContribution, + PluginWorkspacePanelContribution, +} from "./types"; import type { ComponentType } from "react"; import { resolvePluginIcon } from "./icons"; import { pluginReactNativeRuntime } from "./react-native/runtime"; @@ -76,6 +79,10 @@ function isComponentType(value: unknown): boolean { ); } +function normalizePluginText(text: import("@getpaseo/plugin/client").PluginText) { + return typeof text === "string" ? text.trim() : requirePluginText(text); +} + function requireId(value: string, label: string): string { const id = value.trim(); if (!CONTRIBUTION_ID.test(id)) throw new Error(`Invalid ${label}: ${value}`); @@ -83,14 +90,17 @@ function requireId(value: string, label: string): string { } /** A non-empty string, or a function of the screen's params resolved when the header renders. */ -function requireScreenTitle(screenId: string, title: unknown): PluginScreenTitle { - if (typeof title === "function") return title as PluginScreenTitle; - if (typeof title === "string" && title.trim()) return title.trim(); +function requireScreenTitle(screenId: string, title: unknown): PluginScreenTitle { + if (typeof title === "function") return title as PluginScreenTitle; + try { + return requirePluginText(title); + } catch {} throw new Error(`Screen ${screenId} needs a title: a non-empty string or a function of params`); } export type PluginClientRuntime = Pick< PluginClientContext, + | "i18n" | "playAudio" | "paseo" | "rpc" @@ -137,7 +147,9 @@ export function runPluginClientBundle( const removals = new Set(); let setupComplete = false; let stopped = false; - function trackButton(registration: PluginButtonRegistration): PluginButtonRegistration { + function trackButton( + registration: PluginButtonRegistration, + ): PluginButtonRegistration { let active = true; const remove = () => { if (!active) return; @@ -180,7 +192,7 @@ export function runPluginClientBundle( const normalizedId = requireId(contribution.id, `sidebar ${section} item id`); if (ids.has(normalizedId)) throw new Error(`Duplicate sidebar ${section} item: ${normalizedId}`); - const title = contribution.title.trim(); + const title = normalizePluginText(contribution.title); if (!title) throw new Error(`Sidebar item ${normalizedId} has no title`); if (!isComponentType(contribution.Component)) { throw new Error(`Sidebar item ${normalizedId} is not a component`); @@ -198,7 +210,8 @@ export function runPluginClientBundle( 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}`); + if (!normalizePluginText(contribution.title)) + throw new Error(`Invalid settings screen: ${screenId}`); resolvePluginIcon(contribution.icon); settingsScreenIds.add(screenId); return register(collector.settingsScreens, { ...contribution, id: screenId }, () => @@ -245,7 +258,7 @@ export function runPluginClientBundle( resolvePluginIcon(contribution.icon.trim()); const legacy = { id: normalizedId, - title: contribution.title.trim(), + title: normalizePluginText(contribution.title), icon: contribution.icon.trim(), surface: requireId(contribution.surface, "sidebar surface id"), }; @@ -261,7 +274,7 @@ export function runPluginClientBundle( if (workspacePanelIds.has(normalizedId)) { throw new Error(`Duplicate workspace panel: ${normalizedId}`); } - const title = contribution.title.trim(); + const title = normalizePluginText(contribution.title); const icon = contribution.icon.trim(); if (!title) throw new Error(`Workspace panel ${normalizedId} has no title`); if (!icon) throw new Error(`Workspace panel ${normalizedId} has no icon`); @@ -291,7 +304,7 @@ export function runPluginClientBundle( if (commandCenterItemIds.has(normalizedId)) { throw new Error(`Duplicate Command Center item: ${normalizedId}`); } - const title = contribution.title.trim(); + const title = normalizePluginText(contribution.title); const icon = contribution.icon.trim(); if (!title) throw new Error(`Command Center item ${normalizedId} has no title`); if (!icon) throw new Error(`Command Center item ${normalizedId} has no icon`); @@ -324,7 +337,7 @@ export function runPluginClientBundle( if (clientSlashCommandNames.has(name)) { throw new Error(`Duplicate client slash command: ${name}`); } - const description = contribution.description.trim(); + const description = normalizePluginText(contribution.description); if (!description) throw new Error(`Client slash command ${name} has no description`); if (contribution.context !== "workspace" && contribution.context !== "agent") { throw new Error(`Client slash command ${name} has invalid context`); @@ -339,7 +352,7 @@ export function runPluginClientBundle( ...contribution, name, description, - argumentHint: contribution.argumentHint.trim(), + argumentHint: normalizePluginText(contribution.argumentHint), }, () => clientSlashCommandNames.delete(name), ); @@ -349,10 +362,10 @@ export function runPluginClientBundle( if (attachmentSourceIds.has(normalizedId)) { throw new Error(`Duplicate attachment source: ${normalizedId}`); } - const title = contribution.title.trim(); + const title = normalizePluginText(contribution.title); const icon = contribution.icon.trim(); - const pickerTitle = contribution.pickerTitle.trim(); - const searchPlaceholder = contribution.searchPlaceholder.trim(); + const pickerTitle = normalizePluginText(contribution.pickerTitle); + const searchPlaceholder = normalizePluginText(contribution.searchPlaceholder); const method = contribution.search.name.trim(); if (!title) throw new Error(`Attachment source ${normalizedId} has no title`); if (!icon) throw new Error(`Attachment source ${normalizedId} has no icon`); diff --git a/packages/app/src/plugins/installation-provider.test.tsx b/packages/app/src/plugins/installation-provider.test.tsx index 949bc7480f..f2aaf871e7 100644 --- a/packages/app/src/plugins/installation-provider.test.tsx +++ b/packages/app/src/plugins/installation-provider.test.tsx @@ -1,3 +1,4 @@ +import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host"; import { usePaseo, useRpc } from "@getpaseo/plugin/client"; import { defineRpc } from "@getpaseo/plugin"; import type { PaseoApi } from "@getpaseo/client"; @@ -21,6 +22,8 @@ function installation(invoked: string[]): InstalledPlugin { serverId: "host", clientBundle: "", lifetime: new AbortController(), + locale: "en", + i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }), paseo: { dispose: async () => undefined } as PaseoApi, invoke: async (method) => { invoked.push(method); diff --git a/packages/app/src/plugins/installation-provider.tsx b/packages/app/src/plugins/installation-provider.tsx index 40d06ef035..552f8080b8 100644 --- a/packages/app/src/plugins/installation-provider.tsx +++ b/packages/app/src/plugins/installation-provider.tsx @@ -1,5 +1,9 @@ import { QueryClientProvider } from "@tanstack/react-query"; -import { PaseoApiProvider, PluginRpcProvider } from "@getpaseo/plugin/client/host"; +import { + PaseoApiProvider, + PluginRpcProvider, + PluginTranslationProvider, +} from "@getpaseo/plugin/client/host"; import React, { type ReactNode } from "react"; import type { InstalledPlugin } from "./types"; @@ -14,7 +18,9 @@ export function PluginInstallationProvider({ return ( - {children} + + {children} + ); diff --git a/packages/app/src/plugins/registry-lifecycle.test.ts b/packages/app/src/plugins/registry-lifecycle.test.ts index ecec420e3c..a0c525143b 100644 --- a/packages/app/src/plugins/registry-lifecycle.test.ts +++ b/packages/app/src/plugins/registry-lifecycle.test.ts @@ -26,9 +26,15 @@ function observingClient() { return { client, open }; } -function registry() { +function registry( + localeSource: import("@getpaseo/plugin/client/host").PluginLocaleSource = { + getLocale: () => "en", + subscribe: () => () => {}, + }, +) { const setupClients = new Map(); const plugins = new PluginRegistry({ + localeSource, version: "0.8.0", createRuntime: (installation) => { setupClients.set(installation.id, installation.paseo); @@ -43,6 +49,7 @@ function registry() { installation.lifetime.signal, ), paseo: installation.paseo, + i18n: installation.i18n, rpc: async () => { throw new Error("Unexpected plugin RPC"); }, @@ -145,3 +152,58 @@ test("an invalid async client entry is disposed and its rejected continuation is await new Promise((resolve) => setTimeout(resolve, 0)); expect(h.plugins.getSnapshot()).toEqual([]); }); + +test("locale snapshots preserve installation state and observations, then release locale subscriptions", async () => { + let locale = "en"; + const listeners = new Set<() => void>(); + const h = registry({ + getLocale: () => locale, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }); + const translated = { + ...catalog("notes"), + clientBundle: `(function(require) { + const {defineTranslations} = require("@getpaseo/plugin/client"); + const messages = defineTranslations({resources:{en:{title:"Notes"},"zh-CN":{title:"笔记"}}}); + return {default(plugin) { + plugin.paseo.observeEvents(["notes"]); + plugin.addScreen({id:"notes",title:messages.message("title"),Component:function Notes(){return null;}}); + return function(){}; + }}; + })`, + }; + h.plugins.installCatalog("host", [translated], { client: h.client, audio }); + const [before] = h.plugins.getSnapshot(); + expect(before.locale).toBe("en"); + expect( + before.i18n.resolve( + before.surfaces[0].title as import("@getpaseo/plugin/client").PluginText, + before.locale, + ), + ).toBe("Notes"); + locale = "zh-CN"; + for (const listener of listeners) listener(); + const [after] = h.plugins.getSnapshot(); + expect(after.locale).toBe("zh-CN"); + expect( + after.i18n.resolve( + after.surfaces[0].title as import("@getpaseo/plugin/client").PluginText, + after.locale, + ), + ).toBe("笔记"); + expect(after.lifetime).toBe(before.lifetime); + expect(after.queryClient).toBe(before.queryClient); + expect(after.paseo).toBe(before.paseo); + expect(after.surfaces[0].Component).toBe(before.surfaces[0].Component); + expect(h.open).toEqual(new Set(["notes"])); + h.plugins.installCatalog("host", [translated], { client: h.client, audio }); + expect(h.plugins.getSnapshot()[0].i18n).toBe(before.i18n); + h.plugins.removeHost("host"); + expect(listeners.size).toBe(0); + await expect.poll(() => h.open.size).toBe(0); +}); diff --git a/packages/app/src/plugins/registry-requirements.test.ts b/packages/app/src/plugins/registry-requirements.test.ts index eb141e94c2..f348811393 100644 --- a/packages/app/src/plugins/registry-requirements.test.ts +++ b/packages/app/src/plugins/registry-requirements.test.ts @@ -11,6 +11,7 @@ function registry(version: string) { let starts = 0; let cleanups = 0; const result = new PluginRegistry({ + localeSource: { getLocale: () => "en", subscribe: () => () => {} }, version, createRuntime(installation) { starts++; @@ -25,6 +26,7 @@ function registry(version: string) { installation.lifetime.signal, ), paseo: installation.paseo, + i18n: installation.i18n, rpc: async () => { throw new Error("No RPC in this plugin"); }, @@ -99,3 +101,23 @@ it("unloads on a requirement-only edit and recovers after correction", () => { expect(result.getSnapshot().map(({ id }) => id)).toEqual(["example"]); expect(result.getEvaluationError("host", "example")).toBeUndefined(); }); + +it("rejects the unreleased translation API on 0.12.0 before evaluating its imports", () => { + const { result, starts } = registry("0.12.0"); + result.installCatalog( + "host", + [ + { + id: "translated", + requirements: { paseo: ">=0.12.1" }, + clientBundle: `(function(require) { require("@getpaseo/plugin/client").defineTranslations({ resources: { en: { title: "Notes" } } }); throw new Error("unsupported API evaluated"); })`, + }, + ], + { client, audio }, + ); + expect(starts()).toBe(0); + expect(result.getSnapshot()).toEqual([]); + expect(result.getEvaluationError("host", "translated")).toBe( + 'Plugin "translated" requires Paseo >=0.12.1. Your app is 0.12.0. Use a compatible plugin version or update the app.', + ); +}); diff --git a/packages/app/src/plugins/registry.ts b/packages/app/src/plugins/registry.ts index e349446bfe..1ed9621220 100644 --- a/packages/app/src/plugins/registry.ts +++ b/packages/app/src/plugins/registry.ts @@ -1,3 +1,5 @@ +import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host"; +import { i18n } from "@/i18n/i18next"; import type { AudioEngine } from "@/audio"; import { useMemo, useSyncExternalStore } from "react"; import { QueryClient } from "@tanstack/react-query"; @@ -20,6 +22,7 @@ export class PluginRegistry { constructor( private readonly dependencies: { + localeSource: import("@getpaseo/plugin/client/host").PluginLocaleSource; version: string | null; createRuntime: typeof createPluginClientRuntime; }, @@ -81,6 +84,8 @@ export class PluginRegistry { const client = options.client; installation = { lifetime: new AbortController(), + locale: this.dependencies.localeSource.getLocale(), + i18n: new PluginTranslationRuntime(this.dependencies.localeSource), paseo: createPaseoApi(client), invoke: (method, input) => client.invokePluginRpc(entry.id, method, input), id: entry.id, @@ -101,6 +106,10 @@ export class PluginRegistry { timelineTransformers: [], timelineRenderers: [], }; + const unsubscribeTranslations = installation.i18n.subscribe(() => this.publish()); + installation.lifetime.signal.addEventListener("abort", unsubscribeTranslations, { + once: true, + }); const runtime = this.dependencies.createRuntime(installation, options.audio); const evaluated = runPluginClientBundle(entry.id, entry.clientBundle, runtime, () => this.publish(), @@ -120,6 +129,7 @@ export class PluginRegistry { return [installation]; } catch (error) { installation?.lifetime.abort(); + installation?.i18n.dispose(); void installation?.paseo .dispose() .catch((failure) => console.warn(`[Plugins] API cleanup failed for ${key}`, failure)); @@ -160,6 +170,7 @@ export class PluginRegistry { if (this.disposed.has(plugin)) return; this.disposed.add(plugin); plugin.lifetime.abort(); + plugin.i18n.dispose(); plugin.queryClient.clear(); try { void Promise.resolve(plugin.cleanup()).catch((error) => { @@ -173,6 +184,9 @@ export class PluginRegistry { private publish(): void { this.snapshot = [...this.byHost.values()] .flat() + // A new presentation snapshot invalidates label selectors without replacing the installation lifetime. + // eslint-disable-next-line no-map-spread + .map((plugin) => ({ ...plugin, locale: plugin.i18n.locale })) .sort((left, right) => `${left.serverId}/${left.id}`.localeCompare(`${right.serverId}/${right.id}`), ); @@ -181,6 +195,15 @@ export class PluginRegistry { } export const pluginRegistry = new PluginRegistry({ + localeSource: { + getLocale: () => i18n.resolvedLanguage ?? i18n.language, + subscribe: (listener) => { + i18n.on("languageChanged", listener); + return () => { + i18n.off("languageChanged", listener); + }; + }, + }, version: resolveAppVersion(), createRuntime: createPluginClientRuntime, }); diff --git a/packages/app/src/plugins/settings/index.tsx b/packages/app/src/plugins/settings/index.tsx index 0fda205671..3399f241ac 100644 --- a/packages/app/src/plugins/settings/index.tsx +++ b/packages/app/src/plugins/settings/index.tsx @@ -57,7 +57,7 @@ export function PluginSettingsMenuItems({ serverId={serverId} pluginId={pluginId} screenId={screen.id} - title={screen.title} + title={plugin.i18n.resolve(screen.title, plugin.locale)} disabled={disabled} /> ))} diff --git a/packages/app/src/plugins/sidebar-groups.test.ts b/packages/app/src/plugins/sidebar-groups.test.ts index 314e5fe1e7..33b1d2777e 100644 --- a/packages/app/src/plugins/sidebar-groups.test.ts +++ b/packages/app/src/plugins/sidebar-groups.test.ts @@ -1,3 +1,4 @@ +import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host"; import { QueryClient } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; import type { InstalledPlugin } from "./types"; @@ -20,6 +21,8 @@ function installed( serverId, clientBundle: serverId, lifetime: new AbortController(), + locale: "en", + i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }), queryClient: new QueryClient(), paseo: {} as InstalledPlugin["paseo"], invoke: async () => undefined, diff --git a/packages/app/src/plugins/sidebar-groups.ts b/packages/app/src/plugins/sidebar-groups.ts index c764a53dcc..a7d7da8e52 100644 --- a/packages/app/src/plugins/sidebar-groups.ts +++ b/packages/app/src/plugins/sidebar-groups.ts @@ -63,7 +63,7 @@ export function groupPluginSidebarItems( key, pluginId: plugin.id, contributionId: item.id, - title: item.title, + title: plugin.i18n.resolve(item.title, plugin.locale), icon: item.icon, targets: [{ plugin, item }], }); @@ -79,7 +79,7 @@ export function groupPluginSidebarItems( key, pluginId: plugin.id, contributionId: item.id, - title: item.title, + title: plugin.i18n.resolve(item.title, plugin.locale), targets: [{ plugin, item }], }); } diff --git a/packages/app/src/plugins/sidebar-items/index.tsx b/packages/app/src/plugins/sidebar-items/index.tsx index 11657c5d35..b77d1d8593 100644 --- a/packages/app/src/plugins/sidebar-items/index.tsx +++ b/packages/app/src/plugins/sidebar-items/index.tsx @@ -205,7 +205,7 @@ function SidebarItemContent({ const frame = useMemo( () => ({ section, - title: item.title, + title: plugin.i18n.resolve(item.title, plugin.locale), testID: section === "header" ? `plugin-sidebar-${plugin.id}-${item.id}` @@ -214,7 +214,7 @@ function SidebarItemContent({ offerAnchor, releaseAnchor, }), - [anchorTo, item.id, item.title, offerAnchor, plugin.id, releaseAnchor, section], + [anchorTo, item.id, item.title, offerAnchor, plugin, releaseAnchor, section], ); const itemProps = useMemo( () => ({ ...hostProps, currentScreen, openScreen, openPopover }), @@ -228,7 +228,7 @@ function SidebarItemContent({ {Popover ? ( diff --git a/packages/app/src/plugins/surface-contribution.test.ts b/packages/app/src/plugins/surface-contribution.test.ts index 90672ed435..fe4503b640 100644 --- a/packages/app/src/plugins/surface-contribution.test.ts +++ b/packages/app/src/plugins/surface-contribution.test.ts @@ -1,3 +1,4 @@ +import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host"; import { QueryClient } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; import type { InstalledPlugin } from "./types"; @@ -20,6 +21,8 @@ function installation( serverId, clientBundle: serverId, lifetime: new AbortController(), + locale: "en", + i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }), queryClient: new QueryClient(), paseo: {} as InstalledPlugin["paseo"], invoke: async () => undefined, @@ -177,6 +180,8 @@ describe("parsePluginOpenScreenInput", () => { }); describe("resolvePluginScreenTitle", () => { + const resolve = new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }) + .resolve; const Component = () => null; const botNames: Record = { "bot-1": "Bot 1", "bot-2": "Bot 2" }; const bot = { @@ -186,18 +191,18 @@ describe("resolvePluginScreenTitle", () => { }; it("uses a string title as is", () => { - expect(resolvePluginScreenTitle({ id: "deploys", title: "Deploys", Component }, null, {})).toBe( - "Deploys", - ); + expect( + resolvePluginScreenTitle({ id: "deploys", title: "Deploys", Component }, null, {}, resolve), + ).toBe("Deploys"); }); it("derives a function title from the params it is given", () => { - expect(resolvePluginScreenTitle(bot, null, { botId: "bot-2" })).toBe("Bot 2"); - expect(resolvePluginScreenTitle(bot, null, { botId: "bot-1" })).toBe("Bot 1"); + expect(resolvePluginScreenTitle(bot, null, { botId: "bot-2" }, resolve)).toBe("Bot 2"); + expect(resolvePluginScreenTitle(bot, null, { botId: "bot-1" }, resolve)).toBe("Bot 1"); }); it("falls back to the screen id when the function returns empty or throws", () => { - expect(resolvePluginScreenTitle(bot, null, { botId: "missing" })).toBe("bot"); + expect(resolvePluginScreenTitle(bot, null, { botId: "missing" }, resolve)).toBe("bot"); const throwing = { id: "bot", title: () => { @@ -205,13 +210,13 @@ describe("resolvePluginScreenTitle", () => { }, Component, }; - expect(resolvePluginScreenTitle(throwing, null, {})).toBe("bot"); + expect(resolvePluginScreenTitle(throwing, null, {}, resolve)).toBe("bot"); }); it("uses the title of a legacy item that points at the screen", () => { const legacy = { id: "entry", title: "Legacy entry", icon: "Server", surface: "main" }; - expect(resolvePluginScreenTitle({ id: "main", title: "main", Component }, legacy, {})).toBe( - "Legacy entry", - ); + expect( + resolvePluginScreenTitle({ id: "main", title: "main", Component }, legacy, {}, resolve), + ).toBe("Legacy entry"); }); }); diff --git a/packages/app/src/plugins/surface-contribution.ts b/packages/app/src/plugins/surface-contribution.ts index 830677f164..a226e1f55c 100644 --- a/packages/app/src/plugins/surface-contribution.ts +++ b/packages/app/src/plugins/surface-contribution.ts @@ -42,11 +42,12 @@ export function resolvePluginScreenTitle( screen: InstalledPlugin["surfaces"][number], legacySidebarItem: InstalledPlugin["legacySidebarItems"][number] | null, params: PluginScreenParams, + resolve: (text: import("@getpaseo/plugin/client").PluginText) => string, ): string { - if (legacySidebarItem) return legacySidebarItem.title; - if (typeof screen.title === "string") return screen.title; + if (legacySidebarItem) return resolve(legacySidebarItem.title); + if (typeof screen.title !== "function") return resolve(screen.title); try { - const title = screen.title(params); + const title = resolve(screen.title(params)); if (typeof title === "string" && title.trim()) return title.trim(); } catch (error) { console.warn(`[Plugins] Screen ${screen.id} title failed`, error); diff --git a/packages/app/src/plugins/surface-screen.tsx b/packages/app/src/plugins/surface-screen.tsx index 378965cd8f..81bf01879e 100644 --- a/packages/app/src/plugins/surface-screen.tsx +++ b/packages/app/src/plugins/surface-screen.tsx @@ -155,13 +155,14 @@ export function PluginSurfaceScreen() { const serverIds = getPluginSurfaceContributionServerIds(installations, pluginId, identity); return hosts.filter((candidate) => serverIds.includes(candidate.serverId)); }, [hosts, identity, installations, pluginId]); - const title = useMemo( - () => - surface - ? resolvePluginScreenTitle(surface, sidebarItem, params) - : (sidebarItem?.title ?? (pluginId || "Plugin")), - [params, pluginId, sidebarItem, surface], - ); + const title = useMemo(() => { + if (surface && plugin) + return resolvePluginScreenTitle(surface, sidebarItem, params, (text) => + plugin.i18n.resolve(text, plugin.locale), + ); + if (sidebarItem && plugin) return plugin.i18n.resolve(sidebarItem.title, plugin.locale); + return pluginId || "Plugin"; + }, [params, plugin, pluginId, sidebarItem, surface]); const Icon = sidebarItem ? resolvePluginIcon(sidebarItem.icon) : null; const close = useCallback(() => { if (router.canGoBack()) router.back(); diff --git a/packages/app/src/plugins/theme.test.ts b/packages/app/src/plugins/theme.test.ts index c1548d0521..44549e9d58 100644 --- a/packages/app/src/plugins/theme.test.ts +++ b/packages/app/src/plugins/theme.test.ts @@ -1,3 +1,4 @@ +import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host"; import { QueryClient } from "@tanstack/react-query"; import type { PluginThemeContribution } from "@getpaseo/plugin"; import { describe, expect, it } from "vitest"; @@ -50,6 +51,8 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install serverId, clientBundle: serverId, lifetime: new AbortController(), + locale: "en", + i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }), queryClient: new QueryClient(), paseo: {} as InstalledPlugin["paseo"], invoke: async () => undefined, diff --git a/packages/app/src/plugins/themes/index.ts b/packages/app/src/plugins/themes/index.ts index 5a4aab4e3d..fdebdfb52b 100644 --- a/packages/app/src/plugins/themes/index.ts +++ b/packages/app/src/plugins/themes/index.ts @@ -1,6 +1,8 @@ +import type { PluginMessage } from "@getpaseo/plugin/client"; +import { isPluginMessage } from "@getpaseo/plugin/client/host"; import { useMemo } from "react"; import { z } from "zod"; -import type { PluginThemeContribution } from "@getpaseo/plugin"; +import type { PluginThemeContribution } from "../types"; import { useHostFeatureMap } from "@/runtime/host-features"; import { buildDarkSemanticColors, @@ -24,7 +26,7 @@ const hexColorSchema = z const contributionSchema: z.ZodType = z.strictObject({ id: z.string(), - name: z.string().trim().min(1).max(60), + name: z.union([z.string().trim().min(1).max(60), z.custom(isPluginMessage)]), appearance: z.enum(["light", "dark"]), colors: z.strictObject({ background: hexColorSchema, @@ -49,6 +51,7 @@ export interface PluginThemeOption { interface PluginThemeTarget { serverId: string; contribution: PluginThemeContribution; + plugin: InstalledPlugin; } export function parsePluginThemeContribution(value: unknown): PluginThemeContribution { @@ -133,7 +136,7 @@ export function collectPluginThemes( if (!supportedHosts.has(plugin.serverId)) continue; for (const contribution of plugin.themes) { const id = `${plugin.id}/theme/${contribution.id}`; - const target = { serverId: plugin.serverId, contribution }; + const target = { serverId: plugin.serverId, contribution, plugin }; const targets = targetsById.get(id); if (targets) targets.push(target); else targetsById.set(id, [target]); @@ -145,7 +148,7 @@ export function collectPluginThemes( return { id, serverId: target.serverId, - name: target.contribution.name, + name: target.plugin.i18n.resolve(target.contribution.name, target.plugin.locale), swatch: target.contribution.colors.background, theme: buildPluginTheme(target.contribution), }; diff --git a/packages/app/src/plugins/timeline/model.test.ts b/packages/app/src/plugins/timeline/model.test.ts index c64e498333..a93cfe34b5 100644 --- a/packages/app/src/plugins/timeline/model.test.ts +++ b/packages/app/src/plugins/timeline/model.test.ts @@ -1,3 +1,4 @@ +import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host"; import { QueryClient } from "@tanstack/react-query"; import { describe, expect, it, vi } from "vitest"; import type { InstalledPlugin } from "../types"; @@ -12,6 +13,8 @@ function plugin(input: { serverId: "host-1", clientBundle: "bundle", lifetime: new AbortController(), + locale: "en", + i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }), queryClient: new QueryClient(), paseo: {} as InstalledPlugin["paseo"], invoke: async () => undefined, diff --git a/packages/app/src/plugins/types.ts b/packages/app/src/plugins/types.ts index bab295d623..675281a270 100644 --- a/packages/app/src/plugins/types.ts +++ b/packages/app/src/plugins/types.ts @@ -1,25 +1,37 @@ +import type { PluginText } from "@getpaseo/plugin/client"; import type { PaseoApi } from "@getpaseo/client"; import type { QueryClient } from "@tanstack/react-query"; import type { PluginRequirements } from "@getpaseo/protocol/messages"; import type { - PluginAttachmentSourceContribution, + PluginAttachmentSourceContribution as SDKPluginAttachmentSourceContribution, PluginCleanup, - PluginThemeContribution, + PluginThemeContribution as SDKPluginThemeContribution, } from "@getpaseo/plugin"; import type { - PluginCommandCenterItemContribution, - PluginClientSlashCommandContribution, - PluginComposerPillContribution, - PluginSidebarContribution, - PluginSidebarItemContribution, - PluginScreenContribution, - PluginSettingsScreenContribution, + PluginCommandCenterItemContribution as SDKPluginCommandCenterItemContribution, + PluginClientSlashCommandContribution as SDKPluginClientSlashCommandContribution, + PluginComposerPillContribution as SDKPluginComposerPillContribution, + PluginSidebarContribution as SDKPluginSidebarContribution, + PluginSidebarItemContribution as SDKPluginSidebarItemContribution, + PluginScreenContribution as SDKPluginScreenContribution, + PluginSettingsScreenContribution as SDKPluginSettingsScreenContribution, PluginTimelineRendererContribution, PluginTimelineTransformerContribution, PluginPanelLocation, - PluginWorkspacePanelContribution, + PluginWorkspacePanelContribution as SDKPluginWorkspacePanelContribution, } from "@getpaseo/plugin/client"; +type PluginAttachmentSourceContribution = SDKPluginAttachmentSourceContribution; +type PluginThemeContribution = SDKPluginThemeContribution; +type PluginCommandCenterItemContribution = SDKPluginCommandCenterItemContribution; +type PluginClientSlashCommandContribution = SDKPluginClientSlashCommandContribution; +type PluginComposerPillContribution = SDKPluginComposerPillContribution; +type PluginSidebarContribution = SDKPluginSidebarContribution; +type PluginSidebarItemContribution = SDKPluginSidebarItemContribution; +type PluginScreenContribution = SDKPluginScreenContribution; +type PluginSettingsScreenContribution = SDKPluginSettingsScreenContribution; +type PluginWorkspacePanelContribution = SDKPluginWorkspacePanelContribution; + export type PluginSidebarSection = "header" | "footer"; export type EvaluatedPluginWorkspacePanelContribution = PluginWorkspacePanelContribution & { @@ -46,6 +58,9 @@ export interface EvaluatedPlugin { export interface InstalledPlugin extends EvaluatedPlugin { lifetime: AbortController; + /** Immutable presentation locale, refreshed without replacing the installation lifetime. */ + locale: string; + i18n: import("@getpaseo/plugin/client/host").PluginTranslationRuntime; /** The plugin's one Paseo client, `usePaseo()` in every surface; disposed at teardown. */ paseo: PaseoApi; /** Calls one of the plugin's server RPC methods on its host. */ diff --git a/packages/app/src/plugins/workspace-panels/locations.ts b/packages/app/src/plugins/workspace-panels/locations.ts index 5800aecf72..8abd52c801 100644 --- a/packages/app/src/plugins/workspace-panels/locations.ts +++ b/packages/app/src/plugins/workspace-panels/locations.ts @@ -1,7 +1,5 @@ -import type { - PluginPanelLocation, - PluginWorkspacePanelContribution, -} from "@getpaseo/plugin/client"; +import type { PluginWorkspacePanelContribution } from "../types"; +import type { PluginPanelLocation } from "@getpaseo/plugin/client"; import type { PaneHost } from "@/panels/panel-manifest"; import { panelSupportsHost } from "@/panels/panel-manifest"; import type { PluginWorkspaceTabTarget, WorkspaceTabTarget } from "@/workspace-tabs/model"; diff --git a/packages/app/src/plugins/workspace-panels/panel.tsx b/packages/app/src/plugins/workspace-panels/panel.tsx index 998f7e90ff..6972b113d8 100644 --- a/packages/app/src/plugins/workspace-panels/panel.tsx +++ b/packages/app/src/plugins/workspace-panels/panel.tsx @@ -140,9 +140,9 @@ function usePluginPanelDescriptor( }; } return { - label: panel.title, + label: plugin!.i18n.resolve(panel.title, plugin!.locale), subtitle: target.pluginId, - tooltip: panel.title, + tooltip: plugin!.i18n.resolve(panel.title, plugin!.locale), titleState: "ready", icon: resolvePluginIcon(panel.icon), statusBucket: null, diff --git a/packages/app/src/plugins/workspace-panels/resolution.test.ts b/packages/app/src/plugins/workspace-panels/resolution.test.ts index a7e46de422..db4bd86785 100644 --- a/packages/app/src/plugins/workspace-panels/resolution.test.ts +++ b/packages/app/src/plugins/workspace-panels/resolution.test.ts @@ -1,3 +1,4 @@ +import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host"; import { QueryClient } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; import type { InstalledPlugin } from "../types"; @@ -9,6 +10,8 @@ function installed(): InstalledPlugin { serverId: "host-1", clientBundle: "bundle", lifetime: new AbortController(), + locale: "en", + i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }), queryClient: new QueryClient(), paseo: {} as InstalledPlugin["paseo"], invoke: async () => undefined, diff --git a/packages/app/src/plugins/workspace-panels/resolution.ts b/packages/app/src/plugins/workspace-panels/resolution.ts index 7a9355e72a..4c3af57c35 100644 --- a/packages/app/src/plugins/workspace-panels/resolution.ts +++ b/packages/app/src/plugins/workspace-panels/resolution.ts @@ -1,4 +1,4 @@ -import type { PluginWorkspacePanelContribution } from "@getpaseo/plugin/client"; +import type { PluginWorkspacePanelContribution } from "../types"; import type { InstalledPlugin } from "../types"; import type { PluginWorkspaceTabTarget } from "@/workspace-tabs/model"; diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 31f5058940..58761e167e 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -1328,10 +1328,11 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti const installedPlugins = useInstalledPlugins(); const detailTitle = ((): string | undefined => { 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 `${view.pluginId} · ${screen?.title ?? t("settings.title")}`; + const plugin = installedPlugins.find( + (candidate) => candidate.serverId === view.serverId && candidate.id === view.pluginId, + ); + const screen = plugin?.settingsScreens.find((candidate) => candidate.id === view.screenId); + return `${view.pluginId} · ${plugin && screen ? plugin.i18n.resolve(screen.title, plugin.locale) : t("settings.title")}`; } if (view.kind === "host") { const item = HOST_SECTION_ITEMS.find((s) => s.id === view.section); diff --git a/packages/app/src/workspace-tabs/launcher/index.tsx b/packages/app/src/workspace-tabs/launcher/index.tsx index ea983b4bd8..e3e94977b7 100644 --- a/packages/app/src/workspace-tabs/launcher/index.tsx +++ b/packages/app/src/workspace-tabs/launcher/index.tsx @@ -211,7 +211,7 @@ export function useWorkspaceTabLaunchCatalog(input: { }; pluginItems.push({ id: `plugin:${plugin.id}:${panel.id}`, - label: panel.title, + label: plugin.i18n.resolve(panel.title, plugin.locale), Icon: resolvePluginIcon(panel.icon), disabled: false, panelKind: "plugin", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index e04c383488..2ff6be23c4 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -67,6 +67,7 @@ }, "dependencies": { "@agentclientprotocol/sdk": "^1.4.0", + "i18next": "^26.3.0", "use-sync-external-store": "^1.6.0" }, "devDependencies": { diff --git a/packages/plugin/src/attachments.ts b/packages/plugin/src/attachments.ts index 27ca706dd7..0997076254 100644 --- a/packages/plugin/src/attachments.ts +++ b/packages/plugin/src/attachments.ts @@ -1,9 +1,10 @@ +import type { PluginText } from "./text.js"; import { z } from "zod"; import type { PluginAttachmentSourceContribution } from "./contracts.js"; -export function defineAttachmentSource( - definition: Definition, -): Definition { +export function defineAttachmentSource< + Definition extends PluginAttachmentSourceContribution, +>(definition: Definition): Definition { return definition; } diff --git a/packages/plugin/src/boundaries.test.ts b/packages/plugin/src/boundaries.test.ts index 5b1ce4f3dc..0660981d3b 100644 --- a/packages/plugin/src/boundaries.test.ts +++ b/packages/plugin/src/boundaries.test.ts @@ -24,7 +24,8 @@ const clientModule = /^client\//; function resolveLocal(importer: string, specifier: string): string { const base = path.resolve(path.dirname(importer), specifier.replace(/\.js$/, "")); - const result = [base + ".ts", base + ".tsx"].find(existsSync); + const candidates = specifier.endsWith(".json") ? [base] : [base + ".ts", base + ".tsx"]; + const result = candidates.find(existsSync); if (!result) throw new Error(`Cannot resolve ${specifier} from ${importer}`); return result; } diff --git a/packages/plugin/src/client/buttons.ts b/packages/plugin/src/client/buttons.ts index f5272e7ae4..d79e555851 100644 --- a/packages/plugin/src/client/buttons.ts +++ b/packages/plugin/src/client/buttons.ts @@ -1,57 +1,53 @@ +import type { PluginText } from "../text.js"; import type { ComponentType } from "react"; import type { PluginHostProps } from "./contracts.js"; export type PluginButtonContext = | { context: "workspace"; workspaceId: string } | { context: "agent"; workspaceId: string; agentId: string }; - export type PluginButtonIconProps = PluginHostProps & PluginButtonContext & { size: number; color: string }; - export type PluginButtonContentProps = PluginHostProps & PluginButtonContext & { close(): void }; - export type PluginButtonIcon = string | ComponentType; -export type PluginButtonBehavior = +export type PluginButtonBehavior = | { kind: "action"; onPress(): void | Promise } - | { kind: "menu"; items: readonly PluginButtonMenuEntry[] } + | { kind: "menu"; items: readonly PluginButtonMenuEntry[] } | { kind: "popover"; Content: ComponentType }; - -export type PluginButtonMenuEntry = +export type PluginButtonMenuEntry = | { kind: "separator"; id: string } | { kind: "item"; id: string; - title: string; + title: Text; icon?: PluginButtonIcon; visible?: boolean; disabled?: boolean; - behavior: PluginButtonBehavior; + behavior: PluginButtonBehavior; }; -export interface PluginButton { - title: string; +export interface PluginButton { + title: Text; icon: PluginButtonIcon; /** Omit for an icon-only header button. Composer pills use title when omitted. */ - label?: string; + label?: Text; visible?: boolean; disabled?: boolean; - behavior: PluginButtonBehavior; + behavior: PluginButtonBehavior; } - -export interface PluginButtonRegistration { +export interface PluginButtonRegistration { /** Updates presentation in place. Supply a complete behavior to replace it. */ - update(patch: Partial): void; + update(patch: Partial>): void; /** Idempotent. Updates after removal do nothing. */ remove(): void; } - -export interface PluginHeaderButtonContribution { +export interface PluginHeaderButtonContribution { id: string; workspaceId: string; - button: PluginButton; + button: PluginButton; } - -export interface PluginComposerPillContribution extends PluginHeaderButtonContribution { +export interface PluginComposerPillContribution< + Text extends PluginText = string, +> extends PluginHeaderButtonContribution { agentId: string; } diff --git a/packages/plugin/src/client/contracts.ts b/packages/plugin/src/client/contracts.ts index 5bc82d8a58..5ec7bf6f27 100644 --- a/packages/plugin/src/client/contracts.ts +++ b/packages/plugin/src/client/contracts.ts @@ -1,3 +1,4 @@ +import type { PluginText } from "../text.js"; import type { ComponentType } from "react"; import type { PaseoApi } from "@getpaseo/client"; import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types"; @@ -80,10 +81,10 @@ export interface PluginSidebarItemProps extends PluginHostProps { openPopover(Content: ComponentType): void; } -export interface PluginSidebarItemContribution { +export interface PluginSidebarItemContribution { id: string; /** Settings row label, accessibility label, and default label for SidebarRow. */ - title: string; + title: Text; Component: ComponentType; } @@ -99,9 +100,9 @@ export interface PluginOpenPanelOptions { location?: PluginPanelLocation; } -interface PluginWorkspacePanelBase { +interface PluginWorkspacePanelBase { id: string; - title: string; + title: Text; icon: string; locations?: readonly PluginPanelLocation[]; } @@ -127,23 +128,30 @@ interface PluginClientContextAliases { /** @deprecated Use `addScreen`. */ addSurface(id: string, Component: ComponentType): PluginCleanup; /** @deprecated Use `addSidebarHeaderItem`. */ - addSidebarItem(contribution: PluginSidebarContribution): PluginCleanup; + addSidebarItem(contribution: PluginSidebarContribution): PluginCleanup; } export interface PluginClientContext extends PluginCommandCapabilities, PluginClientContextAliases { + readonly i18n: import("./translations.js").PluginI18n; /** Play a base64-encoded audio file on this client; resolves when playback ends. */ playAudio(source: { base64: string; mimeType: string }): Promise; - addSettingsScreen(contribution: PluginSettingsScreenContribution): PluginCleanup; - addScreen(contribution: PluginScreenContribution): PluginCleanup; - addSidebarHeaderItem(contribution: PluginSidebarItemContribution): PluginCleanup; - addSidebarFooterItem(contribution: PluginSidebarItemContribution): PluginCleanup; - addWorkspacePanel(contribution: PluginWorkspacePanelContribution): PluginCleanup; - addCommandCenterItem(contribution: PluginCommandCenterItemContribution): PluginCleanup; - addSlashCommand(contribution: PluginClientSlashCommandContribution): PluginCleanup; - addHeaderButton(contribution: PluginHeaderButtonContribution): PluginButtonRegistration; - addComposerPill(contribution: PluginComposerPillContribution): PluginButtonRegistration; - addAttachmentSource(contribution: PluginAttachmentSourceContribution): PluginCleanup; - addTheme(contribution: PluginThemeContribution): PluginCleanup; + addSettingsScreen(contribution: PluginSettingsScreenContribution): PluginCleanup; + addScreen(contribution: PluginScreenContribution): PluginCleanup; + addSidebarHeaderItem(contribution: PluginSidebarItemContribution): PluginCleanup; + addSidebarFooterItem(contribution: PluginSidebarItemContribution): PluginCleanup; + addWorkspacePanel(contribution: PluginWorkspacePanelContribution): PluginCleanup; + addCommandCenterItem( + contribution: PluginCommandCenterItemContribution, + ): PluginCleanup; + addSlashCommand(contribution: PluginClientSlashCommandContribution): PluginCleanup; + addHeaderButton( + contribution: PluginHeaderButtonContribution, + ): PluginButtonRegistration; + addComposerPill( + contribution: PluginComposerPillContribution, + ): PluginButtonRegistration; + addAttachmentSource(contribution: PluginAttachmentSourceContribution): PluginCleanup; + addTheme(contribution: PluginThemeContribution): PluginCleanup; addTimelineTransformer( contribution: PluginTimelineTransformerContribution, ): PluginCleanup; @@ -155,29 +163,31 @@ export interface PluginClientContext extends PluginCommandCapabilities, PluginCl export type PluginClientContribution = (client: PluginClientContext) => PluginCleanup; -export type PluginWorkspacePanelContribution = - | (PluginWorkspacePanelBase & { +export type PluginWorkspacePanelContribution = + | (PluginWorkspacePanelBase & { context: "workspace"; Component: ComponentType; }) - | (PluginWorkspacePanelBase & { + | (PluginWorkspacePanelBase & { context: "agent"; Component: ComponentType; }); -export interface PluginSettingsScreenContribution { +export interface PluginSettingsScreenContribution { id: string; - title: string; + title: Text; icon: string; Component: ComponentType; } /** The screen header's title: fixed, or derived from the params the screen was opened with. */ -export type PluginScreenTitle = string | ((params: PluginScreenParams) => string); +export type PluginScreenTitle = + | Text + | ((params: PluginScreenParams) => Text); -export interface PluginScreenContribution { +export interface PluginScreenContribution { id: string; - title: PluginScreenTitle; + title: PluginScreenTitle; Component: ComponentType; } @@ -187,9 +197,9 @@ export interface PluginSurfaceContribution { } /** @deprecated Use `PluginSidebarItemContribution` with `addSidebarHeaderItem`. */ -export interface PluginSidebarContribution { +export interface PluginSidebarContribution { id: string; - title: string; + title: Text; icon: string; surface: string; } @@ -256,39 +266,39 @@ export interface PluginAgentCommandContext extends PluginCommandCapabilities { openPanel(id: string, options?: PluginOpenPanelOptions): void; } -interface PluginCommandCenterItemBase { +interface PluginCommandCenterItemBase { id: string; - title: string; + title: Text; icon: string; keywords?: readonly string[]; } -export type PluginCommandCenterItemContribution = - | (PluginCommandCenterItemBase & { +export type PluginCommandCenterItemContribution = + | (PluginCommandCenterItemBase & { context: "global"; onSelect(context: PluginGlobalCommandContext): void | Promise; }) - | (PluginCommandCenterItemBase & { + | (PluginCommandCenterItemBase & { context: "workspace"; onSelect(context: PluginWorkspaceCommandContext): void | Promise; }) - | (PluginCommandCenterItemBase & { + | (PluginCommandCenterItemBase & { context: "agent"; onSelect(context: PluginAgentCommandContext): void | Promise; }); -interface PluginClientSlashCommandBase { +interface PluginClientSlashCommandBase { name: string; - description: string; - argumentHint: string; + description: Text; + argumentHint: Text; } -export type PluginClientSlashCommandContribution = - | (PluginClientSlashCommandBase & { +export type PluginClientSlashCommandContribution = + | (PluginClientSlashCommandBase & { context: "workspace"; onSubmit(context: PluginWorkspaceCommandContext & { args: string }): void | Promise; }) - | (PluginClientSlashCommandBase & { + | (PluginClientSlashCommandBase & { context: "agent"; onSubmit(context: PluginAgentCommandContext & { args: string }): void | Promise; }); diff --git a/packages/plugin/src/client/host.ts b/packages/plugin/src/client/host.ts index 6cd42863e5..987f047109 100644 --- a/packages/plugin/src/client/host.ts +++ b/packages/plugin/src/client/host.ts @@ -1,3 +1,4 @@ +import type { PluginText } from "../text.js"; import { PluginAttachmentSearchPayloadSchema } from "../attachments.js"; export { PluginClientStateProvider, type PluginClientStateSource } from "./client-state.js"; export { @@ -10,7 +11,7 @@ import { PaseoApiProvider } from "./paseo-context.js"; import { callPluginRpc } from "../rpc.js"; export async function searchPluginAttachments( - source: PluginAttachmentSourceContribution, + source: PluginAttachmentSourceContribution, invoke: (method: string, input: unknown) => Promise, query: string, ) { @@ -19,3 +20,12 @@ export async function searchPluginAttachments( } export { callPluginRpc, PaseoApiProvider, PluginRpcProvider }; + +export { + PluginTranslationRuntime, + PluginTranslationProvider, + usePluginTranslationRuntime, + requirePluginText, + isPluginMessage, +} from "./translations.js"; +export type { PluginLocaleSource } from "./translations.js"; diff --git a/packages/plugin/src/client/index.ts b/packages/plugin/src/client/index.ts index 810462354f..8879b14a8b 100644 --- a/packages/plugin/src/client/index.ts +++ b/packages/plugin/src/client/index.ts @@ -67,3 +67,7 @@ export declare function useHosts(): readonly PluginHostSummary[]; export declare function getPaseoClient(serverId: string): import("@getpaseo/client").PaseoApi; /** Open an absolute HTTP(S) URL using the client platform’s external opener. */ export declare function openExternalUrl(url: string): Promise; + +export { defineTranslations, useTranslation } from "./translations.js"; +export type { PluginI18n } from "./translations.js"; +export type { PluginText, PluginMessage, PluginTranslations } from "../text.js"; diff --git a/packages/plugin/src/client/runtime-context-bridge.tsx b/packages/plugin/src/client/runtime-context-bridge.tsx index 6f0b303e3b..b18588b32c 100644 --- a/packages/plugin/src/client/runtime-context-bridge.tsx +++ b/packages/plugin/src/client/runtime-context-bridge.tsx @@ -1,3 +1,4 @@ +import { PluginTranslationProvider, usePluginTranslationRuntime } from "./translations.js"; import { useCallback, type ReactNode } from "react"; import { PluginClientStateProvider, usePluginClientStateSource } from "./client-state.js"; import { PaseoApiProvider, usePaseoContextValue } from "./paseo-context.js"; @@ -7,6 +8,7 @@ export type PluginRuntimeContextBridge = (children: ReactNode) => ReactNode; /** Rebuilds plugin runtime contexts inside React Native portal hosts. */ export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge { + const translations = usePluginTranslationRuntime(); const paseo = usePaseoContextValue(); const rpc = usePluginRpcContextValue(); const state = usePluginClientStateSource(); @@ -24,10 +26,12 @@ export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge { ); return ( - {content} + + {content} + ); }, - [paseo, rpc, state], + [paseo, rpc, state, translations], ); } diff --git a/packages/plugin/src/client/translations.test.ts b/packages/plugin/src/client/translations.test.ts new file mode 100644 index 0000000000..d69d021bca --- /dev/null +++ b/packages/plugin/src/client/translations.test.ts @@ -0,0 +1,104 @@ +import { expect, it } from "vitest"; +import { defineTranslations, PluginTranslationRuntime } from "./translations.js"; + +function language(initial = "en") { + let locale = initial; + const listeners = new Set<() => void>(); + return { + getLocale: () => locale, + subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + set(next: string) { + locale = next; + for (const listener of listeners) listener(); + }, + subscriptions: () => listeners.size, + }; +} +const messages = defineTranslations({ + resources: { + en: { + screen: { + title: "Notes", + fallback: "English fallback", + notes_one: "{{count}} note for {{name}}", + notes_other: "{{count}} notes for {{name}}", + }, + }, + "zh-CN": { screen: { title: "笔记", notes_other: "{{name}}的 {{count}} 条笔记" } }, + }, +}); +it("resolves messages, interpolation, plurals and English fallback with the current app locale", () => { + const source = language(); + const runtime = new PluginTranslationRuntime(source); + const title = messages.message("screen.title"); + expect(runtime.resolve(title)).toBe("Notes"); + expect(runtime.t(messages, "screen.notes", { count: 1, name: "Ada" })).toBe("1 note for Ada"); + expect(runtime.t(messages, "screen.notes", { count: 2, name: "Ada" })).toBe("2 notes for Ada"); + source.set("zh-CN"); + expect(runtime.resolve(title)).toBe("笔记"); + expect(runtime.t(messages, "screen.notes", { count: 2, name: "Ada" })).toBe("Ada的 2 条笔记"); + expect(runtime.t(messages, "screen.fallback")).toBe("English fallback"); + expect(runtime.locale).toBe("zh-CN"); + source.set("fr"); + expect(runtime.resolve(title)).toBe("Notes"); + expect(runtime.locale).toBe("fr"); + runtime.dispose(); +}); +it("isolates installations, resources and clients, and unsubscribes on unload", () => { + const first = language("zh-CN"); + const second = language(); + const a = new PluginTranslationRuntime(first); + const b = new PluginTranslationRuntime(second); + const other = defineTranslations({ resources: { en: { screen: { title: "Other plugin" } } } }); + expect(a.t(messages, "screen.title")).toBe("笔记"); + expect(b.t(messages, "screen.title")).toBe("Notes"); + expect(a.t(other, "screen.title")).toBe("Other plugin"); + let updates = 0; + a.subscribe(() => updates++); + first.set("en"); + expect(updates).toBe(1); + expect(first.subscriptions()).toBe(1); + a.dispose(); + a.dispose(); + expect(first.subscriptions()).toBe(0); + first.set("zh-CN"); + expect(updates).toBe(1); + expect(b.t(messages, "screen.title")).toBe("Notes"); + b.dispose(); +}); + +it("rejects unknown keys, malformed resources, noncanonical locales and placeholder drift", () => { + expect(() => + defineTranslations({ resources: { en: { title: "Notes" }, fr: { typo: "Notes" } } }), + ).toThrow("Unknown translation key"); + expect(() => + defineTranslations({ resources: { en: { title: "Notes" }, "zh-cn": { title: "笔记" } } }), + ).toThrow("canonical"); + expect(() => + defineTranslations({ + resources: { en: { title: "Hello {{name}}" }, fr: { title: "Bonjour {{other}}" } }, + }), + ).toThrow("placeholders differ"); + expect(() => defineTranslations({ resources: { en: {} } })).toThrow("English messages"); + expect(() => defineTranslations({ resources: { en: { value: 42 } } } as never)).toThrow( + "objects of strings", + ); +}); + +it("uses Intl formatting and reports missing JSON interpolation values", () => { + const source = language("en"); + const runtime = new PluginTranslationRuntime(source); + const en: { amount: string; welcome: string } = { + amount: "{{value, number}}", + welcome: "Hello {{name}}", + }; + const formatted = defineTranslations({ resources: { en } }); + expect(runtime.t(formatted, "amount", { value: 1234.5 })).toBe("1,234.5"); + expect(() => runtime.t(formatted, "welcome")).toThrow("Missing translation value: name"); + runtime.dispose(); +}); diff --git a/packages/plugin/src/client/translations.tsx b/packages/plugin/src/client/translations.tsx new file mode 100644 index 0000000000..e6d5687de4 --- /dev/null +++ b/packages/plugin/src/client/translations.tsx @@ -0,0 +1,202 @@ +import { + createContext, + useCallback, + useContext, + useSyncExternalStore, + type ReactNode, +} from "react"; +import { createInstance, type i18n } from "i18next"; +import type { + PluginMessage, + PluginText, + PluginTranslations, + TranslationKey, + TranslationArguments, + TranslationResource, + TranslationOptions, +} from "../text.js"; + +type Resources = PluginTranslations["resources"]; +interface MessageDefinition { + resources: Resources; + key: string; + options?: TranslationOptions; +} +const messageDefinitions = new WeakMap(); + +export function defineTranslations(definition: { + resources: Readonly> & { en: Resource }; +}): PluginTranslations { + const english = validateResources(definition.resources); + return { + resources: definition.resources, + message: (key, ...args) => { + const source = english.get(key) ?? english.get(`${key}_other`); + if (source === undefined) throw new Error(`Unknown translation key: ${key}`); + const options = args[0]; + for (const name of placeholders(source)) { + if (options?.[name] === undefined) + throw new Error(`Missing translation value: ${name} (${key})`); + } + const message = Object.freeze({}) as PluginMessage; + messageDefinitions.set(message, { resources: definition.resources, key, options }); + return message; + }, + }; +} + +/** App language source. Each installation subscribes for exactly its own lifetime. */ +export interface PluginLocaleSource { + getLocale(): string; + subscribe(listener: () => void): () => void; +} + +export interface PluginI18n { + readonly locale: string; + t>( + messages: PluginTranslations, + key: Key, + ...args: TranslationArguments + ): string; +} + +export class PluginTranslationRuntime implements PluginI18n { + private readonly engines = new Map(); + private readonly listeners = new Set<() => void>(); + private readonly unsubscribe: () => void; + private stopped = false; + constructor(private readonly source: PluginLocaleSource) { + this.unsubscribe = source.subscribe(() => { + for (const listener of this.listeners) listener(); + }); + } + get locale(): string { + return this.source.getLocale(); + } + getSnapshot = (): string => this.locale; + subscribe = (listener: () => void): (() => void) => { + if (this.stopped) throw new Error("Plugin translations have stopped"); + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + t>( + messages: PluginTranslations, + key: Key, + ...args: TranslationArguments + ): string { + return this.resolve(messages.message(key, ...args)); + } + resolve = (text: PluginText, locale = this.locale): string => { + if (typeof text === "string") return text; + if (this.stopped) throw new Error("Plugin translations have stopped"); + const message = messageDefinitions.get(text); + if (!message) throw new Error("Plugin labels must use messages.message()"); + let engine = this.engines.get(message.resources); + if (!engine) { + engine = createInstance(); + void engine.init({ + initAsync: false, + lng: this.locale, + fallbackLng: "en", + resources: Object.fromEntries( + Object.entries(message.resources).map(([language, translation]) => [ + language, + { translation }, + ]), + ), + interpolation: { escapeValue: false }, + returnNull: false, + returnObjects: false, + }); + this.engines.set(message.resources, engine); + } + return engine.t(message.key, { ...message.options, lng: locale }); + }; + dispose(): void { + if (this.stopped) return; + this.stopped = true; + this.unsubscribe(); + this.listeners.clear(); + this.engines.clear(); + } +} + +export function isPluginMessage(value: unknown): value is PluginMessage { + return ( + typeof value === "object" && value !== null && messageDefinitions.has(value as PluginMessage) + ); +} +export function requirePluginText(text: unknown): PluginText { + if (typeof text === "string" && text.trim()) return text.trim(); + if (isPluginMessage(text)) return text; + throw new Error("Plugin label must be a non-empty string or a translation message"); +} + +function flatten(resource: TranslationResource, prefix = ""): Map { + if (!resource || typeof resource !== "object" || Array.isArray(resource)) + throw new Error("Translation resources must be objects of strings"); + const result = new Map(); + for (const [key, value] of Object.entries(resource)) { + if (!key || key.includes(".") || ["__proto__", "constructor", "prototype"].includes(key)) + throw new Error(`Invalid translation key: ${key}`); + const path = prefix ? `${prefix}.${key}` : key; + if (typeof value === "string") result.set(path, value); + else for (const entry of flatten(value, path)) result.set(...entry); + } + return result; +} +function pluralBase(key: string): string { + return key.replace(/_(zero|one|two|few|many|other)$/, ""); +} +function placeholders(value: string): string[] { + return [ + ...new Set([...value.matchAll(/{{-?\s*([^},\s]+)(?:[^}]*)}}/g)].map((match) => match[1])), + ].sort(); +} +function validateResources(resources: Resources): Map { + const english = flatten(resources.en); + if (!english.size) throw new Error("Plugin translations require English messages"); + for (const [locale, resource] of Object.entries(resources)) { + if (Intl.getCanonicalLocales(locale)[0] !== locale) + throw new Error(`Translation locale must be canonical: ${locale}`); + for (const [key, value] of flatten(resource)) { + const source = english.get(key) ?? english.get(`${pluralBase(key)}_other`); + if (source === undefined) throw new Error(`Unknown translation key in ${locale}: ${key}`); + if (placeholders(value).join(",") !== placeholders(source).join(",")) + throw new Error(`Translation placeholders differ in ${locale}: ${key}`); + } + } + return english; +} + +const Context = createContext(null); +export function PluginTranslationProvider({ + runtime, + children, +}: { + runtime: PluginTranslationRuntime; + children: ReactNode; +}) { + return {children}; +} +export function usePluginTranslationRuntime(): PluginTranslationRuntime { + const runtime = useContext(Context); + if (!runtime) throw new Error("Plugin translations must run inside a contributed plugin surface"); + return runtime; +} +export function useTranslation( + messages: PluginTranslations, +) { + const runtime = usePluginTranslationRuntime(); + const locale = useSyncExternalStore(runtime.subscribe, runtime.getSnapshot, runtime.getSnapshot); + const t = useCallback( + >( + key: Key, + ...args: TranslationArguments + ) => runtime.resolve(messages.message(key, ...args), locale), + [runtime, messages, locale], + ); + return { locale, t }; +} diff --git a/packages/plugin/src/contracts.ts b/packages/plugin/src/contracts.ts index 98df0758a6..e44e09c9e7 100644 --- a/packages/plugin/src/contracts.ts +++ b/packages/plugin/src/contracts.ts @@ -1,3 +1,4 @@ +import type { PluginText } from "./text.js"; import type { JsonValue } from "@getpaseo/protocol/agent-types"; import type { PluginRpcContract } from "./rpc.js"; @@ -63,19 +64,19 @@ export interface PluginThemeColors { ring: string; } -export interface PluginThemeContribution { +export interface PluginThemeContribution { id: string; - name: string; + name: Text; appearance: "light" | "dark"; colors: PluginThemeColors; } -export interface PluginAttachmentSourceContribution { +export interface PluginAttachmentSourceContribution { id: string; - title: string; + title: Text; icon: string; - pickerTitle: string; - searchPlaceholder: string; + pickerTitle: Text; + searchPlaceholder: Text; search: PluginRpcContract; } diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 48deede303..a41bca2e5d 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -20,3 +20,5 @@ export { type PluginAttachmentSearchPayload, } from "./attachments.js"; export { defineRpc, type PluginRpcContract, type RpcInput, type RpcOutput } from "./rpc.js"; + +export type { PluginText, PluginMessage } from "./text.js"; diff --git a/packages/plugin/src/skill-examples.test.ts b/packages/plugin/src/skill-examples.test.ts index 3a21f58c33..9ce7053e8e 100644 --- a/packages/plugin/src/skill-examples.test.ts +++ b/packages/plugin/src/skill-examples.test.ts @@ -38,3 +38,77 @@ describe("paseo-plugin skill examples", () => { expect(typeErrors(sectionCode("Add a composer pill"))).toEqual([]); }); }); + +describe("plugin translation author types", () => { + it("checks JSON keys, plural counts, literal interpolation names and imperative translation", () => { + expect( + typeErrors(` + import { defineTranslations, useTranslation, type PluginClientContext } from "@getpaseo/plugin/client"; + import en from "../../translations/client/locales/en.json"; + const json = defineTranslations({ resources: { en } }); + const literal = defineTranslations({ resources: { en: { greeting: "Hello {{name}}", value: "{{amount, number}}" } } }); + function Screen() { + const { t } = useTranslation(json); + t("notes", { count: 2 }); + // @ts-expect-error unknown JSON key + t("missing"); + // @ts-expect-error plural base keys require count + t("notes"); + // @ts-expect-error count is numeric + t("notes", { count: "two" }); + literal.message("greeting", { name: "Ada" }); + // @ts-expect-error inline literal resources infer interpolation names + literal.message("greeting"); + return null; + } + function contribute(client: PluginClientContext) { + client.addScreen({ id: "notes", title: json.message("title"), Component: Screen }); + client.i18n.t(json, "notes", { count: 2 }); + // @ts-expect-error imperative keys are checked + client.i18n.t(json, "missing"); + } + `), + ).toEqual([]); + }); +}); + +it("keeps historical string descriptor helpers while accepting translated registrations", () => { + expect( + typeErrors(` + import { defineTranslations, type PluginText, type PluginClientContext, type PluginScreenContribution, type PluginWorkspacePanelContribution, type PluginSettingsScreenContribution, type PluginSidebarItemContribution, type PluginSidebarContribution, type PluginCommandCenterItemContribution, type PluginClientSlashCommandContribution, type PluginButton, type PluginHeaderButtonContribution, type PluginComposerPillContribution, type PluginButtonMenuEntry } from "@getpaseo/plugin/client"; + import { z } from "zod"; + import { defineAttachmentSource, type PluginThemeContribution, type PluginAttachmentSourceContribution } from "@getpaseo/plugin"; + const messages = defineTranslations({ resources: { en: { title: "Notes" } } }); + const Component = () => null; + function historicalScreen(screen: PluginScreenContribution) { + return typeof screen.title === "function" ? screen.title({}).trim() : screen.title.trim(); + } + function historicalLabels(panel: PluginWorkspacePanelContribution, settings: PluginSettingsScreenContribution, sidebar: PluginSidebarItemContribution, legacy: PluginSidebarContribution, command: PluginCommandCenterItemContribution, slash: PluginClientSlashCommandContribution, button: PluginButton, header: PluginHeaderButtonContribution, pill: PluginComposerPillContribution, menu: PluginButtonMenuEntry, theme: PluginThemeContribution, source: PluginAttachmentSourceContribution) { + panel.title.trim(); settings.title.trim(); sidebar.title.trim(); legacy.title.trim(); command.title.trim(); + slash.description.trim(); slash.argumentHint.trim(); button.title.trim(); button.label?.trim(); + header.button.title.trim(); pill.button.title.trim(); if (menu.kind === "item") menu.title.trim(); + theme.name.trim(); source.title.trim(); source.pickerTitle.trim(); source.searchPlaceholder.trim(); + } + function contribute(client: PluginClientContext) { + const oldScreen: PluginScreenContribution = { id: "old", title: (params) => params.name ?? "Notes", Component }; + historicalScreen(oldScreen); + client.addScreen(oldScreen); + client.addScreen({ id: "translated", title: messages.message("title"), Component }); + client.addScreen({ id: "callback", title: (params) => messages.message("title"), Component }); + const translated: PluginScreenContribution = { id: "typed", title: messages.message("title"), Component }; + client.addScreen(translated); + client.addSidebarHeaderItem({ id: "notes", title: messages.message("title"), Component }); + client.addSettingsScreen({ id: "notes", title: messages.message("title"), icon: "File", Component }); + client.addWorkspacePanel({ id: "notes", title: messages.message("title"), icon: "File", context: "workspace", Component }); + client.addCommandCenterItem({ id: "notes", title: messages.message("title"), icon: "File", context: "global", onSelect() {} }); + client.addSlashCommand({ name: "notes", description: messages.message("title"), argumentHint: "", context: "workspace", onSubmit() {} }); + const button = client.addHeaderButton({ id: "notes", workspaceId: "workspace", button: { title: messages.message("title"), icon: "File", behavior: { kind: "menu", items: [{ kind: "item", id: "open", title: messages.message("title"), behavior: { kind: "action", onPress() {} } }] } } }); + button.update({ label: messages.message("title") }); + const source = defineAttachmentSource({ id: "source", title: messages.message("title"), pickerTitle: messages.message("title"), searchPlaceholder: messages.message("title"), icon: "File", search: { name: "source.search", input: z.object({}), output: z.object({}) } }); + client.addAttachmentSource(source); + // @ts-expect-error a translated reference is never typed as a string + source.title.trim(); + } + `), + ).toEqual([]); +}); diff --git a/packages/plugin/src/text.ts b/packages/plugin/src/text.ts new file mode 100644 index 0000000000..646775ea1c --- /dev/null +++ b/packages/plugin/src/text.ts @@ -0,0 +1,67 @@ +/** A persistent label resolved in the presenting app's language. */ +declare const pluginMessage: unique symbol; +export interface PluginMessage { + readonly [pluginMessage]: true; +} +export type PluginText = string | PluginMessage; +export interface TranslationResource { + readonly [key: string]: string | TranslationResource; +} +export interface TranslationOptions { + readonly count?: number; + readonly [name: string]: unknown; +} +export type TranslationKey = { + [Key in keyof Resource & string]: Resource[Key] extends string + ? + | Key + | (Key extends `${infer Base}_${"zero" | "one" | "two" | "few" | "many" | "other"}` + ? Base + : never) + : Resource[Key] extends TranslationResource + ? `${Key}.${TranslationKey}` + : never; +}[keyof Resource & string]; +export interface PluginTranslations { + readonly resources: Readonly> & { readonly en: Resource }; + message>( + key: Key, + ...args: TranslationArguments + ): PluginMessage; +} + +type Lookup = Key extends `${infer Head}.${infer Tail}` + ? Head extends keyof Resource + ? Lookup + : never + : Key extends keyof Resource + ? Resource[Key] + : never; +type Trim = Value extends ` ${infer Rest}` | `${infer Rest} ` + ? Trim + : Value; +type InterpolationName = + Trim extends `-${infer Rest}` + ? InterpolationName + : Trim extends `${infer Name},${string}` + ? Trim + : Trim; +type InterpolationNames = Value extends string + ? string extends Value + ? never + : Value extends `${string}{{${infer Name}}}${infer Rest}` + ? InterpolationName | InterpolationNames + : never + : never; +type MessageOptions = TranslationOptions & + ([Lookup] extends [never] ? {} : { count: number }) & { + [Name in InterpolationNames | Lookup>]: + | string + | number + | boolean + | Date; + }; +export type TranslationArguments = + {} extends MessageOptions + ? [options?: MessageOptions] + : [options: MessageOptions]; diff --git a/packages/plugin/tsconfig.examples.json b/packages/plugin/tsconfig.examples.json index 59a2b702c8..843dcb6378 100644 --- a/packages/plugin/tsconfig.examples.json +++ b/packages/plugin/tsconfig.examples.json @@ -10,7 +10,8 @@ "esModuleInterop": true, "allowSyntheticDefaultImports": true, "noEmit": true, - "types": ["node"] + "types": ["node"], + "resolveJsonModule": true }, "include": ["../../plugin-examples/**/*.ts", "../../plugin-examples/**/*.tsx"] } diff --git a/plugin-examples/translations/OVERVIEW.md b/plugin-examples/translations/OVERVIEW.md new file mode 100644 index 0000000000..17edf994e7 --- /dev/null +++ b/plugin-examples/translations/OVERVIEW.md @@ -0,0 +1,7 @@ +# Workspace notes + +Keep a draft beside your workspace and preview it in a modal. Switch Paseo between English and Simplified Chinese to see the content, sidebar row, screen header, panel title, and commands follow your language while your draft stays intact. Notes and drafts last while the surface is mounted. + +Open **Workspace notes** in the sidebar or **Open workspace notes** in the Command Center. The panel also appears in workspace and Explorer tab menus. An English-only message shows fallback when Chinese is selected. + +This example uses an unreleased translation API. Its `>=0.12.1` requirement is provisional. Assign the release containing the API before publishing it. Listing translations and this overview are independent of the bundled client JSON resources. diff --git a/plugin-examples/translations/OVERVIEW.zh-CN.md b/plugin-examples/translations/OVERVIEW.zh-CN.md new file mode 100644 index 0000000000..496f05de84 --- /dev/null +++ b/plugin-examples/translations/OVERVIEW.zh-CN.md @@ -0,0 +1,7 @@ +# 工作区笔记 + +在工作区旁保留草稿,并在模态框中预览。在 Paseo 中切换英语与简体中文,内容、侧边栏项、界面标题、面板标题和命令会跟随语言变化,草稿保持不变。笔记和草稿在界面挂载期间保留。 + +在侧边栏打开 **工作区笔记**,或在 Command Center 选择 **打开工作区笔记**。工作区和 Explorer 的标签页菜单也提供此面板。选择中文时,一条仅有英语的消息展示回退行为。 + +此示例使用尚未发布的翻译 API。`>=0.12.1` 版本要求为临时值,发布前须改为实际包含该 API 的版本。列表翻译与本文独立于打包到客户端中的 JSON 资源。 diff --git a/plugin-examples/translations/client/locales/en.json b/plugin-examples/translations/client/locales/en.json new file mode 100644 index 0000000000..91cbc4cf69 --- /dev/null +++ b/plugin-examples/translations/client/locales/en.json @@ -0,0 +1,23 @@ +{ + "title": "Workspace notes", + "intro": "A small space for your next idea.", + "draft": "Your draft", + "placeholder": "What would you like to remember?", + "add": "Add note", + "empty": "No notes yet. Start with a thought above.", + "notes_one": "{{count}} note", + "notes_other": "{{count}} notes", + "preview": "Preview draft", + "close": "Close preview", + "previewTitle": "Draft preview", + "draftEmpty": "Your draft is empty.", + "locale": "App language: {{locale}}", + "fallback": "This English-only message demonstrates fallback.", + "open": "Open workspace notes", + "description": "Open the notes screen", + "hint": "[optional text]", + "copyTitle": "Copy notes title", + "tools": "Notes tools", + "pin": "Show notes tools", + "openPanel": "Open notes panel" +} diff --git a/plugin-examples/translations/client/locales/zh-CN.json b/plugin-examples/translations/client/locales/zh-CN.json new file mode 100644 index 0000000000..02e9ce2f95 --- /dev/null +++ b/plugin-examples/translations/client/locales/zh-CN.json @@ -0,0 +1,21 @@ +{ + "title": "工作区笔记", + "intro": "为下一个灵感留一点空间。", + "draft": "你的草稿", + "placeholder": "有什么想记下来的?", + "add": "添加笔记", + "empty": "暂无笔记。在上方写下你的想法吧。", + "notes_other": "{{count}} 条笔记", + "preview": "预览草稿", + "close": "关闭预览", + "previewTitle": "草稿预览", + "draftEmpty": "草稿为空。", + "locale": "应用语言:{{locale}}", + "open": "打开工作区笔记", + "description": "打开笔记页面", + "hint": "[可选文本]", + "copyTitle": "复制笔记标题", + "tools": "笔记工具", + "pin": "显示笔记工具", + "openPanel": "打开笔记面板" +} diff --git a/plugin-examples/translations/client/messages.ts b/plugin-examples/translations/client/messages.ts new file mode 100644 index 0000000000..ed1fc9f21e --- /dev/null +++ b/plugin-examples/translations/client/messages.ts @@ -0,0 +1,5 @@ +import { defineTranslations } from "@getpaseo/plugin/client"; +import en from "./locales/en.json"; +import zhCN from "./locales/zh-CN.json"; + +export const messages = defineTranslations({ resources: { en, "zh-CN": zhCN } }); diff --git a/plugin-examples/translations/client/notes.tsx b/plugin-examples/translations/client/notes.tsx new file mode 100644 index 0000000000..46e6832d42 --- /dev/null +++ b/plugin-examples/translations/client/notes.tsx @@ -0,0 +1,151 @@ +import { useCallback, useMemo, useState } from "react"; +import { Pressable, StyleSheet, Text, View } from "react-native"; +import { Modal, TextInput, ScrollView } from "@getpaseo/plugin/client/react-native"; +import { useTranslation, type PluginSurfaceProps } from "@getpaseo/plugin/client"; +import { messages } from "./messages"; + +function useStyles(theme: PluginSurfaceProps["theme"], compact: boolean) { + return useMemo(() => { + const primary = { padding: 12, borderRadius: 8, backgroundColor: theme.colors.accent }; + return StyleSheet.create({ + screen: { flex: 1, backgroundColor: theme.colors.surface0 }, + content: { + padding: compact ? 20 : 36, + gap: 20, + width: "100%", + maxWidth: 760, + alignSelf: "center", + }, + heading: { gap: 8 }, + title: { color: theme.colors.foreground, fontSize: 28, fontWeight: "600" }, + text: { color: theme.colors.foreground }, + muted: { color: theme.colors.foregroundMuted }, + label: { color: theme.colors.foreground, fontWeight: "600" }, + card: { + gap: 12, + padding: 20, + borderRadius: 12, + borderWidth: 1, + borderColor: theme.colors.border, + backgroundColor: theme.colors.surface1, + }, + input: { + color: theme.colors.foreground, + minHeight: 112, + padding: 12, + borderRadius: 8, + borderColor: theme.colors.border, + borderWidth: 1, + backgroundColor: theme.colors.surface0, + }, + actions: { flexDirection: "row", gap: 12, flexWrap: "wrap" }, + primary, + primaryDisabled: { ...primary, opacity: 0.5 }, + primaryText: { color: theme.colors.accentForeground }, + secondary: { padding: 12, borderRadius: 8, borderWidth: 1, borderColor: theme.colors.border }, + preview: { gap: 16 }, + close: { padding: 12 }, + closeText: { color: theme.colors.accent }, + }); + }, [theme, compact]); +} + +function Preview({ + draft, + theme, + close, +}: { + draft: string; + theme: PluginSurfaceProps["theme"]; + close(): void; +}) { + // This hook also runs inside native portal hosts. + const { t, locale } = useTranslation(messages); + const styles = useStyles(theme, false); + return ( + + {draft || t("draftEmpty")} + {t("locale", { locale })} + + {t("close")} + + + ); +} + +export function Notes({ theme, layout }: PluginSurfaceProps) { + const { t, locale } = useTranslation(messages); + const [draft, setDraft] = useState(""); + const [notes, setNotes] = useState<{ id: number; text: string }[]>([]); + const [preview, setPreview] = useState(false); + const styles = useStyles(theme, layout.compact); + const showPreview = useCallback(() => setPreview(true), []); + const closePreview = useCallback(() => setPreview(false), []); + const addNote = useCallback(() => { + setNotes((previous) => [...previous, { id: previous.length, text: draft }]); + setDraft(""); + }, [draft]); + return ( + + + {t("title")} + {t("intro")} + + + {t("draft")} + + + + {t("add")} + + + {t("preview")} + + + + {t("notes", { count: notes.length })} + {notes.length === 0 ? ( + {t("empty")} + ) : ( + notes.map((note) => ( + + {note.text} + + )) + )} + + {t("locale", { locale })} + {t("fallback")} + + + + + + + + ); +} diff --git a/plugin-examples/translations/index.client.tsx b/plugin-examples/translations/index.client.tsx new file mode 100644 index 0000000000..827fdd7a8b --- /dev/null +++ b/plugin-examples/translations/index.client.tsx @@ -0,0 +1,101 @@ +import { useCallback } from "react"; +import { copyText } from "@getpaseo/plugin/client/react-native"; +import type { + PluginButtonRegistration, + PluginClientContext, + PluginSidebarItemProps, +} from "@getpaseo/plugin/client"; +import { SidebarRow } from "@getpaseo/plugin/client/ui"; +import { messages } from "./client/messages"; +import { Notes } from "./client/notes"; + +function NotesItem({ currentScreen, openScreen }: PluginSidebarItemProps) { + const open = useCallback(() => openScreen({ screenId: "notes" }), [openScreen]); + return ( + + ); +} +export default function contribute(client: PluginClientContext) { + let tools: PluginButtonRegistration | undefined; + client.addScreen({ id: "notes", title: messages.message("title"), Component: Notes }); + client.addSidebarHeaderItem({ + id: "notes", + title: messages.message("title"), + Component: NotesItem, + }); + client.addWorkspacePanel({ + id: "notes", + title: messages.message("title"), + icon: "NotebookPen", + context: "workspace", + locations: ["workspace", "explorer"], + Component: Notes, + }); + client.addSettingsScreen({ + id: "notes", + title: messages.message("title"), + icon: "NotebookPen", + Component: Notes, + }); + client.addCommandCenterItem({ + id: "notes", + title: messages.message("open"), + icon: "NotebookPen", + context: "global", + onSelect({ openScreen }) { + openScreen({ screenId: "notes" }); + }, + }); + client.addSlashCommand({ + name: "notes", + description: messages.message("description"), + argumentHint: messages.message("hint"), + context: "workspace", + onSubmit({ openScreen }) { + openScreen({ screenId: "notes" }); + }, + }); + client.addCommandCenterItem({ + id: "copy-title", + title: messages.message("copyTitle"), + icon: "Copy", + context: "global", + onSelect() { + return copyText(client.i18n.t(messages, "title")); + }, + }); + client.addCommandCenterItem({ + id: "tools", + title: messages.message("pin"), + icon: "NotebookPen", + context: "workspace", + onSelect({ workspace, openPanel }) { + tools?.remove(); + tools = client.addHeaderButton({ + id: "notes-tools", + workspaceId: workspace.id, + button: { + title: messages.message("tools"), + icon: "NotebookPen", + behavior: { + kind: "menu", + items: [ + { + kind: "item", + id: "open", + title: messages.message("openPanel"), + behavior: { + kind: "action", + onPress() { + openPanel("notes"); + }, + }, + }, + ], + }, + }, + }); + }, + }); + return () => tools?.remove(); +} diff --git a/plugin-examples/translations/package.json b/plugin-examples/translations/package.json new file mode 100644 index 0000000000..97a2c54892 --- /dev/null +++ b/plugin-examples/translations/package.json @@ -0,0 +1,16 @@ +{ + "name": "paseo-translation-notes-example", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@getpaseo/plugin": "file:../../packages/plugin", + "@types/react": "~19.2.0", + "react": "19.1.0", + "react-native": "0.81.5", + "typescript": "^5.9.3" + } +} diff --git a/plugin-examples/translations/paseo-plugin.json b/plugin-examples/translations/paseo-plugin.json new file mode 100644 index 0000000000..1d2ebe74e4 --- /dev/null +++ b/plugin-examples/translations/paseo-plugin.json @@ -0,0 +1,12 @@ +{ + "id": "translation-notes", + "name": "Workspace notes", + "description": "A bilingual notes example that keeps your draft while the app language changes.", + "translations": { + "zh-CN": { + "name": "工作区笔记", + "description": "跟随应用语言切换的双语笔记示例,切换时保留草稿。" + } + }, + "requirements": { "paseo": ">=0.12.1" } +} diff --git a/plugin-examples/translations/tsconfig.json b/plugin-examples/translations/tsconfig.json new file mode 100644 index 0000000000..b562ddf622 --- /dev/null +++ b/plugin-examples/translations/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2023"], + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "noEmit": true + }, + "include": ["index.client.tsx", "client/**/*"] +} diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 5e18053d76..6e48a05b3f 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -93,6 +93,8 @@ Empty strings, invalid ranges, and unknown manifest requirement keys are rejecte Prerelease Paseo versions also satisfy a range their stable core (`major.minor.patch`) satisfies, so `0.8.0-beta.1` satisfies `>=0.8.0` but not `<0.8.0`. +A prerelease minimum such as `>=0.12.0-beta.4` therefore also admits `0.12.0-beta.3` through its stable core `0.12.0`. Use a different stable core when you need to exclude earlier builds of that core. + `paseo plugin init` writes `>=` followed by the current CLI version and pins the matching SDK for typechecking. Raise the minimum when adopting a newer API. Add an upper bound when a later release is incompatible; a minimum alone does not promise protection from future breaking changes. @@ -133,6 +135,79 @@ plugin root is a compile error. | `server/` | Daemon bundle only | Node APIs, local resources, credentials, and RPC handlers. | | `shared/` | Both | Zod RPC contracts and plain values imported by both runtimes. | +## Runtime translations {#runtime-translations} + +**Unreleased API.** Set `requirements.paseo` to the release that introduces these APIs before publishing your plugin. The [translation example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/translations) uses `>=0.12.1` provisionally; the introducing release has not been assigned. + +Ship JSON resources with your client bundle. English (`en`) is required; other keys use canonical language tags such as `zh-CN`. Resources contain nested objects with string leaves. Other languages may omit keys, which fall back to English. Unknown keys, malformed resources, noncanonical locale tags, and mismatched interpolation placeholders are rejected when defining translations. + +```ts +// client/messages.ts +import { defineTranslations } from "@getpaseo/plugin/client"; +import en from "./locales/en.json"; +import zhCN from "./locales/zh-CN.json"; + +export const messages = defineTranslations({ resources: { en, "zh-CN": zhCN } }); +``` + +Enable `resolveJsonModule` in your plugin's `tsconfig.json`. Paseo bundles these imports locally; no network loader runs. For example, the resource pair can contain: + +`client/locales/en.json`: + +```json +{ + "title": "Workspace notes", + "add": "Add note", + "notes_one": "{{count}} note", + "notes_other": "{{count}} notes" +} +``` + +`client/locales/zh-CN.json`: + +```json +{ + "title": "工作区笔记", + "add": "添加笔记", + "notes_other": "{{count}} 条笔记" +} +``` + +Call the plugin hook in React UI: + +```tsx +import { useTranslation } from "@getpaseo/plugin/client"; +import { Text } from "react-native"; +import { messages } from "./messages"; + +function NoteCount({ count }: { count: number }) { + const { t, locale } = useTranslation(messages); + return {t("notes", { count })}; +} +``` + +Use `messages.message(key, options?)` for persistent host-rendered labels: + +```tsx +client.addScreen({ + id: "notes", + title: messages.message("title"), + Component: Notes, +}); +``` + +`PluginText` is `string | PluginMessage`. Message references work in screen titles (including title functions), settings and sidebar titles, panel titles, Command Center titles, slash-command descriptions and argument hints, button titles and labels, nested menu titles, attachment-source titles/picker titles/search placeholders, and theme names. Existing literal strings remain valid. Keep IDs, slash-command names, icons, URLs, and command search keywords stable. + +Descriptor types keep their existing string defaults. When explicitly annotating translated descriptors, use the text parameter, for example `PluginScreenContribution`. Inline registrations infer this automatically. + +For transient text in callbacks, call `client.i18n.t(messages, "add")`; it reads the app's current language at invocation. `client.i18n.locale` and the hook's `locale` report the resolved app language even when a message falls back to English. Do not store the result of `t` as a persistent registration label. + +Keys come from English. Plural base keys such as `notes` require a numeric `count`; i18next selects the locale's `_zero`, `_one`, `_two`, `_few`, `_many`, or `_other` form. Use `{{name}}` for named interpolation and pass `{ name: value }`. JSON string values are widened by TypeScript, so interpolation names from imported JSON are checked at runtime; inline literal resources also infer required interpolation names. i18next formatting syntax such as `{{amount, number}}` uses the resolved language's `Intl` formatter. + +An explicit app language wins over the device language; System follows the app's supported-locale resolution. Separate connected apps have independent languages. Changing language updates mounted UI and host labels without reloading the plugin, resetting component state, or replacing its RPC/query cache. Modal and popover content receives the same installation context on web and native. + +Return language-neutral structured RPC data and translate its presentation in the client. Keep user drafts, agent output, raw daemon errors, logs, and terminal text unchanged. Manifest listing translations and `OVERVIEW..md` are separate publication metadata; see [your listing page](publishing#your-listing-page). + ## Runtime modules Paseo builds each bundle from its matching entry. An import from `client/` into the daemon bundle, @@ -936,10 +1011,10 @@ export default function contribute(client: PluginClientContext) { Paseo owns the route, header, close action, host picker, error boundary, and query client. The plugin owns the screen body. `addScreen({ id, title, Component })` registers a screen. `title` is the screen header's title: a -string, or a function that takes the screen's params and returns one, so a bot screen can show the -bot's name. The function runs again when the params change. Registration throws on an empty title -or one that is neither a string nor a function. A title function that throws or returns an empty -string shows the screen ID instead; the screen still renders. +[display text](#runtime-translations), or a function that takes the screen's params and returns +display text. The function runs again when params or language change. Invalid or empty literal +titles are rejected. A title function that throws or resolves to an empty string shows the screen +ID instead; the screen still renders. `openScreen({ screenId, params })` opens a screen with params, such as the bot a bot screen shows. Params live in the screen's URL query, so a reload, back and forward, and a link to the screen keep diff --git a/public-docs/plugins/reference.zh-CN.md b/public-docs/plugins/reference.zh-CN.md index 3215485d2a..0b64372fac 100644 --- a/public-docs/plugins/reference.zh-CN.md +++ b/public-docs/plugins/reference.zh-CN.md @@ -79,6 +79,8 @@ my-plugin/ 预发布的 Paseo 版本也满足其稳定核心版本(`major.minor.patch`)所满足的范围,因此 `0.8.0-beta.1` 满足 `>=0.8.0`,但不满足 `<0.8.0`。 +`>=0.12.0-beta.4` 这样的预发布最低版本也会通过稳定核心 `0.12.0` 接受 `0.12.0-beta.3`。若要排除同一核心的较早构建,请使用不同的稳定核心版本。 + `paseo plugin init` 会写入 `>=` 加当前 CLI 版本,并为类型检查固定匹配的 SDK。采用更新的 API 时,请提高最低版本。当某个之后的版本不兼容时,请添加上限;只有下限并不能保证免受将来破坏性变更的影响。 daemon 会在安装、运行准备命令或加载插件之前检查自己的版本,并在启动、启用和重新加载时再次检查。被拒绝的更新会保留已安装的修订。每个已连接的应用在执行客户端代码之前会检查自己的版本,并在 **设置 → 插件** 中显示不兼容。兼容的 daemon 并不能让较旧的应用变得兼容。没有客户端入口的插件不要求已连接的应用匹配。 @@ -106,6 +108,79 @@ daemon 会在安装、运行准备命令或加载插件之前检查自己的版 | `server/` | 仅 daemon 的包 | Node API、本地资源、凭据和 RPC 处理函数。 | | `shared/` | 两者 | Zod RPC 契约以及两个运行时都会导入的普通值。 | +## 运行时翻译 {#runtime-translations} + +**尚未发布的 API。** 发布插件前,请将 `requirements.paseo` 设置为首次包含这些 API 的版本。[翻译示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/translations) 暂用 `>=0.12.1`,实际引入版本尚未确定。 + +将 JSON 资源随客户端代码一起打包。必须提供英语(`en`);其他语言使用规范语言标签,例如 `zh-CN`。资源由嵌套对象和字符串叶节点组成。其他语言可省略键,缺失时回退到英语。定义翻译时会拒绝未知键、无效资源、非规范语言标签,以及与英语不一致的插值占位符。 + +```ts +// client/messages.ts +import { defineTranslations } from "@getpaseo/plugin/client"; +import en from "./locales/en.json"; +import zhCN from "./locales/zh-CN.json"; + +export const messages = defineTranslations({ resources: { en, "zh-CN": zhCN } }); +``` + +在插件的 `tsconfig.json` 中启用 `resolveJsonModule`。Paseo 在本地打包这些导入,不通过网络加载。资源对示例: + +`client/locales/en.json`: + +```json +{ + "title": "Workspace notes", + "add": "Add note", + "notes_one": "{{count}} note", + "notes_other": "{{count}} notes" +} +``` + +`client/locales/zh-CN.json`: + +```json +{ + "title": "工作区笔记", + "add": "添加笔记", + "notes_other": "{{count}} 条笔记" +} +``` + +在 React UI 中使用插件的 hook: + +```tsx +import { useTranslation } from "@getpaseo/plugin/client"; +import { Text } from "react-native"; +import { messages } from "./messages"; + +function NoteCount({ count }: { count: number }) { + const { t, locale } = useTranslation(messages); + return {t("notes", { count })}; +} +``` + +对于由宿主持续渲染的标签,使用 `messages.message(key, options?)`: + +```tsx +client.addScreen({ + id: "notes", + title: messages.message("title"), + Component: Notes, +}); +``` + +`PluginText` 为 `string | PluginMessage`。消息引用支持界面标题(包括标题函数)、设置与侧边栏标题、面板标题、Command Center 标题、斜杠命令描述与参数提示、按钮标题与标签、嵌套菜单标题、附件来源标题/选择器标题/搜索占位文本,以及主题名称。现有的字符串仍然有效。保持 ID、斜杠命令名称、图标、URL 和命令搜索关键词不变。 + +描述符类型保留现有的字符串默认值。为翻译描述符显式标注类型时,传入文本类型参数,例如 `PluginScreenContribution`。内联注册会自动推导类型。 + +回调中的临时文本使用 `client.i18n.t(messages, "add")`,它读取调用时的应用语言。`client.i18n.locale` 和 hook 的 `locale` 返回解析后的应用语言,即使某条消息回退到英语。不要将 `t` 的返回值用作持续注册的标签。 + +键由英语资源推导。`notes` 等复数基础键要求数字类型的 `count`;i18next 根据语言选择 `_zero`、`_one`、`_two`、`_few`、`_many` 或 `_other` 形式。命名插值使用 `{{name}}`,调用时传入 `{ name: value }`。TypeScript 将导入 JSON 的字符串值拓宽为 `string`,因此 JSON 插值名称在运行时检查;内联字面量资源还能推导必需的插值名称。`{{amount, number}}` 等 i18next 格式化语法使用解析后语言的 `Intl` 格式化器。 + +显式选择的应用语言优先于设备语言;“跟随系统”使用应用支持的语言解析规则。连接到同一 daemon 的不同应用各自保留语言设置。切换语言会更新已挂载的 UI 和宿主标签,不重新加载插件、不重置组件状态,也不替换 RPC/查询缓存。网页和原生的模态框与弹出层均收到相同的安装上下文。 + +RPC 返回与语言无关的结构化数据,在客户端翻译呈现内容。保持用户草稿、agent 输出、原始 daemon 错误、日志与终端文本不变。manifest 列表翻译和 `OVERVIEW..md` 是独立的发布元数据,参见[你的列表页面](publishing#your-listing-page)。 + ## 运行时模块 {#runtime-modules} Paseo 从对应的入口构建每个包。把 `client/` 导入 daemon 的包、把 `server/` 导入应用的包,或在应用的包中任何位置导入 Node 模块,都会导致编译错误。服务端导入 React、React Native 或客户端 SDK 入口也会失败。共享代码只能导入共享代码:不能导入 Node、React、特定运行时的 SDK 入口或特定运行时的类型。 @@ -796,7 +871,7 @@ export default function contribute(client: PluginClientContext) { 路由、标题栏、关闭操作、主机选择器、错误边界和 query client 由 Paseo 负责。界面主体由插件负责。 -`addScreen({ id, title, Component })` 注册一个界面。`title` 是界面标题栏的标题:可以是字符串,也可以是一个接收界面参数并返回字符串的函数,这样机器人界面就可以显示机器人的名称。参数变化时,该函数会再次运行。标题为空,或既不是字符串也不是函数时,注册会抛出错误。如果标题函数抛出错误或返回空字符串,会改为显示界面 ID;界面仍然会渲染。 +`addScreen({ id, title, Component })` 注册一个界面。`title` 是界面标题栏的[显示文本](#runtime-translations),也可以是接收界面参数并返回显示文本的函数。参数或语言变化时,该函数会再次运行。无效标题和空字符串标题会被拒绝。如果标题函数抛出错误或解析为空字符串,会改为显示界面 ID;界面仍然会渲染。 `openScreen({ screenId, params })` 以参数打开一个界面,例如机器人界面要显示的那个机器人。参数保存在界面 URL 的查询字符串中,因此重新加载、前进后退以及指向该界面的链接都会保留它们。任何字符串键都可以;值必须是字符串,其他类型会抛出错误。用其他参数打开同一个界面会显示新的参数。