mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-20 08:12:53 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8cf59961b | ||
|
|
41ac23016d | ||
|
|
8609aab478 | ||
|
|
7765622fcd | ||
|
|
87dfe27065 | ||
|
|
6971bb5788 | ||
|
|
93ceb713b6 | ||
|
|
7f6a0651e8 | ||
|
|
778b0ce773 | ||
|
|
b4f7536788 | ||
|
|
5f18e75fb1 | ||
|
|
d87998bc8c |
+3238
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers |
|
||||
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||
| [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, native surfaces, and attachment sources |
|
||||
| [docs/website-i18n.md](docs/website-i18n.md) | Website languages: locale URLs, translated Markdown pairs, `{#id}` anchors, Chinese style |
|
||||
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
|
||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||
|
||||
+7
-7
@@ -59,8 +59,8 @@
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="33%"><a href="https://hoplite.sh/?utm_source=paseo&utm_medium=readme&utm_campaign=sponsor"><picture><source media="(prefers-color-scheme: dark)" srcset="packages/website/public/sponsors/hoplite-on-dark.svg"><img src="packages/website/public/sponsors/hoplite-on-light.svg" alt="Hoplite" height="40"></picture></a><br><sub>Cloud coding agents</sub></td>
|
||||
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">成为 Paseo 赞助商</a></td>
|
||||
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">成为 Paseo 赞助商</a></td>
|
||||
<td align="center" width="33%"><a href="https://paseo.sh/zh-CN/sponsor">成为 Paseo 赞助商</a></td>
|
||||
<td align="center" width="33%"><a href="https://paseo.sh/zh-CN/sponsor">成为 Paseo 赞助商</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -80,7 +80,7 @@ Paseo 会运行一个名为 daemon 的本地服务,用来管理你的 coding a
|
||||
|
||||
### 桌面 app(推荐)
|
||||
|
||||
从 [paseo.sh/download](https://paseo.sh/download) 或 [GitHub releases 页面](https://github.com/getpaseo/paseo/releases)下载。打开 app 后 daemon 会自动启动,不需要再安装其他东西。
|
||||
从 [paseo.sh/download](https://paseo.sh/zh-CN/download) 或 [GitHub releases 页面](https://github.com/getpaseo/paseo/releases)下载。打开 app 后 daemon 会自动启动,不需要再安装其他东西。
|
||||
|
||||
如果要从手机连接,在 Settings 中扫描显示的二维码。
|
||||
|
||||
@@ -97,8 +97,8 @@ paseo
|
||||
|
||||
完整安装和配置见:
|
||||
|
||||
- [文档](https://paseo.sh/docs)
|
||||
- [配置参考](https://paseo.sh/docs/configuration)
|
||||
- [文档](https://paseo.sh/zh-CN/docs)
|
||||
- [配置参考](https://paseo.sh/zh-CN/docs/configuration)
|
||||
|
||||
## CLI
|
||||
|
||||
@@ -116,7 +116,7 @@ paseo send abc123 "also add tests" # 发送后续任务
|
||||
paseo --host workstation.local:6767 run "run the full test suite"
|
||||
```
|
||||
|
||||
更多内容见[完整 CLI 参考](https://paseo.sh/docs/cli)。
|
||||
更多内容见[完整 CLI 参考](https://paseo.sh/zh-CN/docs/cli)。
|
||||
|
||||
## Skills
|
||||
|
||||
@@ -164,7 +164,7 @@ npm run typecheck
|
||||
|
||||
## 支持 Paseo
|
||||
|
||||
Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/sponsor#spot),logo 会展示在本 README 顶部,以及 paseo.sh 的首页和文档中。
|
||||
Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/zh-CN/sponsor#spot),logo 会展示在本 README 顶部,以及 paseo.sh 的首页和文档中。
|
||||
|
||||
## 相关项目
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# I18n
|
||||
|
||||
Paseo client UI translations live in `packages/app/src/i18n`.
|
||||
Paseo client UI translations live in `packages/app/src/i18n`. Website translations are in [website-i18n.md](website-i18n.md).
|
||||
|
||||
## Supported Locales
|
||||
|
||||
|
||||
@@ -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/<id>/`, with `paseo-plugin.json`, `index.server.ts`,
|
||||
|
||||
@@ -533,6 +533,14 @@ No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or
|
||||
|
||||
Older prerelease bodies on GitHub keep the notes they were synced with; `Release Notes Sync` only writes the release whose tag matches the top entry.
|
||||
|
||||
## Changelog translations
|
||||
|
||||
`CHANGELOG.zh-CN.md` holds the Chinese notes for `paseo.sh/zh-CN/changelog`. Only the website reads it; the app's **What's new** sheet, GitHub release bodies and F-Droid read `CHANGELOG.md`.
|
||||
|
||||
- Translations never gate a release. Translate a new entry in a follow-up PR, or in the changelog commit when you can.
|
||||
- A translated entry uses the English heading unchanged: `## X.Y.Z - YYYY-MM-DD`. The website uses it only while the version and date both match the English entry. Otherwise it shows that release whole in English under a notice, so a beta rewritten in place drops its old translation until the new notes are translated. Delete Chinese entries whose English entry was renamed.
|
||||
- Keep the English sections, bullets and links one to one. Section titles: Added 新增, Improved 改进, Changed 变更, Fixed 修复, Security 安全, Breaking 破坏性变更, Plugins 插件, Notes 说明. Wording follows [website-i18n.md](website-i18n.md#chinese-style).
|
||||
|
||||
## Changelog ownership
|
||||
|
||||
- **The agent running the release writes the changelog entry — beta or stable.** The release context and final wording stay with that agent.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Website translations
|
||||
|
||||
paseo.sh is served in English and Simplified Chinese (`zh-CN`). This doc covers the website in `packages/website`. App UI translations are in [i18n.md](i18n.md).
|
||||
|
||||
## URLs own the language
|
||||
|
||||
- English pages keep their unprefixed paths. Every other language lives under its tag: `/docs/cli` and `/zh-CN/docs/cli` are the same page. `/en/...` is not a page.
|
||||
- The server renders the language in the URL. There is no browser-language detection or redirect, so a shared link always opens in the language it was shared in.
|
||||
- The language selector moves to the same page in the other language and keeps the query and hash. Every internal link on a page carries the page's language, so the choice sticks while the reader navigates. An explicit English URL always renders English.
|
||||
- The choice is not stored. A stored choice could only take effect by redirecting unprefixed URLs, which are the English pages. First-party links into the site carry the reader's language instead: `README.zh-CN.md` links to `/zh-CN/...`.
|
||||
- Routes live under `src/routes/{-$locale}/`. Build internal links with `localizePath` or `useLocalizePath` from `~/i18n`. Links inside Markdown are localized by `DocsMarkdown`. Files, `.md` sources, `llms.txt` and API endpoints keep one path for every language (`isPagePath`).
|
||||
- Give each page's `head` the language-neutral path through `pageMeta` (`src/meta.ts`). It writes the self canonical, the `hreflang` alternates with English as `x-default`, and `og:locale`. The sitemap in `vite.config.ts` lists the same alternates.
|
||||
|
||||
## Where copy lives
|
||||
|
||||
| Content | English | Chinese |
|
||||
| ---------------- | -------------------------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| Interface copy | `src/i18n/resources/en.json` | `src/i18n/resources/zh-CN.json` |
|
||||
| Docs | `public-docs/<page>.md` | `public-docs/<page>.zh-CN.md` |
|
||||
| Blog posts | `posts/<slug>.md` | `posts/<slug>.zh-CN.md` |
|
||||
| Comparison pages | `src/content/alternatives/<slug>.md` | `src/content/alternatives/<slug>.zh-CN.md` |
|
||||
| Legal pages | `legal/<page>.md` | `legal/<page>.zh-CN.md` |
|
||||
| Changelog | `CHANGELOG.md` | `CHANGELOG.zh-CN.md`, see [release.md](release.md#changelog-translations) |
|
||||
| Plugin listings | the plugin's `paseo-plugin.json` and `OVERVIEW.md` | `translations` and `OVERVIEW.zh-CN.md`, see `public-docs/plugins/publishing.md` |
|
||||
|
||||
`en.json` types the keys, so typecheck fails when `zh-CN.json` misses one. A translation file is grouped with its English original by name (`src/markdown-content.ts`). A translation without an English original fails the build.
|
||||
|
||||
Every first-party page has a translation. The changelog and plugin listings are the exceptions: a release or listing without one renders whole in English under a notice. Pages never mix languages paragraph by paragraph. Navigation order, categories and slugs always come from the English file's frontmatter.
|
||||
|
||||
## Editing a translated doc
|
||||
|
||||
Edit the English file and its `.zh-CN.md` in the same PR, and add the `.zh-CN.md` with any new page. Code blocks, commands, flags, config keys and links are copied verbatim, so code-only changes are a copy. Never delete a translation to get around an edit, and never leave one that describes behavior the English no longer has.
|
||||
|
||||
Translated headings pin the English anchor with a trailing `{#id}`, so `/zh-CN/docs/cli#running-agents` lands on the same section as the English link:
|
||||
|
||||
```md
|
||||
## 运行 Agent {#running-agents}
|
||||
|
||||
:::example[回复 Slack 提及 {#answer-a-slack-mention}]
|
||||
```
|
||||
|
||||
When you rename an English heading, change the `{#id}` in the translation to the new slug. `src/content-translations.test.ts` fails when a docs, blog, comparison or legal page has no translation, and when a translation's headings differ from the English in count, level, order or anchor. It cannot tell whether the prose under a heading is current. The `.md` source endpoints serve files verbatim, so `{#id}` shows there as written.
|
||||
|
||||
## Chinese style
|
||||
|
||||
- Keep code, commands, file paths, IDs, brand and product names, and command output in English.
|
||||
- Keep these terms in English: Agent, worktree, daemon, Provider, PR, diff, Composer, Hub, workflow, heartbeat, slug.
|
||||
- Translate these: workspace 工作区, host 主机, relay 中继, plugin 插件, skill 技能, review 审查, schedule 定时任务, orchestration 编排, configuration 配置, prompt 提示词, subagent 子 Agent.
|
||||
- Quote app UI labels with the app's Chinese wording from `packages/app/src/i18n/resources/zh-CN.ts`, such as **设置 → 插件**. Labels the app has not translated stay in English, as do labels in Hub, GitHub, Slack and Discord. Add the English label in parentheses only where readers search for it, such as an error message.
|
||||
- Put a space between Chinese and Latin text or numbers: `启动 Agent`, `共 3 个`.
|
||||
- Bold that ends in full-width punctuation does not render when Chinese text follows it directly: `**注意:**这里` shows the asterisks. Move the punctuation outside the bold (`**注意**:这里`).
|
||||
Generated
+3
@@ -42116,6 +42116,7 @@
|
||||
"version": "0.12.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"i18next": "^26.3.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -43338,9 +43339,11 @@
|
||||
"@tanstack/react-start": "^1.168.26",
|
||||
"framer-motion": "^12.35.2",
|
||||
"github-slugger": "^2.0.0",
|
||||
"i18next": "^26.3.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"react": "^19.1.4",
|
||||
"react-dom": "^19.1.4",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-markdown": "^10.1.0",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-pretty-code": "^0.14.3",
|
||||
|
||||
@@ -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";
|
||||
@@ -166,3 +167,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,164 @@
|
||||
import { installDeveloperToastDismissal } from "../developer-ui.mjs";
|
||||
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 installDeveloperToastDismissal(second);
|
||||
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().catch(() => undefined);
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -149,9 +149,10 @@ async function expectVerticalOrder<Key extends string>(
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const measured = await Promise.all(
|
||||
keys.map(async (key) => ({ key, top: await rowTop(locate(key)) })),
|
||||
);
|
||||
// One row at a time: concurrent actions that all run the developer toast handler do not
|
||||
// all resume, and the poll then waits out its timeout.
|
||||
const measured: { key: Key; top: number | null }[] = [];
|
||||
for (const key of keys) measured.push({ key, top: await rowTop(locate(key)) });
|
||||
if (!measured.every((entry): entry is { key: Key; top: number } => entry.top !== null))
|
||||
return null;
|
||||
return measured.sort((a, b) => a.top - b.top).map((entry) => entry.key);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,11 +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,
|
||||
PluginSerializableIcon,
|
||||
} from "@getpaseo/plugin";
|
||||
import type { PluginAttachmentItem, PluginSerializableIcon } from "@getpaseo/plugin";
|
||||
import type { PluginAttachmentSourceContribution } from "../types";
|
||||
import { searchPluginAttachments } from "@getpaseo/plugin/client/host";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { AttachmentMenuItem } from "@/composer/input/input";
|
||||
@@ -136,7 +133,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,
|
||||
@@ -148,9 +145,9 @@ export function usePluginAttachmentPicker(
|
||||
);
|
||||
const menuItems = useMemo(
|
||||
() =>
|
||||
sources.map(({ key, source }) => ({
|
||||
sources.map(({ key, source, plugin }) => ({
|
||||
id: `plugin:${key}`,
|
||||
label: `Attach ${source.title}`,
|
||||
label: `Attach ${plugin.i18n.resolve(source.title, plugin.locale)}`,
|
||||
icon: <ThemedSourceIcon icon={source.icon} uniProps={iconColorMapping} />,
|
||||
onSelect: () => setActiveKey(key),
|
||||
})),
|
||||
@@ -165,8 +162,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}
|
||||
|
||||
@@ -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";
|
||||
@@ -16,6 +17,7 @@ function ResourceIcon({ icon, color = "" }: { icon: PluginSerializableIcon; colo
|
||||
const ThemedResourceIcon = withUnistyles(ResourceIcon);
|
||||
|
||||
interface PluginResourceAttachmentPillProps {
|
||||
serverId: string;
|
||||
attachment: PluginResourceComposerAttachment;
|
||||
index: number;
|
||||
disabled: boolean;
|
||||
@@ -26,6 +28,7 @@ interface PluginResourceAttachmentPillProps {
|
||||
}
|
||||
|
||||
export function PluginResourceAttachmentPill({
|
||||
serverId,
|
||||
attachment,
|
||||
index,
|
||||
disabled,
|
||||
@@ -34,6 +37,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 handleOpen = useCallback(() => onOpen(attachment), [attachment, onOpen]);
|
||||
const handleRemove = useCallback(() => onRemove(index), [index, onRemove]);
|
||||
const icon = useMemo(
|
||||
@@ -45,14 +55,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",
|
||||
@@ -172,3 +176,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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
/** Returns the normalized icon, or throws naming why the icon is unusable. */
|
||||
@@ -18,7 +26,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();
|
||||
}
|
||||
@@ -33,7 +46,8 @@ function isComponent(value: unknown): boolean {
|
||||
function validateBehavior(
|
||||
behavior: PluginButtonBehavior,
|
||||
validation: ButtonValidation,
|
||||
): PluginButtonBehavior {
|
||||
resolve: (text: PluginText) => string,
|
||||
): ResolvedPluginButtonBehavior {
|
||||
switch (behavior.kind) {
|
||||
case "action":
|
||||
if (typeof behavior.onPress !== "function")
|
||||
@@ -55,10 +69,10 @@ function validateBehavior(
|
||||
return {
|
||||
...item,
|
||||
icon: item.icon === undefined ? undefined : validation.parseIcon(item.icon),
|
||||
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),
|
||||
};
|
||||
}),
|
||||
};
|
||||
@@ -68,23 +82,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 {
|
||||
return {
|
||||
...button,
|
||||
icon: validation.parseIcon(button.icon),
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(pluginIconComponent(item.icon)),
|
||||
},
|
||||
|
||||
@@ -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 { parsePluginIcon, parseSerializablePluginIcon } 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}`);
|
||||
const icon = parsePluginIcon(`Settings screen ${screenId}`, contribution.icon);
|
||||
settingsScreenIds.add(screenId);
|
||||
return register(collector.settingsScreens, { ...contribution, id: screenId, icon }, () =>
|
||||
@@ -243,7 +256,7 @@ export function runPluginClientBundle(
|
||||
const normalizedId = requireId(contribution.id, "sidebar item id");
|
||||
const legacy = {
|
||||
id: normalizedId,
|
||||
title: contribution.title.trim(),
|
||||
title: normalizePluginText(contribution.title),
|
||||
icon: parsePluginIcon(`Sidebar item ${normalizedId}`, contribution.icon),
|
||||
surface: requireId(contribution.surface, "sidebar surface id"),
|
||||
};
|
||||
@@ -259,7 +272,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);
|
||||
if (!title) throw new Error(`Workspace panel ${normalizedId} has no title`);
|
||||
const icon = parsePluginIcon(`Workspace panel ${normalizedId}`, contribution.icon);
|
||||
if (contribution.context !== "workspace" && contribution.context !== "agent") {
|
||||
@@ -287,7 +300,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);
|
||||
if (!title) throw new Error(`Command Center item ${normalizedId} has no title`);
|
||||
const icon = parsePluginIcon(`Command Center item ${normalizedId}`, contribution.icon);
|
||||
if (
|
||||
@@ -318,7 +331,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`);
|
||||
@@ -333,7 +346,7 @@ export function runPluginClientBundle(
|
||||
...contribution,
|
||||
name,
|
||||
description,
|
||||
argumentHint: contribution.argumentHint.trim(),
|
||||
argumentHint: normalizePluginText(contribution.argumentHint),
|
||||
},
|
||||
() => clientSlashCommandNames.delete(name),
|
||||
);
|
||||
@@ -343,9 +356,9 @@ export function runPluginClientBundle(
|
||||
if (attachmentSourceIds.has(normalizedId)) {
|
||||
throw new Error(`Duplicate attachment source: ${normalizedId}`);
|
||||
}
|
||||
const title = contribution.title.trim();
|
||||
const pickerTitle = contribution.pickerTitle.trim();
|
||||
const searchPlaceholder = contribution.searchPlaceholder.trim();
|
||||
const title = normalizePluginText(contribution.title);
|
||||
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`);
|
||||
const icon = parseSerializablePluginIcon(
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
@@ -135,7 +145,13 @@ export class PluginRegistry {
|
||||
}
|
||||
}
|
||||
this.byHost.set(serverId, installed);
|
||||
this.publish();
|
||||
if (
|
||||
installed.length !== catalog.length ||
|
||||
installed.length !== previous.length ||
|
||||
installed.some((plugin, index) => plugin !== previous[index])
|
||||
) {
|
||||
this.publish();
|
||||
}
|
||||
const installedTimelineBundles = installed
|
||||
.filter((plugin) => plugin.timelineTransformers.length > 0)
|
||||
.map((plugin) => `${plugin.id}\0${plugin.clientBundle}`);
|
||||
@@ -160,6 +176,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 +190,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 +201,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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -64,7 +64,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 }],
|
||||
});
|
||||
@@ -80,7 +80,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);
|
||||
|
||||
@@ -149,13 +149,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?.icon ?? null;
|
||||
const close = useCallback(() => {
|
||||
if (router.canGoBack()) router.back();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: pluginIconComponent(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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -209,7 +209,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: pluginIconComponent(panel.icon),
|
||||
disabled: false,
|
||||
panelKind: "plugin",
|
||||
|
||||
@@ -55,18 +55,19 @@ describe("plugin scaffold", () => {
|
||||
expect(await readdir(directory)).toContain("paseo-plugin.json");
|
||||
});
|
||||
|
||||
it("includes the author overview in the packed npm artifact", async () => {
|
||||
it("includes the author overview and its translations in the packed npm artifact", async () => {
|
||||
const parent = await mkdtemp(path.join(tmpdir(), "paseo-plugin-scaffold-"));
|
||||
directories.push(parent);
|
||||
const directory = path.join(parent, "packed-plugin");
|
||||
await scaffoldPluginDirectory(directory);
|
||||
await writeFile(path.join(directory, "OVERVIEW.zh-CN.md"), "插件概览\n");
|
||||
|
||||
const { stdout } = await execAsync("npm pack --dry-run --ignore-scripts --json", {
|
||||
cwd: directory,
|
||||
});
|
||||
const [artifact] = JSON.parse(stdout) as { files: { path: string }[] }[];
|
||||
expect(artifact.files.map((file) => file.path)).toEqual(
|
||||
expect.arrayContaining(["OVERVIEW.md", "paseo-plugin.json"]),
|
||||
expect.arrayContaining(["OVERVIEW.md", "OVERVIEW.zh-CN.md", "paseo-plugin.json"]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -105,6 +106,7 @@ describe("plugin scaffold", () => {
|
||||
files: [
|
||||
"paseo-plugin.json",
|
||||
"OVERVIEW.md",
|
||||
"OVERVIEW.*.md",
|
||||
"index.client.ts",
|
||||
"index.client.tsx",
|
||||
"index.server.ts",
|
||||
|
||||
@@ -28,6 +28,9 @@ when they help. Use sentence case and plain factual language, with no em dashes.
|
||||
Omit installation commands, badges, changelog, contributing or license sections,
|
||||
marketing, and unsupported claims. Avoid implementation filler such as empty
|
||||
cleanup functions, theme-token field lists, or lists of absent features.
|
||||
|
||||
To translate the overview, add a whole copy beside this file named for the
|
||||
language, such as OVERVIEW.zh-CN.md. Readers in that language see it instead.
|
||||
-->
|
||||
`;
|
||||
|
||||
@@ -184,6 +187,7 @@ export async function scaffoldPluginDirectory(
|
||||
files: [
|
||||
"paseo-plugin.json",
|
||||
"OVERVIEW.md",
|
||||
"OVERVIEW.*.md",
|
||||
"index.client.ts",
|
||||
"index.client.tsx",
|
||||
"index.server.ts",
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"i18next": "^26.3.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PluginText } from "../text.js";
|
||||
import type { ComponentType } from "react";
|
||||
import type { PluginIconProps } from "../icons.js";
|
||||
import type { PluginHostProps, PluginIcon } from "./contracts.js";
|
||||
@@ -12,46 +13,44 @@ export type PluginButtonContentProps = PluginHostProps & PluginButtonContext & {
|
||||
|
||||
export type PluginButtonIcon = PluginIcon<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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -81,10 +82,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>;
|
||||
}
|
||||
|
||||
@@ -102,9 +103,9 @@ export interface PluginOpenPanelOptions {
|
||||
location?: PluginPanelLocation;
|
||||
}
|
||||
|
||||
interface PluginWorkspacePanelBase {
|
||||
interface PluginWorkspacePanelBase<Text extends PluginText = string> {
|
||||
id: string;
|
||||
title: string;
|
||||
title: Text;
|
||||
icon: PluginIcon;
|
||||
locations?: readonly PluginPanelLocation[];
|
||||
}
|
||||
@@ -130,23 +131,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;
|
||||
@@ -158,29 +166,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: PluginIcon;
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -190,9 +200,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: PluginIcon;
|
||||
surface: string;
|
||||
}
|
||||
@@ -259,39 +269,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: PluginIcon;
|
||||
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>;
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -68,3 +68,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();
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PluginText } from "./text.js";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type { PluginSerializableIcon } from "./icons.js";
|
||||
import type { PluginRpcContract } from "./rpc.js";
|
||||
@@ -64,19 +65,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: PluginSerializableIcon;
|
||||
pickerTitle: string;
|
||||
searchPlaceholder: string;
|
||||
pickerTitle: Text;
|
||||
searchPlaceholder: Text;
|
||||
search: PluginRpcContract;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ export {
|
||||
type PluginAttachmentSearchPayload,
|
||||
} from "./attachments.js";
|
||||
export { defineRpc, type PluginRpcContract, type RpcInput, type RpcOutput } from "./rpc.js";
|
||||
|
||||
export type { PluginText, PluginMessage } from "./text.js";
|
||||
export {
|
||||
findSvgIconProblem,
|
||||
PLUGIN_SVG_ICON_MAX_BYTES,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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>];
|
||||
@@ -10,7 +10,8 @@
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
"types": ["node"],
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["../../plugin-examples/**/*.ts", "../../plugin-examples/**/*.tsx"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pluginMediaKind } from "./plugin-registry.js";
|
||||
import {
|
||||
PluginRegistryIndexSchema,
|
||||
PublishedPluginDetailSchema,
|
||||
pluginMediaKind,
|
||||
} from "./plugin-registry.js";
|
||||
|
||||
describe("plugin media kind", () => {
|
||||
it.each([
|
||||
@@ -16,3 +20,64 @@ describe("plugin media kind", () => {
|
||||
expect(pluginMediaKind(url)).toBe(kind);
|
||||
});
|
||||
});
|
||||
|
||||
const LEGACY_PLUGIN = {
|
||||
id: "acme/theme",
|
||||
name: "Acme theme",
|
||||
description: "A dark theme.",
|
||||
categories: ["themes"],
|
||||
author: { github: "acme" },
|
||||
repository: { url: "https://github.com/acme/theme" },
|
||||
artifact: { kind: "git", remote: "https://github.com/acme/theme", commit: "a".repeat(40) },
|
||||
media: [],
|
||||
submittedAt: "2026-01-01T00:00:00.000Z",
|
||||
reviewedAt: "2026-01-02T00:00:00.000Z",
|
||||
updatedAt: "2026-01-02T00:00:00.000Z",
|
||||
publishedAt: "2026-01-02T00:00:00.000Z",
|
||||
};
|
||||
const LEGACY_INDEX = {
|
||||
schemaVersion: 1,
|
||||
registry: { name: "Paseo", url: "https://plugins.paseo.sh" },
|
||||
categories: [{ slug: "themes", label: "Themes" }],
|
||||
plugins: [LEGACY_PLUGIN],
|
||||
featured: [],
|
||||
generatedAt: "2026-01-02T00:00:00.000Z",
|
||||
};
|
||||
|
||||
describe("plugin listing translations", () => {
|
||||
it("parses an index published before translations existed", () => {
|
||||
const index = PluginRegistryIndexSchema.parse(LEGACY_INDEX);
|
||||
expect(index.plugins[0].translations).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps per-language name and description, each optional", () => {
|
||||
const translations = { "zh-CN": { description: "深色主题。" } };
|
||||
const index = PluginRegistryIndexSchema.parse({
|
||||
...LEGACY_INDEX,
|
||||
plugins: [{ ...LEGACY_PLUGIN, translations }],
|
||||
});
|
||||
expect(index.plugins[0].translations).toEqual(translations);
|
||||
});
|
||||
|
||||
it("keeps translated overviews on the detail document beside the English readme", () => {
|
||||
const detail = PublishedPluginDetailSchema.parse({
|
||||
...LEGACY_PLUGIN,
|
||||
readme: "# Acme theme",
|
||||
overviewTranslations: { "zh-CN": "# Acme 主题" },
|
||||
});
|
||||
expect(detail.readme).toBe("# Acme theme");
|
||||
expect(detail.overviewTranslations).toEqual({ "zh-CN": "# Acme 主题" });
|
||||
expect(PublishedPluginDetailSchema.parse({ ...LEGACY_PLUGIN, readme: "" })).not.toHaveProperty(
|
||||
"overviewTranslations",
|
||||
);
|
||||
});
|
||||
|
||||
it("still requires the English name and description", () => {
|
||||
const { name: _name, ...nameless } = LEGACY_PLUGIN;
|
||||
const result = PluginRegistryIndexSchema.safeParse({
|
||||
...LEGACY_INDEX,
|
||||
plugins: [{ ...nameless, translations: { "zh-CN": { name: "Acme 主题" } } }],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,10 +29,19 @@ export const PluginRegistryArtifactSchema = z.discriminatedUnion("kind", [
|
||||
.optional(),
|
||||
}),
|
||||
]);
|
||||
/**
|
||||
* Listing text in other languages, keyed by language tag such as `zh-CN`. A missing field falls back
|
||||
* to the English `name` or `description`, which stay required so older clients keep working.
|
||||
*/
|
||||
export const PluginListingTranslationsSchema = z.record(
|
||||
z.string(),
|
||||
z.object({ name: z.string().optional(), description: z.string().optional() }),
|
||||
);
|
||||
export const PublishedPluginSchema = z.object({
|
||||
id: PluginRegistryIdSchema,
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
translations: PluginListingTranslationsSchema.optional(),
|
||||
license: z.string().optional(),
|
||||
categories: z.array(z.string()),
|
||||
author: z.object({ github: z.string(), name: z.string().optional(), npm: z.string().optional() }),
|
||||
@@ -54,7 +63,11 @@ export function pluginMediaKind(url: string): "image" | "video" {
|
||||
const extension = new URL(url).pathname.split(".").pop()?.toLowerCase() ?? "";
|
||||
return VIDEO_EXTENSIONS.has(extension) ? "video" : "image";
|
||||
}
|
||||
export const PublishedPluginDetailSchema = PublishedPluginSchema.extend({ readme: z.string() });
|
||||
export const PublishedPluginDetailSchema = PublishedPluginSchema.extend({
|
||||
readme: z.string(),
|
||||
/** The overview in other languages, keyed like `translations`. Each one replaces `readme` whole. */
|
||||
overviewTranslations: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
export const PluginRegistryIndexSchema = z.object({
|
||||
schemaVersion: z.literal(1),
|
||||
registry: z.object({ name: z.string(), url: z.string().url() }),
|
||||
|
||||
@@ -128,6 +128,21 @@ describe("plugin manifest", () => {
|
||||
await expect(readPluginManifest(directory)).rejects.toThrow();
|
||||
});
|
||||
|
||||
// Listing translations are read by the registry when it publishes, never by the daemon, so a
|
||||
// value the registry would refuse must not stop a plugin from installing.
|
||||
it.each([{ "zh-cn": { name: "示例" } }, { "zh-CN": { desc: "示例" } }, "zh-CN"])(
|
||||
"ignores listing translations: %j",
|
||||
async (translations) => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-"));
|
||||
directories.push(directory);
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "described", translations }),
|
||||
);
|
||||
await expect(readPluginManifest(directory)).resolves.toEqual({ id: "described" });
|
||||
},
|
||||
);
|
||||
|
||||
it("accepts only non-empty argv arrays for build commands", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-"));
|
||||
directories.push(directory);
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { expect, test, type Page } from "playwright/test";
|
||||
|
||||
function languageButton(page: Page) {
|
||||
return page.getByRole("button", { name: /^(Language|语言)/ }).first();
|
||||
}
|
||||
|
||||
async function chooseLanguage(page: Page, language: string) {
|
||||
await languageButton(page).click();
|
||||
await page.getByRole("menuitemradio", { name: language }).click();
|
||||
}
|
||||
|
||||
function describeAlternates(links: Element[]): string[] {
|
||||
return links.map((link) => `${link.getAttribute("hreflang")} ${link.getAttribute("href")}`);
|
||||
}
|
||||
|
||||
test.describe("server-rendered Chinese pages", () => {
|
||||
test.use({ javaScriptEnabled: false });
|
||||
|
||||
test("render in Chinese with matching language metadata before any script runs", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/zh-CN/docs/cli");
|
||||
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "zh-CN");
|
||||
await expect(page).toHaveTitle("CLI 参考 - Paseo 文档");
|
||||
await expect(page.getByRole("heading", { level: 1 })).toHaveText(/CLI 参考$/);
|
||||
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
|
||||
"href",
|
||||
"https://paseo.sh/zh-CN/docs/cli",
|
||||
);
|
||||
const alternates = await page
|
||||
.locator('link[rel="alternate"][hreflang]')
|
||||
.evaluateAll(describeAlternates);
|
||||
expect(alternates).toEqual([
|
||||
"en https://paseo.sh/docs/cli",
|
||||
"zh-CN https://paseo.sh/zh-CN/docs/cli",
|
||||
"x-default https://paseo.sh/docs/cli",
|
||||
]);
|
||||
await expect(page.locator('meta[property="og:locale"]')).toHaveAttribute("content", "zh_CN");
|
||||
});
|
||||
|
||||
test("keep English pages at their unprefixed URLs", async ({ page }) => {
|
||||
await page.goto("/docs/cli");
|
||||
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "en");
|
||||
await expect(page.getByRole("heading", { level: 1 })).toHaveText(/CLI reference$/);
|
||||
});
|
||||
|
||||
test("land translated anchors on the English section ids", async ({ page }) => {
|
||||
await page.goto("/zh-CN/docs/cli#running-agents");
|
||||
|
||||
await expect(page.locator("#running-agents")).toHaveText(/运行 Agent/);
|
||||
await expect(page.locator("main")).not.toContainText("{#");
|
||||
});
|
||||
|
||||
test("show the app's Chinese labels in the homepage mockup", async ({ page }) => {
|
||||
await page.goto("/zh-CN");
|
||||
|
||||
const mockup = page.getByRole("img", {
|
||||
name: "Paseo 桌面应用,并排打开了编程 Agent、一段对话和一份代码 diff",
|
||||
});
|
||||
await expect(mockup).toContainText("新建工作区");
|
||||
await expect(mockup).toContainText("待查看");
|
||||
await expect(mockup).toContainText("给 Agent 发消息");
|
||||
await expect(mockup).not.toContainText("Ready to review");
|
||||
});
|
||||
|
||||
test("keep no space after full-width punctuation in the homepage subtitle", async ({ page }) => {
|
||||
await page.goto("/zh-CN");
|
||||
|
||||
await expect(page.getByText(/^同时运行多个编程 Agent/)).toHaveText(
|
||||
"同时运行多个编程 Agent,在任何机器上。在桌前,也在手机上。",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("switches language on the same page, keeping the query and then every link", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/plugins/all?q=dark");
|
||||
await chooseLanguage(page, "简体中文");
|
||||
|
||||
await expect(page).toHaveURL(/\/zh-CN\/plugins\/all\?q=dark$/);
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "zh-CN");
|
||||
await expect(page.getByRole("searchbox", { name: "搜索插件" })).toHaveValue("dark");
|
||||
|
||||
await page.getByRole("link", { name: "文档", exact: true }).first().click();
|
||||
await expect(page).toHaveURL(/\/zh-CN\/docs$/);
|
||||
|
||||
await chooseLanguage(page, "English");
|
||||
await expect(page).toHaveURL(/\/docs$/);
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "en");
|
||||
});
|
||||
|
||||
test("shows the language as an abbreviation and lists every language by name", async ({ page }) => {
|
||||
await page.goto("/docs/cli#running-agents");
|
||||
const button = languageButton(page);
|
||||
await expect(button).toHaveText("EN");
|
||||
await expect(button).toHaveAccessibleName("Language: English");
|
||||
|
||||
await button.click();
|
||||
await expect(page.getByRole("menuitemradio", { name: "English" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
const chinese = page.getByRole("menuitemradio", { name: /简体中文/ });
|
||||
await expect(chinese).toHaveAttribute("aria-checked", "false");
|
||||
await expect(page.getByRole("menu")).toBeInViewport({ ratio: 1 });
|
||||
await expect(chinese).toHaveAttribute("href", "/zh-CN/docs/cli#running-agents");
|
||||
|
||||
await page.mouse.click(5, 400);
|
||||
await expect(page.getByRole("menu")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("switches language from the keyboard", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await languageButton(page).focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByRole("menuitemradio", { name: "English" })).toBeFocused();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("menu")).toHaveCount(0);
|
||||
await expect(languageButton(page)).toBeFocused();
|
||||
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(page.getByRole("menuitemradio", { name: /简体中文/ })).toBeFocused();
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expect(page).toHaveURL(/\/zh-CN$/);
|
||||
await expect(languageButton(page)).toHaveText("ZH");
|
||||
await expect(languageButton(page)).toBeFocused();
|
||||
});
|
||||
|
||||
test.describe("on a phone", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
|
||||
|
||||
const cases = [
|
||||
{ from: "/", choose: "简体中文", to: "/zh-CN", abbreviation: "ZH" },
|
||||
{ from: "/zh-CN", choose: "English", to: "/", abbreviation: "EN" },
|
||||
{ from: "/docs/cli", choose: "简体中文", to: "/zh-CN/docs/cli", abbreviation: "ZH" },
|
||||
];
|
||||
for (const { from, choose, to, abbreviation } of cases) {
|
||||
test(`switches from ${from} to ${to} with taps`, async ({ page, baseURL }) => {
|
||||
await page.goto(from);
|
||||
await languageButton(page).tap();
|
||||
await expect(page.getByRole("menu")).toBeInViewport({ ratio: 1 });
|
||||
await page.getByRole("menuitemradio", { name: choose }).tap();
|
||||
|
||||
await expect(page).toHaveURL(new URL(to, baseURL).href);
|
||||
await expect(languageButton(page)).toHaveText(abbreviation);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("finds plugins by their Chinese and English listing text", async ({ page }) => {
|
||||
await page.goto(`/zh-CN/plugins/all?q=${encodeURIComponent("深色")}`);
|
||||
await expect(page.getByRole("link", { name: /Dracula/ })).toBeVisible();
|
||||
|
||||
await page.goto(`/zh-CN/plugins/all?q=${encodeURIComponent("传令官")}`);
|
||||
await expect(page.getByRole("link", { name: /传令官/ })).toBeVisible();
|
||||
|
||||
await page.goto("/zh-CN/plugins/all?q=herald");
|
||||
await expect(page.getByRole("link", { name: /传令官/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows a translated plugin overview, or the English one under a notice", async ({ page }) => {
|
||||
await page.goto("/zh-CN/plugins/omercnet/dracula");
|
||||
await expect(page.getByRole("heading", { name: "行为" })).toBeVisible();
|
||||
await expect(page.getByRole("note")).toHaveCount(0);
|
||||
|
||||
await page.goto("/zh-CN/plugins/gpambrozio/herald");
|
||||
await expect(page.getByRole("heading", { level: 1 })).toHaveText("传令官");
|
||||
await expect(page.getByRole("note")).toHaveText(
|
||||
"该插件的概览尚未翻译为你的语言,因此以英文显示。",
|
||||
);
|
||||
await expect(page.getByRole("heading", { name: "Behavior" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows the changelog in Chinese", async ({ page }) => {
|
||||
await page.goto("/zh-CN/changelog");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "新增" }).first()).toBeVisible();
|
||||
await expect(page.getByRole("note")).toHaveCount(0);
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import securityPlugin from "./overview.fixture.json" with { type: "json" };
|
||||
import { expect, test, type Page, type Locator } from "playwright/test";
|
||||
import registry from "./registry.fixture.json" with { type: "json" };
|
||||
import { CATEGORIES } from "../src/plugins/categories";
|
||||
import copy from "../src/i18n/resources/en.json" with { type: "json" };
|
||||
|
||||
async function openPlugins(page: Page) {
|
||||
await page.goto("/plugins");
|
||||
@@ -635,7 +636,7 @@ test.describe("registry fixture layout", () => {
|
||||
await openPlugins(page);
|
||||
const categories = page.getByRole("region", { name: "Categories" });
|
||||
await expect(categories.getByRole("link")).toHaveText(
|
||||
CATEGORIES.map((category) => new RegExp(`^${category.label}\\s*\\d+$`)),
|
||||
CATEGORIES.map((slug) => new RegExp(`^${copy.plugins.categories[slug].label}\\s*\\d+$`)),
|
||||
);
|
||||
await expect(categories.getByRole("link", { name: /Extras/ })).toContainText("0");
|
||||
await expect(pluginCards(page, "What’s new")).toHaveText([
|
||||
|
||||
@@ -187,7 +187,13 @@
|
||||
"publishedAt": "2026-09-19T00:00:00.000Z",
|
||||
"updatedAt": "2026-09-19T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Herald\n\n## Behavior\n\nPaseo plugin: speaks one sentence when an agent needs you, and lists everything waiting in a sidebar panel"
|
||||
"readme": "# Herald\n\n## Behavior\n\nPaseo plugin: speaks one sentence when an agent needs you, and lists everything waiting in a sidebar panel",
|
||||
"translations": {
|
||||
"zh-CN": {
|
||||
"name": "传令官",
|
||||
"description": "Paseo 插件:当 Agent 需要你时朗读一句话,并在侧边栏列出所有等待中的事项"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gpambrozio/launchd-jobs",
|
||||
@@ -413,7 +419,15 @@
|
||||
"publishedAt": "2026-09-17T00:00:00.000Z",
|
||||
"updatedAt": "2026-09-17T00:00:00.000Z",
|
||||
"installs": 0,
|
||||
"readme": "# Dracula\n\n## Behavior\n\nDracula Classic and Alucard Classic themes for Paseo."
|
||||
"readme": "# Dracula\n\n## Behavior\n\nDracula Classic and Alucard Classic themes for Paseo.",
|
||||
"translations": {
|
||||
"zh-CN": {
|
||||
"description": "适用于 Paseo 的 Dracula Classic 和 Alucard Classic 深色主题。"
|
||||
}
|
||||
},
|
||||
"overviewTranslations": {
|
||||
"zh-CN": "# Dracula\n\n## 行为\n\n适用于 Paseo 的 Dracula Classic 和 Alucard Classic 主题。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "omercnet/fresh-worktrees",
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
---
|
||||
title: "Privacy Policy"
|
||||
description: "What stays on your machines, what the encrypted relay can see, and what Paseo Hub stores."
|
||||
lastUpdated: "2026-08-29"
|
||||
---
|
||||
|
||||
Paseo is local-first. Installing or using the open-source software does not send us your code, prompts, files, terminal output, or agent conversations. This policy explains the separate data boundaries for local Paseo, the optional official relay, the hosted Paseo Hub, and paseo.sh.
|
||||
|
||||
## Who is responsible
|
||||
|
||||
**Mohamed Boudra Ziani**, operating as Paseo\
|
||||
NIF/VAT ID: ES26617095T\
|
||||
Roc Boronat 48, Bajos 2\
|
||||
08005 Barcelona, Spain\
|
||||
Email: [hello@moboudra.com](mailto:hello@moboudra.com)
|
||||
|
||||
Mohamed Boudra Ziani is the data controller for personal data processed through the official Paseo website, relay, and hosted Hub. Independently self-hosted daemons, Hubs, and relays are controlled by their operators and are not covered by this policy.
|
||||
|
||||
## Local Paseo apps and daemons
|
||||
|
||||
Paseo runs on your machines. It does not send us analytics, telemetry, advertising identifiers, or crash reports.
|
||||
|
||||
Packaged desktop apps check GitHub Releases for updates. GitHub receives the ordinary network information needed to answer that request under its own privacy policy.
|
||||
|
||||
Agents such as Claude Code, Codex, and OpenCode communicate with their providers using credentials on your machine. Paseo does not manage or intercept those provider API calls.
|
||||
|
||||
## The official relay
|
||||
|
||||
The relay is optional. To connect your client and daemon, it processes:
|
||||
|
||||
- IP addresses and connection timing
|
||||
- Session identifiers and public handshake keys
|
||||
- Message sizes and aggregate bandwidth
|
||||
- Temporary connection and routing state
|
||||
|
||||
Your client and daemon encrypt application traffic end-to-end with NaCl box encryption. The relay carries ciphertext and cannot read your code, prompts, terminal output, or agent conversations. Payloads exist in relay memory only while being forwarded. We do not store message contents. Infrastructure may retain limited operational logs and aggregate metrics for security, capacity planning, and troubleshooting.
|
||||
|
||||
## Paseo Hub
|
||||
|
||||
When you create or use a hosted Hub account, we process:
|
||||
|
||||
- Your name, email, account credentials, sessions, IP address, and user agent
|
||||
- Your organization, members, roles, invitations, and daemon registrations
|
||||
- Identifiers and credentials for services you connect
|
||||
- Webhook events, messages, comments, attachment metadata, and related context received from those services
|
||||
- Workflow configurations, trigger inputs, outputs, execution state, activity, and audit records
|
||||
- Stripe customer identifiers and subscription information needed to provide access
|
||||
|
||||
Your repositories, local files, and agent-provider credentials remain on your infrastructure unless a workflow explicitly sends information to Hub or a connected service. Hub does not provide AI inference.
|
||||
|
||||
## Who controls workflow data
|
||||
|
||||
Paseo controls account, billing, security, and service-operation data. When an organization uses Hub to process personal data in its workflows, that organization decides why the data is processed and Paseo processes it on the organization's behalf.
|
||||
|
||||
## Why we process data
|
||||
|
||||
We process data to:
|
||||
|
||||
- Provide accounts, Hub workflows, relay connectivity, billing, and support
|
||||
- Authenticate users, daemons, and connected services
|
||||
- Prevent abuse and protect the services
|
||||
- Maintain operational and audit records
|
||||
- Meet accounting, tax, and other legal obligations
|
||||
|
||||
The legal bases are performance of our contract with you, our legitimate interests in operating and protecting the services, and compliance with legal obligations.
|
||||
|
||||
## Service providers
|
||||
|
||||
We use Fly.io for hosted infrastructure, Stripe for subscriptions and payments, and GitHub for software releases and connected GitHub features. Slack, Discord, Linear, and other services receive data only when you choose to connect or use them.
|
||||
|
||||
Some providers may process data outside the European Economic Area. Where required, we use appropriate contractual safeguards for those transfers.
|
||||
|
||||
We do not sell personal data, share it with advertisers, or use it to train AI models.
|
||||
|
||||
## Retention and deletion
|
||||
|
||||
We keep account and organization data while your account remains active. We retain operational, workflow, and audit records while needed to provide and protect the service. Billing records may be kept for legally required accounting and tax periods. Short-lived authorization codes and sessions expire automatically.
|
||||
|
||||
You can request account deletion by emailing us. Some records may remain where the law requires it or where they form part of another organization's legitimate audit history.
|
||||
|
||||
## Cookies
|
||||
|
||||
The marketing website does not use analytics or advertising cookies. Hub uses only the session and security cookies needed to sign you in and operate your account.
|
||||
|
||||
## Your rights
|
||||
|
||||
Depending on applicable law, you may request access, correction, deletion, restriction, objection, or portability of your personal data. Email [hello@moboudra.com](mailto:hello@moboudra.com). You may also complain to the [Spanish Data Protection Agency](https://www.aepd.es/).
|
||||
|
||||
## Security
|
||||
|
||||
We use access controls, encrypted transport, and limited service permissions. No online service can guarantee absolute security. Read Paseo's [security model](https://github.com/getpaseo/paseo/blob/main/SECURITY.md) or report a vulnerability privately to [hello@moboudra.com](mailto:hello@moboudra.com).
|
||||
|
||||
## Children
|
||||
|
||||
The official services are for professional developers and are not directed at children under 16.
|
||||
|
||||
## Changes
|
||||
|
||||
We will update this page and its date when our services or data practices materially change.
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
title: "隐私政策"
|
||||
description: "哪些数据留在你的机器上、加密中继能看到什么,以及 Paseo Hub 存储哪些数据。"
|
||||
lastUpdated: "2026-08-29"
|
||||
---
|
||||
|
||||
本中文版本为英文原文的翻译,仅供参考。如两者存在任何不一致,以英文版本为准。
|
||||
|
||||
Paseo 是本地优先的。安装或使用这款开源软件不会向我们发送你的代码、提示词、文件、终端输出或 Agent 对话。本政策说明本地 Paseo、可选的官方中继、托管的 Paseo Hub 以及 paseo.sh 各自独立的数据边界。
|
||||
|
||||
## 谁负责
|
||||
|
||||
**Mohamed Boudra Ziani**,以 Paseo 名义经营\
|
||||
NIF/VAT ID:ES26617095T\
|
||||
Roc Boronat 48, Bajos 2\
|
||||
08005 Barcelona, Spain\
|
||||
邮箱:[hello@moboudra.com](mailto:hello@moboudra.com)
|
||||
|
||||
Mohamed Boudra Ziani 是通过官方 Paseo 网站、中继和托管 Hub 处理的个人数据的数据控制者。独立自托管的 daemon、Hub 和中继由其运营者控制,不受本政策约束。
|
||||
|
||||
## 本地 Paseo 应用和 daemon
|
||||
|
||||
Paseo 在你的机器上运行。它不会向我们发送分析数据、遥测数据、广告标识符或崩溃报告。
|
||||
|
||||
打包的桌面应用会检查 GitHub Releases 以获取更新。GitHub 会依据其自身的隐私政策,接收响应该请求所需的常规网络信息。
|
||||
|
||||
Claude Code、Codex 和 OpenCode 等 Agent 使用你机器上的凭据与各自的 Provider 通信。Paseo 不管理也不拦截这些 Provider API 调用。
|
||||
|
||||
## 官方中继
|
||||
|
||||
中继是可选的。为了连接你的客户端和 daemon,它会处理:
|
||||
|
||||
- IP 地址和连接时间
|
||||
- 会话标识符和公开握手密钥
|
||||
- 消息大小和总带宽
|
||||
- 临时连接和路由状态
|
||||
|
||||
你的客户端和 daemon 使用 NaCl box 加密对应用流量进行端到端加密。中继传输的是密文,无法读取你的代码、提示词、终端输出或 Agent 对话。有效载荷仅在转发期间存在于中继内存中。我们不存储消息内容。基础设施可能会保留有限的运维日志和汇总指标,用于安全、容量规划和故障排查。
|
||||
|
||||
## Paseo Hub
|
||||
|
||||
当你创建或使用托管 Hub 账户时,我们会处理:
|
||||
|
||||
- 你的姓名、邮箱、账户凭据、会话、IP 地址和用户代理
|
||||
- 你的组织、成员、角色、邀请和 daemon 注册信息
|
||||
- 你所连接服务的标识符和凭据
|
||||
- 从这些服务接收的 webhook 事件、消息、评论、附件元数据及相关上下文
|
||||
- 工作流配置、触发输入、输出、执行状态、活动和审计记录
|
||||
- 提供访问所需的 Stripe 客户标识符和订阅信息
|
||||
|
||||
除非某个工作流明确将信息发送到 Hub 或已连接的服务,否则你的代码仓库、本地文件和 Agent Provider 凭据都保留在你自己的基础设施上。Hub 不提供 AI 推理。
|
||||
|
||||
## 谁控制工作流数据
|
||||
|
||||
Paseo 控制账户、账单、安全和服务运营数据。当某个组织使用 Hub 在其工作流中处理个人数据时,由该组织决定处理数据的目的,Paseo 代表该组织处理这些数据。
|
||||
|
||||
## 我们为什么处理数据
|
||||
|
||||
我们处理数据是为了:
|
||||
|
||||
- 提供账户、Hub 工作流、中继连接、账单和支持
|
||||
- 对用户、daemon 和已连接的服务进行身份验证
|
||||
- 防止滥用并保护服务
|
||||
- 维护运营和审计记录
|
||||
- 履行会计、税务及其他法律义务
|
||||
|
||||
法律依据是履行我们与你之间的合同、我们在运营和保护服务方面的正当利益,以及遵守法律义务。
|
||||
|
||||
## 服务提供商
|
||||
|
||||
我们使用 Fly.io 提供托管基础设施,使用 Stripe 处理订阅和付款,使用 GitHub 发布软件并提供已连接的 GitHub 功能。Slack、Discord、Linear 及其他服务仅在你选择连接或使用它们时才会接收数据。
|
||||
|
||||
部分服务提供商可能在欧洲经济区以外处理数据。在需要时,我们会为这些传输采取适当的合同保障措施。
|
||||
|
||||
我们不会出售个人数据、不会与广告商共享,也不会用于训练 AI 模型。
|
||||
|
||||
## 保留与删除
|
||||
|
||||
在你的账户保持有效期间,我们会保留账户和组织数据。在提供和保护服务所需的期间内,我们会保留运营、工作流和审计记录。账单记录可能会在法律要求的会计和税务期间内保留。短期授权码和会话会自动过期。
|
||||
|
||||
你可以发送邮件请求删除账户。在法律要求的情况下,或在记录构成其他组织合法审计历史的一部分时,部分记录可能会保留。
|
||||
|
||||
## Cookie
|
||||
|
||||
营销网站不使用分析或广告 Cookie。Hub 仅使用让你登录和运营账户所需的会话及安全 Cookie。
|
||||
|
||||
## 你的权利
|
||||
|
||||
根据适用法律,你可以请求访问、更正、删除、限制处理、反对处理你的个人数据,或请求数据可携带。请发送邮件至 [hello@moboudra.com](mailto:hello@moboudra.com)。你也可以向[西班牙数据保护局](https://www.aepd.es/)投诉。
|
||||
|
||||
## 安全
|
||||
|
||||
我们采用访问控制、加密传输和有限的服务权限。任何在线服务都无法保证绝对安全。请阅读 Paseo 的[安全模型](https://github.com/getpaseo/paseo/blob/main/SECURITY.md),或私下向 [hello@moboudra.com](mailto:hello@moboudra.com) 报告漏洞。
|
||||
|
||||
## 儿童
|
||||
|
||||
官方服务面向专业开发者,不针对 16 岁以下的儿童。
|
||||
|
||||
## 变更
|
||||
|
||||
当我们的服务或数据处理方式发生重大变化时,我们会更新本页面及其日期。
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
title: "Terms of Service"
|
||||
description: "Terms for the official Paseo Relay and hosted Paseo Hub services."
|
||||
lastUpdated: "2026-08-29"
|
||||
---
|
||||
|
||||
These Terms govern the official services operated at paseo.sh, relay.paseo.sh, and hub.paseo.sh. By using the official relay or hosted Hub, you agree to them. Our [Privacy Policy](/privacy) explains how those services process data.
|
||||
|
||||
## Who provides the services
|
||||
|
||||
**Mohamed Boudra Ziani**, operating as Paseo\
|
||||
NIF/VAT ID: ES26617095T\
|
||||
Roc Boronat 48, Bajos 2\
|
||||
08005 Barcelona, Spain\
|
||||
Email: [hello@moboudra.com](mailto:hello@moboudra.com)
|
||||
|
||||
## Paseo's open-source software
|
||||
|
||||
Paseo is open-source software licensed under the Apache License 2.0. You can install, modify, and self-host it under that license without purchasing Paseo Hub or using the official relay.
|
||||
|
||||
These Terms do not replace or restrict the open-source license. They apply only to services operated by Paseo. A self-hosted Hub or relay is operated by whoever hosts it.
|
||||
|
||||
## The official relay
|
||||
|
||||
The relay is an optional service that connects Paseo clients to your daemon without requiring you to expose the daemon directly. Traffic is encrypted end-to-end between your client and daemon. The relay carries encrypted data but cannot read its contents.
|
||||
|
||||
The relay is subject to reasonable and fair use. We may limit bandwidth, connection volume, or abusive traffic when necessary to keep it reliable and secure.
|
||||
|
||||
## Paseo Hub
|
||||
|
||||
Hub lets you connect daemons, configure workflows, receive events from connected services, and instruct agents running on your infrastructure. Hub does not provide AI inference.
|
||||
|
||||
You are responsible for:
|
||||
|
||||
- The workflows, permissions, and connected services you configure
|
||||
- The people you invite and the access you give them
|
||||
- Actions performed by your agents and connected accounts
|
||||
- Reviewing generated code and other agent output
|
||||
- Maintaining backups of your repositories and local data
|
||||
|
||||
## Your content
|
||||
|
||||
You keep ownership of your prompts, workflow configuration, code, messages, and outputs. You give Paseo only the permission needed to receive, transmit, store, and process that content to operate the services you request.
|
||||
|
||||
We do not sell your content, use it for advertising, or use it to train AI models.
|
||||
|
||||
## Accounts and teams
|
||||
|
||||
Keep your account credentials secure. Organization owners and administrators are responsible for invitations, permissions, connected services, and activity performed by their members. If you use Hub for an organization, you confirm that you are authorized to act for it and to process the data sent through its workflows.
|
||||
|
||||
## Acceptable use
|
||||
|
||||
You must not use the services to:
|
||||
|
||||
- Break applicable law or infringe another person's rights
|
||||
- Attack systems, distribute malware, or evade access controls
|
||||
- Gain unauthorized access to another account, daemon, or service
|
||||
- Interfere with the service or bypass reasonable usage limits
|
||||
- Resell the official services without our written permission
|
||||
|
||||
## Trials, subscriptions, and payment
|
||||
|
||||
Current prices, taxes, billing periods, and seat calculations are shown before purchase. Trial terms are shown when you start a trial. A no-card trial ends unless you add a valid payment method before it expires.
|
||||
|
||||
Paid subscriptions renew automatically for the billing period shown at checkout until canceled. You can manage payment methods, invoices, and cancellation through the billing portal. Cancellation normally takes effect at the end of the current paid period. Payments are non-refundable except where the law requires otherwise.
|
||||
|
||||
Stripe processes payments. Paseo does not store complete payment-card details. Nothing in these Terms removes cancellation, refund, withdrawal, or other rights given to you by applicable consumer law.
|
||||
|
||||
## Third-party services
|
||||
|
||||
Paseo can connect to services such as GitHub, Slack, Discord, Linear, Anthropic, and OpenAI. Those services have their own terms and privacy policies. Paseo is not responsible for their availability, output, or handling of data.
|
||||
|
||||
## Availability and changes
|
||||
|
||||
We work to keep the official relay and Hub available, but do not promise uninterrupted service or a service level unless we agree one in writing. We may change features, introduce reasonable limits, or suspend access when needed for security, abuse prevention, legal compliance, or operation of the service.
|
||||
|
||||
Where practical, we will give reasonable notice before a change that materially reduces a paid service.
|
||||
|
||||
## Suspension and termination
|
||||
|
||||
You may stop using the services or cancel your subscription at any time. We may suspend access for non-payment, serious abuse, security threats, or a material breach of these Terms. Where practical, we will explain the reason and allow you to correct it.
|
||||
|
||||
## Warranty and liability
|
||||
|
||||
Software and automated agents can make mistakes. Review important actions and maintain appropriate backups.
|
||||
|
||||
To the extent permitted by law, the official services are provided as available and without implied warranties. Paseo is not liable for indirect or consequential losses caused by agent output, third-party services, or your workflow configuration. Our total liability relating to a paid service will not exceed the amount you paid for it during the preceding 12 months.
|
||||
|
||||
Nothing here limits liability or statutory rights that cannot legally be limited.
|
||||
|
||||
## Governing law
|
||||
|
||||
These Terms are governed by Spanish law. If you are a consumer, you keep the mandatory protections and court rights provided by the law where you live.
|
||||
|
||||
## Changes and contact
|
||||
|
||||
We may update these Terms as the services change. We will announce material changes through the service or by email where appropriate. Questions can be sent to [hello@moboudra.com](mailto:hello@moboudra.com).
|
||||
@@ -0,0 +1,99 @@
|
||||
---
|
||||
title: "服务条款"
|
||||
description: "官方 Paseo 中继和托管 Paseo Hub 服务的条款。"
|
||||
lastUpdated: "2026-08-29"
|
||||
---
|
||||
|
||||
本中文版本为英文原文的翻译,仅供参考。如两者存在任何不一致,以英文版本为准。
|
||||
|
||||
本条款适用于在 paseo.sh、relay.paseo.sh 和 hub.paseo.sh 上运营的官方服务。使用官方中继或托管 Hub,即表示你同意本条款。我们的[隐私政策](/privacy)说明了这些服务如何处理数据。
|
||||
|
||||
## 谁提供服务
|
||||
|
||||
**Mohamed Boudra Ziani**,以 Paseo 名义经营\
|
||||
NIF/VAT ID:ES26617095T\
|
||||
Roc Boronat 48, Bajos 2\
|
||||
08005 Barcelona, Spain\
|
||||
邮箱:[hello@moboudra.com](mailto:hello@moboudra.com)
|
||||
|
||||
## Paseo 开源软件
|
||||
|
||||
Paseo 是依据 Apache License 2.0 授权的开源软件。你可以在该许可证下安装、修改和自托管它,无需购买 Paseo Hub 或使用官方中继。
|
||||
|
||||
本条款不取代也不限制开源许可证,仅适用于由 Paseo 运营的服务。自托管的 Hub 或中继由其托管者运营。
|
||||
|
||||
## 官方中继
|
||||
|
||||
中继是一项可选服务,它将 Paseo 客户端连接到你的 daemon,而无需你直接暴露 daemon。流量在你的客户端和 daemon 之间进行端到端加密。中继传输加密数据,但无法读取其内容。
|
||||
|
||||
中继须遵守合理公平使用原则。在必要时,为保持其可靠和安全,我们可能会限制带宽、连接数量或滥用流量。
|
||||
|
||||
## Paseo Hub
|
||||
|
||||
Hub 让你可以连接 daemon、配置工作流、接收已连接服务的事件,并向运行在你基础设施上的 Agent 下达指令。Hub 不提供 AI 推理。
|
||||
|
||||
你需要对以下事项负责:
|
||||
|
||||
- 你配置的工作流、权限和已连接的服务
|
||||
- 你邀请的人员以及你授予他们的访问权限
|
||||
- 你的 Agent 和已连接账户执行的操作
|
||||
- 审查生成的代码和其他 Agent 输出
|
||||
- 维护代码仓库和本地数据的备份
|
||||
|
||||
## 你的内容
|
||||
|
||||
你保留对提示词、工作流配置、代码、消息和输出的所有权。你仅授予 Paseo 为运营你所请求的服务而接收、传输、存储和处理这些内容所需的许可。
|
||||
|
||||
我们不会出售你的内容、不会将其用于广告,也不会用它训练 AI 模型。
|
||||
|
||||
## 账户和团队
|
||||
|
||||
请妥善保管你的账户凭据。组织所有者和管理员对邀请、权限、已连接的服务及其成员的活动负责。如果你为某个组织使用 Hub,即表示你确认你有权代表该组织行事,并有权处理通过其工作流发送的数据。
|
||||
|
||||
## 可接受的使用
|
||||
|
||||
你不得使用这些服务:
|
||||
|
||||
- 违反适用法律或侵犯他人权利
|
||||
- 攻击系统、传播恶意软件或规避访问控制
|
||||
- 未经授权访问他人的账户、daemon 或服务
|
||||
- 干扰服务或绕过合理的使用限制
|
||||
- 未经我们书面许可转售官方服务
|
||||
|
||||
## 试用、订阅和付款
|
||||
|
||||
当前价格、税费、计费周期和席位计算方式会在购买前显示。试用条款会在你开始试用时显示。无需绑卡的试用会在到期时结束,除非你在到期前添加有效的付款方式。
|
||||
|
||||
付费订阅会按结账时显示的计费周期自动续订,直至取消。你可以通过账单门户管理付款方式、发票和取消订阅。取消通常在当前付费周期结束时生效。除法律另有要求外,付款不予退还。
|
||||
|
||||
付款由 Stripe 处理。Paseo 不存储完整的支付卡信息。本条款中的任何内容均不会剥夺适用的消费者保护法赋予你的取消、退款、撤回或其他权利。
|
||||
|
||||
## 第三方服务
|
||||
|
||||
Paseo 可以连接 GitHub、Slack、Discord、Linear、Anthropic 和 OpenAI 等服务。这些服务有各自的条款和隐私政策。Paseo 不对它们的可用性、输出或数据处理负责。
|
||||
|
||||
## 可用性和变更
|
||||
|
||||
我们努力保持官方中继和 Hub 可用,但除非另行书面约定,否则不承诺服务不中断或任何服务等级。在出于安全、防止滥用、法律合规或服务运营的需要时,我们可能会更改功能、引入合理限制或暂停访问。
|
||||
|
||||
在可行的情况下,对于会实质性削减付费服务的变更,我们会提前给予合理通知。
|
||||
|
||||
## 暂停和终止
|
||||
|
||||
你可以随时停止使用服务或取消订阅。如出现未付款、严重滥用、安全威胁或实质性违反本条款的情况,我们可能会暂停访问。在可行的情况下,我们会说明原因并允许你予以纠正。
|
||||
|
||||
## 保证和责任
|
||||
|
||||
软件和自动化 Agent 可能会犯错。请审查重要操作并保留适当的备份。
|
||||
|
||||
在法律允许的范围内,官方服务按现状提供,不附带任何默示保证。对于由 Agent 输出、第三方服务或你的工作流配置造成的间接或后果性损失,Paseo 不承担责任。我们就某项付费服务承担的总责任不超过你在此前 12 个月内为该服务支付的金额。
|
||||
|
||||
本条款中的任何内容均不限制依法不得限制的责任或法定权利。
|
||||
|
||||
## 适用法律
|
||||
|
||||
本条款受西班牙法律管辖。如果你是消费者,你仍享有你所在地法律规定的强制性保护和诉讼权利。
|
||||
|
||||
## 变更和联系方式
|
||||
|
||||
随着服务的变化,我们可能会更新本条款。在适当的情况下,我们会通过服务或电子邮件公布重大变更。如有疑问,请发送邮件至 [hello@moboudra.com](mailto:hello@moboudra.com)。
|
||||
@@ -20,9 +20,11 @@
|
||||
"@tanstack/react-start": "^1.168.26",
|
||||
"framer-motion": "^12.35.2",
|
||||
"github-slugger": "^2.0.0",
|
||||
"i18next": "^26.3.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"react": "^19.1.4",
|
||||
"react-dom": "^19.1.4",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-markdown": "^10.1.0",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-pretty-code": "^0.14.3",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
title: "你好,世界"
|
||||
description: "Paseo 博客的第一篇文章。"
|
||||
date: "2026-03-29"
|
||||
---
|
||||
|
||||
欢迎来到 Paseo 博客。
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
title: "我对 Electron 的看法错了"
|
||||
description: "当小体积安装包不再比渲染、通知和打包 Node daemon 更重要时,我为什么把 Paseo 从 Tauri 迁移到了 Electron。"
|
||||
date: "2026-05-28"
|
||||
draft: "false"
|
||||
---
|
||||
|
||||
开始构建 Paseo 时,我选择了 Tauri。
|
||||
|
||||
当时的理由看起来显而易见。我在做一个使用 Web 界面的桌面应用,希望交付的东西不显得臃肿。和很多开发者一样,我也早已默认 Electron 是那个不好的选项。
|
||||
|
||||
Tauri 具备所有对的东西:Rust、小体积二进制、原生 WebView、更低的内存占用。它看起来是更好的选择。
|
||||
|
||||
我还经常在产品落地页上看到 “Built with Tauri”,我想这对我的影响比我意识到的更大。它让 Tauri 显得像是酷小孩们都在用的东西。
|
||||
|
||||
我以为自己比那些 Electron 应用更懂行。一开始,我似乎也确实是对的。在 macOS 上,应用体积很小,界面运行良好,安装包的大小让我自我感觉良好。
|
||||
|
||||
让应用在 Windows 上运行起来也没什么问题。Tauri 在那里使用基于 Chromium 的 WebView2,所以应用的表现与我的预期足够接近。确实有一些平台差异要处理,但都不是 Tauri 特有的。
|
||||
|
||||
麻烦是从 Linux 开始的。
|
||||
|
||||
Tauri 不会在各个平台上捆绑同一个浏览器引擎,这正是它的设计初衷。在 Linux 上,它依赖 WebKitGTK。这听起来很优雅,直到你开始在不同发行版、不同 GPU 配置以及 Wayland/X11 的差异之间调试渲染行为。
|
||||
|
||||
对 Paseo 来说,这变成了大量我并不想做的产品工作。Tauri 使用的 WebKitGTK 绑定太旧了。Wayland 有问题。等我终于让应用跑起来,它看起来就是不一样。不只是字重或抗锯齿这类小事,有些页面的布局确实存在差异。
|
||||
|
||||
这些我也许都能忍,但随后我开始实现通知功能。
|
||||
|
||||
对 Paseo 来说,通知不是锦上添花。当一个 Agent 完成、失败或需要关注时,我希望用户点击通知就能跳转到正确的位置。
|
||||
|
||||
Tauri 的通知插件可以显示通知,但[桌面端的点击处理](https://github.com/tauri-apps/plugins-workspace/issues/2150)并没有以我需要的方式提供。有一个叫 Actions API 的东西可以给通知附加回调,但文档把它标注为仅限移动端,这很奇怪。
|
||||
|
||||
这让我注意到 Tauri 正在向移动应用扩展,很难不产生一些疑虑。我还在和最基本的桌面产品问题较劲,而这个框架已经转向了完全不同的方向。
|
||||
|
||||
我尝试了一堆 Rust crate,但没有一个能按我想要的方式工作,最后只好为每个平台分别编写通知处理逻辑。这并不简单,而且现在我还得维护它。
|
||||
|
||||
还有 daemon 的问题。这部分其实不能怪 Tauri,但它让我进一步倒向了 Electron。
|
||||
|
||||
Paseo 有一个 Node.js daemon,而我想要一键式的体验:下载应用、打开、开始使用。我不想告诉用户“先去运行这条命令”,也不想向他们解释什么是 “daemon”。
|
||||
|
||||
Tauri 支持 sidecar,所以我把 daemon 打包了进去,它也能工作。但这本身就成了一个项目:不同平台和目标三元组需要不同的二进制文件,还有打包细节、权限、进程启动、路径和升级。
|
||||
|
||||
某一刻我意识到,我其实是在多绕几步重新造一个 Electron。
|
||||
|
||||
于是我决定试试 Electron。迁移过程有点痛苦,但我出乎意料地很快就让一切跑了起来。经过一周扎实的工作,Paseo 反而变得更轻、更简单了。界面在各个平台上看起来都一样。通知的表现符合我的需要。daemon 也更简单了,因为 Electron 已经内置了 Node。
|
||||
|
||||
总的来说,从那以后我对 Electron 一直很满意。它大多数时候不会碍事。出现性能问题时,原因往往在我的应用代码,而不是 Electron 本身。我想这也是 Electron 名声不好的原因:做出一个臃肿的 Electron 应用实在太容易了。
|
||||
|
||||
我的结论并不是 Tauri 不好。它只是不适合 Paseo,而我做出那个选择更多是凭感觉,而不是客观地权衡利弊。
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getChangelogReleaseGroups, localizeReleases, parseChangelog } from "~/changelog";
|
||||
|
||||
const ENGLISH = parseChangelog(
|
||||
[
|
||||
"# Changelog",
|
||||
"",
|
||||
"## 0.2.0 - 2026-05-02",
|
||||
"",
|
||||
"- New",
|
||||
"",
|
||||
"## 0.1.0 - 2026-05-01",
|
||||
"",
|
||||
"- Old",
|
||||
].join("\n"),
|
||||
"en",
|
||||
);
|
||||
|
||||
describe("localizeReleases", () => {
|
||||
it("uses a translation whose version and date match the English entry", () => {
|
||||
const chinese = parseChangelog("## 0.1.0 - 2026-05-01\n\n- 旧\n", "zh-CN");
|
||||
|
||||
expect(localizeReleases(ENGLISH, chinese).map((release) => release.locale)).toEqual([
|
||||
"en",
|
||||
"zh-CN",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows a release in English when its notes changed after translation", () => {
|
||||
const stale = parseChangelog("## 0.2.0 - 2026-04-30\n\n- 新\n", "zh-CN");
|
||||
|
||||
expect(localizeReleases(ENGLISH, stale)[0]).toBe(ENGLISH[0]);
|
||||
});
|
||||
|
||||
it("drops a translation whose English entry was renamed, as when a beta is promoted", () => {
|
||||
const beta = parseChangelog("## 0.2.0-beta.1 - 2026-05-02\n\n- 新\n", "zh-CN");
|
||||
|
||||
expect(localizeReleases(ENGLISH, beta)).toEqual(ENGLISH);
|
||||
});
|
||||
|
||||
it("refuses a duplicated version", () => {
|
||||
expect(() => parseChangelog("## 1.0.0 - 2026-05-01\n## 1.0.0 - 2026-05-01\n", "zh-CN")).toThrow(
|
||||
"Duplicate changelog version: 1.0.0",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function publishedVersions(locale: "en" | "zh-CN"): string[] {
|
||||
return getChangelogReleaseGroups(locale)
|
||||
.flatMap((group) => group.releases)
|
||||
.map((release) => `${release.version} ${release.date}`);
|
||||
}
|
||||
|
||||
describe("the published changelog", () => {
|
||||
it("lists the same releases in every language", () => {
|
||||
expect(publishedVersions("zh-CN")).toEqual(publishedVersions("en"));
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,13 @@
|
||||
import changelogMarkdown from "../../../CHANGELOG.md?raw";
|
||||
import englishChangelog from "../../../CHANGELOG.md?raw";
|
||||
import chineseChangelog from "../../../CHANGELOG.zh-CN.md?raw";
|
||||
import { DEFAULT_LOCALE, type Locale, localizePath } from "~/i18n";
|
||||
|
||||
export interface ChangelogRelease {
|
||||
version: string;
|
||||
date: string;
|
||||
markdown: string;
|
||||
/** The language of `markdown`; English when the release has no translation yet. */
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
export interface ChangelogReleaseGroup {
|
||||
@@ -21,7 +25,7 @@ function minorVersion(version: string): string {
|
||||
return version.split(".").slice(0, 2).join(".");
|
||||
}
|
||||
|
||||
function parseChangelog(markdown: string): ChangelogRelease[] {
|
||||
export function parseChangelog(markdown: string, locale: Locale): ChangelogRelease[] {
|
||||
const releases: ChangelogRelease[] = [];
|
||||
const versions = new Set<string>();
|
||||
let currentRelease: ChangelogRelease | null = null;
|
||||
@@ -40,6 +44,7 @@ function parseChangelog(markdown: string): ChangelogRelease[] {
|
||||
version,
|
||||
date: heading[2],
|
||||
markdown: "",
|
||||
locale,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
@@ -51,6 +56,24 @@ function parseChangelog(markdown: string): ChangelogRelease[] {
|
||||
return releases;
|
||||
}
|
||||
|
||||
/**
|
||||
* The English releases, each replaced by its translation when one exists. A translation counts
|
||||
* only when it carries the same version and date as the English entry, so a stale translation never
|
||||
* stands in for newer notes. A release without a current translation is shown whole in English, and
|
||||
* a translation whose English entry was renamed or removed, such as a promoted beta, is dropped:
|
||||
* a release never waits on its translation.
|
||||
*/
|
||||
export function localizeReleases(
|
||||
english: readonly ChangelogRelease[],
|
||||
translated: readonly ChangelogRelease[],
|
||||
): ChangelogRelease[] {
|
||||
const byVersion = new Map(translated.map((release) => [release.version, release]));
|
||||
return english.map((release) => {
|
||||
const translation = byVersion.get(release.version);
|
||||
return translation?.date === release.date ? translation : release;
|
||||
});
|
||||
}
|
||||
|
||||
function groupChangelogReleases(releases: ChangelogRelease[]): ChangelogReleaseGroup[] {
|
||||
const groups: ChangelogReleaseGroup[] = [];
|
||||
|
||||
@@ -67,9 +90,18 @@ function groupChangelogReleases(releases: ChangelogRelease[]): ChangelogReleaseG
|
||||
return groups;
|
||||
}
|
||||
|
||||
const changelogReleases = parseChangelog(changelogMarkdown);
|
||||
const englishReleases = parseChangelog(englishChangelog, DEFAULT_LOCALE);
|
||||
|
||||
export const changelogReleaseGroups = groupChangelogReleases(changelogReleases);
|
||||
const releaseGroups: Record<Locale, ChangelogReleaseGroup[]> = {
|
||||
en: groupChangelogReleases(englishReleases),
|
||||
"zh-CN": groupChangelogReleases(
|
||||
localizeReleases(englishReleases, parseChangelog(chineseChangelog, "zh-CN")),
|
||||
),
|
||||
};
|
||||
|
||||
export function getChangelogReleaseGroups(locale: Locale): ChangelogReleaseGroup[] {
|
||||
return releaseGroups[locale];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep link from a downloadable version to its notes.
|
||||
@@ -81,9 +113,8 @@ export const changelogReleaseGroups = groupChangelogReleases(changelogReleases);
|
||||
* changelog commit hasn't been deployed yet) get the plain changelog, never a
|
||||
* dead anchor.
|
||||
*/
|
||||
export function changelogLink(version: string): { to: "/changelog"; hash?: string } {
|
||||
const known = changelogReleases.some((release) => release.version === version);
|
||||
return known
|
||||
? { to: "/changelog", hash: releaseAnchor(minorVersion(version)) }
|
||||
: { to: "/changelog" };
|
||||
export function changelogLink(version: string, locale: Locale): { to: string; hash?: string } {
|
||||
const to = localizePath("/changelog", locale);
|
||||
const known = englishReleases.some((release) => release.version === version);
|
||||
return known ? { to, hash: releaseAnchor(minorVersion(version)) } : { to };
|
||||
}
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LandingPage } from "~/components/landing-page";
|
||||
import { getAgentPage } from "~/data/agent-pages";
|
||||
import { type AgentPageSlug, getAgentPage } from "~/data/agent-pages";
|
||||
import { getTranslator, routeLocale } from "~/i18n";
|
||||
import { pageMeta } from "~/meta";
|
||||
|
||||
export function agentRouteOptions(slug: string) {
|
||||
const page = getAgentPage(slug);
|
||||
export function agentRouteOptions(slug: AgentPageSlug) {
|
||||
return {
|
||||
head: () => pageMeta(page.metaTitle, page.metaDescription, `/${slug}`),
|
||||
head: ({ params }: { params: { locale?: string } }) => {
|
||||
const locale = routeLocale(params);
|
||||
const page = getAgentPage(slug, getTranslator(locale));
|
||||
return pageMeta({
|
||||
title: page.metaTitle,
|
||||
description: page.metaDescription,
|
||||
path: `/${slug}`,
|
||||
locale,
|
||||
});
|
||||
},
|
||||
component: function AgentLandingPage() {
|
||||
const { t } = useTranslation();
|
||||
const page = getAgentPage(slug, t);
|
||||
return <LandingPage title={page.title} subtitle={page.subtitle} />;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DocsMarkdown } from "~/components/docs-markdown";
|
||||
import { SiteShell } from "~/components/site-shell";
|
||||
import { UntranslatedNotice } from "~/components/untranslated-notice";
|
||||
import { getAlternativePage } from "~/data/alternative-pages";
|
||||
import { getTranslator, routeLocale, useLocale } from "~/i18n";
|
||||
import { pageMeta } from "~/meta";
|
||||
|
||||
export function alternativeRouteOptions(slug: string) {
|
||||
const page = getAlternativePage(slug);
|
||||
|
||||
return {
|
||||
head: () =>
|
||||
pageMeta(
|
||||
page?.title ?? "Alternative - Paseo",
|
||||
page?.description ?? "",
|
||||
`/alternatives/${slug}`,
|
||||
),
|
||||
head: ({ params }: { params: { locale?: string } }) => {
|
||||
const locale = routeLocale(params);
|
||||
const page = getAlternativePage(slug, locale);
|
||||
return pageMeta({
|
||||
title: page?.title ?? getTranslator(locale)("common.notFoundTitle"),
|
||||
description: page?.description ?? "",
|
||||
path: `/alternatives/${slug}`,
|
||||
locale,
|
||||
});
|
||||
},
|
||||
component: function AlternativePageRoute() {
|
||||
return <AlternativePageContent slug={slug} />;
|
||||
},
|
||||
@@ -20,20 +25,22 @@ export function alternativeRouteOptions(slug: string) {
|
||||
}
|
||||
|
||||
function AlternativePageContent({ slug }: { slug: string }) {
|
||||
const page = getAlternativePage(slug);
|
||||
const { t } = useTranslation();
|
||||
const page = getAlternativePage(slug, useLocale());
|
||||
|
||||
if (!page) {
|
||||
return (
|
||||
<SiteShell width="default">
|
||||
<p className="text-muted-foreground">Page not found.</p>
|
||||
<p className="text-muted-foreground">{t("common.notFound")}</p>
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SiteShell width="default">
|
||||
<UntranslatedNotice contentLocale={page.locale} />
|
||||
<div className="comparison-prose">
|
||||
<DocsMarkdown>{page.content}</DocsMarkdown>
|
||||
<DocsMarkdown lang={page.locale}>{page.content}</DocsMarkdown>
|
||||
</div>
|
||||
</SiteShell>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocalizePath } from "~/i18n";
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
label: string;
|
||||
/** Internal path. The last item is the current page and has no href. */
|
||||
/** Language-neutral internal path. The last item is the current page and has no href. */
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export function Breadcrumbs({ items }: { items: BreadcrumbItem[] }) {
|
||||
const { t } = useTranslation();
|
||||
const localize = useLocalizePath();
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className="not-prose mb-6">
|
||||
<nav aria-label={t("common.breadcrumb")} className="not-prose mb-6">
|
||||
<ol className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
{items.map((item, index) => {
|
||||
const last = index === items.length - 1;
|
||||
@@ -17,7 +21,7 @@ export function Breadcrumbs({ items }: { items: BreadcrumbItem[] }) {
|
||||
<li key={`${item.href ?? ""}#${item.label}`} className="flex items-center gap-2">
|
||||
{index > 0 && <ChevronRight size={14} className="text-border" />}
|
||||
{item.href && !last ? (
|
||||
<Link to={item.href} className="hover:text-foreground transition-colors">
|
||||
<Link to={localize(item.href)} className="hover:text-foreground transition-colors">
|
||||
{item.label}
|
||||
</Link>
|
||||
) : (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface CodeBlockProps {
|
||||
children: string;
|
||||
@@ -11,6 +12,7 @@ const SIZE_PADDING: Record<NonNullable<CodeBlockProps["size"]>, string> = {
|
||||
};
|
||||
|
||||
export function CodeBlock({ children, size = "md" }: CodeBlockProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
const handleCopy = React.useCallback(() => {
|
||||
@@ -31,7 +33,7 @@ export function CodeBlock({ children, size = "md" }: CodeBlockProps) {
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors p-1"
|
||||
title="Copy to clipboard"
|
||||
title={t("common.copyToClipboard")}
|
||||
>
|
||||
{copied ? (
|
||||
<svg
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type BreadcrumbItem, Breadcrumbs } from "~/components/breadcrumbs";
|
||||
import { type Doc, type DocsNavNode, getDocBreadcrumbGroups } from "~/docs";
|
||||
|
||||
@@ -8,13 +9,15 @@ interface DocsBreadcrumbsProps {
|
||||
}
|
||||
|
||||
export function DocsBreadcrumbs({ doc, tree }: DocsBreadcrumbsProps) {
|
||||
const { t } = useTranslation();
|
||||
const docsLabel = t("nav.docs");
|
||||
const items = useMemo<BreadcrumbItem[]>(
|
||||
() => [
|
||||
{ label: "Docs", href: "/docs" },
|
||||
{ label: docsLabel, href: "/docs" },
|
||||
...getDocBreadcrumbGroups(doc, tree).map((group) => ({ label: group.label })),
|
||||
{ label: doc.frontmatter.nav },
|
||||
],
|
||||
[doc, tree],
|
||||
[doc, docsLabel, tree],
|
||||
);
|
||||
return <Breadcrumbs items={items} />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Check, Copy, FileText } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface DocsMarkdownActionsProps {
|
||||
content: string;
|
||||
@@ -7,6 +8,7 @@ interface DocsMarkdownActionsProps {
|
||||
}
|
||||
|
||||
export function DocsMarkdownActions({ content, markdownHref }: DocsMarkdownActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -27,7 +29,7 @@ export function DocsMarkdownActions({ content, markdownHref }: DocsMarkdownActio
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||
{copied ? "Copied" : "Copy as markdown"}
|
||||
{copied ? t("docs.copied") : t("docs.copyMarkdown")}
|
||||
</button>
|
||||
<a
|
||||
href={markdownHref}
|
||||
@@ -36,7 +38,7 @@ export function DocsMarkdownActions({ content, markdownHref }: DocsMarkdownActio
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<FileText size={14} />
|
||||
View as markdown
|
||||
{t("docs.viewMarkdown")}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import * as React from "react";
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { openEnclosingExamples } from "~/docs-example-target";
|
||||
import { type Locale, localizePath, useLocale } from "~/i18n";
|
||||
import { getDocsHighlighter, docsRehypePlugins, docsRemarkPlugins } from "~/docs-rehype";
|
||||
|
||||
function getCodeText(children: React.ReactNode): string {
|
||||
@@ -65,9 +67,15 @@ function DocsPre({
|
||||
return <pre {...props}>{children}</pre>;
|
||||
}
|
||||
|
||||
const docsMarkdownComponents: Components = {
|
||||
pre: DocsPre,
|
||||
};
|
||||
/** Links to other site pages stay in the reader's language; files and external URLs are unchanged. */
|
||||
function markdownComponents(locale: Locale): Components {
|
||||
return {
|
||||
pre: DocsPre,
|
||||
a: ({ href, node: _node, ...props }) => (
|
||||
<a {...props} href={href === undefined ? undefined : localizePath(href, locale)} />
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// A collapsed example hides everything inside it, so a link to one scrolls to
|
||||
// nothing until the block is open.
|
||||
@@ -91,15 +99,21 @@ function useHashTarget(content: string): void {
|
||||
}, [content]);
|
||||
}
|
||||
|
||||
export function DocsMarkdown({ children }: { children: string }) {
|
||||
/** Renders Markdown written in `lang`, which differs from the page's language for a fallback. */
|
||||
export function DocsMarkdown({ children, lang }: { children: string; lang: Locale }) {
|
||||
useHashTarget(children);
|
||||
const { t } = useTranslation();
|
||||
const locale = useLocale();
|
||||
const anchorLabel = t("docs.headingAnchor");
|
||||
const rehypePlugins = React.useMemo(() => docsRehypePlugins(anchorLabel), [anchorLabel]);
|
||||
const components = React.useMemo(() => markdownComponents(locale), [locale]);
|
||||
|
||||
return (
|
||||
<div className="docs-prose">
|
||||
<div className="docs-prose" lang={lang}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={docsRemarkPlugins}
|
||||
rehypePlugins={docsRehypePlugins}
|
||||
components={docsMarkdownComponents}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={components}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Link, useLocation } from "@tanstack/react-router";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type DocsNavNode } from "~/docs";
|
||||
import { splitLocalePath, useLocalizePath } from "~/i18n";
|
||||
|
||||
interface DocsNavProps {
|
||||
nodes: DocsNavNode[];
|
||||
@@ -47,13 +49,14 @@ function PageLink({
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const isActive = location.pathname === node.href;
|
||||
const href = useLocalizePath()(node.href);
|
||||
const isActive = location.pathname === href;
|
||||
const ref = useScrollIntoViewWhenActive(isActive);
|
||||
|
||||
return (
|
||||
<Link
|
||||
ref={ref}
|
||||
to={node.href}
|
||||
to={href}
|
||||
activeOptions={ACTIVE_OPTIONS_EXACT}
|
||||
onClick={onNavigate}
|
||||
data-docs-nav-active={isActive ? "" : undefined}
|
||||
@@ -80,7 +83,8 @@ function GroupNode({
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const currentHref = location.pathname;
|
||||
const localize = useLocalizePath();
|
||||
const currentHref = splitLocalePath(location.pathname).path;
|
||||
const containsActive = useMemo(() => nodeContainsHref(node, currentHref), [node, currentHref]);
|
||||
const isActive = node.href === currentHref;
|
||||
const ref = useScrollIntoViewWhenActive(isActive);
|
||||
@@ -129,7 +133,7 @@ function GroupNode({
|
||||
) : (
|
||||
<Link
|
||||
ref={ref}
|
||||
to={node.href}
|
||||
to={localize(node.href)}
|
||||
activeOptions={ACTIVE_OPTIONS_EXACT}
|
||||
onClick={handleClick}
|
||||
aria-expanded={hasChildren ? isOpen : undefined}
|
||||
@@ -158,9 +162,12 @@ function CategoryNode({
|
||||
mobile?: boolean;
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// Categories are named in the English docs' frontmatter; the page language translates the name.
|
||||
const label = t(`docs.categories.${node.label}`, { defaultValue: node.label });
|
||||
return (
|
||||
<div className={mobile ? "space-y-1" : "space-y-1 mt-6 first:mt-0"}>
|
||||
<div className="px-3 py-2 text-xs font-medium text-foreground">{node.label}</div>
|
||||
<div className="px-3 py-2 text-xs font-medium text-foreground">{label}</div>
|
||||
<NavTree nodes={node.children} mobile={mobile} onNavigate={onNavigate} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type DocHeading } from "~/docs";
|
||||
import { openEnclosingExamples } from "~/docs-example-target";
|
||||
|
||||
@@ -53,6 +54,7 @@ function OutlineLink({
|
||||
}
|
||||
|
||||
export function DocsOutline({ headings }: DocsOutlineProps) {
|
||||
const { t } = useTranslation();
|
||||
const [activeId, setActiveId] = useState<string | null>(() =>
|
||||
headings.length > 0 ? headings[0].id : null,
|
||||
);
|
||||
@@ -91,10 +93,12 @@ export function DocsOutline({ headings }: DocsOutlineProps) {
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="On this page"
|
||||
aria-label={t("docs.onThisPage")}
|
||||
className="sticky top-8 max-h-[calc(100vh-4rem)] overflow-y-auto py-8 pr-4"
|
||||
>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-3 px-3">On this page</div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-3 px-3">
|
||||
{t("docs.onThisPage")}
|
||||
</div>
|
||||
<ul className="space-y-1 border-l border-border">
|
||||
{headings.map((heading) => (
|
||||
<li key={heading.id} className="text-sm">
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Doc } from "~/docs";
|
||||
|
||||
const GITHUB_BASE_URL = "https://github.com/getpaseo/paseo/blob/main";
|
||||
|
||||
export function DocsSourceFooter({ doc }: { doc: Doc }) {
|
||||
const { t } = useTranslation();
|
||||
const sourceUrl = `${GITHUB_BASE_URL}/${doc.sourcePath}`;
|
||||
|
||||
return (
|
||||
@@ -14,7 +16,7 @@ export function DocsSourceFooter({ doc }: { doc: Doc }) {
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 font-medium text-white/60 underline-offset-4 transition-colors hover:text-foreground hover:underline"
|
||||
>
|
||||
View on GitHub
|
||||
{t("docs.viewOnGitHub")}
|
||||
<ExternalLink aria-hidden="true" className="size-3.5" />
|
||||
</a>
|
||||
</footer>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
DEFAULT_MOCKUP_STATE,
|
||||
DESIGN_HEIGHT,
|
||||
@@ -10,12 +11,10 @@ import {
|
||||
ScaledMockup,
|
||||
} from "~/components/mockup";
|
||||
|
||||
const ALT =
|
||||
"Paseo desktop app with coding agents, a conversation, and a code diff open side by side";
|
||||
|
||||
const PILL_TRANSITION = { duration: 0.34, ease: [0.22, 0.61, 0.36, 1] as const };
|
||||
|
||||
export function HeroMockup() {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<MockupStateId>(DEFAULT_MOCKUP_STATE);
|
||||
|
||||
return (
|
||||
@@ -23,10 +22,10 @@ export function HeroMockup() {
|
||||
<div className="mb-4 flex flex-wrap items-center justify-center gap-1 sm:gap-2">
|
||||
{MOCKUP_STATES.map((option) => (
|
||||
<StatePill
|
||||
key={option.id}
|
||||
id={option.id}
|
||||
label={option.label}
|
||||
selected={option.id === state}
|
||||
key={option}
|
||||
id={option}
|
||||
label={t(`landing.mockup.${option}`)}
|
||||
selected={option === state}
|
||||
onSelect={setState}
|
||||
/>
|
||||
))}
|
||||
@@ -36,7 +35,7 @@ export function HeroMockup() {
|
||||
width={DESIGN_WIDTH}
|
||||
height={DESIGN_HEIGHT}
|
||||
borderRadius={18}
|
||||
label={ALT}
|
||||
label={t("landing.mockup.alt")}
|
||||
className="ring-1 ring-white/10"
|
||||
>
|
||||
<MockupWindow state={state} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PLUGINS_LINKED } from "~/plugins/launch";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowRight,
|
||||
Blocks,
|
||||
@@ -73,6 +74,7 @@ import type { DesktopPlatform, MobilePlatform } from "~/platform";
|
||||
import { isMobilePlatform } from "~/platform";
|
||||
import { useRelease, useVisitorPlatform } from "~/routes/__root";
|
||||
import { HeroMockup } from "~/components/hero-mockup";
|
||||
import { useLocale, useLocalizePath } from "~/i18n";
|
||||
import {
|
||||
AntigravityIcon,
|
||||
ClaudeCodeIcon,
|
||||
@@ -356,6 +358,7 @@ function SectionTitle({
|
||||
}
|
||||
|
||||
function SocialProofWall() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<motion.section
|
||||
initial={FADE_IN_UP}
|
||||
@@ -364,8 +367,8 @@ function SocialProofWall() {
|
||||
transition={EASE_OUT_05}
|
||||
>
|
||||
<SectionTitle
|
||||
title="Loved by developers"
|
||||
description="See what developers are saying about Paseo"
|
||||
title={t("landing.socialProof.title")}
|
||||
description={t("landing.socialProof.description")}
|
||||
/>
|
||||
|
||||
<div className="social-proof-marquee space-y-4 overflow-hidden">
|
||||
@@ -403,6 +406,7 @@ function SocialProofRow({
|
||||
}
|
||||
|
||||
function SocialProofCard({ tweet, inert }: { tweet: SocialProofTweet; inert?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<a
|
||||
href={tweet.url}
|
||||
@@ -410,7 +414,7 @@ function SocialProofCard({ tweet, inert }: { tweet: SocialProofTweet; inert?: bo
|
||||
rel="noreferrer"
|
||||
tabIndex={inert ? -1 : undefined}
|
||||
className="group flex h-[154px] w-[320px] shrink-0 flex-col justify-between overflow-hidden rounded-2xl border border-white/10 bg-white/[0.03] p-4 transition-colors hover:border-white/20 hover:bg-white/[0.05] md:w-[420px]"
|
||||
aria-label={`Read ${tweet.name}'s original post`}
|
||||
aria-label={t("landing.socialProof.readPost", { name: tweet.name })}
|
||||
>
|
||||
<div>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
@@ -434,10 +438,12 @@ function SocialProofCard({ tweet, inert }: { tweet: SocialProofTweet; inert?: bo
|
||||
}
|
||||
|
||||
function MultiProviderSection() {
|
||||
const { t } = useTranslation();
|
||||
const localize = useLocalizePath();
|
||||
return (
|
||||
<FeatureSection
|
||||
title="Works with your tools"
|
||||
description="Bring your subscriptions, skills and configuration"
|
||||
title={t("landing.providers.title")}
|
||||
description={t("landing.providers.description")}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-4">
|
||||
{FEATURED_AGENTS.map((agent) => (
|
||||
@@ -452,10 +458,12 @@ function MultiProviderSection() {
|
||||
</div>
|
||||
))}
|
||||
<a
|
||||
href="/agents"
|
||||
href={localize("/agents")}
|
||||
className="flex items-center justify-center gap-2 rounded-xl border border-dashed border-white/10 bg-white/[0.01] px-3 py-3 text-white/50 hover:text-white/80 hover:border-white/20 hover:bg-white/[0.03] transition-colors sm:gap-3 sm:px-5 sm:py-4"
|
||||
>
|
||||
<span className="text-sm font-medium sm:text-base">+{ADDITIONAL_AGENT_COUNT} more</span>
|
||||
<span className="text-sm font-medium sm:text-base">
|
||||
{t("common.more", { count: ADDITIONAL_AGENT_COUNT })}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</FeatureSection>
|
||||
@@ -463,38 +471,40 @@ function MultiProviderSection() {
|
||||
}
|
||||
|
||||
function TurnkeySection() {
|
||||
const { t } = useTranslation();
|
||||
const localize = useLocalizePath();
|
||||
return (
|
||||
<FeatureSection
|
||||
title="Run it anywhere"
|
||||
description="Use Paseo locally, from another machine, or with a team"
|
||||
title={t("landing.runAnywhere.title")}
|
||||
description={t("landing.runAnywhere.description")}
|
||||
>
|
||||
<div className="space-y-8">
|
||||
<ProductLead
|
||||
icon={Monitor}
|
||||
title="Desktop app"
|
||||
description="The one click experience, download the app and it just works, no server required"
|
||||
title={t("landing.runAnywhere.desktopTitle")}
|
||||
description={t("landing.runAnywhere.desktopDescription")}
|
||||
/>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<TurnkeyExtensionCard
|
||||
icon={Smartphone}
|
||||
title="Mobile"
|
||||
description="Full client and native, not a WebView"
|
||||
ctaHref="/download"
|
||||
ctaLabel="Download"
|
||||
title={t("landing.runAnywhere.mobileTitle")}
|
||||
description={t("landing.runAnywhere.mobileDescription")}
|
||||
ctaHref={localize("/download")}
|
||||
ctaLabel={t("landing.runAnywhere.mobileCta")}
|
||||
/>
|
||||
<TurnkeyExtensionCard
|
||||
icon={Laptop}
|
||||
title="Remote machines"
|
||||
description="Run Paseo on a home lab, or a cloud machine"
|
||||
ctaHref="/docs#server--cli"
|
||||
ctaLabel="Docs"
|
||||
title={t("landing.runAnywhere.remoteTitle")}
|
||||
description={t("landing.runAnywhere.remoteDescription")}
|
||||
ctaHref={localize("/docs#server--cli")}
|
||||
ctaLabel={t("landing.runAnywhere.remoteCta")}
|
||||
/>
|
||||
<TurnkeyExtensionCard
|
||||
icon={Users}
|
||||
title="Teams and triggers"
|
||||
description="Share access or start work from GitHub, Slack, and Discord"
|
||||
ctaHref="/hub"
|
||||
ctaLabel="Paseo Hub"
|
||||
title={t("landing.runAnywhere.teamsTitle")}
|
||||
description={t("landing.runAnywhere.teamsDescription")}
|
||||
ctaHref={localize("/hub")}
|
||||
ctaLabel={t("landing.runAnywhere.teamsCta")}
|
||||
showIntegrationIcons
|
||||
/>
|
||||
</div>
|
||||
@@ -601,43 +611,31 @@ type AutomationKind = "mcp" | "cli" | "sdk";
|
||||
const AUTOMATION_OPTIONS: Array<{
|
||||
kind: AutomationKind;
|
||||
label: string;
|
||||
caption: string;
|
||||
icon: LucideIcon;
|
||||
}> = [
|
||||
{
|
||||
kind: "mcp",
|
||||
label: "MCP",
|
||||
caption: "From another agent",
|
||||
icon: Bot,
|
||||
},
|
||||
{
|
||||
kind: "cli",
|
||||
label: "CLI",
|
||||
caption: "From the terminal",
|
||||
icon: Terminal,
|
||||
},
|
||||
{
|
||||
kind: "sdk",
|
||||
label: "SDK",
|
||||
caption: "From code",
|
||||
icon: Braces,
|
||||
},
|
||||
{ kind: "mcp", label: "MCP", icon: Bot },
|
||||
{ kind: "cli", label: "CLI", icon: Terminal },
|
||||
{ kind: "sdk", label: "SDK", icon: Braces },
|
||||
];
|
||||
|
||||
const AUTOMATION_LINKS = [
|
||||
{ href: "/docs/mcp", label: "MCP docs" },
|
||||
{ href: "/docs/cli", label: "CLI docs" },
|
||||
{ href: "/docs/sdk", label: "SDK docs" },
|
||||
] as const;
|
||||
|
||||
function AutomationSection() {
|
||||
const { t } = useTranslation();
|
||||
const localize = useLocalizePath();
|
||||
const [activeKind, setActiveKind] = React.useState<AutomationKind>("mcp");
|
||||
const links = React.useMemo(
|
||||
() =>
|
||||
AUTOMATION_OPTIONS.map((option) => ({
|
||||
href: localize(`/docs/${option.kind}`),
|
||||
label: t(`landing.automation.${option.kind}Docs`),
|
||||
})),
|
||||
[localize, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<FeatureSection
|
||||
title="Built for automation"
|
||||
description="Use MCP, the CLI, or the TypeScript SDK to automate Paseo"
|
||||
links={AUTOMATION_LINKS}
|
||||
title={t("landing.automation.title")}
|
||||
description={t("landing.automation.description")}
|
||||
links={links}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-[14rem_minmax(0,1fr)]">
|
||||
<div className="grid self-start gap-2" role="tablist">
|
||||
@@ -665,6 +663,7 @@ function AutomationSelector({
|
||||
active: boolean;
|
||||
onSelect: (kind: AutomationKind) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const Icon = option.icon;
|
||||
const handleClick = React.useCallback(() => onSelect(option.kind), [onSelect, option.kind]);
|
||||
|
||||
@@ -684,7 +683,9 @@ function AutomationSelector({
|
||||
<Icon className="h-3 w-3" strokeWidth={1.5} />
|
||||
<span className="text-[10px]">{option.label}</span>
|
||||
</div>
|
||||
<p className="text-xs leading-snug text-white/85 md:text-sm">{option.caption}</p>
|
||||
<p className="text-xs leading-snug text-white/85 md:text-sm">
|
||||
{t(`landing.automation.${option.kind}Caption`)}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -703,25 +704,26 @@ function AutomationDetail({ kind }: { kind: AutomationKind }) {
|
||||
}
|
||||
|
||||
function McpAutomationTranscript() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="ml-auto w-fit max-w-xl rounded-xl rounded-tr-none bg-white/[0.07] px-4 py-3">
|
||||
<p className="text-sm leading-relaxed text-white/75">
|
||||
Take the open GitHub issues labeled ready and fan them out to separate worktree agents.
|
||||
{t("landing.automation.transcriptRequest")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Bot className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" strokeWidth={1.5} />
|
||||
<div className="min-w-0 flex-1 space-y-4">
|
||||
<p className="text-sm leading-relaxed text-white/55">
|
||||
I found two ready issues. I will run each in its own worktree.
|
||||
{t("landing.automation.transcriptPlan")}
|
||||
</p>
|
||||
<div className="space-y-2 font-mono text-[11px]">
|
||||
<McpAgentCall issue="#412" provider="claude/opus-4.6" />
|
||||
<McpAgentCall issue="#417" provider="codex/gpt-5.6-sol" />
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-white/55">
|
||||
Done, two agents are running. I will let you know when they finish.
|
||||
{t("landing.automation.transcriptDone")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -741,6 +743,7 @@ function McpAgentCall({ issue, provider }: { issue: string; provider: string })
|
||||
}
|
||||
|
||||
function CliAutomationExample() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="font-mono text-[11px] leading-5 text-white/60">
|
||||
<div className="space-y-6">
|
||||
@@ -765,7 +768,7 @@ function CliAutomationExample() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-white/30"># Target another host</div>
|
||||
<div className="text-white/30">{t("landing.automation.otherHostComment")}</div>
|
||||
<ShellPrompt>
|
||||
<span className="text-white">paseo ls</span>{" "}
|
||||
<span className="text-sky-300/75">--host</span>{" "}
|
||||
@@ -832,20 +835,27 @@ function SdkAutomationExample() {
|
||||
}
|
||||
|
||||
function ExtensibleSection() {
|
||||
const { t } = useTranslation();
|
||||
const pluginActions = React.useMemo(() => <PluginActions />, []);
|
||||
return (
|
||||
<FeatureSection
|
||||
title="Extend it with plugins"
|
||||
description="Add new panels and functionality that work in desktop, web and mobile"
|
||||
title={t("landing.plugins.title")}
|
||||
description={t("landing.plugins.description")}
|
||||
>
|
||||
<div className="space-y-8">
|
||||
<ProductLead icon={Puzzle} title="Plugins" actions={PLUGIN_ACTIONS} />
|
||||
<ProductLead icon={Puzzle} title={t("landing.plugins.lead")} actions={pluginActions} />
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{PLUGIN_CAPABILITIES.map((capability) => (
|
||||
<FeatureCard key={capability.title} {...capability} />
|
||||
<FeatureCard
|
||||
key={capability.key}
|
||||
icon={capability.icon}
|
||||
title={t(`landing.plugins.${capability.key}Title`)}
|
||||
description={t(`landing.plugins.${capability.key}Description`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-white/45">Paseo is Apache 2.0.</p>
|
||||
<p className="text-sm text-white/45">{t("landing.plugins.license")}</p>
|
||||
<a
|
||||
href="https://github.com/getpaseo/paseo"
|
||||
target="_blank"
|
||||
@@ -853,7 +863,7 @@ function ExtensibleSection() {
|
||||
className="inline-flex shrink-0 items-center gap-2 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<GitFork className="h-4 w-4" strokeWidth={1.5} />
|
||||
Fork the repo
|
||||
{t("landing.plugins.fork")}
|
||||
<ExternalLink className="h-3.5 w-3.5 opacity-70" />
|
||||
</a>
|
||||
</div>
|
||||
@@ -866,89 +876,59 @@ const PLUGINS_BROWSE = PLUGINS_LINKED
|
||||
? { href: "/plugins", external: false }
|
||||
: { href: "https://paseo.cafe", external: true };
|
||||
|
||||
const PLUGIN_ACTIONS = (
|
||||
<>
|
||||
<a
|
||||
href={PLUGINS_BROWSE.href}
|
||||
{...(PLUGINS_BROWSE.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-foreground px-3 py-1.5 text-xs text-background transition-colors hover:bg-foreground/90"
|
||||
>
|
||||
Browse plugins
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
<a
|
||||
href="/docs/plugins"
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-white/12 px-3 py-1.5 text-xs text-white/80 transition-colors hover:bg-white/10"
|
||||
>
|
||||
Plugin docs
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
function PluginActions() {
|
||||
const { t } = useTranslation();
|
||||
const localize = useLocalizePath();
|
||||
return (
|
||||
<>
|
||||
<a
|
||||
href={localize(PLUGINS_BROWSE.href)}
|
||||
{...(PLUGINS_BROWSE.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-foreground px-3 py-1.5 text-xs text-background transition-colors hover:bg-foreground/90"
|
||||
>
|
||||
{t("landing.plugins.browse")}
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
<a
|
||||
href={localize("/docs/plugins")}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-white/12 px-3 py-1.5 text-xs text-white/80 transition-colors hover:bg-white/10"
|
||||
>
|
||||
{t("landing.plugins.docs")}
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const PLUGIN_CAPABILITIES: ReadonlyArray<{
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
icon: LayoutPanelLeft,
|
||||
title: "UI",
|
||||
description: "Screens, workspace panels, sidebar items, slash commands, and themes",
|
||||
},
|
||||
{
|
||||
icon: Workflow,
|
||||
title: "Agent lifecycle",
|
||||
description: "Change config and MCP servers, answer permissions, and follow up",
|
||||
},
|
||||
{
|
||||
icon: Bot,
|
||||
title: "Providers",
|
||||
description: "Add a coding agent as a provider, used like the built-in ones",
|
||||
},
|
||||
];
|
||||
const PLUGIN_CAPABILITIES = [
|
||||
{ icon: LayoutPanelLeft, key: "ui" },
|
||||
{ icon: Workflow, key: "lifecycle" },
|
||||
{ icon: Bot, key: "providers" },
|
||||
] as const;
|
||||
|
||||
const PRINCIPLES: ReadonlyArray<{ icon: LucideIcon; title: string; description: string }> = [
|
||||
{
|
||||
icon: Lock,
|
||||
title: "Private",
|
||||
description: "No telemetry, tracking, or forced login. Code stays on your machine.",
|
||||
},
|
||||
{
|
||||
icon: Compass,
|
||||
title: "Independent",
|
||||
description: "Paseo doesn't answer to investors. Its users guide what gets built.",
|
||||
},
|
||||
{
|
||||
icon: Gem,
|
||||
title: "Polished",
|
||||
description: "Install the app and start working. No need to know what a daemon is.",
|
||||
},
|
||||
{
|
||||
icon: Merge,
|
||||
title: "Unified",
|
||||
description: "Every coding agent, used the same way. Switch providers any time.",
|
||||
},
|
||||
{
|
||||
icon: Blocks,
|
||||
title: "Composable",
|
||||
description: "Run agents on a laptop or a server, and connect from any device.",
|
||||
},
|
||||
{
|
||||
icon: Puzzle,
|
||||
title: "Extensible",
|
||||
description: "If Paseo doesn't fit how you work, change it with a plugin or fork.",
|
||||
},
|
||||
];
|
||||
const PRINCIPLES = [
|
||||
{ icon: Lock, key: "private" },
|
||||
{ icon: Compass, key: "independent" },
|
||||
{ icon: Gem, key: "polished" },
|
||||
{ icon: Merge, key: "unified" },
|
||||
{ icon: Blocks, key: "composable" },
|
||||
{ icon: Puzzle, key: "extensible" },
|
||||
] as const;
|
||||
|
||||
function PhilosophySection() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<FeatureSection
|
||||
title="Philosophy"
|
||||
description="What Paseo stands for, and what every feature is built on"
|
||||
title={t("landing.philosophy.title")}
|
||||
description={t("landing.philosophy.description")}
|
||||
>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{PRINCIPLES.map((principle) => (
|
||||
<FeatureCard key={principle.title} {...principle} />
|
||||
<FeatureCard
|
||||
key={principle.key}
|
||||
icon={principle.icon}
|
||||
title={t(`landing.philosophy.${principle.key}Title`)}
|
||||
description={t(`landing.philosophy.${principle.key}Description`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</FeatureSection>
|
||||
@@ -956,6 +936,8 @@ function PhilosophySection() {
|
||||
}
|
||||
|
||||
function GetStarted() {
|
||||
const { t } = useTranslation();
|
||||
const localize = useLocalizePath();
|
||||
const platform = useVisitorPlatform();
|
||||
return (
|
||||
<div className="pt-10">
|
||||
@@ -974,17 +956,17 @@ function GetStarted() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1 pt-6">
|
||||
<span className="text-xs text-muted-foreground">Supports</span>
|
||||
<span className="text-xs text-muted-foreground">{t("landing.getStarted.supports")}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{FEATURED_AGENTS.map((agent) => (
|
||||
<AgentBadge key={agent.name} name={agent.name} Icon={agent.Icon} />
|
||||
))}
|
||||
</div>
|
||||
<a
|
||||
href="/agents"
|
||||
href={localize("/agents")}
|
||||
className="whitespace-nowrap text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
+{ADDITIONAL_AGENT_COUNT} more
|
||||
{t("common.more", { count: ADDITIONAL_AGENT_COUNT })}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -997,23 +979,25 @@ const SECONDARY_CTA_CLASS =
|
||||
"inline-flex items-center justify-center gap-2 rounded-lg border border-white/12 px-3 py-2.5 text-sm text-white hover:bg-white/10 transition-colors";
|
||||
|
||||
function DesktopDownloadButton({ platform }: { platform: DesktopPlatform }) {
|
||||
const download = getDesktopDownload(useRelease(), platform);
|
||||
const { t } = useTranslation();
|
||||
const download = getDesktopDownload(useRelease(), platform, useLocale());
|
||||
const Icon = download.icon;
|
||||
return (
|
||||
<a href={download.href} className={PRIMARY_CTA_CLASS}>
|
||||
<Icon className="h-4 w-4" />
|
||||
Download for {download.label}
|
||||
{t("landing.getStarted.downloadFor", { platform: download.label })}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function StoreButton({ platform }: { platform: MobilePlatform }) {
|
||||
const { t } = useTranslation();
|
||||
const store = MOBILE_STORES[platform];
|
||||
const Icon = store.icon;
|
||||
return (
|
||||
<a href={store.href} target="_blank" rel="noopener noreferrer" className={PRIMARY_CTA_CLASS}>
|
||||
<Icon className="h-4 w-4" />
|
||||
Get the {store.label} app
|
||||
{t("landing.getStarted.getStoreApp", { store: store.label })}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1021,10 +1005,12 @@ function StoreButton({ platform }: { platform: MobilePlatform }) {
|
||||
// On a phone the desktop build is the secondary path, so it points at /download
|
||||
// instead of handing the visitor a .dmg they cannot open.
|
||||
function DesktopAppLink() {
|
||||
const { t } = useTranslation();
|
||||
const localize = useLocalizePath();
|
||||
return (
|
||||
<a href="/download" className={SECONDARY_CTA_CLASS}>
|
||||
<a href={localize("/download")} className={SECONDARY_CTA_CLASS}>
|
||||
<Monitor className="h-4 w-4" strokeWidth={1.5} />
|
||||
Desktop app
|
||||
{t("landing.getStarted.desktopApp")}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1054,35 +1040,45 @@ function StoreIconLinks() {
|
||||
);
|
||||
}
|
||||
|
||||
const SERVER_INSTALL_TRIGGER = (
|
||||
<span
|
||||
className="inline-flex items-center justify-center rounded-lg border border-white/12 px-3 py-2.5 text-white hover:bg-white/10 transition-colors"
|
||||
aria-label="Install the daemon on a remote machine"
|
||||
>
|
||||
<TerminalIcon className="h-5 w-5" />
|
||||
</span>
|
||||
);
|
||||
|
||||
const SERVER_INSTALL_FOOTNOTE = (
|
||||
<>
|
||||
Requires Node.js 18+. Run <span className="font-mono text-white/40">paseo</span> to start the
|
||||
daemon.
|
||||
</>
|
||||
);
|
||||
const SERVER_INSTALL_FOOTNOTE_COMPONENTS = {
|
||||
code: <span className="font-mono text-white/40" />,
|
||||
};
|
||||
|
||||
function ServerInstallButton() {
|
||||
const { t } = useTranslation();
|
||||
const trigger = React.useMemo(
|
||||
() => (
|
||||
<span
|
||||
className="inline-flex items-center justify-center rounded-lg border border-white/12 px-3 py-2.5 text-white hover:bg-white/10 transition-colors"
|
||||
aria-label={t("landing.getStarted.serverInstallLabel")}
|
||||
>
|
||||
<TerminalIcon className="h-5 w-5" />
|
||||
</span>
|
||||
),
|
||||
[t],
|
||||
);
|
||||
const footnote = React.useMemo(
|
||||
() => (
|
||||
<Trans
|
||||
i18nKey="landing.getStarted.serverInstallFootnote"
|
||||
components={SERVER_INSTALL_FOOTNOTE_COMPONENTS}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<CommandDialog
|
||||
trigger={SERVER_INSTALL_TRIGGER}
|
||||
title="Run agents on a remote machine"
|
||||
description="For headless machines you want to connect to from the Paseo apps. The desktop app already includes a built-in daemon"
|
||||
trigger={trigger}
|
||||
title={t("landing.getStarted.serverInstallTitle")}
|
||||
description={t("landing.getStarted.serverInstallDescription")}
|
||||
command="npm install -g @getpaseo/cli && paseo"
|
||||
footnote={SERVER_INSTALL_FOOTNOTE}
|
||||
footnote={footnote}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PhoneShowcase() {
|
||||
const { t } = useTranslation();
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const textInView = useInView(containerRef, { once: true, margin: "-80px" });
|
||||
|
||||
@@ -1142,10 +1138,10 @@ function PhoneShowcase() {
|
||||
<path d="M12 5v14M5 12l7 7 7-7" />
|
||||
</svg>
|
||||
<p className="max-w-md text-balance text-center text-lg text-white/80">
|
||||
When you want to step away from your desk, you can.
|
||||
{t("landing.showcase.heading")}
|
||||
</p>
|
||||
<p className="max-w-sm text-balance text-center text-sm text-white/50">
|
||||
The native mobile app has full feature parity with desktop.
|
||||
{t("landing.showcase.subheading")}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -1159,7 +1155,7 @@ function PhoneShowcase() {
|
||||
style={leftPhoneStyle}
|
||||
className="w-[160px] md:w-[240px] absolute"
|
||||
role="img"
|
||||
aria-label="Paseo workspace drawer"
|
||||
aria-label={t("landing.showcase.sidebarLabel")}
|
||||
>
|
||||
<PhoneFrame time="18:54" depth="right">
|
||||
<MobileSidebar />
|
||||
@@ -1173,7 +1169,7 @@ function PhoneShowcase() {
|
||||
transition={EASE_OUT_06_DELAY_01}
|
||||
className="w-[220px] md:w-[240px] relative z-10"
|
||||
role="img"
|
||||
aria-label="Paseo agent chat"
|
||||
aria-label={t("landing.showcase.chatLabel")}
|
||||
>
|
||||
<PhoneFrame time="18:53">
|
||||
<MobileChat />
|
||||
@@ -1185,7 +1181,7 @@ function PhoneShowcase() {
|
||||
style={rightPhoneStyle}
|
||||
className="w-[160px] md:w-[240px] absolute"
|
||||
role="img"
|
||||
aria-label="Paseo diff view"
|
||||
aria-label={t("landing.showcase.diffLabel")}
|
||||
>
|
||||
<PhoneFrame time="18:55" depth="left">
|
||||
<MobileDiff />
|
||||
@@ -1196,7 +1192,41 @@ function PhoneShowcase() {
|
||||
);
|
||||
}
|
||||
|
||||
const FAQ_ENTRIES = [
|
||||
"free",
|
||||
"code",
|
||||
"agents",
|
||||
"providers",
|
||||
"desktop",
|
||||
"voice",
|
||||
"network",
|
||||
"git",
|
||||
"ban",
|
||||
"worktrees",
|
||||
] as const;
|
||||
|
||||
const FAQ_LINKS = {
|
||||
relay: "/docs/security",
|
||||
agents: "/agents",
|
||||
muse: "/docs/muse-code",
|
||||
voice: "/docs/voice",
|
||||
configuration: "/docs/configuration",
|
||||
worktrees: "/docs/worktrees",
|
||||
};
|
||||
|
||||
function FAQ() {
|
||||
const { t } = useTranslation();
|
||||
const localize = useLocalizePath();
|
||||
const links = React.useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
Object.entries(FAQ_LINKS).map(([name, href]) => [
|
||||
name,
|
||||
<a key={name} href={localize(href)} className="underline hover:text-white/80" />,
|
||||
]),
|
||||
),
|
||||
[localize],
|
||||
);
|
||||
return (
|
||||
<motion.div
|
||||
initial={FADE_IN_UP}
|
||||
@@ -1205,78 +1235,13 @@ function FAQ() {
|
||||
transition={EASE_OUT_05}
|
||||
className="space-y-6"
|
||||
>
|
||||
<h2 className="text-3xl font-medium">FAQ</h2>
|
||||
<h2 className="text-3xl font-medium">{t("landing.faq.title")}</h2>
|
||||
<div className="space-y-6">
|
||||
<FAQItem question="Is this free?">
|
||||
Yes. Paseo is free and open source. You need agent providers installed with your own
|
||||
credentials. Voice is local-first by default and can optionally use cloud speech providers
|
||||
if you configure them.
|
||||
</FAQItem>
|
||||
<FAQItem question="Does my code leave my machine?">
|
||||
Paseo doesn't send your code anywhere. Agents run locally and talk to their own APIs
|
||||
as they normally would. For remote access, you can use the optional{" "}
|
||||
<a href="/docs/security" className="underline hover:text-white/80">
|
||||
end-to-end encrypted relay
|
||||
</a>
|
||||
, connect directly over your local network, or use your own tunnel.
|
||||
</FAQItem>
|
||||
<FAQItem question="What agents does it support?">
|
||||
Paseo supports many providers. It has custom implementations for Claude, Codex, OpenCode,
|
||||
Pi, OMP, Antigravity, and Muse Code, and supports many more via ACP. See the full list
|
||||
here:{" "}
|
||||
<a href="/agents" className="underline hover:text-white/80">
|
||||
all supported providers
|
||||
</a>
|
||||
. See{" "}
|
||||
<a href="/docs/muse-code" className="underline hover:text-white/80">
|
||||
Muse Code setup and limitations
|
||||
</a>
|
||||
.
|
||||
</FAQItem>
|
||||
<FAQItem question="How does Paseo run providers?">
|
||||
Paseo runs the providers installed on your machine as you'd normally run them. Paseo
|
||||
doesn't modify or change their behavior.
|
||||
</FAQItem>
|
||||
<FAQItem question="Do I need the desktop app?">
|
||||
No. You can run the daemon headless and use any client to connect. The desktop app just
|
||||
bundles the daemon with a UI.
|
||||
</FAQItem>
|
||||
<FAQItem question="How does voice work?">
|
||||
Voice runs locally on your device by default. You talk, the app transcribes and sends it
|
||||
to your agent as text. Optionally, you can configure OpenAI speech providers for
|
||||
higher-quality transcription and text-to-speech. See the{" "}
|
||||
<a href="/docs/voice" className="underline hover:text-white/80">
|
||||
voice docs
|
||||
</a>
|
||||
.
|
||||
</FAQItem>
|
||||
<FAQItem question="Can I connect from outside my network?">
|
||||
Yes. You can use the hosted relay (end-to-end encrypted, Paseo can't read your
|
||||
traffic), set up your own tunnel (Tailscale, Cloudflare Tunnel, etc.), or expose the
|
||||
daemon port directly. See{" "}
|
||||
<a href="/docs/configuration" className="underline hover:text-white/80">
|
||||
configuration
|
||||
</a>
|
||||
.
|
||||
</FAQItem>
|
||||
<FAQItem question="Do I need git or GitHub?">
|
||||
No. Paseo works in any directory. Worktrees are optional and only relevant if you use git.
|
||||
You can run agents anywhere you'd normally work.
|
||||
</FAQItem>
|
||||
<FAQItem question="Can I get banned for using Paseo?">
|
||||
Paseo is designed to use each provider's officially supported integration and does
|
||||
not attempt to bypass its terms of service. It doesn't extract tokens or call
|
||||
inference APIs directly.
|
||||
</FAQItem>
|
||||
<FAQItem question="How do worktrees work?">
|
||||
When you launch an agent with the worktree option (from the app, desktop, or CLI), Paseo
|
||||
creates a git worktree and runs the agent inside it. The agent works on an isolated branch
|
||||
without touching your main working directory. See the{" "}
|
||||
<a href="/docs/worktrees" className="underline hover:text-white/80">
|
||||
worktrees docs
|
||||
</a>
|
||||
.
|
||||
</FAQItem>
|
||||
{FAQ_ENTRIES.map((entry) => (
|
||||
<FAQItem key={entry} question={t(`landing.faq.${entry}Question`)}>
|
||||
<Trans i18nKey={`landing.faq.${entry}Answer`} components={links} />
|
||||
</FAQItem>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { useLocation, useNavigate } from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion, type Transition } from "framer-motion";
|
||||
import { Check, ChevronDown, Languages } from "lucide-react";
|
||||
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
LANGUAGE_ABBREVIATIONS,
|
||||
LANGUAGE_NAMES,
|
||||
LOCALES,
|
||||
type Locale,
|
||||
localizePath,
|
||||
splitLocalePath,
|
||||
useLocale,
|
||||
} from "~/i18n";
|
||||
|
||||
const MENU_HIDDEN = { opacity: 0, y: -4 };
|
||||
const MENU_SHOWN = { opacity: 1, y: 0 };
|
||||
const MENU_TRANSITION: Transition = { duration: 0.15, ease: "easeOut" };
|
||||
|
||||
const ITEM_SELECTOR = '[role="menuitemradio"]';
|
||||
|
||||
/** The closest the menu comes to the edge of the screen. */
|
||||
const VIEWPORT_MARGIN = 8;
|
||||
|
||||
/** Which item takes focus when the menu opens: the current language, or an end the arrow key named. */
|
||||
type OpenFocus = "current" | "first" | "last";
|
||||
|
||||
/**
|
||||
* Switches the page to the same page in another language, keeping its query and hash so filters
|
||||
* and anchors survive. The URL carries the choice, so every link on the new page stays in it.
|
||||
*
|
||||
* The trigger shows the language abbreviated; the menu names every language in itself. It follows
|
||||
* the menu button pattern: arrows move between languages, Escape and outside presses close it.
|
||||
*/
|
||||
export function LanguageSelector() {
|
||||
const { t } = useTranslation();
|
||||
const locale = useLocale();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const menuId = useId();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [openFocus, setOpenFocus] = useState<OpenFocus | null>(null);
|
||||
const open = openFocus !== null;
|
||||
|
||||
const close = useCallback(() => setOpenFocus(null), []);
|
||||
|
||||
// The menu hangs from the trigger's end, which on a narrow header can push it off the screen.
|
||||
useLayoutEffect(() => {
|
||||
if (open && menuRef.current) keepInViewport(menuRef.current);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openFocus) return;
|
||||
focusOnOpen(menuItems(menuRef.current), openFocus);
|
||||
}, [openFocus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function closeOnOutsidePress(event: PointerEvent) {
|
||||
if (!rootRef.current?.contains(event.target as Node)) close();
|
||||
}
|
||||
document.addEventListener("pointerdown", closeOnOutsidePress);
|
||||
return () => document.removeEventListener("pointerdown", closeOnOutsidePress);
|
||||
}, [open, close]);
|
||||
|
||||
const toggle = useCallback(() => setOpenFocus((current) => (current ? null : "current")), []);
|
||||
|
||||
const onTriggerKeyDown = useCallback((event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setOpenFocus(event.key === "ArrowDown" ? "first" : "last");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onMenuKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const items = menuItems(menuRef.current);
|
||||
const index = items.indexOf(document.activeElement as HTMLElement);
|
||||
const moveTo = (next: number) => {
|
||||
event.preventDefault();
|
||||
items[(next + items.length) % items.length]?.focus();
|
||||
};
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
return moveTo(index + 1);
|
||||
case "ArrowUp":
|
||||
return moveTo(index - 1);
|
||||
case "Home":
|
||||
return moveTo(0);
|
||||
case "End":
|
||||
return moveTo(items.length - 1);
|
||||
case " ":
|
||||
event.preventDefault();
|
||||
items[index]?.click();
|
||||
return;
|
||||
case "Escape":
|
||||
event.preventDefault();
|
||||
close();
|
||||
triggerRef.current?.focus();
|
||||
return;
|
||||
case "Tab":
|
||||
close();
|
||||
return;
|
||||
}
|
||||
},
|
||||
[close],
|
||||
);
|
||||
|
||||
const { path } = splitLocalePath(location.pathname);
|
||||
const suffix = `${location.searchStr}${location.hash ? `#${location.hash}` : ""}`;
|
||||
|
||||
const choose = useCallback(
|
||||
(href: string) => {
|
||||
close();
|
||||
triggerRef.current?.focus();
|
||||
void navigate({ href });
|
||||
},
|
||||
[close, navigate],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative inline-flex">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
aria-label={t("languageSelector.current", { language: LANGUAGE_NAMES[locale] })}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? menuId : undefined}
|
||||
onClick={toggle}
|
||||
onKeyDown={onTriggerKeyDown}
|
||||
className={`-mx-1.5 inline-flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30 ${open ? "text-foreground" : "text-muted-foreground"}`}
|
||||
>
|
||||
<Languages size={16} aria-hidden="true" />
|
||||
<span className="font-medium tracking-wide">{LANGUAGE_ABBREVIATIONS[locale]}</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
className={`transition-transform ${open ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
ref={menuRef}
|
||||
id={menuId}
|
||||
role="menu"
|
||||
aria-label={t("languageSelector.label")}
|
||||
onKeyDown={onMenuKeyDown}
|
||||
initial={MENU_HIDDEN}
|
||||
animate={MENU_SHOWN}
|
||||
exit={MENU_HIDDEN}
|
||||
transition={MENU_TRANSITION}
|
||||
className="absolute right-0 top-full z-50 mt-2 flex min-w-44 flex-col gap-0.5 rounded-lg border border-border bg-card p-1 shadow-xl shadow-black/40"
|
||||
>
|
||||
{LOCALES.map((option) => (
|
||||
<LanguageOption
|
||||
key={option}
|
||||
language={option}
|
||||
href={`${localizePath(path, option)}${suffix}`}
|
||||
checked={option === locale}
|
||||
onChoose={choose}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One language in the menu. It is a real link to the same page in that language, so it can be
|
||||
* opened in a new tab; a plain click switches in place.
|
||||
*/
|
||||
function LanguageOption({
|
||||
language,
|
||||
href,
|
||||
checked,
|
||||
onChoose,
|
||||
}: {
|
||||
language: Locale;
|
||||
href: string;
|
||||
checked: boolean;
|
||||
onChoose: (href: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const localName = t(`languageSelector.names.${language}`);
|
||||
const onClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
onChoose(href);
|
||||
},
|
||||
[href, onChoose],
|
||||
);
|
||||
const focusOnHover = useCallback(
|
||||
(event: React.MouseEvent<HTMLAnchorElement>) => event.currentTarget.focus(),
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
hrefLang={language}
|
||||
role="menuitemradio"
|
||||
aria-checked={checked}
|
||||
tabIndex={-1}
|
||||
onClick={onClick}
|
||||
onMouseEnter={focusOnHover}
|
||||
className={`flex items-center gap-3 whitespace-nowrap rounded-md px-2.5 py-2 text-sm outline-none transition-colors focus:bg-secondary focus:text-foreground ${checked ? "text-foreground" : "text-muted-foreground"}`}
|
||||
>
|
||||
<span className="flex flex-col">
|
||||
<span lang={language}>{LANGUAGE_NAMES[language]}</span>
|
||||
{localName !== LANGUAGE_NAMES[language] && (
|
||||
<span className="text-xs text-extra-muted-foreground">{localName}</span>
|
||||
)}
|
||||
</span>
|
||||
<Check
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
className={`ml-auto shrink-0 ${checked ? "" : "invisible"}`}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function menuItems(menu: HTMLElement | null): HTMLElement[] {
|
||||
return menu ? [...menu.querySelectorAll<HTMLElement>(ITEM_SELECTOR)] : [];
|
||||
}
|
||||
|
||||
function keepInViewport(menu: HTMLElement) {
|
||||
const { left, right } = menu.getBoundingClientRect();
|
||||
let shift = 0;
|
||||
if (left < VIEWPORT_MARGIN) shift = VIEWPORT_MARGIN - left;
|
||||
else if (right > window.innerWidth - VIEWPORT_MARGIN) {
|
||||
shift = window.innerWidth - VIEWPORT_MARGIN - right;
|
||||
}
|
||||
menu.style.translate = `${shift}px 0`;
|
||||
}
|
||||
|
||||
function focusOnOpen(items: HTMLElement[], openFocus: OpenFocus) {
|
||||
if (openFocus === "first") items[0]?.focus();
|
||||
else if (openFocus === "last") items.at(-1)?.focus();
|
||||
else items.find((item) => item.getAttribute("aria-checked") === "true")?.focus();
|
||||
}
|
||||
@@ -1,38 +1,44 @@
|
||||
import type { ReactNode } from "react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import { SiteShell } from "~/components/site-shell";
|
||||
import { UntranslatedNotice } from "~/components/untranslated-notice";
|
||||
import { formatDate, localizePath, useLocale } from "~/i18n";
|
||||
import { getLegalDocument, type LegalDocumentName } from "~/legal";
|
||||
|
||||
interface LegalPageProps {
|
||||
title: string;
|
||||
lastUpdated: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function LegalPage({ title, lastUpdated, children }: LegalPageProps) {
|
||||
export function LegalPage({ name }: { name: LegalDocumentName }) {
|
||||
const { t } = useTranslation();
|
||||
const locale = useLocale();
|
||||
const document = getLegalDocument(name, locale);
|
||||
const components = React.useMemo<Components>(
|
||||
() => ({
|
||||
a: ({ href, node: _node, ...props }) => {
|
||||
const external = href !== undefined && /^https?:\/\//i.test(href);
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
href={href === undefined ? undefined : localizePath(href, locale)}
|
||||
{...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}),
|
||||
[locale],
|
||||
);
|
||||
return (
|
||||
<SiteShell width="prose">
|
||||
<article className="space-y-8 text-white/70 leading-relaxed [&_a]:underline [&_a]:underline-offset-2 [&_a:hover]:text-white [&_h2]:text-xl [&_h2]:font-medium [&_h2]:text-white [&_li]:pl-1 [&_section]:space-y-3 [&_ul]:ml-5 [&_ul]:list-disc [&_ul]:space-y-1">
|
||||
<header className="space-y-3">
|
||||
<h1 className="text-3xl font-medium text-white">{title}</h1>
|
||||
<p className="text-sm text-white/50">Last updated: {lastUpdated}</p>
|
||||
<article className="space-y-4 text-white/70 leading-relaxed [&_a]:underline [&_a]:underline-offset-2 [&_a:hover]:text-white [&_h2]:pt-4 [&_h2]:text-xl [&_h2]:font-medium [&_h2]:text-white [&_li]:pl-1 [&_strong]:font-medium [&_strong]:text-white [&_ul]:ml-5 [&_ul]:list-disc [&_ul]:space-y-1">
|
||||
<header className="space-y-3 pb-4">
|
||||
<h1 className="text-3xl font-medium text-white">{document.title}</h1>
|
||||
<p className="text-sm text-white/50">
|
||||
{t("legal.lastUpdated", { date: formatDate(document.lastUpdated, locale, "long") })}
|
||||
</p>
|
||||
</header>
|
||||
{children}
|
||||
<UntranslatedNotice contentLocale={document.locale} />
|
||||
<div className="space-y-4" lang={document.locale}>
|
||||
<ReactMarkdown components={components}>{document.content}</ReactMarkdown>
|
||||
</div>
|
||||
</article>
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function PaseoLegalIdentity() {
|
||||
return (
|
||||
<address className="not-italic">
|
||||
<strong className="font-medium text-white">Mohamed Boudra Ziani</strong>, operating as Paseo
|
||||
<br />
|
||||
NIF/VAT ID: ES26617095T
|
||||
<br />
|
||||
Roc Boronat 48, Bajos 2
|
||||
<br />
|
||||
08005 Barcelona, Spain
|
||||
<br />
|
||||
Email: <a href="mailto:hello@moboudra.com">hello@moboudra.com</a>
|
||||
</address>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import type * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DiffStat } from "./atoms";
|
||||
import { CodexIcon } from "./icons";
|
||||
|
||||
@@ -219,14 +220,18 @@ export function Composer({
|
||||
showProgress?: boolean;
|
||||
busy?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="shrink-0 px-[27px] pt-[16px] pb-[17px]">
|
||||
<div className={showProgress ? "pb-[13px]" : ""}>
|
||||
{showProgress ? (
|
||||
<div className="flex items-center gap-[8px]">
|
||||
<ComposerPill>6/6 tasks</ComposerPill>
|
||||
<ComposerPill>
|
||||
<CircleDot size={12} className="text-mock-dot-running" />3 subagents
|
||||
{t("landing.mockup.app.tasksProgress", { completed: 6, total: 6 })}
|
||||
</ComposerPill>
|
||||
<ComposerPill>
|
||||
<CircleDot size={12} className="text-mock-dot-running" />
|
||||
{t("landing.mockup.app.subagents", { count: 3 })}
|
||||
</ComposerPill>
|
||||
<ComposerPill>
|
||||
<DiffStat add="1.9k" remove="684" />
|
||||
@@ -238,7 +243,7 @@ export function Composer({
|
||||
<div className="px-[14px] pt-[13px] pb-[12px]">
|
||||
<div className="flex items-start gap-[10px]">
|
||||
<span className="min-w-0 flex-1 text-[13px] text-mock-surface4">
|
||||
Message the agent, tag @files, or use /commands and /skills
|
||||
{t("landing.mockup.app.composerPlaceholder")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-[17px] pt-[26px]">
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
RefreshCw,
|
||||
Rows3,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Code, DiffStat } from "./atoms";
|
||||
import { TABBAR_H } from "./geometry";
|
||||
import { ReactFileIcon } from "./icons";
|
||||
@@ -536,10 +537,11 @@ export function ChangesBranchRow() {
|
||||
}
|
||||
|
||||
export function ChangesScopeRow() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex h-[36px] shrink-0 items-center border-b border-mock-border px-[14px]">
|
||||
<span className="flex items-center gap-[5px] text-[12px] text-mock-fg-muted">
|
||||
Uncommitted
|
||||
{t("landing.mockup.app.uncommitted")}
|
||||
<ChevronDown size={12} />
|
||||
</span>
|
||||
<span className="ml-[9px] flex items-center gap-[5px] text-[11.5px] tabular-nums">
|
||||
@@ -556,10 +558,11 @@ export function ChangesScopeRow() {
|
||||
}
|
||||
|
||||
export function ChangesCommitsRow() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex h-[36px] shrink-0 items-center gap-[8px] border-t border-mock-border px-[10px] text-[12px] text-mock-fg">
|
||||
<ChevronRight size={13} className="text-mock-fg-muted" />
|
||||
Commits
|
||||
{t("landing.mockup.app.commits")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
SquareTerminal,
|
||||
} from "lucide-react";
|
||||
import type * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CodexIcon } from "../icons";
|
||||
|
||||
const TOOL_ROWS = [
|
||||
@@ -116,6 +117,7 @@ function Transcript() {
|
||||
}
|
||||
|
||||
function Composer() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="shrink-0 px-[18px] pt-[10px] pb-[30px]">
|
||||
<div className="mb-[16px]">
|
||||
@@ -125,7 +127,9 @@ function Composer() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-mock-border-accent bg-mock-surface1 px-[17px] pt-[15px] pb-[13px]">
|
||||
<span className="block text-[16px] text-mock-fg-xmuted">Message, @files, /commands</span>
|
||||
<span className="block text-[16px] text-mock-fg-xmuted">
|
||||
{t("landing.mockup.app.mobileComposerPlaceholder")}
|
||||
</span>
|
||||
<div className="flex items-center gap-[13px] pt-[26px]">
|
||||
<Plus size={20} className="shrink-0 text-mock-fg-muted" strokeWidth={1.9} />
|
||||
<span className="flex shrink-0 items-center gap-[5px]">
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
SquareDot,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Code } from "../atoms";
|
||||
import { ReactFileIcon } from "../icons";
|
||||
|
||||
@@ -73,15 +74,16 @@ const DIFF_LINES: MobileDiffLine[] = [
|
||||
];
|
||||
|
||||
function Tabs() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-[5px] px-[18px] pt-[4px] pb-[14px]">
|
||||
<span className="flex items-center gap-[7px] rounded-[8px] px-[10px] py-[6px] text-[16px] text-mock-fg-muted">
|
||||
<Files size={17} strokeWidth={1.8} />
|
||||
Files
|
||||
{t("landing.mockup.app.files")}
|
||||
</span>
|
||||
<span className="flex items-center gap-[7px] rounded-[8px] bg-mock-surface2 px-[10px] py-[6px] text-[16px] text-mock-fg">
|
||||
<FileDiff size={17} strokeWidth={1.8} />
|
||||
Changes
|
||||
{t("landing.mockup.app.changes")}
|
||||
</span>
|
||||
<span className="flex items-center gap-[7px] rounded-[8px] px-[10px] py-[6px] text-[16px] text-mock-fg-muted tabular-nums">
|
||||
<GitPullRequest size={17} strokeWidth={1.8} />
|
||||
@@ -110,10 +112,11 @@ function BranchRow() {
|
||||
}
|
||||
|
||||
function ScopeRow() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-[12px] border-y border-mock-border px-[18px] py-[11px]">
|
||||
<span className="flex items-center gap-[5px] text-[16px] text-mock-fg">
|
||||
Uncommitted
|
||||
{t("landing.mockup.app.uncommitted")}
|
||||
<ChevronDown size={16} className="text-mock-fg-muted" />
|
||||
</span>
|
||||
<span className="flex items-center gap-[6px] text-[15px] tabular-nums">
|
||||
@@ -170,6 +173,7 @@ const GUTTER_STYLE = { width: GUTTER_W };
|
||||
const GUTTER_RULE_STYLE = { left: GUTTER_W };
|
||||
|
||||
export function MobileDiff() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Tabs />
|
||||
@@ -192,7 +196,7 @@ export function MobileDiff() {
|
||||
))}
|
||||
</div>
|
||||
<div className="flex h-[52px] shrink-0 items-center border-t border-mock-border px-[18px] pb-[16px] text-[16px] text-mock-fg">
|
||||
Commits
|
||||
{t("landing.mockup.app.commits")}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LetterTile, PaseoTile } from "./atoms";
|
||||
|
||||
// Icons passed as props are hoisted so they are not rebuilt on every render.
|
||||
@@ -86,6 +87,7 @@ function BlankPageGlyph({ running }: { running?: boolean }) {
|
||||
}
|
||||
|
||||
function WorkspaceMeta({ host, pr, passed }: Pick<RowData, "host" | "pr" | "passed">) {
|
||||
const { t } = useTranslation();
|
||||
if (!host && !pr) return null;
|
||||
return (
|
||||
<span className="mt-[3px] flex items-center gap-[6px] text-[12.5px] text-mock-fg-muted">
|
||||
@@ -107,7 +109,7 @@ function WorkspaceMeta({ host, pr, passed }: Pick<RowData, "host" | "pr" | "pass
|
||||
<span className="text-mock-fg-xmuted">·</span>
|
||||
<span className="flex items-center gap-[4px] text-mock-success">
|
||||
<CircleCheck size={13} />
|
||||
passed
|
||||
{t("landing.mockup.app.checksPassedShort")}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
@@ -247,18 +249,25 @@ function GroupHeader({ icon, label }: { icon: React.ReactNode; label: string })
|
||||
}
|
||||
|
||||
export function MobileSidebar() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-mock-sidebar">
|
||||
<div className="pt-[4px]">
|
||||
<NavRow icon={NEW_WORKSPACE_ICON} label="New workspace" trailing={NEW_WORKSPACE_CLOSE} />
|
||||
<NavRow icon={HISTORY_ICON} label="History" />
|
||||
<NavRow icon={SCHEDULES_ICON} label="Schedules" />
|
||||
<NavRow
|
||||
icon={NEW_WORKSPACE_ICON}
|
||||
label={t("landing.mockup.app.newWorkspace")}
|
||||
trailing={NEW_WORKSPACE_CLOSE}
|
||||
/>
|
||||
<NavRow icon={HISTORY_ICON} label={t("landing.mockup.app.history")} />
|
||||
<NavRow icon={SCHEDULES_ICON} label={t("landing.mockup.app.schedules")} />
|
||||
</div>
|
||||
|
||||
<div className="mt-[4px] h-px bg-mock-border" />
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<div className="px-[20px] pt-[16px] pb-[6px] text-[15px] text-mock-fg-muted">Pinned</div>
|
||||
<div className="px-[20px] pt-[16px] pb-[6px] text-[15px] text-mock-fg-muted">
|
||||
{t("landing.mockup.app.pinned")}
|
||||
</div>
|
||||
|
||||
<div className="px-[8px]">
|
||||
{PINNED.map(({ key, ...row }) => (
|
||||
@@ -274,21 +283,21 @@ export function MobileSidebar() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<GroupHeader icon={READY_ICON} label="Ready to review" />
|
||||
<GroupHeader icon={READY_ICON} label={t("landing.mockup.app.readyToReview")} />
|
||||
<div className="px-[8px]">
|
||||
{READY.map(({ key, ...row }) => (
|
||||
<Row key={key} {...row} indented />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<GroupHeader icon={WORKING_ICON} label="Working" />
|
||||
<GroupHeader icon={WORKING_ICON} label={t("landing.mockup.app.working")} />
|
||||
<div className="px-[8px]">
|
||||
{WORKING.map(({ key, ...row }) => (
|
||||
<Row key={key} {...row} indented />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<GroupHeader icon={DONE_ICON} label="Done" />
|
||||
<GroupHeader icon={DONE_ICON} label={t("landing.mockup.app.done")} />
|
||||
<div className="px-[8px]">
|
||||
{DONE.map(({ key, ...row }) => (
|
||||
<Row key={key} {...row} indented />
|
||||
@@ -300,7 +309,7 @@ export function MobileSidebar() {
|
||||
<div className="flex h-[64px] shrink-0 items-center px-[20px] pb-[16px] text-mock-fg-muted">
|
||||
<span className="flex flex-1 items-center gap-[10px] text-[16px] text-mock-fg">
|
||||
<SquarePlus size={19} strokeWidth={1.8} />
|
||||
Add project
|
||||
{t("landing.mockup.app.addProject")}
|
||||
</span>
|
||||
<span className="flex items-center gap-[22px]">
|
||||
<Columns2 size={20} strokeWidth={1.8} />
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { DiffRows, REVIEW_DIFF_ROWS, ScopeRow } from "./diff";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// --- review -----------------------------------------------------------------
|
||||
|
||||
/** The selected Changes-tree file, open in its own workspace tab. */
|
||||
export function DiffReviewPane() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-mock-surface0">
|
||||
<ScopeRow label="Uncommitted" />
|
||||
<ScopeRow label={t("landing.mockup.app.uncommitted")} />
|
||||
<DiffRows rows={REVIEW_DIFF_ROWS} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
RefreshCw,
|
||||
Rows3,
|
||||
} from "lucide-react";
|
||||
import type { TFunction } from "i18next";
|
||||
import type * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChangesBranchRow, ChangesCommitsRow, ChangesScopeRow, ExplorerChangesTree } from "./diff";
|
||||
import { RIGHT_PANEL_W, TABBAR_H, TITLEBAR_H } from "./geometry";
|
||||
import type { MockupStateId } from "./states";
|
||||
@@ -75,15 +77,18 @@ const FILES_TAB_GLYPH = <Files size={12} />;
|
||||
|
||||
// --- Changes ---------------------------------------------------------------
|
||||
|
||||
const CHANGES_TABS: PanelTab[] = [
|
||||
{ label: "Files", icon: FILES_TAB_GLYPH },
|
||||
{ label: "Changes", icon: CHANGES_TAB_GLYPH },
|
||||
{ label: "3981", icon: PR_TAB_GLYPH },
|
||||
];
|
||||
function changesTabs(t: TFunction): PanelTab[] {
|
||||
return [
|
||||
{ label: t("landing.mockup.app.files"), icon: FILES_TAB_GLYPH },
|
||||
{ label: t("landing.mockup.app.changes"), icon: CHANGES_TAB_GLYPH },
|
||||
{ label: "3981", icon: PR_TAB_GLYPH },
|
||||
];
|
||||
}
|
||||
|
||||
function ChangesPanel({ selectedFile }: { selectedFile?: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<PanelShell tabs={CHANGES_TABS} active={1}>
|
||||
<PanelShell tabs={changesTabs(t)} active={1}>
|
||||
<ChangesBranchRow />
|
||||
<ChangesScopeRow />
|
||||
<ExplorerChangesTree selectedFile={selectedFile} />
|
||||
@@ -94,8 +99,6 @@ function ChangesPanel({ selectedFile }: { selectedFile?: string }) {
|
||||
|
||||
// --- Ship (GitHub PR checks) ------------------------------------------------
|
||||
|
||||
const SHIP_TABS: PanelTab[] = CHANGES_TABS;
|
||||
|
||||
const CHECKS: { name: string; context: string; time: string }[] = [
|
||||
{ name: "changes", context: "CI", time: "11s" },
|
||||
{ name: "setup", context: "Docker", time: "5s" },
|
||||
@@ -113,15 +116,16 @@ function CheckGlyph() {
|
||||
}
|
||||
|
||||
function ShipPanel() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<PanelShell tabs={SHIP_TABS} active={2}>
|
||||
<PanelShell tabs={changesTabs(t)} active={2}>
|
||||
<div
|
||||
className="flex shrink-0 items-center border-b border-mock-border px-[16px]"
|
||||
style={SUBHEADER_STYLE}
|
||||
>
|
||||
<span className="flex items-center gap-[6px] text-[12px] text-mock-fg-muted">
|
||||
<ExternalLink size={13} />
|
||||
View
|
||||
{t("landing.mockup.app.viewPr")}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<RefreshCw size={13} className="text-mock-fg-muted" />
|
||||
@@ -135,7 +139,7 @@ function ShipPanel() {
|
||||
<div className="flex items-center gap-[7px] pt-[6px] text-[11.5px]">
|
||||
<span className="flex items-center gap-[5px] text-mock-success">
|
||||
<GitPullRequest size={12} />
|
||||
Open
|
||||
{t("landing.mockup.app.prOpen")}
|
||||
</span>
|
||||
<span className="text-mock-fg-muted">getpaseo/paseo</span>
|
||||
</div>
|
||||
@@ -144,9 +148,11 @@ function ShipPanel() {
|
||||
<div className="flex items-center gap-[11px] bg-mock-surface1 px-[16px] py-[11px]">
|
||||
<CheckGlyph />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[13px] font-medium text-mock-fg">All checks have passed</span>
|
||||
<span className="block text-[13px] font-medium text-mock-fg">
|
||||
{t("landing.mockup.app.checksPassed")}
|
||||
</span>
|
||||
<span className="block text-[11px] text-mock-fg-muted">
|
||||
9 successful, 13 skipped checks
|
||||
{t("landing.mockup.app.checksDetail", { successful: 9, skipped: 13 })}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown size={14} className="text-mock-fg-muted" />
|
||||
@@ -154,7 +160,7 @@ function ShipPanel() {
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<div className="flex items-center gap-[6px] px-[16px] pt-[11px] pb-[4px] text-[11.5px] text-mock-fg-muted">
|
||||
9 successful checks
|
||||
{t("landing.mockup.app.successfulChecks", { count: 9 })}
|
||||
<ChevronDown size={12} />
|
||||
</div>
|
||||
{CHECKS.map((check) => (
|
||||
@@ -170,9 +176,11 @@ function ShipPanel() {
|
||||
))}
|
||||
<div className="flex items-center gap-[6px] px-[16px] pt-[13px] pb-[4px] text-[11.5px] text-mock-fg-muted">
|
||||
<ChevronDown size={12} />
|
||||
Activity
|
||||
{t("landing.mockup.app.activity")}
|
||||
</div>
|
||||
<div className="px-[16px] pt-[2px] text-[12px] text-mock-fg-muted">
|
||||
{t("landing.mockup.app.noActivity")}
|
||||
</div>
|
||||
<div className="px-[16px] pt-[2px] text-[12px] text-mock-fg-muted">No activity yet</div>
|
||||
</div>
|
||||
</PanelShell>
|
||||
);
|
||||
@@ -181,16 +189,19 @@ function ShipPanel() {
|
||||
// --- Plugins (invented custom dashboard) ------------------------------------
|
||||
// No real reference exists; this reads as a user-built native surface plugin.
|
||||
|
||||
const PLUGIN_TABS: PanelTab[] = [
|
||||
...CHANGES_TABS,
|
||||
{ label: "Release Radar", icon: <Puzzle size={12} /> },
|
||||
];
|
||||
const PLUGIN_TAB: PanelTab = { label: "Release Radar", icon: <Puzzle size={12} /> };
|
||||
|
||||
const TILES: { label: string; value: string; tile: string }[] = [
|
||||
{ label: "Merged · 7d", value: "12", tile: "text-mock-tile-teal" },
|
||||
{ label: "In review", value: "4", tile: "text-mock-tile-amber" },
|
||||
{ label: "Failing", value: "1", tile: "text-mock-tile-red" },
|
||||
];
|
||||
function pluginTabs(t: TFunction): PanelTab[] {
|
||||
return [...changesTabs(t), PLUGIN_TAB];
|
||||
}
|
||||
|
||||
function tiles(t: TFunction): { label: string; value: string; tile: string }[] {
|
||||
return [
|
||||
{ label: t("landing.mockup.app.mergedWeek"), value: "12", tile: "text-mock-tile-teal" },
|
||||
{ label: t("landing.mockup.app.inReview"), value: "4", tile: "text-mock-tile-amber" },
|
||||
{ label: t("landing.mockup.app.failing"), value: "1", tile: "text-mock-tile-red" },
|
||||
];
|
||||
}
|
||||
|
||||
// Weekly merge counts — a small bar chart the plugin author drew. Heights and
|
||||
// their style objects are precomputed so the render loop allocates nothing.
|
||||
@@ -204,41 +215,44 @@ const BARS: { id: string; style: { height: string } }[] = [
|
||||
{ id: "sun", style: { height: "62px" } },
|
||||
];
|
||||
|
||||
const RECENT: { title: string; meta: string; tone: string }[] = [
|
||||
{
|
||||
title: "Connect to remote daemons over SSH",
|
||||
meta: "#3989 · 20m ago",
|
||||
tone: "bg-mock-dot-success",
|
||||
},
|
||||
{
|
||||
title: "Keep restored workspaces out of loops",
|
||||
meta: "#3987 · 1h ago",
|
||||
tone: "bg-mock-dot-success",
|
||||
},
|
||||
{
|
||||
title: "Honor configured commit signing",
|
||||
meta: "#3976 · reviewing",
|
||||
tone: "bg-mock-dot-warning",
|
||||
},
|
||||
];
|
||||
function recentlyMerged(t: TFunction): { title: string; meta: string; tone: string }[] {
|
||||
return [
|
||||
{
|
||||
title: "Connect to remote daemons over SSH",
|
||||
meta: `#3989 · ${t("landing.mockup.app.minutesAgo", { count: 20 })}`,
|
||||
tone: "bg-mock-dot-success",
|
||||
},
|
||||
{
|
||||
title: "Keep restored workspaces out of loops",
|
||||
meta: `#3987 · ${t("landing.mockup.app.hoursAgo", { count: 1 })}`,
|
||||
tone: "bg-mock-dot-success",
|
||||
},
|
||||
{
|
||||
title: "Honor configured commit signing",
|
||||
meta: `#3976 · ${t("landing.mockup.app.reviewing")}`,
|
||||
tone: "bg-mock-dot-warning",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function PluginDashboard() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<PanelShell tabs={PLUGIN_TABS} active={3}>
|
||||
<PanelShell tabs={pluginTabs(t)} active={3}>
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-[8px] border-b border-mock-border px-[16px]"
|
||||
style={SUBHEADER_STYLE}
|
||||
>
|
||||
<Puzzle size={14} className="text-mock-accent-bright" />
|
||||
<span className="text-[13px] font-medium text-mock-fg">Release Radar</span>
|
||||
<span className="text-[11px] text-mock-fg-muted">plugin</span>
|
||||
<span className="text-[11px] text-mock-fg-muted">{t("landing.mockup.app.plugin")}</span>
|
||||
<span className="flex-1" />
|
||||
<RefreshCw size={13} className="text-mock-fg-muted" />
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden px-[16px] pt-[14px]">
|
||||
<div className="flex gap-[9px]">
|
||||
{TILES.map((tile) => (
|
||||
{tiles(t).map((tile) => (
|
||||
<div
|
||||
key={tile.label}
|
||||
className="flex-1 rounded-[10px] border border-mock-border-accent bg-mock-surface1 px-[12px] py-[11px]"
|
||||
@@ -253,7 +267,7 @@ function PluginDashboard() {
|
||||
|
||||
<div className="mt-[16px] rounded-[10px] border border-mock-border-accent bg-mock-surface1 px-[14px] pt-[12px] pb-[13px]">
|
||||
<div className="flex items-center justify-between text-[11px] text-mock-fg-muted">
|
||||
<span>Merges · last 7 days</span>
|
||||
<span>{t("landing.mockup.app.mergesWeek")}</span>
|
||||
<span className="flex items-center gap-[5px] text-mock-tile-teal">
|
||||
<Rows3 size={11} />
|
||||
+18%
|
||||
@@ -270,9 +284,11 @@ function PluginDashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-[16px] text-[11px] text-mock-fg-muted">Recently merged</div>
|
||||
<div className="mt-[16px] text-[11px] text-mock-fg-muted">
|
||||
{t("landing.mockup.app.recentlyMerged")}
|
||||
</div>
|
||||
<div className="pt-[6px]">
|
||||
{RECENT.map((item) => (
|
||||
{recentlyMerged(t).map((item) => (
|
||||
<div key={item.title} className="flex items-center gap-[10px] py-[7px]">
|
||||
<span className={`size-[7px] shrink-0 rounded-full ${item.tone}`} />
|
||||
<span className="min-w-0 flex-1">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user