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.
This commit is contained in:
Mohamed Boudra
2026-10-20 08:43:16 +02:00
parent d87998bc8c
commit 87dfe27065
67 changed files with 1680 additions and 194 deletions
@@ -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);
});
@@ -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);
});
@@ -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();
}
}
@@ -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,
+7
View File
@@ -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
<View style={styles.attachmentTray} testID="composer-attachment-tray">
{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 (
<PluginResourceAttachmentPill
serverId={args.serverId}
key={`${attachment.pluginId}:${attachment.sourceId}:${attachment.item.id}`}
attachment={attachment}
index={index}
@@ -2295,6 +2300,7 @@ function ComposerContentImpl({
const attachmentTray = useMemo(
() =>
renderAttachmentTray({
serverId,
selectedAttachments,
pendingFiles,
isComposerLocked,
@@ -2314,6 +2320,7 @@ function ComposerContentImpl({
handleOpenAttachment,
handleRemoveAttachment,
isComposerLocked,
serverId,
selectedAttachments,
pendingFiles,
t,
@@ -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: <ThemedSourceIcon Icon={Icon} uniProps={iconColorMapping} />,
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}
+13 -3
View File
@@ -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}
>
<AttachmentLabel
icon={icon}
title={attachment.item.title}
subtitle={`${attachment.sourceTitle} ${attachment.item.identifier}`}
subtitle={`${sourceTitle} ${attachment.item.identifier}`}
/>
</AttachmentPill>
);
@@ -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();
});
+20 -9
View File
@@ -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<PluginText>,
path: readonly string[],
): (() => void | Promise<void>) | 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<PluginText>,
): PluginButtonRegistration<PluginText> {
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<PluginText>,
): PluginButtonRegistration<PluginText> {
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<PluginText>,
placement: ButtonPlacement,
context: PluginButtonContext,
): PluginButtonRegistration {
): PluginButtonRegistration<PluginText> {
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<PluginText> = 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);
},
+40 -9
View File
@@ -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<PluginText>;
type PluginButtonBehavior = SDKPluginButtonBehavior<PluginText>;
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<PluginButtonBehavior, { kind: "menu" }>
| { 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<PluginButton, "title" | "label" | "behavior"> & {
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),
};
}
+4 -5
View File
@@ -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";
@@ -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) {
@@ -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"
@@ -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",
@@ -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)),
},
+39 -26
View File
@@ -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<PluginText> {
if (typeof title === "function") return title as PluginScreenTitle<PluginText>;
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<PluginCleanup>();
let setupComplete = false;
let stopped = false;
function trackButton(registration: PluginButtonRegistration): PluginButtonRegistration {
function trackButton(
registration: PluginButtonRegistration<PluginText>,
): PluginButtonRegistration<PluginText> {
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`);
@@ -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);
@@ -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 (
<QueryClientProvider client={plugin.queryClient}>
<PaseoApiProvider paseo={plugin.paseo}>
<PluginRpcProvider invoke={plugin.invoke}>{children}</PluginRpcProvider>
<PluginRpcProvider invoke={plugin.invoke}>
<PluginTranslationProvider runtime={plugin.i18n}>{children}</PluginTranslationProvider>
</PluginRpcProvider>
</PaseoApiProvider>
</QueryClientProvider>
);
@@ -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<string, PaseoApi>();
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);
});
@@ -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.',
);
});
+23
View File
@@ -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,
});
+1 -1
View File
@@ -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}
/>
))}
@@ -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,
+2 -2
View File
@@ -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 }],
});
}
@@ -205,7 +205,7 @@ function SidebarItemContent({
const frame = useMemo<SidebarItemFrame>(
() => ({
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<PluginSidebarItemProps>(
() => ({ ...hostProps, currentScreen, openScreen, openPopover }),
@@ -228,7 +228,7 @@ function SidebarItemContent({
{Popover ? (
<SidebarPopoverSurface
section={section}
title={item.title}
title={plugin.i18n.resolve(item.title, plugin.locale)}
testID={`${frame.testID}-popover`}
>
<SurfaceErrorBoundary installation={plugin} Surface={Popover}>
@@ -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<string, string> = { "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");
});
});
@@ -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);
+8 -7
View File
@@ -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();
+3
View File
@@ -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,
+7 -4
View File
@@ -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<PluginThemeContribution> = 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<PluginMessage>(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),
};
@@ -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,
+25 -10
View File
@@ -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<PluginText>;
type PluginThemeContribution = SDKPluginThemeContribution<PluginText>;
type PluginCommandCenterItemContribution = SDKPluginCommandCenterItemContribution<PluginText>;
type PluginClientSlashCommandContribution = SDKPluginClientSlashCommandContribution<PluginText>;
type PluginComposerPillContribution = SDKPluginComposerPillContribution<PluginText>;
type PluginSidebarContribution = SDKPluginSidebarContribution<PluginText>;
type PluginSidebarItemContribution = SDKPluginSidebarItemContribution<PluginText>;
type PluginScreenContribution = SDKPluginScreenContribution<PluginText>;
type PluginSettingsScreenContribution = SDKPluginSettingsScreenContribution<PluginText>;
type PluginWorkspacePanelContribution = SDKPluginWorkspacePanelContribution<PluginText>;
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. */
@@ -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";
@@ -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,
@@ -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,
@@ -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";
+5 -4
View File
@@ -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);
@@ -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",
+1
View File
@@ -67,6 +67,7 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"i18next": "^26.3.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
+4 -3
View File
@@ -1,9 +1,10 @@
import type { PluginText } from "./text.js";
import { z } from "zod";
import type { PluginAttachmentSourceContribution } from "./contracts.js";
export function defineAttachmentSource<Definition extends PluginAttachmentSourceContribution>(
definition: Definition,
): Definition {
export function defineAttachmentSource<
Definition extends PluginAttachmentSourceContribution<PluginText>,
>(definition: Definition): Definition {
return definition;
}
+2 -1
View File
@@ -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;
}
+17 -21
View File
@@ -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<PluginButtonIconProps>;
export type PluginButtonBehavior =
export type PluginButtonBehavior<Text extends PluginText = string> =
| { kind: "action"; onPress(): void | Promise<void> }
| { kind: "menu"; items: readonly PluginButtonMenuEntry[] }
| { kind: "menu"; items: readonly PluginButtonMenuEntry<Text>[] }
| { kind: "popover"; Content: ComponentType<PluginButtonContentProps> };
export type PluginButtonMenuEntry =
export type PluginButtonMenuEntry<Text extends PluginText = string> =
| { kind: "separator"; id: string }
| {
kind: "item";
id: string;
title: string;
title: Text;
icon?: PluginButtonIcon;
visible?: boolean;
disabled?: boolean;
behavior: PluginButtonBehavior;
behavior: PluginButtonBehavior<Text>;
};
export interface PluginButton {
title: string;
export interface PluginButton<Text extends PluginText = string> {
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<Text>;
}
export interface PluginButtonRegistration {
export interface PluginButtonRegistration<Text extends PluginText = string> {
/** Updates presentation in place. Supply a complete behavior to replace it. */
update(patch: Partial<PluginButton>): void;
update(patch: Partial<PluginButton<Text>>): void;
/** Idempotent. Updates after removal do nothing. */
remove(): void;
}
export interface PluginHeaderButtonContribution {
export interface PluginHeaderButtonContribution<Text extends PluginText = string> {
id: string;
workspaceId: string;
button: PluginButton;
button: PluginButton<Text>;
}
export interface PluginComposerPillContribution extends PluginHeaderButtonContribution {
export interface PluginComposerPillContribution<
Text extends PluginText = string,
> extends PluginHeaderButtonContribution<Text> {
agentId: string;
}
+48 -38
View File
@@ -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<PluginPopoverProps>): void;
}
export interface PluginSidebarItemContribution {
export interface PluginSidebarItemContribution<Text extends PluginText = string> {
id: string;
/** Settings row label, accessibility label, and default label for SidebarRow. */
title: string;
title: Text;
Component: ComponentType<PluginSidebarItemProps>;
}
@@ -99,9 +100,9 @@ export interface PluginOpenPanelOptions {
location?: PluginPanelLocation;
}
interface PluginWorkspacePanelBase {
interface PluginWorkspacePanelBase<Text extends PluginText = string> {
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<PluginSurfaceProps>): PluginCleanup;
/** @deprecated Use `addSidebarHeaderItem`. */
addSidebarItem(contribution: PluginSidebarContribution): PluginCleanup;
addSidebarItem(contribution: PluginSidebarContribution<PluginText>): 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<void>;
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<PluginText>): PluginCleanup;
addScreen(contribution: PluginScreenContribution<PluginText>): PluginCleanup;
addSidebarHeaderItem(contribution: PluginSidebarItemContribution<PluginText>): PluginCleanup;
addSidebarFooterItem(contribution: PluginSidebarItemContribution<PluginText>): PluginCleanup;
addWorkspacePanel(contribution: PluginWorkspacePanelContribution<PluginText>): PluginCleanup;
addCommandCenterItem(
contribution: PluginCommandCenterItemContribution<PluginText>,
): PluginCleanup;
addSlashCommand(contribution: PluginClientSlashCommandContribution<PluginText>): PluginCleanup;
addHeaderButton(
contribution: PluginHeaderButtonContribution<PluginText>,
): PluginButtonRegistration<PluginText>;
addComposerPill(
contribution: PluginComposerPillContribution<PluginText>,
): PluginButtonRegistration<PluginText>;
addAttachmentSource(contribution: PluginAttachmentSourceContribution<PluginText>): PluginCleanup;
addTheme(contribution: PluginThemeContribution<PluginText>): PluginCleanup;
addTimelineTransformer<ItemType extends AgentTimelineItem["type"]>(
contribution: PluginTimelineTransformerContribution<ItemType>,
): PluginCleanup;
@@ -155,29 +163,31 @@ export interface PluginClientContext extends PluginCommandCapabilities, PluginCl
export type PluginClientContribution = (client: PluginClientContext) => PluginCleanup;
export type PluginWorkspacePanelContribution =
| (PluginWorkspacePanelBase & {
export type PluginWorkspacePanelContribution<Text extends PluginText = string> =
| (PluginWorkspacePanelBase<Text> & {
context: "workspace";
Component: ComponentType<PluginWorkspacePanelProps>;
})
| (PluginWorkspacePanelBase & {
| (PluginWorkspacePanelBase<Text> & {
context: "agent";
Component: ComponentType<PluginAgentPanelProps>;
});
export interface PluginSettingsScreenContribution {
export interface PluginSettingsScreenContribution<Text extends PluginText = string> {
id: string;
title: string;
title: Text;
icon: string;
Component: ComponentType<PluginSurfaceProps>;
}
/** 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 extends PluginText = string> =
| Text
| ((params: PluginScreenParams) => Text);
export interface PluginScreenContribution {
export interface PluginScreenContribution<Text extends PluginText = string> {
id: string;
title: PluginScreenTitle;
title: PluginScreenTitle<Text>;
Component: ComponentType<PluginScreenProps>;
}
@@ -187,9 +197,9 @@ export interface PluginSurfaceContribution {
}
/** @deprecated Use `PluginSidebarItemContribution` with `addSidebarHeaderItem`. */
export interface PluginSidebarContribution {
export interface PluginSidebarContribution<Text extends PluginText = string> {
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<Text extends PluginText = string> {
id: string;
title: string;
title: Text;
icon: string;
keywords?: readonly string[];
}
export type PluginCommandCenterItemContribution =
| (PluginCommandCenterItemBase & {
export type PluginCommandCenterItemContribution<Text extends PluginText = string> =
| (PluginCommandCenterItemBase<Text> & {
context: "global";
onSelect(context: PluginGlobalCommandContext): void | Promise<void>;
})
| (PluginCommandCenterItemBase & {
| (PluginCommandCenterItemBase<Text> & {
context: "workspace";
onSelect(context: PluginWorkspaceCommandContext): void | Promise<void>;
})
| (PluginCommandCenterItemBase & {
| (PluginCommandCenterItemBase<Text> & {
context: "agent";
onSelect(context: PluginAgentCommandContext): void | Promise<void>;
});
interface PluginClientSlashCommandBase {
interface PluginClientSlashCommandBase<Text extends PluginText = string> {
name: string;
description: string;
argumentHint: string;
description: Text;
argumentHint: Text;
}
export type PluginClientSlashCommandContribution =
| (PluginClientSlashCommandBase & {
export type PluginClientSlashCommandContribution<Text extends PluginText = string> =
| (PluginClientSlashCommandBase<Text> & {
context: "workspace";
onSubmit(context: PluginWorkspaceCommandContext & { args: string }): void | Promise<void>;
})
| (PluginClientSlashCommandBase & {
| (PluginClientSlashCommandBase<Text> & {
context: "agent";
onSubmit(context: PluginAgentCommandContext & { args: string }): void | Promise<void>;
});
+11 -1
View File
@@ -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<PluginText>,
invoke: (method: string, input: unknown) => Promise<unknown>,
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";
+4
View File
@@ -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<void>;
export { defineTranslations, useTranslation } from "./translations.js";
export type { PluginI18n } from "./translations.js";
export type { PluginText, PluginMessage, PluginTranslations } from "../text.js";
@@ -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 (
<PaseoApiProvider paseo={paseo}>
<PluginRpcProvider invoke={rpc.invoke}>{content}</PluginRpcProvider>
<PluginRpcProvider invoke={rpc.invoke}>
<PluginTranslationProvider runtime={translations}>{content}</PluginTranslationProvider>
</PluginRpcProvider>
</PaseoApiProvider>
);
},
[paseo, rpc, state],
[paseo, rpc, state, translations],
);
}
@@ -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();
});
+202
View File
@@ -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<PluginMessage, MessageDefinition>();
export function defineTranslations<const Resource extends TranslationResource>(definition: {
resources: Readonly<Record<string, TranslationResource>> & { en: Resource };
}): PluginTranslations<Resource> {
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<Resource extends TranslationResource, Key extends TranslationKey<Resource>>(
messages: PluginTranslations<Resource>,
key: Key,
...args: TranslationArguments<Resource, Key>
): string;
}
export class PluginTranslationRuntime implements PluginI18n {
private readonly engines = new Map<Resources, i18n>();
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<Resource extends TranslationResource, Key extends TranslationKey<Resource>>(
messages: PluginTranslations<Resource>,
key: Key,
...args: TranslationArguments<Resource, Key>
): 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<string, string> {
if (!resource || typeof resource !== "object" || Array.isArray(resource))
throw new Error("Translation resources must be objects of strings");
const result = new Map<string, string>();
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<string, string> {
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<PluginTranslationRuntime | null>(null);
export function PluginTranslationProvider({
runtime,
children,
}: {
runtime: PluginTranslationRuntime;
children: ReactNode;
}) {
return <Context.Provider value={runtime}>{children}</Context.Provider>;
}
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<Resource extends TranslationResource>(
messages: PluginTranslations<Resource>,
) {
const runtime = usePluginTranslationRuntime();
const locale = useSyncExternalStore(runtime.subscribe, runtime.getSnapshot, runtime.getSnapshot);
const t = useCallback(
<Key extends TranslationKey<Resource>>(
key: Key,
...args: TranslationArguments<Resource, Key>
) => runtime.resolve(messages.message(key, ...args), locale),
[runtime, messages, locale],
);
return { locale, t };
}
+7 -6
View File
@@ -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<Text extends PluginText = string> {
id: string;
name: string;
name: Text;
appearance: "light" | "dark";
colors: PluginThemeColors;
}
export interface PluginAttachmentSourceContribution {
export interface PluginAttachmentSourceContribution<Text extends PluginText = string> {
id: string;
title: string;
title: Text;
icon: string;
pickerTitle: string;
searchPlaceholder: string;
pickerTitle: Text;
searchPlaceholder: Text;
search: PluginRpcContract;
}
+2
View File
@@ -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";
@@ -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<PluginText> = { 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([]);
});
+67
View File
@@ -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<Resource> = {
[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<Resource[Key]>}`
: never;
}[keyof Resource & string];
export interface PluginTranslations<Resource extends TranslationResource = TranslationResource> {
readonly resources: Readonly<Record<string, TranslationResource>> & { readonly en: Resource };
message<Key extends TranslationKey<Resource>>(
key: Key,
...args: TranslationArguments<Resource, Key>
): PluginMessage;
}
type Lookup<Resource, Key extends string> = Key extends `${infer Head}.${infer Tail}`
? Head extends keyof Resource
? Lookup<Resource[Head], Tail>
: never
: Key extends keyof Resource
? Resource[Key]
: never;
type Trim<Value extends string> = Value extends ` ${infer Rest}` | `${infer Rest} `
? Trim<Rest>
: Value;
type InterpolationName<Value extends string> =
Trim<Value> extends `-${infer Rest}`
? InterpolationName<Rest>
: Trim<Value> extends `${infer Name},${string}`
? Trim<Name>
: Trim<Value>;
type InterpolationNames<Value> = Value extends string
? string extends Value
? never
: Value extends `${string}{{${infer Name}}}${infer Rest}`
? InterpolationName<Name> | InterpolationNames<Rest>
: never
: never;
type MessageOptions<Resource, Key extends string> = TranslationOptions &
([Lookup<Resource, `${Key}_other`>] extends [never] ? {} : { count: number }) & {
[Name in InterpolationNames<Lookup<Resource, Key> | Lookup<Resource, `${Key}_other`>>]:
| string
| number
| boolean
| Date;
};
export type TranslationArguments<Resource, Key extends string> =
{} extends MessageOptions<Resource, Key>
? [options?: MessageOptions<Resource, Key>]
: [options: MessageOptions<Resource, Key>];
+2 -1
View File
@@ -10,7 +10,8 @@
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"noEmit": true,
"types": ["node"]
"types": ["node"],
"resolveJsonModule": true
},
"include": ["../../plugin-examples/**/*.ts", "../../plugin-examples/**/*.tsx"]
}