Compare commits

...
Author SHA1 Message Date
5d70bf0645 fix(android): restore image picker after Activity recreation (#7546)
Backport expo/expo#49634 to SDK 54 so retained launchers register
against the current Activity after their previous owner is destroyed.
Cover repeated recreation and replacement-before-destroy ordering with
native regression tests, and document the native rebuild requirement.

Co-authored-by: luke <luopeihuan@gmail.com>
Co-authored-by: Ido Yana <idoyana@gmail.com>
Co-authored-by: RoundRedDot <roundreddot@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-21 09:17:26 +02:00
Mohamed Boudra fbd90cb88d Keep setup execution and output owned by the workspace (#7473)
* perf(workspaces): stop duplicating setup output in progress snapshots

Keep the required combined log field empty for setup clients, which display per-command output. Preserve concatenation only for legacy agent bootstrap timeline cards whose released renderers still read it directly.

* refactor(workspaces): own setup exclusively at the workspace boundary

Preserve keyed modern agent worktree creation while retiring legacy placement that selected an agent-owned setup lifecycle. Keep output in per-command workspace snapshots and retain empty strings required by existing wire schemas.

* fix(server): avoid duplicate cwd in worktree provisioning request

* test(e2e): keep developer toasts out of concurrent app assertions

Concurrent layout measurements could invoke the same Playwright locator handler before its first dismissal finished, leaving checks waiting for a removed button. Hide only the Expo toast host through test styling while retaining console error capture.
2026-10-21 14:16:24 +07:00
paseo-ai[bot] a891155b82 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-21 06:55:06 +00:00
Mohamed Boudra 006fd053ac Record Android freezes in opt-in diagnostic builds (#7456)
* feat(app): record Android stalls in opt-in diagnostic builds

* refactor(app): own watchdog sinks and bounded log storage

* fix(app): preserve cumulative browser profiling history
2026-10-21 13:48:05 +07:00
Mohamed Boudra 2545250b2f fix(app): keep saved workspace tabs when a store changes before hydration (#7547)
On every page load the layout mode publisher sets the workspace layout store
before its stored state has been read. Zustand persist wrote the unhydrated
defaults, and hydration merges the stored layout into memory without writing
it back, so storage held no tabs until the next layout change. Leaving the
page inside that window lost every workspace's tabs. Validated persist storage
now drops writes that arrive while its read is in flight.
2026-10-21 13:45:16 +07:00
Mohamed Boudra 30464cf234 Translate plugin content and labels in each app language (#7478)
* feat(website): serve paseo.sh in Simplified Chinese

Every page of the website now has a Simplified Chinese version under
/zh-CN: the interface, docs, blog posts, comparison pages, legal pages,
all changelog releases and the homepage mockup. English URLs are unchanged.

The URL alone decides the language. There is no browser-language redirect
and no stored preference, because either would have to redirect the
unprefixed English URLs that people share and search engines index.
Internal links carry the page's language instead, and hreflang alternates
point Chinese search results at /zh-CN.

Translated headings pin the English anchor with {#id}, so section links
work in both languages. content-translations.test.ts fails when a page
has no translation or a translation's headings drift from the English.
Changelog releases and plugin listings without a translation render
whole in English under a notice, so a release never waits on one.

Plugin manifests and the registry gain optional translations and
OVERVIEW.zh-CN.md fields; older clients and registries ignore them.

* feat(website): replace the native language select with an EN/ZH menu

The header showed an OS-native select whose popup did not match the site.
The trigger now shows the language abbreviated (EN, ZH) and opens a menu in
the app's dropdown style that names each language in itself, with its name
in the page language underneath. Rows are real links to the same page in
that language; arrows, Escape, outside presses and taps behave as a menu
button, and the menu keeps itself on screen when a narrow header pushes the
trigger toward an edge.

Also drops the stray space after the full-width comma in the Chinese
homepage subtitle.

* docs(website): translate main's new docs and 0.12.0 release notes

Main added Keep run workspaces to the schedules docs, rewrote plugin icons
around inline SVG, and promoted 0.12.0 with new entries. Bring the Chinese
pages and changelog in line so no section falls back to English.

* fix(server): leave plugin listing translations to the registry

The daemon rejected manifests whose translations the registry would refuse,
so a plugin main installs could fail to load here. Paseo never reads
translations; the registry validates them at publish. Docs now say which
checks are automated at publish and which are human review.

* docs(plugins): describe the registry's final overview translation discovery

* style(server): format manifest test

* feat(plugins): translate plugin UI in each app language

Keep translation resources and subscriptions inside each installation. Resolve host labels against the presentation locale so live language changes preserve mounted drafts and RPC state. Existing descriptor types retain string defaults for source compatibility.

* test(plugins): dismiss developer chrome in second translation client

* test(app): measure sidebar rows one at a time

After a reload the developer error toast appears while expectVerticalOrder
measures every row at once. Each concurrent boundingBox runs the toast
locator handler, and only one of them resumes, so the poll timed out on
every attempt since #7424. Measuring sequentially keeps one action in flight.

* test(app): return sidebar row measuring to main

#7480 hides the developer toast instead of running a locator handler, so
measuring the rows concurrently no longer races it. Reverts 41ac23016.

* test(app): wait for the compact shell before opening compact Changes

The shell survives the breakpoint, so right after the resize the wide
Explorer could still be visible: the test skipped the Explorer toggle and
the tab then disappeared, or toggled at the wrong time. Wait for the
compact-shell marker, as the composer helpers do since #7424.

* docs(website): translate main's plugin file panel docs
2026-10-21 13:40:46 +07:00
Mohamed Boudra 98d34a3dc1 ci: run pull-request checks for every base branch (#7545)
Stacked PRs and PRs into next got no CI because the pull_request trigger
was limited to main. Drop the base filter from CI and desktop packaging,
and lock it with a workflow contract test.
2026-10-21 13:32:10 +07:00
paseo-ai[bot] 77453388e4 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-21 06:22:06 +00:00
Mohamed Boudra bd15e40e94 Serve paseo.sh in Simplified Chinese (#7373)
* feat(website): serve paseo.sh in Simplified Chinese

Every page of the website now has a Simplified Chinese version under
/zh-CN: the interface, docs, blog posts, comparison pages, legal pages,
all changelog releases and the homepage mockup. English URLs are unchanged.

The URL alone decides the language. There is no browser-language redirect
and no stored preference, because either would have to redirect the
unprefixed English URLs that people share and search engines index.
Internal links carry the page's language instead, and hreflang alternates
point Chinese search results at /zh-CN.

Translated headings pin the English anchor with {#id}, so section links
work in both languages. content-translations.test.ts fails when a page
has no translation or a translation's headings drift from the English.
Changelog releases and plugin listings without a translation render
whole in English under a notice, so a release never waits on one.

Plugin manifests and the registry gain optional translations and
OVERVIEW.zh-CN.md fields; older clients and registries ignore them.

* feat(website): replace the native language select with an EN/ZH menu

The header showed an OS-native select whose popup did not match the site.
The trigger now shows the language abbreviated (EN, ZH) and opens a menu in
the app's dropdown style that names each language in itself, with its name
in the page language underneath. Rows are real links to the same page in
that language; arrows, Escape, outside presses and taps behave as a menu
button, and the menu keeps itself on screen when a narrow header pushes the
trigger toward an edge.

Also drops the stray space after the full-width comma in the Chinese
homepage subtitle.

* docs(website): translate main's new docs and 0.12.0 release notes

Main added Keep run workspaces to the schedules docs, rewrote plugin icons
around inline SVG, and promoted 0.12.0 with new entries. Bring the Chinese
pages and changelog in line so no section falls back to English.

* fix(server): leave plugin listing translations to the registry

The daemon rejected manifests whose translations the registry would refuse,
so a plugin main installs could fail to load here. Paseo never reads
translations; the registry validates them at publish. Docs now say which
checks are automated at publish and which are human review.

* docs(plugins): describe the registry's final overview translation discovery

* style(server): format manifest test

* test(app): measure sidebar rows one at a time

After a reload the developer error toast appears while expectVerticalOrder
measures every row at once. Each concurrent boundingBox runs the toast
locator handler, and only one of them resumes, so the poll timed out on
every attempt since #7424. Measuring sequentially keeps one action in flight.

* test(app): return sidebar row measuring to main

#7480 hides the developer toast instead of running a locator handler, so
measuring the rows concurrently no longer races it. Reverts 41ac23016.

* test(app): wait for the compact shell before opening compact Changes

The shell survives the breakpoint, so right after the resize the wide
Explorer could still be visible: the test skipped the Explorer toggle and
the tab then disappeared, or toggled at the wrong time. Wait for the
compact-shell marker, as the composer helpers do since #7424.

* docs(website): translate main's plugin file panel docs

* test(app): edit the code font size after the font family remount

A committed appearance change remounts the settings screen through
AppearanceStyleBoundary, and the new form starts from the stored size.
Filling the size before that remount lost the edit in CI.

* docs(website): translate main's client/ui import docs

Follows #7474: one /client/ui entry, StreamedText instead of useRevealedText,
SettingsGroup dropped from the settings table, and the new
'UI imports in v0.13' migration section.

* test(app): wait for the previous layout's file panel picker to leave

After a resize the old layout's picker animates out while the new one is
already mounted, so the Use button matched two elements and the strict
locator failed. Wait for the shell's layout and a single picker first.
2026-10-21 13:13:03 +07:00
414 changed files with 26259 additions and 7692 deletions
+9 -1
View File
@@ -3,8 +3,8 @@ name: CI
on:
push:
branches: [main]
# Every base branch, so stacked PRs and PRs into `next` get the same checks.
pull_request:
branches: [main]
merge_group:
workflow_dispatch:
@@ -417,6 +417,14 @@ jobs:
- name: Run app unit tests
run: npm run test --workspace=@getpaseo/app
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Run Android module tests
run: npm run test:android --workspace=@getpaseo/app
playwright-1:
name: playwright (shard 1/8)
needs: changes
-1
View File
@@ -6,7 +6,6 @@ on:
# Packaging leaves ordinary PR CI, but a PR that changes what gets packaged
# or how it is smoked still proves the real artifacts before merge.
pull_request:
branches: [main]
paths:
- "packages/desktop/**"
- "plugins/**"
+3238
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -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 |
@@ -106,6 +107,8 @@ npm run format:check # Check formatting without writing
Repo dev commands use checkout-local state by default. In this checkout, `PASEO_HOME` resolves to `.dev/paseo-home`, and `npm run cli -- ...` targets that same dev home automatically. The packaged desktop app and production-style daemon keep using `~/.paseo` on port `6767`.
When Mo requests an APK, use `npm run build:apk:arm64` for an ARM64 build: writes `artifacts/paseo-dogfood-arm64.apk` with diagnostics without starting a device; `npm run build:apk` builds all four Android architectures.
See [docs/development.md](docs/development.md) for full setup, build sync requirements, and debugging.
## Release branches
+7 -7
View File
@@ -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 的首页和文档中。
## 相关项目
+2
View File
@@ -71,6 +71,8 @@ normal root agent immediately, and closing its still-open tab archives it.
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation reuses that workspace unless a new workspace is explicitly requested.
Setup belongs to the workspace, including combined workspace-plus-agent creation and the supported agent `worktree` request. Run setup through the workspace workflow before launching configured terminals or scripts. Setup output is read in the Setup panel from per-command snapshots; never produce setup chat timeline rows or concatenate command logs. Legacy agent `git`/`worktreeName` inputs and hidden MCP worktree placement are rejected with workspace-creation guidance. Existing wire schemas retain an empty combined-log string for parsing compatibility.
Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the parent and open-tab lifecycle labels: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
`notifyOnFinish` defaults to `true` for agent-scoped creation and prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
+65 -23
View File
@@ -61,39 +61,81 @@ Gradle auto-fetches the platform/build-tools it needs once licenses are accepted
## Local build + install
The locked `expo-modules-core@57.0.23` needs the patch installed by `npm install`.
It backports
[Expo's Activity-result launcher fix](https://github.com/expo/expo/pull/49634): changing
font size or another unhandled configuration can otherwise disable image selection
until the app process restarts. Rebuild the native app after changing this patch;
a Metro reload cannot update it. Remove the patch when the installed
`expo-modules-core` includes the fix.
Run its native regression tests from `packages/app/android` after prebuild:
```bash
./gradlew :expo-modules-core:testDebugUnitTest \
--tests expo.modules.kotlin.activityresult.AppContextActivityResultRegistryRecreationTest \
--no-daemon --max-workers=1 -Dorg.gradle.parallel=false
```
From repo root:
```bash
npm run android:development # Debug build
npm run android:production # Release build
npm run android:clear # Remove generated Android project
npm run build:apk # Universal dogfood APK: 32/64-bit ARM and x86
npm run build:apk:arm64 # Smaller dogfood APK for ARM64 phones
npm run dev:android # Build/install the debug app and start development
npm run dev:ios # Build/install the iOS development app
```
For a production-ID release APK that local Android profiling tools can attach to:
`build:apk` writes `artifacts/paseo-dogfood-universal.apk`; `build:apk:arm64` writes
`artifacts/paseo-dogfood-arm64.apk`. Neither command launches or installs on a device. Both enable
`PASEO_WATCHDOG_BUILD=1`, the production React profiling renderer, and Android's shell-profileable
flag. These builds keep the production package ID (`sh.paseo`) and release optimizations. Use this APK for
personal dogfooding. Android system-trace spans require the separate `PASEO_PROFILE_BUILD=1` flag;
watchdog recording does not send duplicate spans to that sink by default. Normal store/release
workflows leave diagnostics disabled.
The older `android`, `android:development`, and `ios` commands remain aliases for development.
`android:production` still builds and installs a release through Expo; use `build:apk` when you
need an artifact. `android:clear` removes the generated Android project.
Run `npm run test:android --workspace=@getpaseo/app` to prebuild Android and run native module unit tests. The app CI job runs this command with the Android toolchain.
### Diagnostic logs without ADB
In a dogfood build, open Android's Files app → Downloads → **Paseo diagnostics** and share all
retained `.jsonl` files. No app screen, storage permission, or attached debugger is required.
The recorder retains eight files of about 5 MiB each across reconnects and app restarts. Rotation
replaces the oldest files, so collect them soon after reproducing a freeze. Uninstall/reinstall
can leave older Downloads files that the new installation cannot manage.
Each file identifies its build and session. Events include application-transport frame sizes and
message/request IDs, connection transitions, parse/validation/dispatch spans, named React subtree
render timings, and foreground JS heartbeat gaps. Payloads, passwords, and message contents
are excluded; connection identifiers include host addresses. Each size has a unit: **bytes** when
already available, otherwise **UTF-16 code units** for text. These are
client transport observations, after relay decryption where applicable, not packet arrival times.
The native recorder flags a heartbeat overdue by more than 500 ms (250 ms expected interval).
A heartbeat gap bounds the unresponsive interval; it is not an exact blocked-stack duration.
Backgrounding suspends detection. `js.stall.started` can be written while JS remains blocked;
`js.stall.recovered` records the gap when JS resumes. Recovery can appear without a start event
when JS resumes before the next native sampling tick. Process death leaves earlier files intact,
but queued/unflushed records can be lost. A missing recovery event alone does not prove a crash.
`react.render.slow` records subtree rendering taking at least 16 ms; one-second summaries include
faster renders. Nested profiler timings overlap: don't add parent and child times. React's render
measurements do not measure native layout/paint or the complete commit phase. `recorder.dropped`
means the bounded queue overflowed; missing events in that interval must not be treated as absence
of activity. Storage failure stops recording rather than blocking the app.
For an ordinary release APK with system trace markers only:
```bash
PASEO_PROFILE_BUILD=1 npm run android:production
```
This keeps the `sh.paseo` package id, release Hermes bundle, and release optimizations. It adds
`<profileable android:shell="true" />` and enables local Android trace markers for workspace mounts
and daemon WebSocket traffic. The markers contain message types and sizes, never payload contents,
and emit only while a system trace records the `sh.paseo` app (`perfetto -a sh.paseo ...`).
Or from `packages/app`:
```bash
# Debug
npx cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive
npx cross-env APP_VARIANT=development expo run:android --variant=debug
# Release
npx cross-env APP_VARIANT=production expo prebuild --platform android --clean --non-interactive
npx cross-env APP_VARIANT=production expo run:android --variant=release
# Clear generated Android project
rm -rf android
```
Those markers emit only while a system trace records `sh.paseo` (`perfetto -a sh.paseo ...`);
they do not produce Downloads files.
## Running on an emulator against a worktree daemon
+1 -1
View File
@@ -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
+6
View File
@@ -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`,
+12 -3
View File
@@ -62,9 +62,10 @@ release. This applies to both beta and stable releases.
the changelog. Retarget remaining PRs based on `next` to `main` and delete the integrated
`next`. Create it fresh when needed again.
**Setup still needed:** CI, Docker, and Nix PR checks currently target only `main`,
and GitHub permits only squash merges. Enable checks and required-check protection
for `next`, CI on its pushes, and merge commits for the integration PR. Handle PR
**Setup still needed:** PR checks run for every base branch, but CI, Docker, and Nix
push runs target only `main`, and GitHub permits only squash merges. Enable
required-check protection for `next`, CI on its pushes, and merge commits for the
integration PR. Handle PR
base changes (`edited` events) so retargeting runs checks against the new base;
GitHub's default PR events do not cover this. Deployment triggers stay unchanged.
@@ -533,6 +534,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.
+51
View File
@@ -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 (`**注意**:这里`).
+1 -1
View File
@@ -1 +1 @@
sha256-7B/Zyo/DaHdd3PVq8Hn1HG+x+wLdZBVxgx/cbtaM/S8=
sha256-Et1ofRqkqFoGETnbeqXuy2RerFT0kkk5F+VwDXak+fk=
+3
View File
@@ -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",
+6 -3
View File
@@ -41,12 +41,16 @@
"dev:server:watch": "concurrently --kill-others --names protocol,client,server --prefix-colors yellow,blue,cyan \"npm run watch:protocol\" \"npm run watch:client\" \"npm run dev:server:raw\"",
"dev:server:raw": "npm run dev --workspace=@getpaseo/server",
"dev:app": "cross-env PASEO_LISTEN=127.0.0.1:6768 EXPO_PORT=8081 ./scripts/dev-app.sh",
"dev:android": "npm run dev:android --workspace=@getpaseo/app",
"dev:ios": "npm run dev:ios --workspace=@getpaseo/app",
"test:e2e:mobile": "./scripts/test-mobile-agent-device.sh",
"test:e2e:composer-keyboard:android": "./packages/app/e2e/mobile/composer-keyboard/android.sh",
"dev:website": "npm run dev --workspace=@getpaseo/website",
"postinstall": "node scripts/postinstall-patches.mjs",
"prepare": "lefthook install --force",
"build": "npm run build --workspaces --if-present",
"build:apk": "node scripts/build-apk.mjs",
"build:apk:arm64": "node scripts/build-apk.mjs arm64",
"build:highlight": "npm run build --workspace=@getpaseo/highlight",
"build:highlight:clean": "npm run build:clean --workspace=@getpaseo/highlight",
"build:plugin": "npm run build --workspace=@getpaseo/plugin",
@@ -80,15 +84,14 @@
"acp:version-drift:check": "node scripts/check-acp-catalog-version-drift.mjs --fail-on-drift",
"acp:version-drift:update": "node scripts/check-acp-catalog-version-drift.mjs --update",
"start": "npm run start --workspace=@getpaseo/server",
"android": "npm run android --workspace=@getpaseo/app",
"android": "npm run dev:android",
"android:development": "npm run android:development --workspace=@getpaseo/app",
"android:production": "npm run android:production --workspace=@getpaseo/app",
"android:release": "npm run android:production --workspace=@getpaseo/app",
"android:clear": "npm run android:clear --workspace=@getpaseo/app",
"android:clean": "npm run android:clean --workspace=@getpaseo/app",
"fdroid:changelogs": "node scripts/sync-fdroid-changelogs.mjs",
"fdroid:changelogs:check": "node scripts/sync-fdroid-changelogs.mjs --check",
"ios": "npm run ios --workspace=@getpaseo/app",
"ios": "npm run dev:ios",
"web": "npm run web --workspace=@getpaseo/app",
"dev:desktop": "cross-env PASEO_LISTEN=127.0.0.1:6768 npm run dev --workspace=@getpaseo/desktop",
"dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop",
+5 -1
View File
@@ -10,6 +10,7 @@ const withAndroidScroll = require("./modules/paseo-scroll/app.plugin");
const { getNativeReleaseVersion } = require("./native-release-version");
const appVariant = process.env.APP_VARIANT ?? "production";
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
const isWatchdogBuild = process.env.PASEO_WATCHDOG_BUILD === "1";
const isProfileBuild = process.env.PASEO_PROFILE_BUILD === "1";
const buildProfile = isFdroidBuild
@@ -181,7 +182,7 @@ export default {
},
],
...buildProfile.fdroidPlugins,
...(isProfileBuild ? [withAndroidProfileable] : []),
...(isProfileBuild || isWatchdogBuild ? [withAndroidProfileable] : []),
],
experiments: {
typedRoutes: true,
@@ -191,6 +192,9 @@ export default {
extra: {
fdroidBuild: isFdroidBuild,
profileBuild: isProfileBuild,
watchdogBuild: isWatchdogBuild,
diagnosticRevision: process.env.PASEO_DIAGNOSTIC_REVISION,
diagnosticBuildTime: process.env.PASEO_DIAGNOSTIC_BUILD_TIME,
router: {},
eas: {
projectId: "0e7f65ce-0367-46c8-a238-2b65963d235a",
@@ -832,6 +832,9 @@ test("compact Changes keeps its actions compact and menu-only", async ({ page })
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await page.setViewportSize({ width: 480, height: 900 });
// The shell survives the breakpoint, so the wide Explorer can still be on screen right after the
// resize. Check for the compact tab only once the shell reports the compact layout.
await expect(page.getByTestId("compact-shell")).toBeVisible();
const compactChangesTab = page
.getByTestId("workspace-tab-changes_tree")
@@ -1844,9 +1847,14 @@ async function changeCodeTypographyFromSettings(
await page.getByTestId("sidebar-settings").click();
await expect(page).toHaveURL(new RegExp(`${buildSettingsSectionRoute("general")}|/settings$`));
await page.getByRole("button", { name: "Appearance" }).click();
// A committed font change remounts the screen (AppearanceStyleBoundary), and the new form starts
// from the stored size. Edit the size only in the remounted form, or the remount drops it.
const sizeField = page.getByLabel("Code font size");
await sizeField.evaluate((field) => field.setAttribute("data-e2e-before-font-family", ""));
await page.getByLabel("Code font family").fill(typography.fontFamily);
await page.getByLabel("Code font family").press("Enter");
await page.getByLabel("Code font size").fill(String(typography.fontSize));
await expect(page.locator("[data-e2e-before-font-family]")).toHaveCount(0);
await sizeField.fill(String(typography.fontSize));
await page.getByLabel("Code font size").press("Enter");
await expect(page.getByLabel("Code font family")).toHaveValue(typography.fontFamily);
await expect(page.getByLabel("Code font size")).toHaveValue(String(typography.fontSize));
@@ -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);
});
@@ -60,10 +60,14 @@ async function captureWideAndCompact(
ready: () => Promise<void>,
) {
await capture(page, testInfo, `${name}-wide`);
// The shell survives the breakpoint, so the previous layout's copy can still be on screen right
// after a resize. Check `ready` only once the shell reports the new layout.
await page.setViewportSize(COMPACT);
await expect(page.getByTestId("compact-shell")).toBeVisible();
await ready();
await capture(page, testInfo, `${name}-compact`);
await page.setViewportSize(WIDE);
await expect(page.getByTestId("wide-shell")).toBeVisible();
await ready();
}
@@ -195,6 +199,9 @@ test("directory examples mount the PDF frame and write Markdown through the file
).toBeVisible();
await expect(page.getByRole("radio", { name: "Source — Paseo", exact: true })).toBeVisible();
await captureWideAndCompact(page, testInfo, "markdown-example-picker", async () => {
// The previous layout's picker animates out (the sheet slides down, the dialog fades), so
// both pickers are mounted for a moment after a resize.
await expect(page.getByRole("button", { name: "Use", exact: true })).toHaveCount(1);
await expect(page.getByRole("button", { name: "Use", exact: true })).toBeInViewport({
ratio: 1,
});
@@ -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);
});
@@ -1,4 +1,5 @@
import { expect, test } from "../support/fixtures";
import type { Page } from "@playwright/test";
import { openAddProjectFlow, addProjectFlow } from "../support/helpers/add-project-flow";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
import {
@@ -31,6 +32,28 @@ import {
setSidebarNavItemVisible,
} from "../support/helpers/sidebar-nav-settings";
async function showDeveloperToast(page: Page): Promise<void> {
await page.evaluate(() => {
document.getElementById("error-toast")?.remove();
const toast = document.createElement("div");
toast.id = "error-toast";
toast.style.cssText = "position:fixed;inset:0;z-index:2147483647;background:white";
// Expo renders its unnamed dismiss button in an open shadow root.
const button = document.createElement("button");
button.style.cssText = "width:24px;height:24px";
button.addEventListener("click", () => toast.remove());
toast.attachShadow({ mode: "open" }).appendChild(button);
document.body.appendChild(toast);
});
}
test("developer toasts leave concurrent sidebar layout checks uninterrupted", async ({ page }) => {
await gotoAppShell(page);
await showDeveloperToast(page);
await expectSidebarOrder(page, ["new-workspace", "history", "search", "schedules"]);
await expect(page.locator("#error-toast")).toBeHidden();
});
test("fixed footer line keeps its five icons, Help and Settings at the end", async ({ page }) => {
test.setTimeout(120_000);
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
@@ -2,6 +2,7 @@ import { test, expect } from "../support/fixtures";
import { createTempGitRepo } from "../support/helpers/workspace";
import {
connectWorkspaceSetupClient,
setupCommandOutput,
createWorkspaceThroughDaemon,
openWorkspaceScriptsMenu,
seedProjectForWorkspaceSetup,
@@ -33,7 +34,9 @@ test("scripts menu resizes when a service row grows after launch", async ({ page
await seedProjectForWorkspaceSetup(client, repo.path);
const completed = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"),
(payload) =>
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
@@ -18,6 +18,7 @@ import {
} from "../support/helpers/terminal-perf";
import {
connectWorkspaceSetupClient,
setupCommandOutput,
createWorkspaceThroughDaemon,
expectSetupPanel,
openHomeWithProject,
@@ -92,7 +93,8 @@ test.describe("Workspace setup streaming", () => {
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
@@ -170,16 +172,19 @@ test.describe("Workspace setup streaming", () => {
await seedProjectForWorkspaceSetup(client, repo.path);
const initialRunning = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "running" && payload.detail.log === "",
(payload) => payload.status === "running" && payload.detail.commands.length === 0,
);
const runningWithOutput = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "running" && payload.detail.log.includes("starting setup"),
(payload) =>
payload.status === "running" &&
setupCommandOutput(payload.detail).includes("starting setup"),
);
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
);
await createWorkspaceThroughDaemon(client, {
@@ -192,8 +197,8 @@ test.describe("Workspace setup streaming", () => {
const completedPayload = await completed;
expect(initialPayload.detail.log).toBe("");
expect(runningPayload.detail.log).toContain("starting setup");
expect(completedPayload.detail.log).toContain("setup complete");
expect(setupCommandOutput(runningPayload.detail)).toContain("starting setup");
expect(setupCommandOutput(completedPayload.detail)).toContain("setup complete");
expect(completedPayload.error).toBeNull();
} finally {
await client.close();
@@ -215,7 +220,9 @@ test.describe("Workspace setup streaming", () => {
await seedProjectForWorkspaceSetup(client, repo.path);
const failed = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "failed" && payload.detail.log.includes("setup failed"),
(payload) =>
payload.status === "failed" &&
setupCommandOutput(payload.detail).includes("setup failed"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
@@ -224,8 +231,8 @@ test.describe("Workspace setup streaming", () => {
});
const failedPayload = await failed;
expect(failedPayload.detail.log).toContain("starting setup");
expect(failedPayload.detail.log).toContain("setup failed");
expect(setupCommandOutput(failedPayload.detail)).toContain("starting setup");
expect(setupCommandOutput(failedPayload.detail)).toContain("setup failed");
expect(failedPayload.error).toMatch(/failed/i);
await openHomeWithProject(page, repo.path);
@@ -331,7 +338,8 @@ test.describe("Workspace setup streaming", () => {
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
@@ -377,7 +385,8 @@ test.describe("Workspace setup streaming", () => {
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
);
const result = await client.createPaseoWorktree({
@@ -0,0 +1,164 @@
import { hideDeveloperToasts } 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 hideDeveloperToasts(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();
}
}
@@ -376,3 +376,7 @@ export function observeSetupPanelTraffic(page: Page) {
},
};
}
export function setupCommandOutput(detail: { commands: Array<{ log: string }> }): string {
return detail.commands.map((command) => command.log).join("\n");
}
+2
View File
@@ -9,6 +9,8 @@ import "@expo/metro-runtime";
// oxlint-disable-next-line import/no-unassigned-import -- Preserve Expo's entry side effects.
import "expo-router/build/fast-refresh";
import { renderRootComponent } from "expo-router/build/renderRootComponent";
import { startWatchdog } from "./src/watchdog";
import { RootApp } from "./src/root-app";
startWatchdog();
renderRootComponent(RootApp);
+10
View File
@@ -5,6 +5,7 @@ const path = require("path");
const projectRoot = __dirname;
const appNodeModulesRoot = path.resolve(projectRoot, "node_modules");
const appSrcRoot = path.resolve(projectRoot, "src");
const isWatchdogBuild = process.env.PASEO_WATCHDOG_BUILD === "1";
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
const fdroidModuleOverrides = {
"expo-camera": path.resolve(appSrcRoot, "fdroid/expo-camera.tsx"),
@@ -16,6 +17,7 @@ const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
.toLowerCase();
const config = getDefaultConfig(projectRoot);
config.cacheVersion = isWatchdogBuild ? "watchdog" : "standard";
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
// Keep app exports deterministic across dev machines and CI. Metro's Watchman
@@ -75,6 +77,14 @@ function resolveWithCustomWebOverlay(context, moduleName, platform) {
}
config.resolver.resolveRequest = (context, moduleName, platform) => {
if (
isWatchdogBuild &&
platform === "android" &&
/[/\\]Renderer[/\\]shims[/\\]/.test(context.originModulePath) &&
/implementations\/(ReactFabric|ReactNativeRenderer)-prod$/.test(moduleName)
) {
return defaultResolveRequest(context, moduleName.replace(/-prod$/, "-profiling"), platform);
}
if (isFdroidBuild && platform === "android" && fdroidModuleOverrides[moduleName]) {
return resolveWithCustomWebOverlay(context, fdroidModuleOverrides[moduleName], platform);
}
@@ -13,3 +13,7 @@ useDefaultAndroidSdkVersions()
android {
namespace "sh.paseo.trace"
}
dependencies {
testImplementation "junit:junit:4.13.2"
}
@@ -0,0 +1,77 @@
package sh.paseo.trace
import android.content.Context
import android.os.SystemClock
import android.util.Log
import org.json.JSONObject
import java.util.UUID
import java.util.concurrent.ArrayBlockingQueue
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicLong
/** All serialization and storage belongs to the worker, never the JS/UI threads. */
internal class DiagnosticRecorder(private val context: Context, private val metadata: Map<String, String>) {
private data class Event(val name: String, val data: Map<String, String>, val monoMs: Long = SystemClock.elapsedRealtime(), val wallMs: Long = System.currentTimeMillis())
private val queue = ArrayBlockingQueue<Event>(2048)
private val dropped = AtomicLong()
private val worker = Executors.newSingleThreadScheduledExecutor()
private val session = UUID.randomUUID().toString()
@Volatile private var failed = false
private val watchdog = HeartbeatWatchdog(SystemClock.elapsedRealtime(), ::record)
private val log = RotatingLog(DownloadsLogStorage(context, session), { part ->
serialize(Event("file.start", metadata + mapOf("schema" to "1", "part" to "$part")))
}, { error ->
failed = true
queue.clear()
Log.e("PaseoDiagnostics", "Diagnostic recording stopped: storage unavailable", error)
})
init {
record("session.start", metadata + mapOf("session" to session, "schema" to "1", "heartbeatMs" to "${HeartbeatTiming.intervalMs}", "stallThresholdMs" to "${HeartbeatTiming.overdueMs}"))
worker.scheduleWithFixedDelay({ drain() }, 0, HeartbeatTiming.intervalMs, TimeUnit.MILLISECONDS)
}
fun record(name: String, data: Map<String, String>) {
if (failed) return
// Limit native memory even if an instrumentation caller accidentally supplies large fields.
val bounded = data.entries.take(24).associate { it.key.take(80) to it.value.take(256) }
if (!queue.offer(Event(name.take(120), bounded))) dropped.incrementAndGet()
}
fun heartbeat() = watchdog.heartbeat(SystemClock.elapsedRealtime())
fun active(value: Boolean) = watchdog.active(value, SystemClock.elapsedRealtime())
private fun drain() {
if (failed) return
watchdog.check(SystemClock.elapsedRealtime())
val lost = dropped.getAndSet(0)
if (lost > 0) write(Event("recorder.dropped", mapOf("count" to "$lost")))
var remaining = 2048
while (remaining-- > 0 && !failed) {
val event = queue.poll() ?: break
write(event)
}
log.finishBatch()
}
private fun write(event: Event) = log.write(serialize(event))
private fun serialize(event: Event): ByteArray = (JSONObject().apply {
put("event", event.name)
put("session", session)
put("wallMs", event.wallMs)
put("monoMs", event.monoMs)
put("data", JSONObject(event.data))
}.toString() + "\n").toByteArray(Charsets.UTF_8)
fun close() {
worker.execute {
record("session.end", emptyMap())
drain()
log.finishBatch()
}
worker.shutdown()
}
}
@@ -0,0 +1,36 @@
package sh.paseo.trace
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.provider.MediaStore
import java.io.OutputStream
internal class DownloadsLogStorage(private val context: Context, private val session: String) : LogStorage {
private val resolver = context.contentResolver
private val collection = MediaStore.Downloads.EXTERNAL_CONTENT_URI
private val directory = "Download/Paseo diagnostics/"
override fun create(part: Int): String {
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, "paseo-diagnostics-${System.currentTimeMillis()}-${session.take(8)}-$part.jsonl")
put(MediaStore.MediaColumns.MIME_TYPE, "application/x-ndjson")
put(MediaStore.MediaColumns.RELATIVE_PATH, directory)
}
return checkNotNull(resolver.insert(collection, values)).toString()
}
override fun append(id: String): OutputStream =
checkNotNull(resolver.openOutputStream(Uri.parse(id), "wa")).buffered(64 * 1024)
override fun newest(): List<String> = buildList {
resolver.query(collection, arrayOf(MediaStore.MediaColumns._ID),
"${MediaStore.MediaColumns.RELATIVE_PATH} = ? AND ${MediaStore.MediaColumns.OWNER_PACKAGE_NAME} = ?",
arrayOf(directory, context.packageName), "${MediaStore.MediaColumns._ID} DESC")?.use { cursor ->
while (cursor.moveToNext()) add(ContentUris.withAppendedId(collection, cursor.getLong(0)).toString())
}
}
override fun delete(id: String) { resolver.delete(Uri.parse(id), null, null) }
}
@@ -0,0 +1,38 @@
package sh.paseo.trace
internal object HeartbeatTiming {
const val intervalMs = 250L
const val overdueMs = 500L
const val gapMs = intervalMs + overdueMs
}
/** Monotonic time is supplied by the owner, so suspension and recovery are deterministic. */
internal class HeartbeatWatchdog(startMs: Long, private val emit: (String, Map<String, String>) -> Unit) {
private var foreground = true
private var lastHeartbeat = startMs
private var overdue = false
@Synchronized fun heartbeat(now: Long) {
if (foreground && now - lastHeartbeat > HeartbeatTiming.gapMs) {
emit("js.stall.recovered", mapOf("heartbeatGapMs" to "${now - lastHeartbeat}", "overdueMs" to "${now - lastHeartbeat - HeartbeatTiming.intervalMs}"))
}
lastHeartbeat = now
overdue = false
}
@Synchronized fun active(value: Boolean, now: Long) {
if (foreground == value) return
emit("app.lifecycle", mapOf("state" to if (value) "foreground" else "background", "stallOpen" to "$overdue"))
foreground = value
lastHeartbeat = now
overdue = false
}
@Synchronized fun check(now: Long) {
val gap = now - lastHeartbeat
if (foreground && gap > HeartbeatTiming.gapMs && !overdue) {
emit("js.stall.started", mapOf("heartbeatGapMs" to "$gap", "lastHeartbeatMonoMs" to "$lastHeartbeat"))
overdue = true
}
}
}
@@ -5,9 +5,23 @@ import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class PaseoNativeTraceModule : Module() {
private var recorder: DiagnosticRecorder? = null
override fun definition() = ModuleDefinition {
Name("PaseoNativeTrace")
Function("startRecorder") { metadata: Map<String, String> ->
if (recorder == null) {
appContext.reactContext?.applicationContext?.let { recorder = DiagnosticRecorder(it, metadata) }
}
HeartbeatTiming.intervalMs
}
Function("record") { name: String, data: Map<String, String> -> recorder?.record(name, data) }
Function("heartbeat") { recorder?.heartbeat() }
OnActivityEntersForeground { recorder?.active(true) }
OnActivityEntersBackground { recorder?.active(false) }
OnDestroy { recorder?.close(); recorder = null }
Function("beginSection") { name: String ->
Trace.beginSection(name.take(127))
}
@@ -0,0 +1,61 @@
package sh.paseo.trace
import java.io.OutputStream
/** Storage supplies installation-owned files, newest first. */
internal interface LogStorage {
fun create(part: Int): String
fun append(id: String): OutputStream
fun newest(): List<String>
fun delete(id: String)
}
/** Owns file identity, bounded retention and the terminal storage-failure state. */
internal class RotatingLog(
private val storage: LogStorage,
private val header: (Int) -> ByteArray,
private val onFailure: (Exception) -> Unit,
private val maxBytes: Int = 5 * 1024 * 1024,
) {
private var current: String? = null
private var output: OutputStream? = null
private var size = 0
private var part = 0
var failed = false
private set
fun write(bytes: ByteArray) = attempt {
if (current == null || size + bytes.size > maxBytes) {
finishBatch()
if (failed) return@attempt
val nextPart = part++
current = storage.create(nextPart)
output = storage.append(checkNotNull(current))
val first = header(nextPart)
output!!.write(first)
size = first.size
storage.newest().drop(8).forEach(storage::delete)
}
if (output == null) output = storage.append(checkNotNull(current))
output!!.write(bytes)
size += bytes.size
}
// Closing publishes size and modification time in Android Files.
fun finishBatch() = attempt {
output?.close()
output = null
}
private fun attempt(action: () -> Unit) {
if (failed) return
try {
action()
} catch (error: Exception) {
failed = true
runCatching { output?.close() }
output = null
onFailure(error)
}
}
}
@@ -0,0 +1,40 @@
package sh.paseo.trace
import org.junit.Assert.assertEquals
import org.junit.Test
class HeartbeatWatchdogTest {
@Test fun recordsOneStartAndRecoveryWithMeasuredGap() {
val events = mutableListOf<Pair<String, Map<String, String>>>()
val watchdog = HeartbeatWatchdog(0) { name, fields -> events.add(name to fields) }
watchdog.heartbeat(250)
watchdog.check(1000)
assertEquals(emptyList<Pair<String, Map<String, String>>>(), events)
watchdog.check(1001)
watchdog.check(2000)
watchdog.heartbeat(2250)
assertEquals(listOf(
"js.stall.started" to mapOf("heartbeatGapMs" to "751", "lastHeartbeatMonoMs" to "250"),
"js.stall.recovered" to mapOf("heartbeatGapMs" to "2000", "overdueMs" to "1750")
), events)
}
@Test fun backgroundAndResumeDoNotLookLikeStalls() {
val events = mutableListOf<String>()
val watchdog = HeartbeatWatchdog(0) { name, _ -> events.add(name) }
watchdog.active(false, 100)
watchdog.check(300000)
watchdog.heartbeat(300100)
watchdog.active(true, 400000)
watchdog.heartbeat(400250)
watchdog.check(400500)
assertEquals(listOf("app.lifecycle", "app.lifecycle"), events)
}
@Test fun recoveryIsReportedEvenIfNativeSamplingMissedTheGap() {
val events = mutableListOf<String>()
val watchdog = HeartbeatWatchdog(0) { name, _ -> events.add(name) }
watchdog.heartbeat(900)
assertEquals(listOf("js.stall.recovered"), events)
}
}
@@ -0,0 +1,59 @@
package sh.paseo.trace
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.OutputStream
import org.junit.Assert.*
import org.junit.Test
class RotatingLogTest {
private class MemoryStorage : LogStorage {
val files = linkedMapOf<String, ByteArrayOutputStream>()
var fail = false
var opens = 0
override fun create(part: Int): String {
val id = "part-$part"
files[id] = ByteArrayOutputStream()
return id
}
override fun append(id: String): OutputStream {
opens++
if (fail) throw IOException("disk full")
return files.getValue(id)
}
override fun newest() = files.keys.reversed()
override fun delete(id: String) { files.remove(id) }
}
@Test fun rotatesAtByteLimitAndKeepsEightIncludingCurrent() {
val storage = MemoryStorage()
val log = RotatingLog(storage, { "header:$it\n".toByteArray() }, { throw it }, 20)
repeat(10) { log.write("123456789012".toByteArray()); log.finishBatch() }
assertEquals((2..9).map { "part-$it" }, storage.files.keys.toList())
for (part in 2..9) assertEquals("header:$part\n123456789012", storage.files.getValue("part-$part").toString("UTF-8"))
assertFalse(log.failed)
}
@Test fun reopeningBatchAppendsWithoutRepeatingHeaderAndExactLimitFits() {
val storage = MemoryStorage()
val log = RotatingLog(storage, { "H".toByteArray() }, { throw it }, 5)
log.write("12".toByteArray())
log.finishBatch()
log.write("34".toByteArray())
assertEquals(listOf("part-0"), storage.files.keys.toList())
assertEquals("H1234", storage.files.getValue("part-0").toString("UTF-8"))
log.write("5".toByteArray())
assertEquals("H5", storage.files.getValue("part-1").toString("UTF-8"))
}
@Test fun storageFailureStopsRecordingWithoutRetryStorm() {
val storage = MemoryStorage()
var failures = 0
val log = RotatingLog(storage, { byteArrayOf() }, { failures++ })
storage.fail = true
repeat(100) { log.write(byteArrayOf(1)); log.finishBatch() }
assertTrue(log.failed)
assertEquals(1, failures)
assertEquals(1, storage.opens)
}
}
+9 -4
View File
@@ -8,12 +8,12 @@
"start:expo": "cross-env APP_VARIANT=development expo start",
"reset-project": "node ./scripts/reset-project.js",
"eas-build-post-install": "npm --prefix ../.. run build:app-deps && npm run build:terminal-webview",
"android": "npm run android:development",
"android:development": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug",
"android": "npm run dev:android",
"android:development": "npm run dev:android",
"android:production": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=production expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=production expo run:android --variant=release",
"android:release": "npm run android:production",
"android:clear": "node -e \"require('node:fs').rmSync('android', { recursive: true, force: true })\"",
"ios": "npm --prefix ../.. run build:client && expo run:ios",
"ios": "npm run dev:ios",
"ios:release": "npm --prefix ../.. run build:client && expo run:ios --configuration Release",
"web": "npm --prefix ../.. run build:client && concurrently --kill-others --names protocol,client,expo --prefix-colors yellow,blue,magenta \"npm --prefix ../.. run watch:protocol\" \"npm --prefix ../.. run watch:client\" \"npm run web:expo\"",
"web:expo": "expo start --web",
@@ -36,7 +36,12 @@
"profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs",
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs",
"build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs"
"build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs",
"dev:android": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug",
"dev:ios": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo run:ios",
"build:apk": "npm --prefix ../.. run build:apk",
"build:apk:arm64": "npm --prefix ../.. run build:apk:arm64",
"test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest --no-daemon --max-workers=2 -Dorg.gradle.parallel=false"
},
"dependencies": {
"@codemirror/commands": "6.10.4",
@@ -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,
@@ -1,4 +1,4 @@
import React, { useMemo, type ReactNode } from "react";
import { useMemo, type ReactNode } from "react";
import {
View,
Text,
@@ -186,48 +186,6 @@ function ShellDetailSection({ command, output, ds }: ShellDetailProps) {
);
}
interface WorktreeSetupDetailProps {
log: string;
branchName: string;
worktreePath: string;
ds: DetailStyles;
}
function WorktreeSetupDetailSection({
log,
branchName,
worktreePath,
ds,
}: WorktreeSetupDetailProps) {
const setupLog = log.replace(/^\n+/, "");
const hasLog = setupLog.length > 0;
return (
<View style={ds.sectionFillStyle}>
<View style={ds.codeBlockFillStyle}>
<ScrollView
style={ds.codeVerticalScrollStyle}
contentContainerStyle={styles.codeVerticalContent}
nestedScrollEnabled
showsVerticalScrollIndicator
>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
contentContainerStyle={styles.codeHorizontalContent}
>
<View style={styles.codeLine} dataSet={CODE_SURFACE_DATASET}>
<Text selectable style={styles.scrollText}>
{hasLog ? setupLog : `Preparing worktree ${branchName} at ${worktreePath}`}
</Text>
</View>
</ScrollView>
</ScrollView>
</View>
</View>
);
}
function resolveSubAgentFallbackHeader(
subAgentType: string | null | undefined,
description: string | null | undefined,
@@ -678,17 +636,7 @@ function buildDetailSections(
<ShellDetailSection key="shell" command={detail.command} output={detail.output} ds={ds} />,
];
}
if (detail.type === "worktree_setup") {
return [
<WorktreeSetupDetailSection
key="worktree-setup"
log={detail.log}
branchName={detail.branchName}
worktreePath={detail.worktreePath}
ds={ds}
/>,
];
}
if (detail.type === "worktree_setup") return [];
if (detail.type === "sub_agent") {
return [
<SubAgentDetailSection
+7
View File
@@ -315,6 +315,7 @@ interface PendingFileAttachment {
}
interface RenderAttachmentTrayArgs {
serverId: string;
selectedAttachments: ComposerAttachment[];
pendingFiles: PendingFileAttachment[];
isComposerLocked: boolean;
@@ -331,6 +332,7 @@ interface RenderAttachmentTrayArgs {
function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | null {
const {
serverId,
selectedAttachments,
pendingFiles,
isComposerLocked,
@@ -343,6 +345,7 @@ function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | nu
<View style={styles.attachmentTray} testID="composer-attachment-tray">
{selectedAttachments.map((attachment, index) =>
renderComposerAttachmentPill({
serverId,
attachment,
index,
disabled: isComposerLocked,
@@ -393,6 +396,7 @@ function renderQueueTrack(args: RenderQueueTrackArgs): ReactElement | null {
}
interface RenderComposerAttachmentPillArgs {
serverId: string;
attachment: ComposerAttachment;
index: number;
disabled: boolean;
@@ -453,6 +457,7 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R
if (attachment.kind === "plugin_resource") {
return (
<PluginResourceAttachmentPill
serverId={args.serverId}
key={`${attachment.pluginId}:${attachment.sourceId}:${attachment.item.id}`}
attachment={attachment}
index={index}
@@ -2295,6 +2300,7 @@ function ComposerContentImpl({
const attachmentTray = useMemo(
() =>
renderAttachmentTray({
serverId,
selectedAttachments,
pendingFiles,
isComposerLocked,
@@ -2314,6 +2320,7 @@ function ComposerContentImpl({
handleOpenAttachment,
handleRemoveAttachment,
isComposerLocked,
serverId,
selectedAttachments,
pendingFiles,
t,
@@ -18,7 +18,7 @@ export function filePanelIcon(panel: FilePanel): PluginIcon {
export function filePanelTitle(t: TFunction, panel: FilePanel): string {
return panel.builtin
? t(`panels.file.filePanels.builtin.${BUILTIN_PRESENTATION[panel.id].title}`)
: panel.contribution.title;
: panel.plugin.i18n.resolve(panel.contribution.title, panel.plugin.locale);
}
/** "Markdown — Paseo", "Rich Markdown — markdown-panel". */
+2 -7
View File
@@ -1,12 +1,7 @@
import { useMemo } from "react";
import type {
PluginFile,
PluginFileLocation,
PluginFilePanelContribution,
PluginFileTab,
} from "@getpaseo/plugin/client";
import type { PluginFile, PluginFileLocation, PluginFileTab } from "@getpaseo/plugin/client";
import { useInstalledPlugins } from "@/plugins/registry";
import type { InstalledPlugin } from "@/plugins/types";
import type { PluginFilePanelContribution, InstalledPlugin } from "@/plugins/types";
import { filePanelCatalog, type RenderingFilePanel, type SourceFilePanel } from "./catalog";
export interface PluginFilePanel {
+4 -42
View File
@@ -183,32 +183,19 @@ function resolveSetupStatusLabel(
return labels.waiting;
}
function resolveCommandLog(
command: SetupCommand,
autoExpandIndex: number | null,
log: string,
): string {
if ("log" in command && typeof command.log === "string") {
return command.log;
}
if (command.index === autoExpandIndex) return log;
return "";
}
interface BuildCommandRowPropsArgs {
command: SetupCommand;
autoExpandIndex: number | null;
log: string;
expandedIndices: Set<number>;
manuallyCollapsed: Set<number>;
snapshotError: string | null | undefined;
}
function buildCommandRowState(args: BuildCommandRowPropsArgs) {
const { command, autoExpandIndex, log, expandedIndices, manuallyCollapsed, snapshotError } = args;
const { command, autoExpandIndex, expandedIndices, manuallyCollapsed, snapshotError } = args;
const isExpanded = expandedIndices.has(command.index);
const hasError = command.status === "failed" && Boolean(snapshotError);
const commandLog = resolveCommandLog(command, autoExpandIndex, log);
const commandLog = command.log;
const hasLog = commandLog.trim().length > 0;
const isExpandable = command.status !== "running" || hasLog || hasError;
const isAutoExpanded = command.index === autoExpandIndex && !manuallyCollapsed.has(command.index);
@@ -285,9 +272,7 @@ function SetupPanel() {
);
const commands = snapshot?.detail.commands ?? EMPTY_COMMANDS;
const log = snapshot?.detail.log ?? "";
const hasNoSetupCommands =
snapshot?.status === "completed" && commands.length === 0 && log.trim().length === 0;
const hasNoSetupCommands = snapshot?.status === "completed" && commands.length === 0;
const isWaiting = !snapshot || (snapshot.status === "running" && commands.length === 0);
const { expandedIndices, manuallyCollapsed, toggleExpanded } = useExpandedSetupCommands();
@@ -323,7 +308,6 @@ function SetupPanel() {
workspaceId={target.workspaceId}
snapshot={snapshot}
commands={commands}
log={log}
isWaiting={isWaiting}
hasNoSetupCommands={hasNoSetupCommands}
autoExpandIndex={autoExpandIndex}
@@ -341,7 +325,6 @@ interface SetupPanelBodyProps {
workspaceId: string;
snapshot: WorkspaceSetupSnapshot | null;
commands: SetupCommand[];
log: string;
isWaiting: boolean;
hasNoSetupCommands: boolean;
autoExpandIndex: number | null;
@@ -352,7 +335,7 @@ interface SetupPanelBodyProps {
function SetupPanelBody(props: SetupPanelBodyProps) {
const { t } = useTranslation();
const { snapshot, commands, log } = props;
const { snapshot, commands } = props;
if (snapshot?.status === "blocked" && snapshot.blockedSource) {
return (
<BlockedSetupNotice
@@ -389,7 +372,6 @@ function SetupPanelBody(props: SetupPanelBodyProps) {
const rowState = buildCommandRowState({
command,
autoExpandIndex: props.autoExpandIndex,
log,
expandedIndices: props.expandedIndices,
manuallyCollapsed: props.manuallyCollapsed,
snapshotError: snapshot?.error,
@@ -404,7 +386,6 @@ function SetupPanelBody(props: SetupPanelBodyProps) {
/>
);
})}
<StandaloneLogView commands={commands} log={log} />
<TopLevelSetupError snapshotError={snapshot?.error ?? null} commands={commands} />
</View>
);
@@ -523,25 +504,6 @@ function SetupCommandChevron({ showDetail }: { showDetail: boolean }) {
);
}
function StandaloneLogView({ commands, log }: { commands: SetupCommand[]; log: string }) {
const { t } = useTranslation();
if (commands.length !== 0 || log.trim().length === 0) return null;
return (
<ScrollView
style={styles.logScroll}
contentContainerStyle={styles.logScrollContent}
showsVerticalScrollIndicator
testID="workspace-setup-log"
accessible
accessibilityLabel={t("workspace.setup.accessibility.log")}
>
<Text selectable dataSet={CODE_SURFACE_DATASET} style={styles.logText}>
{log}
</Text>
</ScrollView>
);
}
function TopLevelSetupError({
snapshotError,
commands,
+33 -16
View File
@@ -1,3 +1,4 @@
import { watchdog } from "@/watchdog";
import type { DaemonClientTrace } from "@getpaseo/client/internal/daemon-client";
import { requireOptionalNativeModule } from "expo-modules-core";
import { isProfileBuild } from "@/constants/build-profile";
@@ -9,17 +10,14 @@ interface PaseoNativeTraceModule {
const traceModule = requireOptionalNativeModule<PaseoNativeTraceModule>("PaseoNativeTrace");
export const nativePerformanceTrace: DaemonClientTrace = {
isEnabled() {
return isProfileBuild && traceModule !== null;
},
beginSection(name, args) {
traceModule?.beginSection(formatSectionName(name, args));
},
endSection() {
traceModule?.endSection();
},
};
const nativeSink: DaemonClientTrace | null =
isProfileBuild && traceModule
? {
isEnabled: () => true,
beginSection: (name, args) => traceModule.beginSection(formatSectionName(name, args)),
endSection: () => traceModule.endSection(),
}
: null;
function formatSectionName(name: string, args?: Record<string, string>): string {
if (!args) {
@@ -29,10 +27,29 @@ function formatSectionName(name: string, args?: Record<string, string>): string
return `${name}|${fields.join(";")}`;
}
const instantSinks: ((name: string, args?: Record<string, string>) => void)[] = [];
if (watchdog) instantSinks.push(watchdog.record);
if (nativeSink)
instantSinks.push((name, args) => {
nativeSink.beginSection(name, args);
nativeSink.endSection();
});
export function traceInstant(name: string, args?: Record<string, string>): void {
if (!nativePerformanceTrace.isEnabled()) {
return;
}
nativePerformanceTrace.beginSection(name, args);
nativePerformanceTrace.endSection();
for (const write of instantSinks) write(name, args);
}
/** Each client owns its section stack; overlapping hosts never share span identity. */
export function createPerformanceTrace(context: Record<string, string>): DaemonClientTrace {
const sinks = [watchdog?.trace(context), nativeSink].filter(
(sink): sink is DaemonClientTrace => sink != null,
);
return {
isEnabled: () => sinks.length > 0,
beginSection(name, args) {
for (const sink of sinks) sink.beginSection(name, args);
},
endSection() {
for (const sink of sinks) sink.endSection();
},
};
}
+10 -10
View File
@@ -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}
+13 -3
View File
@@ -1,3 +1,4 @@
import { useInstalledPlugin } from "../registry";
import { useCallback, useMemo } from "react";
import { withUnistyles } from "react-native-unistyles";
import { AttachmentLabel, AttachmentPill } from "@/components/attachment-pill";
@@ -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",
@@ -173,3 +177,80 @@ describe("plugin buttons", () => {
expect(calls).toBe(2);
});
});
it("updates translated tooltips and nested menu labels without closing open content", () => {
let locale = "en";
const listeners = new Set<() => void>();
const plugin = installation();
plugin.i18n.dispose();
plugin.i18n = new PluginTranslationRuntime({
getLocale: () => locale,
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
});
const translations = defineTranslations({
resources: {
en: { title: "Notes", menu: "Open notes" },
"zh-CN": { title: "笔记", menu: "打开笔记" },
},
});
const buttons = store();
const registration = buttons.addHeaderButton(plugin, {
id: "notes",
workspaceId: "workspace",
button: {
title: translations.message("title"),
icon: "Scan",
behavior: {
kind: "menu",
items: [
{
kind: "item",
id: "open",
title: translations.message("menu"),
behavior: { kind: "action", onPress() {} },
},
],
},
},
});
const original = buttons.getSnapshot()[0];
buttons.setOpen(original.key, true);
locale = "zh-CN";
for (const listener of listeners) listener();
const translated = buttons.getSnapshot()[0];
expect(translated.key).toBe(original.key);
expect(translated.open).toBe(true);
expect(translated.button.title).toBe("笔记");
expect(translated.button.behavior).toMatchObject({
kind: "menu",
items: [{ title: "打开笔记" }],
});
registration.update({ disabled: false });
locale = "en";
for (const listener of listeners) listener();
expect(buttons.getSnapshot()[0].button.title).toBe("Notes");
registration.remove();
plugin.i18n.dispose();
expect(listeners.size).toBe(0);
expect(buttons.getSnapshot()).toEqual([]);
});
it("keeps the previous descriptor after rejecting an invalid update", () => {
const buttons = store();
const plugin = installation();
const registration = buttons.addHeaderButton(plugin, {
id: "review",
workspaceId: "workspace",
button: button(),
});
expect(() => registration.update({ title: "" })).toThrow("Plugin button needs title");
expect(() => registration.update({ disabled: true })).not.toThrow();
expect(buttons.getSnapshot()[0].button.title).toBe("Review");
registration.remove();
plugin.i18n.dispose();
});
+20 -9
View File
@@ -1,5 +1,7 @@
import type {
PluginText,
PluginButtonBehavior,
PluginButton,
PluginButtonContext,
PluginButtonRegistration,
PluginHeaderButtonContribution,
@@ -27,7 +29,7 @@ export interface RegisteredPluginButton {
}
function resolveAction(
behavior: PluginButtonBehavior,
behavior: PluginButtonBehavior<PluginText>,
path: readonly string[],
): (() => void | Promise<void>) | null {
if (path.length === 0) return behavior.kind === "action" ? behavior.onPress : null;
@@ -55,8 +57,8 @@ export class PluginButtonStore {
addHeaderButton(
installation: InstalledPlugin,
input: PluginHeaderButtonContribution,
): PluginButtonRegistration {
input: PluginHeaderButtonContribution<PluginText>,
): PluginButtonRegistration<PluginText> {
return this.add(installation, input, "header", {
context: "workspace",
workspaceId: input.workspaceId.trim(),
@@ -65,8 +67,8 @@ export class PluginButtonStore {
addComposerPill(
installation: InstalledPlugin,
input: PluginComposerPillContribution,
): PluginButtonRegistration {
input: PluginComposerPillContribution<PluginText>,
): PluginButtonRegistration<PluginText> {
if (!input.agentId.trim()) throw new Error("Plugin composer pill needs an agent");
return this.add(installation, input, "composer", {
context: "agent",
@@ -77,10 +79,10 @@ export class PluginButtonStore {
private add(
installation: InstalledPlugin,
input: PluginHeaderButtonContribution,
input: PluginHeaderButtonContribution<PluginText>,
placement: ButtonPlacement,
context: PluginButtonContext,
): PluginButtonRegistration {
): PluginButtonRegistration<PluginText> {
const id = requireButtonId(input.id);
if (!context.workspaceId.trim()) throw new Error("Plugin button needs a workspace");
const duplicate = this.entries.some(
@@ -94,7 +96,8 @@ export class PluginButtonStore {
);
if (duplicate) throw new Error(`Duplicate plugin button: ${id}`);
const key = this.nextKey++;
const initialButton = validateButton(input.button, this.validation);
let sourceButton: PluginButton<PluginText> = input.button;
const initialButton = validateButton(sourceButton, this.validation, installation.i18n.resolve);
this.publish([
...this.entries,
{
@@ -108,15 +111,23 @@ export class PluginButtonStore {
open: false,
},
]);
const unsubscribe = installation.i18n.subscribe(() => {
this.replace(key, {
button: validateButton(sourceButton, this.validation, installation.i18n.resolve),
});
});
return {
update: (patch) => {
const entry = this.entries.find((candidate) => candidate.key === key);
if (!entry) return;
const button = validateButton({ ...entry.button, ...patch }, this.validation);
const nextSource = { ...sourceButton, ...patch };
const button = validateButton(nextSource, this.validation, installation.i18n.resolve);
sourceButton = nextSource;
const close = !button.visible || button.disabled || patch.behavior !== undefined;
this.replace(key, { button, open: close ? false : entry.open });
},
remove: () => {
unsubscribe();
const next = this.entries.filter((entry) => entry.key !== key);
if (next.length !== this.entries.length) this.publish(next);
},
+40 -9
View File
@@ -1,4 +1,12 @@
import type { PluginButton, PluginButtonBehavior, PluginButtonIcon } from "@getpaseo/plugin/client";
import type { PluginText } from "@getpaseo/plugin/client";
import type {
PluginButton as SDKPluginButton,
PluginButtonBehavior as SDKPluginButtonBehavior,
PluginButtonIcon,
} from "@getpaseo/plugin/client";
type PluginButton = SDKPluginButton<PluginText>;
type PluginButtonBehavior = SDKPluginButtonBehavior<PluginText>;
export interface ButtonValidation {
/** 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),
};
}
+4 -5
View File
@@ -1,9 +1,8 @@
import type {
PluginButtonBehavior,
PluginButtonIcon,
PluginButtonMenuEntry,
PluginHostProps,
} from "@getpaseo/plugin/client";
ResolvedPluginButtonBehavior as PluginButtonBehavior,
ResolvedPluginButtonMenuEntry as PluginButtonMenuEntry,
} from "./validation";
import type { PluginButtonIcon, PluginHostProps } from "@getpaseo/plugin/client";
import type { PluginTheme } from "@getpaseo/plugin";
import { useCallback, useMemo, useSyncExternalStore, type ReactNode } from "react";
import { Pressable, Text, View, useWindowDimensions } from "react-native";
@@ -24,6 +24,7 @@ export function createPluginClientRuntime(
);
return {
...capabilities,
i18n: installation.i18n,
playAudio: createPlayAudio(audio, installation.lifetime.signal),
hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal),
addComposerPill(contribution) {
@@ -36,8 +36,8 @@ export function usePluginClientSlashCommands(input: {
{
pluginId: plugin.id,
name: contribution.name,
description: contribution.description,
argumentHint: contribution.argumentHint,
description: plugin.i18n.resolve(contribution.description, plugin.locale),
argumentHint: plugin.i18n.resolve(contribution.argumentHint, plugin.locale),
async run(args: string) {
const context =
contribution.context === "agent"
@@ -1,3 +1,4 @@
import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host";
import { QueryClient } from "@tanstack/react-query";
import { createPaseoApi, type PaseoApi } from "@getpaseo/client";
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
@@ -61,6 +62,8 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
serverId: "host-1",
clientBundle: "bundle",
lifetime: new AbortController(),
locale: "en",
i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }),
paseo: createPaseoApi(
new DaemonClient({
url: "ws://127.0.0.1:1",
@@ -81,7 +81,7 @@ export function buildPluginCommandCenterContributions(
visibility: "always",
presentation: {
kind: "action",
title: item.title,
title: plugin.i18n.resolve(item.title, plugin.locale),
sectionTitle: plugin.id,
icon: getCommandCenterIcon(pluginIconComponent(item.icon)),
},
+40 -27
View File
@@ -1,3 +1,4 @@
import { requirePluginText } from "@getpaseo/plugin/client/host";
import type { createPluginHosts } from "./hosts";
import { openExternalUrl } from "@/utils/open-external-url";
import * as pluginUiRuntime from "./react-native/ui";
@@ -11,27 +12,29 @@ import * as ReactNative from "react-native";
// eslint-disable-next-line no-restricted-imports -- plugin bundles receive TanStack's real runtime, not Paseo's query wrappers.
import * as ReactQuery from "@tanstack/react-query";
import * as Zod from "zod";
import { type PluginCleanup } from "@getpaseo/plugin";
import {
type PluginAttachmentSourceContribution,
type PluginCleanup,
type PluginThemeContribution,
} from "@getpaseo/plugin";
import {
type PluginCommandCenterItemContribution,
type PluginClientContext,
type PluginClientSlashCommandContribution,
type PluginSidebarContribution,
type PluginScreenContribution,
type PluginScreenProps,
type PluginScreenTitle,
type PluginSidebarItemContribution,
type PluginText,
type PluginSurfaceProps,
type PluginTimelineRendererContribution,
type PluginTimelineTransformerContribution,
type PluginWorkspacePanelContribution,
type PluginButtonRegistration,
} from "@getpaseo/plugin/client";
import type { EvaluatedPlugin, PluginSidebarSection } from "./types";
import type {
EvaluatedPlugin,
PluginSidebarSection,
PluginAttachmentSourceContribution,
PluginThemeContribution,
PluginCommandCenterItemContribution,
PluginClientSlashCommandContribution,
PluginSidebarContribution,
PluginScreenContribution,
PluginSidebarItemContribution,
PluginWorkspacePanelContribution,
} from "./types";
import type { ComponentType } from "react";
import { parsePluginIcon, parseSerializablePluginIcon } from "./icons";
import { parsePluginThemeContribution } from "./themes";
@@ -75,6 +78,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}`);
@@ -82,14 +89,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"
@@ -138,7 +148,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;
@@ -181,7 +193,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`);
@@ -199,7 +211,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 }, () =>
@@ -244,7 +257,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"),
};
@@ -258,7 +271,7 @@ export function runPluginClientBundle(
addFilePanel(contribution) {
const panelId = requireId(contribution.id, "file panel id");
if (filePanelIds.has(panelId)) throw new Error(`Duplicate file panel: ${panelId}`);
const title = contribution.title.trim();
const title = normalizePluginText(contribution.title);
if (!title) throw new Error(`File panel ${panelId} has no title`);
const icon = parsePluginIcon(`File panel ${panelId}`, contribution.icon);
const files = contribution.files;
@@ -304,7 +317,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") {
@@ -332,7 +345,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 (
@@ -363,7 +376,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`);
@@ -378,7 +391,7 @@ export function runPluginClientBundle(
...contribution,
name,
description,
argumentHint: contribution.argumentHint.trim(),
argumentHint: normalizePluginText(contribution.argumentHint),
},
() => clientSlashCommandNames.delete(name),
);
@@ -388,9 +401,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>
);
@@ -1,3 +1,5 @@
import { filePanelTitle } from "@/file-panels/choice/presentation";
import { i18n } from "@/i18n/i18next";
import { createPluginHosts } from "./hosts";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { PaseoApi } from "@getpaseo/client";
@@ -26,9 +28,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 +51,7 @@ function registry() {
installation.lifetime.signal,
),
paseo: installation.paseo,
i18n: installation.i18n,
rpc: async () => {
throw new Error("Unexpected plugin RPC");
},
@@ -145,3 +154,74 @@ 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;}});
plugin.addFilePanel({id:"notes",title:messages.message("title"),icon:"FileText",files:{extensions:["notes"]},Component:function NotesFile(){return null;}});
return function(){};
}};
})`,
};
h.plugins.installCatalog("host", [translated], { client: h.client, audio });
expect(h.plugins.getEvaluationError("host", "notes")).toBeUndefined();
const [before] = h.plugins.getSnapshot();
function fileTitle(plugin: typeof before) {
const contribution = plugin.filePanels[0];
return filePanelTitle(i18n.t, {
id: "plugin:notes/notes",
builtin: false,
files: contribution.files,
plugin,
contribution,
});
}
expect(fileTitle(before)).toBe("Notes");
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(fileTitle(after)).toBe("笔记");
expect(fileTitle(before)).toBe("Notes");
expect(after.filePanels[0].Component).toBe(before.filePanels[0].Component);
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");
},
@@ -100,6 +102,26 @@ it("unloads on a requirement-only edit and recovers after correction", () => {
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.',
);
});
it("skips file-panel bundles on older apps and installs them on the required app", () => {
const entry = {
id: "files",
+30 -1
View File
@@ -1,3 +1,5 @@
import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host";
import { i18n } from "@/i18n/i18next";
import type { AudioEngine } from "@/audio";
import { useMemo, useSyncExternalStore } from "react";
import { QueryClient } from "@tanstack/react-query";
@@ -20,6 +22,7 @@ export class PluginRegistry {
constructor(
private readonly dependencies: {
localeSource: import("@getpaseo/plugin/client/host").PluginLocaleSource;
version: string | null;
createRuntime: typeof createPluginClientRuntime;
},
@@ -81,6 +84,8 @@ export class PluginRegistry {
const client = options.client;
installation = {
lifetime: new AbortController(),
locale: this.dependencies.localeSource.getLocale(),
i18n: new PluginTranslationRuntime(this.dependencies.localeSource),
paseo: createPaseoApi(client),
invoke: (method, input) => client.invokePluginRpc(entry.id, method, input),
id: entry.id,
@@ -102,6 +107,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(),
@@ -121,6 +130,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));
@@ -136,7 +146,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}`);
@@ -161,6 +177,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) => {
@@ -174,6 +191,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}`),
);
@@ -182,6 +202,15 @@ export class PluginRegistry {
}
export const pluginRegistry = new PluginRegistry({
localeSource: {
getLocale: () => i18n.resolvedLanguage ?? i18n.language,
subscribe: (listener) => {
i18n.on("languageChanged", listener);
return () => {
i18n.off("languageChanged", listener);
};
},
},
version: resolveAppVersion(),
createRuntime: createPluginClientRuntime,
});
+1 -1
View File
@@ -57,7 +57,7 @@ export function PluginSettingsMenuItems({
serverId={serverId}
pluginId={pluginId}
screenId={screen.id}
title={screen.title}
title={plugin.i18n.resolve(screen.title, plugin.locale)}
disabled={disabled}
/>
))}
@@ -1,3 +1,4 @@
import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host";
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import type { InstalledPlugin } from "./types";
@@ -20,6 +21,8 @@ function installed(
serverId,
clientBundle: serverId,
lifetime: new AbortController(),
locale: "en",
i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }),
queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined,
+2 -2
View File
@@ -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,
@@ -178,6 +181,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 = {
@@ -187,18 +192,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: () => {
@@ -206,13 +211,13 @@ describe("resolvePluginScreenTitle", () => {
},
Component,
};
expect(resolvePluginScreenTitle(throwing, null, {})).toBe("bot");
expect(resolvePluginScreenTitle(throwing, null, {}, resolve)).toBe("bot");
});
it("uses the title of a legacy item that points at the screen", () => {
const legacy = { id: "entry", title: "Legacy entry", icon: "Server", surface: "main" };
expect(resolvePluginScreenTitle({ id: "main", title: "main", Component }, legacy, {})).toBe(
"Legacy entry",
);
expect(
resolvePluginScreenTitle({ id: "main", title: "main", Component }, legacy, {}, resolve),
).toBe("Legacy entry");
});
});
@@ -42,11 +42,12 @@ export function resolvePluginScreenTitle(
screen: InstalledPlugin["surfaces"][number],
legacySidebarItem: InstalledPlugin["legacySidebarItems"][number] | null,
params: PluginScreenParams,
resolve: (text: import("@getpaseo/plugin/client").PluginText) => string,
): string {
if (legacySidebarItem) return legacySidebarItem.title;
if (typeof screen.title === "string") return screen.title;
if (legacySidebarItem) return resolve(legacySidebarItem.title);
if (typeof screen.title !== "function") return resolve(screen.title);
try {
const title = screen.title(params);
const title = resolve(screen.title(params));
if (typeof title === "string" && title.trim()) return title.trim();
} catch (error) {
console.warn(`[Plugins] Screen ${screen.id} title failed`, error);
+8 -7
View File
@@ -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();
+3
View File
@@ -1,3 +1,4 @@
import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host";
import { QueryClient } from "@tanstack/react-query";
import type { PluginThemeContribution } from "@getpaseo/plugin";
import { describe, expect, it } from "vitest";
@@ -50,6 +51,8 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install
serverId,
clientBundle: serverId,
lifetime: new AbortController(),
locale: "en",
i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }),
queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined,
+7 -4
View File
@@ -1,6 +1,8 @@
import type { PluginMessage } from "@getpaseo/plugin/client";
import { isPluginMessage } from "@getpaseo/plugin/client/host";
import { useMemo } from "react";
import { z } from "zod";
import type { PluginThemeContribution } from "@getpaseo/plugin";
import type { PluginThemeContribution } from "../types";
import { useHostFeatureMap } from "@/runtime/host-features";
import {
buildDarkSemanticColors,
@@ -24,7 +26,7 @@ const hexColorSchema = z
const contributionSchema: z.ZodType<PluginThemeContribution> = z.strictObject({
id: z.string(),
name: z.string().trim().min(1).max(60),
name: z.union([z.string().trim().min(1).max(60), z.custom<PluginMessage>(isPluginMessage)]),
appearance: z.enum(["light", "dark"]),
colors: z.strictObject({
background: hexColorSchema,
@@ -49,6 +51,7 @@ export interface PluginThemeOption {
interface PluginThemeTarget {
serverId: string;
contribution: PluginThemeContribution;
plugin: InstalledPlugin;
}
export function parsePluginThemeContribution(value: unknown): PluginThemeContribution {
@@ -133,7 +136,7 @@ export function collectPluginThemes(
if (!supportedHosts.has(plugin.serverId)) continue;
for (const contribution of plugin.themes) {
const id = `${plugin.id}/theme/${contribution.id}`;
const target = { serverId: plugin.serverId, contribution };
const target = { serverId: plugin.serverId, contribution, plugin };
const targets = targetsById.get(id);
if (targets) targets.push(target);
else targetsById.set(id, [target]);
@@ -145,7 +148,7 @@ export function collectPluginThemes(
return {
id,
serverId: target.serverId,
name: target.contribution.name,
name: target.plugin.i18n.resolve(target.contribution.name, target.plugin.locale),
swatch: target.contribution.colors.background,
theme: buildPluginTheme(target.contribution),
};
@@ -1,3 +1,4 @@
import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host";
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import type { InstalledPlugin } from "../types";
@@ -12,6 +13,8 @@ function plugin(input: {
serverId: "host-1",
clientBundle: "bundle",
lifetime: new AbortController(),
locale: "en",
i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }),
queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined,
+28 -11
View File
@@ -1,26 +1,39 @@
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 {
PluginFilePanelContribution,
PluginCommandCenterItemContribution,
PluginClientSlashCommandContribution,
PluginComposerPillContribution,
PluginSidebarContribution,
PluginSidebarItemContribution,
PluginScreenContribution,
PluginSettingsScreenContribution,
PluginFilePanelContribution as SDKPluginFilePanelContribution,
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 PluginFilePanelContribution = SDKPluginFilePanelContribution<PluginText>;
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 & {
@@ -48,6 +61,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. */
@@ -59,6 +75,7 @@ export interface InstalledPlugin extends EvaluatedPlugin {
}
export type {
PluginFilePanelContribution,
PluginAttachmentSourceContribution,
PluginCommandCenterItemContribution,
PluginClientSlashCommandContribution,
@@ -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";
+6 -1
View File
@@ -1,3 +1,4 @@
import { RenderProfile } from "@/utils/render-profiler";
import { useCallback, useState, type ComponentProps } from "react";
import { ExpoRoot } from "expo-router";
import Head from "expo-router/head";
@@ -13,7 +14,11 @@ const { ctx: context } = require("expo-router/_ctx") as {
};
export function RootApp() {
return <RootRouter context={context} />;
return (
<RenderProfile id="App">
<RootRouter context={context} />
</RenderProfile>
);
}
export function RootRouter({ context: routes }: Pick<ComponentProps<typeof ExpoRoot>, "context">) {
+12 -5
View File
@@ -1,3 +1,4 @@
import { watchdog } from "@/watchdog";
import { useSyncExternalStore, useMemo } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import equal from "fast-deep-equal/es6";
@@ -83,7 +84,7 @@ import {
type ViewedTimelineOwnerPorts,
} from "@/timeline/viewed-timeline-sync";
import { projectIconCache } from "@/projects/icon-cache";
import { nativePerformanceTrace } from "@/performance/native-trace";
import { createPerformanceTrace } from "@/performance/native-trace";
import { revokePushNotifications } from "@/push-notifications";
import { createAppWebSocketFactory } from "./websocket-factory";
@@ -544,7 +545,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
appVersion: resolveAppVersion() ?? undefined,
runtimeGeneration,
capabilities: appCapabilities,
trace: nativePerformanceTrace,
trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }),
providerSnapshots: "wire",
...(host.password ? { password: host.password } : {}),
localCredential: () => readDesktopManagedLocalCredential(connection),
@@ -604,7 +605,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
localCredential: () => readDesktopManagedLocalCredential(connection),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
capabilities: appCapabilities,
trace: nativePerformanceTrace,
trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }),
}),
getClientId: () => getOrCreateClientId(),
mountClientHandlers: ({ client, host }) => {
@@ -1159,12 +1160,18 @@ export class HostRuntimeController {
});
}
private logConnectionTransition(_input: {
private logConnectionTransition(input: {
from: HostRuntimeConnectionMachineState["tag"];
to: HostRuntimeConnectionMachineState["tag"];
event: HostRuntimeConnectionMachineEvent;
}): void {
// Intentionally empty - logging removed.
watchdog?.record("host.transition", {
host: this.host.serverId,
from: input.from,
to: input.to,
event: input.event.type,
epoch: String(this.connectionEpoch),
});
}
private trackConnectionFirstSeen(): void {
+5 -4
View File
@@ -1331,10 +1331,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);
@@ -13,29 +13,43 @@ export function createValidatedPersistStorage<State>(
});
const accepted = new Map<string, StorageValue<State>>();
const reading = new Set<string>();
async function read(name: string): Promise<StorageValue<State> | null> {
const raw = await backingStorage.getItem(name);
if (raw === null) return null;
let decoded: unknown;
try {
decoded = JSON.parse(raw);
} catch {
await backingStorage.removeItem(name);
return null;
}
const result = envelopeSchema.safeParse(decoded);
if (!result.success) {
await backingStorage.removeItem(name);
return null;
}
return result.data;
}
return {
getItem: async (name) => {
accepted.delete(name);
const raw = await backingStorage.getItem(name);
if (raw === null) return null;
let decoded: unknown;
reading.add(name);
try {
decoded = JSON.parse(raw);
} catch {
await backingStorage.removeItem(name);
return null;
return await read(name);
} finally {
reading.delete(name);
}
const result = envelopeSchema.safeParse(decoded);
if (!result.success) {
await backingStorage.removeItem(name);
return null;
}
return result.data;
},
setItem: async (name, value) => {
// A store that changes before hydration settles writes the defaults it started with.
// Hydration then merges the stored state in memory without writing it back, so that
// write would replace the stored state until the next change.
if (reading.has(name)) return;
const previous = accepted.get(name);
if (
options.skipUnchanged &&
@@ -52,3 +52,39 @@ it("skips unchanged reconciliations and immediately saves real layout changes",
store.getState().layoutByWorkspace[workspaceKey],
);
});
it("keeps the saved layout when the shell publishes its layout mode before hydration", async () => {
const values = new Map<string, string>();
const storage: StateStorage = {
getItem: (name) => values.get(name) ?? null,
setItem: (name, value) => {
values.set(name, value);
},
removeItem: (name) => {
values.delete(name);
},
};
const workspaceKey = "server:workspace";
const saved = createWorkspaceLayoutStore(undefined, storage);
await saved.persist.rehydrate();
saved.getState().openTab({
workspaceKey,
target: { kind: "file", path: "notes.md" },
intent: "reveal",
});
// A page load: the shell publishes its mode from the first commit, before the read settles.
const loaded = createWorkspaceLayoutStore(undefined, storage);
loaded.getState().setLayoutMode("single-pane");
await new Promise<void>((resolve) => loaded.persist.onFinishHydration(() => resolve()));
expect(loaded.getState().layoutByWorkspace[workspaceKey]).toEqual(
saved.getState().layoutByWorkspace[workspaceKey],
);
// Leaving the page before any other layout change must not lose the tabs.
const reloaded = createWorkspaceLayoutStore(undefined, storage);
await reloaded.persist.rehydrate();
expect(reloaded.getState().layoutByWorkspace[workspaceKey]).toEqual(
saved.getState().layoutByWorkspace[workspaceKey],
);
});
+36 -34
View File
@@ -1,3 +1,4 @@
import { watchdog } from "@/watchdog";
import { Profiler, type ProfilerOnRenderCallback, type ReactNode } from "react";
export interface RenderProfileSample {
@@ -28,12 +29,40 @@ function getSearchParam(name: string): string | null {
return new URLSearchParams(search).get(name);
}
export function isRenderProfileEnabled(): boolean {
return (
globalThis.__PASEO_RENDER_PROFILE_ENABLED__ === true || getSearchParam("renderProfile") === "1"
);
interface RenderSink {
isEnabled(): boolean;
render(sample: RenderProfileSample): void;
reasons(id: string, reasons: string[]): void;
}
function createBrowserSink(): RenderSink {
return {
isEnabled: () =>
globalThis.__PASEO_RENDER_PROFILE_ENABLED__ === true ||
getSearchParam("renderProfile") === "1",
render(sample) {
globalThis.__PASEO_RENDER_PROFILE__ ??= [];
globalThis.__PASEO_RESET_RENDER_PROFILE__ ??= () => {
globalThis.__PASEO_RENDER_PROFILE__ = [];
globalThis.__PASEO_RENDER_PROFILE_REASONS__ = {};
};
// Browser QA compares cumulative commit counts until an explicit reset.
globalThis.__PASEO_RENDER_PROFILE__.push(sample);
},
reasons(id, reasons) {
globalThis.__PASEO_RENDER_PROFILE_REASONS__ ??= {};
const counts = (globalThis.__PASEO_RENDER_PROFILE_REASONS__[id] ??= {});
for (const reason of reasons) counts[reason] = (counts[reason] ?? 0) + 1;
},
};
}
// Choose the destination once. Browser QA can still enable capture at runtime.
const sink: RenderSink = watchdog
? { isEnabled: () => true, render: watchdog.render, reasons: () => {} }
: createBrowserSink();
export const isRenderProfileEnabled = sink.isEnabled;
const onRender: ProfilerOnRenderCallback = (
id,
phase,
@@ -41,30 +70,10 @@ const onRender: ProfilerOnRenderCallback = (
baseDuration,
startTime,
commitTime,
) => {
globalThis.__PASEO_RENDER_PROFILE__ ??= [];
globalThis.__PASEO_RENDER_PROFILE__.push({
id,
phase,
actualDuration,
baseDuration,
startTime,
commitTime,
});
};
) => sink.render({ id, phase, actualDuration, baseDuration, startTime, commitTime });
export function RenderProfile({ id, children }: { id: string; children: ReactNode }) {
if (!isRenderProfileEnabled()) {
return children;
}
globalThis.__PASEO_RENDER_PROFILE__ ??= [];
globalThis.__PASEO_RENDER_PROFILE_REASONS__ ??= {};
globalThis.__PASEO_RESET_RENDER_PROFILE__ = () => {
globalThis.__PASEO_RENDER_PROFILE__ = [];
globalThis.__PASEO_RENDER_PROFILE_REASONS__ = {};
};
if (!sink.isEnabled()) return children;
return (
<Profiler id={id} onRender={onRender}>
{children}
@@ -73,12 +82,5 @@ export function RenderProfile({ id, children }: { id: string; children: ReactNod
}
export function recordRenderProfileReasons(id: string, reasons: string[]) {
if (!isRenderProfileEnabled() || reasons.length === 0) {
return;
}
globalThis.__PASEO_RENDER_PROFILE_REASONS__ ??= {};
const counts = (globalThis.__PASEO_RENDER_PROFILE_REASONS__[id] ??= {});
for (const reason of reasons) {
counts[reason] = (counts[reason] ?? 0) + 1;
}
if (sink.isEnabled()) sink.reasons(id, reasons);
}
@@ -77,3 +77,16 @@ describe("tool-call detail state", () => {
assert.strictEqual(hasMeaningfulToolCallDetail(detail), true);
});
});
it("keeps historical setup rows readable as history without a chat output view", () => {
assert.strictEqual(
hasMeaningfulToolCallDetail({
type: "worktree_setup",
branchName: "old",
worktreePath: "/workspace",
log: "historical output",
commands: [],
}),
false,
);
});
@@ -40,12 +40,6 @@ function hasMeaningfulFetchDetail(detail: Extract<ToolCallDetail, { type: "fetch
return Boolean(detail.url || detail.result || detail.codeText);
}
function hasMeaningfulWorktreeSetupDetail(
detail: Extract<ToolCallDetail, { type: "worktree_setup" }>,
): boolean {
return Boolean(detail.branchName || detail.worktreePath || detail.log);
}
function hasMeaningfulSubAgentDetail(
detail: Extract<ToolCallDetail, { type: "sub_agent" }>,
): boolean {
@@ -81,7 +75,7 @@ export function hasMeaningfulToolCallDetail(detail: ToolCallDetail | undefined):
case "fetch":
return hasMeaningfulFetchDetail(detail);
case "worktree_setup":
return hasMeaningfulWorktreeSetupDetail(detail);
return false;
case "sub_agent":
return hasMeaningfulSubAgentDetail(detail);
case "plain_text":
@@ -98,6 +92,7 @@ export function isPendingToolCallDetail(params: {
status: "executing" | "running" | "completed" | "failed" | "canceled";
error: unknown;
}): boolean {
if (params.detail?.type === "worktree_setup") return false;
const isRunning = params.status === "running" || params.status === "executing";
return isRunning && params.error == null && !hasMeaningfulToolCallDetail(params.detail);
}
+35
View File
@@ -0,0 +1,35 @@
import { requireOptionalNativeModule } from "expo-modules-core";
import Constants from "expo-constants";
import { resolveAppVersion } from "@/utils/app-version";
import { createRecorder } from "./internal/recorder";
interface NativeRecorder {
startRecorder(metadata: Record<string, string>): number;
record(name: string, fields: Record<string, string>): void;
heartbeat(): void;
}
const native =
Constants.expoConfig?.extra?.watchdogBuild === true
? requireOptionalNativeModule<NativeRecorder>("PaseoNativeTrace")
: null;
export const watchdog = native
? createRecorder(
(name, fields) => native.record(name, fields),
() => performance.now(),
)
: null;
let started = false;
export function startWatchdog(): void {
if (!native || !watchdog || started) return;
started = true;
const heartbeatMs = native.startRecorder({
version: resolveAppVersion() ?? "unknown",
build: String(Constants.expoConfig?.extra?.diagnosticRevision ?? "unknown"),
builtAt: String(Constants.expoConfig?.extra?.diagnosticBuildTime ?? "unknown"),
reactProfiling: "true",
});
setInterval(() => native.heartbeat(), heartbeatMs);
setInterval(() => watchdog.flushRenders(), 1000);
}
@@ -0,0 +1,103 @@
import { expect, test } from "vitest";
import { createRecorder } from "./recorder";
function fixture() {
const events: { name: string; fields: Record<string, string> }[] = [];
let now = 0;
const recorder = createRecorder(
(name, fields) => events.push({ name, fields }),
() => now,
);
return {
recorder,
events,
advance: (ms: number) => {
now += ms;
},
};
}
test("interleaved hosts keep independent parent spans and durations", () => {
const { recorder, events, advance } = fixture();
const a = recorder.trace({ host: "a" });
const b = recorder.trace({ host: "b" });
a.beginSection("frame");
advance(5);
b.beginSection("frame");
a.beginSection("parse");
advance(15);
a.endSection();
b.endSection();
a.endSection();
expect(
events.map(({ name, fields }) => [
name,
fields.host,
fields.span,
fields.parent,
fields.durationMs,
]),
).toEqual([
["frame.begin", "a", "1:1", "", undefined],
["frame.begin", "b", "2:1", "", undefined],
["parse.begin", "a", "1:2", "1:1", undefined],
["parse.end", "a", "1:2", undefined, "15"],
["frame.end", "b", "2:1", undefined, "15"],
["frame.end", "a", "1:1", undefined, "20"],
]);
});
test("slow renders preserve commit identity while fast renders are summarized", () => {
const { recorder, events } = fixture();
for (const actualDuration of [1, 2, 30])
recorder.render({
id: "Stream",
phase: "update",
actualDuration,
startTime: 4,
commitTime: 40,
});
recorder.flushRenders();
recorder.flushRenders();
expect(events).toEqual([
{
name: "react.render.slow",
fields: {
region: "Stream",
phase: "update",
renderMs: "30",
startMs: "4",
commitMs: "40",
jsMonoMs: "0",
},
},
{
name: "react.render.summary",
fields: { region: "Stream", count: "3", totalMs: "33", maxMs: "30", jsMonoMs: "0" },
},
]);
});
test("dynamic regions are bounded and summaries reset each interval", () => {
const { recorder, events } = fixture();
for (let i = 0; i < 10000; i++)
recorder.render({
id: String(i),
phase: "mount",
actualDuration: 1,
startTime: 0,
commitTime: 1,
});
recorder.flushRenders();
expect(events.length).toBe(129);
expect(events.at(-1)?.fields).toEqual({
region: "other",
count: "9872",
totalMs: "9872",
maxMs: "1",
jsMonoMs: "0",
});
recorder.render({ id: "new", phase: "mount", actualDuration: 1, startTime: 0, commitTime: 1 });
recorder.flushRenders();
expect(events.at(-1)?.fields.region).toBe("new");
});
@@ -0,0 +1,79 @@
import type { DaemonClientTrace } from "@getpaseo/client/internal/daemon-client";
type Fields = Record<string, string>;
interface RenderSample {
id: string;
phase: string;
actualDuration: number;
startTime: number;
commitTime: number;
}
/** Bounded render aggregation and scoped trace identity; no payloads enter this boundary. */
export function createRecorder(write: (name: string, fields: Fields) => void, now: () => number) {
let nextClient = 0;
const renders = new Map<string, { count: number; totalMs: number; maxMs: number }>();
const record = (name: string, fields: Fields = {}) =>
write(name, { ...fields, jsMonoMs: String(now()) });
return {
record,
trace(context: Fields): DaemonClientTrace {
const client = String(++nextClient);
let sequence = 0;
const sections: { id: string; name: string; start: number }[] = [];
return {
isEnabled: () => true,
beginSection(name, fields) {
const id = `${client}:${++sequence}`;
record(`${name}.begin`, {
...context,
...fields,
client,
span: id,
parent: sections.at(-1)?.id ?? "",
});
sections.push({ id, name, start: now() });
},
endSection() {
const section = sections.pop();
if (section)
record(`${section.name}.end`, {
...context,
client,
span: section.id,
durationMs: String(now() - section.start),
});
},
};
},
render(sample: RenderSample) {
if (sample.actualDuration >= 16) {
record("react.render.slow", {
region: sample.id,
phase: sample.phase,
renderMs: String(sample.actualDuration),
startMs: String(sample.startTime),
commitMs: String(sample.commitTime),
});
}
// Dynamic agent/tab IDs cannot grow this map without bound.
const key = renders.has(sample.id) || renders.size < 128 ? sample.id : "other";
const summary = renders.get(key) ?? { count: 0, totalMs: 0, maxMs: 0 };
summary.count++;
summary.totalMs += sample.actualDuration;
summary.maxMs = Math.max(summary.maxMs, sample.actualDuration);
renders.set(key, summary);
},
flushRenders() {
for (const [region, summary] of renders) {
record("react.render.summary", {
region,
count: String(summary.count),
totalMs: String(summary.totalMs),
maxMs: String(summary.maxMs),
});
}
renders.clear();
},
};
}
@@ -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",
+125 -202
View File
@@ -200,38 +200,75 @@ test("traces WebSocket frames, message types, and JSON parse duration", async ()
mock.triggerOpen({ preserveSent: true });
await connectPromise;
expect(recorder.records).toEqual([
{
phase: "begin",
name: "paseo.ws.message.outbound",
args: { envelopeType: "hello", messageType: "hello" },
},
{ phase: "end" },
{
phase: "begin",
name: "paseo.ws.frame.outbound",
args: { kind: "text", size: expect.any(String) },
},
{ phase: "end" },
{
phase: "begin",
name: "paseo.ws.frame.inbound",
args: { kind: "text", size: expect.any(String) },
},
{
phase: "begin",
name: "paseo.ws.json.parse",
args: { size: expect.any(String) },
},
{ phase: "end" },
{
phase: "begin",
name: "paseo.ws.message.inbound",
args: { envelopeType: "session", messageType: "status" },
},
{ phase: "end" },
{ phase: "end" },
]);
const response = client.fetchAgents({ requestId: "diagnostic-request", scope: "active" });
mock.triggerMessage(
wrapSessionMessage({
type: "fetch_agents_response",
payload: {
requestId: "diagnostic-request",
entries: [],
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
},
}),
);
await response;
const spans = recorder.records.filter((record) => record.phase === "begin");
expect(spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "paseo.ws.frame.inbound",
args: {
kind: "text",
size: expect.any(String),
sizeUnit: "utf16-code-units",
connectionAttempt: "1",
},
}),
expect.objectContaining({ name: "paseo.ws.json.parse" }),
expect.objectContaining({ name: "paseo.ws.validate" }),
expect.objectContaining({
name: "paseo.ws.message.outbound",
args: {
envelopeType: "session",
messageType: "fetch_agents_request",
requestId: "diagnostic-request",
connectionAttempt: "1",
},
}),
expect.objectContaining({
name: "paseo.ws.message.inbound",
args: {
envelopeType: "session",
messageType: "fetch_agents_response",
requestId: "diagnostic-request",
connectionAttempt: "1",
},
}),
expect.objectContaining({
name: "paseo.ws.dispatch",
args: {
messageType: "fetch_agents_response",
requestId: "diagnostic-request",
connectionAttempt: "1",
},
}),
]),
);
const stack: string[] = [];
const parentage: [string, string | undefined][] = [];
for (const record of recorder.records) {
if (record.phase === "begin") {
parentage.push([record.name, stack.at(-1)]);
stack.push(record.name);
} else {
expect(stack.length).toBeGreaterThan(0);
stack.pop();
}
}
expect(stack).toEqual([]);
expect(parentage).toContainEqual(["paseo.ws.frame.outbound", "paseo.ws.message.outbound"]);
expect(parentage).toContainEqual(["paseo.ws.dispatch", "paseo.ws.frame.inbound"]);
expect(JSON.stringify(recorder.records)).not.toContain("trace_unit_test");
});
test("does not infer browser automation capabilities from Electron runtime", async () => {
@@ -3335,62 +3372,6 @@ test("sends create_agent_request with workspace and caller identity", async () =
await expect(createPromise).rejects.toThrow("compat test sentinel");
});
test("sends worktree target and autoArchive in create_agent_request", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const createPromise = client.createAgent({
provider: "codex",
cwd: "/tmp/project",
worktree: {
mode: "branch-off",
newBranch: "agent-lifecycle-dispatch",
base: "main",
},
autoArchive: true,
});
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request).toEqual(
expect.objectContaining({
type: "create_agent_request",
worktree: {
mode: "branch-off",
newBranch: "agent-lifecycle-dispatch",
base: "main",
},
autoArchive: true,
}),
);
mock.triggerMessage(
wrapSessionMessage({
type: "status",
payload: {
status: "agent_create_failed",
requestId: request.requestId,
error: "worktree auto archive sentinel",
},
}),
);
await expect(createPromise).rejects.toThrow("worktree auto archive sentinel");
});
test("sends internal in create_agent_request when the host advertises internal agents", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
@@ -3609,120 +3590,6 @@ test("sends structured attachments with create_agent_request", async () => {
await expect(createPromise).rejects.toThrow("attachment test sentinel");
});
test("sends worktree base-ref fields in create_agent_request git options", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const createPromise = client.createAgent({
provider: "codex",
cwd: "/tmp/project",
requestId: "req-agent-ref",
git: {
createWorktree: true,
worktreeSlug: "review-pr-123",
refName: "feature/worktree-base-ref",
action: "checkout",
githubPrNumber: 123,
},
});
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request.git).toEqual({
createWorktree: true,
worktreeSlug: "review-pr-123",
refName: "feature/worktree-base-ref",
action: "checkout",
githubPrNumber: 123,
});
mock.triggerMessage(
wrapSessionMessage({
type: "status",
payload: {
status: "agent_create_failed",
requestId: request.requestId,
error: "git ref fields sentinel",
},
}),
);
await expect(createPromise).rejects.toThrow("git ref fields sentinel");
});
test("omitting create_agent_request worktree base-ref fields preserves legacy wire shape", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const createPromise = client.createAgent({
provider: "codex",
cwd: "/tmp/project",
requestId: "req-agent-legacy",
git: {
createWorktree: true,
worktreeSlug: "feature-a",
},
});
expect(assertStr(mock.sent[0])).toBe(
JSON.stringify({
type: "session",
message: {
type: "create_agent_request",
config: {
provider: "codex",
cwd: "/tmp/project",
},
git: {
createWorktree: true,
worktreeSlug: "feature-a",
},
labels: {},
requestId: "req-agent-legacy",
},
}),
);
mock.triggerMessage(
wrapSessionMessage({
type: "status",
payload: {
status: "agent_create_failed",
requestId: "req-agent-legacy",
error: "legacy git shape sentinel",
},
}),
);
await expect(createPromise).rejects.toThrow("legacy git shape sentinel");
});
test("sends structured first-agent context attachments with create_paseo_worktree_request", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
@@ -7844,3 +7711,59 @@ test("measures an outbound message in UTF-8 bytes against the daemon's message l
expect(mock.sent).toHaveLength(1);
expect(client.getConnectionState().status).toBe("connected");
});
test("sends worktree target and autoArchive in create_agent_request", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const createPromise = client.createAgent({
provider: "codex",
cwd: "/tmp/project",
worktree: {
mode: "branch-off",
newBranch: "agent-lifecycle-dispatch",
base: "main",
},
autoArchive: true,
});
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request).toEqual(
expect.objectContaining({
type: "create_agent_request",
worktree: {
mode: "branch-off",
newBranch: "agent-lifecycle-dispatch",
base: "main",
},
autoArchive: true,
}),
);
mock.triggerMessage(
wrapSessionMessage({
type: "status",
payload: {
status: "agent_create_failed",
requestId: request.requestId,
error: "worktree auto archive sentinel",
},
}),
);
await expect(createPromise).rejects.toThrow("worktree auto archive sentinel");
});
+91 -23
View File
@@ -459,12 +459,12 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides {
outputSchema?: Record<string, unknown>;
images?: CreateAgentRequestMessage["images"];
attachments?: CreateAgentRequestMessage["attachments"];
/** @deprecated Use createWorkspace({ source, agent, idempotencyKey }); rejected by createAgent. */
git?: GitSetupOptions;
worktree?: CreateAgentRequestMessage["worktree"];
autoArchive?: CreateAgentRequestMessage["autoArchive"];
background?: CreateAgentRequestMessage["background"];
// COMPAT(createAgentWorktree): low-level old callers may still send the
// create-agent worktree field. Added in v0.2.0; remove after 2027-01-17.
/** @deprecated Use createWorkspace({ source, agent, idempotencyKey }); rejected by createAgent. */
worktreeName?: string;
requestId?: string;
labels?: Record<string, string>;
@@ -1147,19 +1147,34 @@ function assertTransportFrameFits(frame: string | Uint8Array | ArrayBuffer): voi
throw messageTooLargeError(size, MAX_WEBSOCKET_MESSAGE_BYTES);
}
function describeInboundTransportFrame(
frame: unknown,
rawBytes: Uint8Array | null,
): Record<string, string> {
function describeInboundTransportFrame(frame: unknown): Record<string, string> {
if (typeof frame === "string") {
return { kind: "text", size: String(frame.length) };
return { kind: "text", size: String(frame.length), sizeUnit: "utf16-code-units" };
}
if (rawBytes) {
return { kind: "binary", size: String(rawBytes.byteLength) };
if (frame instanceof ArrayBuffer || ArrayBuffer.isView(frame)) {
return { kind: "binary", size: String(frame.byteLength), sizeUnit: "bytes" };
}
return { kind: "unknown", size: "0" };
}
function describeRequestIdentity(value: unknown): Record<string, string> {
if (!value || typeof value !== "object") return {};
const message = "message" in value ? value.message : value;
if (!message || typeof message !== "object") return {};
if ("requestId" in message && typeof message.requestId === "string")
return { requestId: message.requestId };
if (
"payload" in message &&
message.payload &&
typeof message.payload === "object" &&
"requestId" in message.payload &&
typeof message.payload.requestId === "string"
) {
return { requestId: message.payload.requestId };
}
return {};
}
function hashForLog(value: string): string {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
@@ -1277,6 +1292,7 @@ export class DaemonClient {
private connectTimeout: ReturnType<typeof setTimeout> | null = null;
private pendingGenericTransportErrorTimeout: ReturnType<typeof setTimeout> | null = null;
private reconnectAttempt = 0;
private traceConnectionAttempt = 0;
private shouldReconnect = true;
private connectPromise: Promise<void> | null = null;
private connectResolve: (() => void) | null = null;
@@ -1429,6 +1445,7 @@ export class DaemonClient {
headers: selected.headers,
...(selected.protocols ? { protocols: selected.protocols } : {}),
});
this.traceConnectionAttempt += 1;
this.transport = transport;
this.lastServerInfoMessage = null;
this.authFailureReasonValue = null;
@@ -1457,6 +1474,7 @@ export class DaemonClient {
this.transportCleanup = [
transport.onOpen(() => {
this.traceInstant("paseo.connection.open");
if (this.pendingGenericTransportErrorTimeout) {
clearTimeout(this.pendingGenericTransportErrorTimeout);
this.pendingGenericTransportErrorTimeout = null;
@@ -1465,6 +1483,11 @@ export class DaemonClient {
void this.sendHelloMessage();
}),
transport.onClose((event) => {
this.traceInstant("paseo.connection.close", {
code: String(
event && typeof event === "object" && "code" in event ? event.code : "unknown",
),
});
this.resetConnectTimeout();
if (this.pendingGenericTransportErrorTimeout) {
clearTimeout(this.pendingGenericTransportErrorTimeout);
@@ -1484,6 +1507,7 @@ export class DaemonClient {
});
}),
transport.onError((event) => {
this.traceInstant("paseo.connection.error");
this.resetConnectTimeout();
const reason = describeTransportError(event);
const isGeneric = reason === "Transport error";
@@ -1755,7 +1779,7 @@ export class DaemonClient {
if (!trace?.isEnabled()) {
return false;
}
trace.beginSection(name, args);
trace.beginSection(name, { ...args, connectionAttempt: String(this.traceConnectionAttempt) });
return true;
}
@@ -1770,12 +1794,26 @@ export class DaemonClient {
this.endTraceSection(isOpen);
}
private beginSessionDispatchTrace(message: SessionOutboundMessage): boolean {
if (!this.config.trace?.isEnabled()) return false;
const fields = { messageType: message.type, ...describeRequestIdentity(message) };
this.traceInstant("paseo.ws.message.inbound", { envelopeType: "session", ...fields });
return this.beginTraceSection("paseo.ws.dispatch", fields);
}
private sendJsonMessage(envelopeType: string, messageType: string, message: unknown): void {
this.traceInstant("paseo.ws.message.outbound", {
envelopeType,
messageType,
});
this.sendTransportFrame(JSON.stringify(message));
const isOpen =
this.config.trace?.isEnabled() === true &&
this.beginTraceSection("paseo.ws.message.outbound", {
envelopeType,
messageType,
...describeRequestIdentity(message),
});
try {
this.sendTransportFrame(JSON.stringify(message));
} finally {
this.endTraceSection(isOpen);
}
}
private sendTransportFrame(frame: string | Uint8Array | ArrayBuffer): void {
@@ -1787,6 +1825,7 @@ export class DaemonClient {
const isOpen = this.beginTraceSection("paseo.ws.frame.outbound", {
kind: typeof frame === "string" ? "text" : "binary",
size: String(getTransportFrameSize(frame)),
sizeUnit: typeof frame === "string" ? "utf16-code-units" : "bytes",
});
try {
this.transport.send(frame);
@@ -2924,6 +2963,11 @@ export class DaemonClient {
}
async createAgent(options: CreateAgentRequestOptions): Promise<AgentSnapshotPayload> {
if (options.git !== undefined || options.worktreeName !== undefined) {
throw new Error(
"Legacy git/worktreeName agent placement was removed. Use createWorkspace({ source, agent, idempotencyKey }) or create a workspace first and pass workspaceId.",
);
}
if (options.background !== undefined) this.requireBackgroundWorkspaces();
const result = await this.creations.createAgent({
...options,
@@ -6394,12 +6438,13 @@ export class DaemonClient {
return;
}
const rawBytes = asUint8Array(rawData);
const isOpen = this.beginTraceSection(
"paseo.ws.frame.inbound",
describeInboundTransportFrame(rawData, rawBytes),
describeInboundTransportFrame(rawData),
);
try {
// Record arrival before conversion: encoding a large text frame is JS work too.
const rawBytes = asUint8Array(rawData);
if (rawBytes && this.tryHandleBinaryFrame(rawBytes)) {
return;
}
@@ -6419,17 +6464,26 @@ export class DaemonClient {
let parsedJson: unknown;
const parseTraceOpen = this.beginTraceSection("paseo.ws.json.parse", {
size: String(bytes),
sizeUnit: rawBytesLength === undefined ? "utf16-code-units" : "bytes",
});
try {
parsedJson = JSON.parse(payload);
} catch {
this.traceInstant("paseo.ws.json.invalid");
return;
} finally {
this.endTraceSection(parseTraceOpen);
}
const parsed = validateWSOutboundMessage(parsedJson);
const validationTrace = this.beginTraceSection("paseo.ws.validate");
let parsed: ReturnType<typeof validateWSOutboundMessage>;
try {
parsed = validateWSOutboundMessage(parsedJson);
} finally {
this.endTraceSection(validationTrace);
}
if (!parsed.success) {
this.traceInstant("paseo.ws.validation.failed");
const responseIdentity = extractCorrelatedResponseIdentity(parsedJson);
const envelopeType =
parsedJson != null &&
@@ -6462,6 +6516,7 @@ export class DaemonClient {
}
if (parsed.data.type === "hello.rejected") {
this.traceInstant("paseo.connection.rejected", { reason: parsed.data.reason });
const reasonMessage = {
password_required: "Password required",
incorrect_password: "Incorrect password",
@@ -6473,11 +6528,12 @@ export class DaemonClient {
return;
}
this.traceInstant("paseo.ws.message.inbound", {
envelopeType: "session",
messageType: parsed.data.message.type,
});
this.handleSessionMessage(parsed.data.message);
const dispatchTrace = this.beginSessionDispatchTrace(parsed.data.message);
try {
this.handleSessionMessage(parsed.data.message);
} finally {
this.endTraceSection(dispatchTrace);
}
const msgType = parsed.data.message.type;
this.runtimeMetrics?.recordMessage(msgType, bytes, perfNow() - startMs);
if (parsed.data.message.type === "agent_stream") {
@@ -6492,6 +6548,7 @@ export class DaemonClient {
envelopeType: "binary",
messageType: "file",
opcode: String(fileFrame.opcode),
requestId: fileFrame.requestId,
});
this.consecutiveLivenessFailures = 0;
this.handleFileTransferFrame(fileFrame);
@@ -6507,6 +6564,7 @@ export class DaemonClient {
envelopeType: "binary",
messageType: "terminal",
opcode: String(frame.opcode),
slot: String(frame.slot),
});
this.consecutiveLivenessFailures = 0;
const binaryStartMs = perfNow();
@@ -6625,6 +6683,12 @@ export class DaemonClient {
next.status === "disconnected" && typeof next.reason === "string" ? next.reason : null;
const reason = metadata?.reason ?? reasonFromNext;
const reasonCode = metadata?.reasonCode ?? toReasonCode(reason);
this.traceInstant("paseo.connection.state", {
from: previous.status,
to: next.status,
event: metadata?.event ?? "STATE_UPDATE",
reasonCode: reasonCode ?? "none",
});
this.logger.debug(
{
serverId: this.logServerId,
@@ -6724,6 +6788,10 @@ export class DaemonClient {
const baseDelay = this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS;
const maxDelay = this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS;
const delay = Math.min(baseDelay * 2 ** attempt, maxDelay);
this.traceInstant("paseo.connection.reconnect", {
attempt: String(attempt),
delayMs: String(delay),
});
this.reconnectAttempt = attempt + 1;
this.reconnectTimeout = setTimeout(() => {
this.reconnectTimeout = null;
+1
View File
@@ -68,6 +68,7 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"i18next": "^26.3.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
+4 -3
View File
@@ -1,9 +1,10 @@
import type { PluginText } from "./text.js";
import { z } from "zod";
import type { PluginAttachmentSourceContribution } from "./contracts.js";
export function defineAttachmentSource<Definition extends PluginAttachmentSourceContribution>(
definition: Definition,
): Definition {
export function defineAttachmentSource<
Definition extends PluginAttachmentSourceContribution<PluginText>,
>(definition: Definition): Definition {
return definition;
}
+2 -1
View File
@@ -29,7 +29,8 @@ const clientModule = /^client\//;
function resolveLocal(importer: string, specifier: string): string {
const base = path.resolve(path.dirname(importer), specifier.replace(/\.js$/, ""));
const result = [base + ".ts", base + ".tsx"].find(existsSync);
const candidates = specifier.endsWith(".json") ? [base] : [base + ".ts", base + ".tsx"];
const result = candidates.find(existsSync);
if (!result) throw new Error(`Cannot resolve ${specifier} from ${importer}`);
return result;
}
+17 -18
View File
@@ -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;
}
+51 -41
View File
@@ -1,3 +1,4 @@
import type { PluginText } from "../text.js";
import type { ComponentType } from "react";
import type { PaseoApi } from "@getpaseo/client";
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
@@ -82,10 +83,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>;
}
@@ -103,9 +104,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[];
}
@@ -129,9 +130,9 @@ export interface PluginFilePanelProps extends PluginNavigableHostProps {
location: PluginFileLocation;
}
export interface PluginFilePanelContribution {
export interface PluginFilePanelContribution<Text extends PluginText = string> {
id: string;
title: string;
title: Text;
icon: PluginIcon;
/** Extensions without the dot, case-insensitive, and exact filenames. */
files: { extensions: readonly string[]; filenames?: readonly string[] };
@@ -148,24 +149,31 @@ 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;
addFilePanel(contribution: PluginFilePanelContribution): 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;
addFilePanel(contribution: PluginFilePanelContribution<PluginText>): PluginCleanup;
addTimelineTransformer<ItemType extends AgentTimelineItem["type"]>(
contribution: PluginTimelineTransformerContribution<ItemType>,
): PluginCleanup;
@@ -177,29 +185,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>;
}
@@ -209,9 +219,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;
}
@@ -278,39 +288,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>;
});
+11 -1
View File
@@ -1,3 +1,4 @@
import type { PluginText } from "../text.js";
import { PluginAttachmentSearchPayloadSchema } from "../attachments.js";
export { PluginClientStateProvider, type PluginClientStateSource } from "./client-state.js";
export {
@@ -10,7 +11,7 @@ import { PaseoApiProvider } from "./paseo-context.js";
import { callPluginRpc } from "../rpc.js";
export async function searchPluginAttachments(
source: PluginAttachmentSourceContribution,
source: PluginAttachmentSourceContribution<PluginText>,
invoke: (method: string, input: unknown) => Promise<unknown>,
query: string,
) {
@@ -19,3 +20,12 @@ export async function searchPluginAttachments(
}
export { callPluginRpc, PaseoApiProvider, PluginRpcProvider };
export {
PluginTranslationRuntime,
PluginTranslationProvider,
usePluginTranslationRuntime,
requirePluginText,
isPluginMessage,
} from "./translations.js";
export type { PluginLocaleSource } from "./translations.js";
+4
View File
@@ -87,3 +87,7 @@ export declare function useHosts(): readonly PluginHostSummary[];
export declare function getPaseoClient(serverId: string): import("@getpaseo/client").PaseoApi;
/** Open an absolute HTTP(S) URL using the client platform’s external opener. */
export declare function openExternalUrl(url: string): Promise<void>;
export { defineTranslations, useTranslation } from "./translations.js";
export type { PluginI18n } from "./translations.js";
export type { PluginText, PluginMessage, PluginTranslations } from "../text.js";
@@ -1,3 +1,4 @@
import { PluginTranslationProvider, usePluginTranslationRuntime } from "./translations.js";
import { useCallback, type ReactNode } from "react";
import { PluginClientStateProvider, usePluginClientStateSource } from "./client-state.js";
import { PaseoApiProvider, usePaseoContextValue } from "./paseo-context.js";
@@ -7,6 +8,7 @@ export type PluginRuntimeContextBridge = (children: ReactNode) => ReactNode;
/** Rebuilds plugin runtime contexts inside React Native portal hosts. */
export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge {
const translations = usePluginTranslationRuntime();
const paseo = usePaseoContextValue();
const rpc = usePluginRpcContextValue();
const state = usePluginClientStateSource();
@@ -24,10 +26,12 @@ export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge {
);
return (
<PaseoApiProvider paseo={paseo}>
<PluginRpcProvider invoke={rpc.invoke}>{content}</PluginRpcProvider>
<PluginRpcProvider invoke={rpc.invoke}>
<PluginTranslationProvider runtime={translations}>{content}</PluginTranslationProvider>
</PluginRpcProvider>
</PaseoApiProvider>
);
},
[paseo, rpc, state],
[paseo, rpc, state, translations],
);
}
@@ -0,0 +1,104 @@
import { expect, it } from "vitest";
import { defineTranslations, PluginTranslationRuntime } from "./translations.js";
function language(initial = "en") {
let locale = initial;
const listeners = new Set<() => void>();
return {
getLocale: () => locale,
subscribe(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
set(next: string) {
locale = next;
for (const listener of listeners) listener();
},
subscriptions: () => listeners.size,
};
}
const messages = defineTranslations({
resources: {
en: {
screen: {
title: "Notes",
fallback: "English fallback",
notes_one: "{{count}} note for {{name}}",
notes_other: "{{count}} notes for {{name}}",
},
},
"zh-CN": { screen: { title: "笔记", notes_other: "{{name}}的 {{count}} 条笔记" } },
},
});
it("resolves messages, interpolation, plurals and English fallback with the current app locale", () => {
const source = language();
const runtime = new PluginTranslationRuntime(source);
const title = messages.message("screen.title");
expect(runtime.resolve(title)).toBe("Notes");
expect(runtime.t(messages, "screen.notes", { count: 1, name: "Ada" })).toBe("1 note for Ada");
expect(runtime.t(messages, "screen.notes", { count: 2, name: "Ada" })).toBe("2 notes for Ada");
source.set("zh-CN");
expect(runtime.resolve(title)).toBe("笔记");
expect(runtime.t(messages, "screen.notes", { count: 2, name: "Ada" })).toBe("Ada的 2 条笔记");
expect(runtime.t(messages, "screen.fallback")).toBe("English fallback");
expect(runtime.locale).toBe("zh-CN");
source.set("fr");
expect(runtime.resolve(title)).toBe("Notes");
expect(runtime.locale).toBe("fr");
runtime.dispose();
});
it("isolates installations, resources and clients, and unsubscribes on unload", () => {
const first = language("zh-CN");
const second = language();
const a = new PluginTranslationRuntime(first);
const b = new PluginTranslationRuntime(second);
const other = defineTranslations({ resources: { en: { screen: { title: "Other plugin" } } } });
expect(a.t(messages, "screen.title")).toBe("笔记");
expect(b.t(messages, "screen.title")).toBe("Notes");
expect(a.t(other, "screen.title")).toBe("Other plugin");
let updates = 0;
a.subscribe(() => updates++);
first.set("en");
expect(updates).toBe(1);
expect(first.subscriptions()).toBe(1);
a.dispose();
a.dispose();
expect(first.subscriptions()).toBe(0);
first.set("zh-CN");
expect(updates).toBe(1);
expect(b.t(messages, "screen.title")).toBe("Notes");
b.dispose();
});
it("rejects unknown keys, malformed resources, noncanonical locales and placeholder drift", () => {
expect(() =>
defineTranslations({ resources: { en: { title: "Notes" }, fr: { typo: "Notes" } } }),
).toThrow("Unknown translation key");
expect(() =>
defineTranslations({ resources: { en: { title: "Notes" }, "zh-cn": { title: "笔记" } } }),
).toThrow("canonical");
expect(() =>
defineTranslations({
resources: { en: { title: "Hello {{name}}" }, fr: { title: "Bonjour {{other}}" } },
}),
).toThrow("placeholders differ");
expect(() => defineTranslations({ resources: { en: {} } })).toThrow("English messages");
expect(() => defineTranslations({ resources: { en: { value: 42 } } } as never)).toThrow(
"objects of strings",
);
});
it("uses Intl formatting and reports missing JSON interpolation values", () => {
const source = language("en");
const runtime = new PluginTranslationRuntime(source);
const en: { amount: string; welcome: string } = {
amount: "{{value, number}}",
welcome: "Hello {{name}}",
};
const formatted = defineTranslations({ resources: { en } });
expect(runtime.t(formatted, "amount", { value: 1234.5 })).toBe("1,234.5");
expect(() => runtime.t(formatted, "welcome")).toThrow("Missing translation value: name");
runtime.dispose();
});
+202
View File
@@ -0,0 +1,202 @@
import {
createContext,
useCallback,
useContext,
useSyncExternalStore,
type ReactNode,
} from "react";
import { createInstance, type i18n } from "i18next";
import type {
PluginMessage,
PluginText,
PluginTranslations,
TranslationKey,
TranslationArguments,
TranslationResource,
TranslationOptions,
} from "../text.js";
type Resources = PluginTranslations["resources"];
interface MessageDefinition {
resources: Resources;
key: string;
options?: TranslationOptions;
}
const messageDefinitions = new WeakMap<PluginMessage, MessageDefinition>();
export function defineTranslations<const Resource extends TranslationResource>(definition: {
resources: Readonly<Record<string, TranslationResource>> & { en: Resource };
}): PluginTranslations<Resource> {
const english = validateResources(definition.resources);
return {
resources: definition.resources,
message: (key, ...args) => {
const source = english.get(key) ?? english.get(`${key}_other`);
if (source === undefined) throw new Error(`Unknown translation key: ${key}`);
const options = args[0];
for (const name of placeholders(source)) {
if (options?.[name] === undefined)
throw new Error(`Missing translation value: ${name} (${key})`);
}
const message = Object.freeze({}) as PluginMessage;
messageDefinitions.set(message, { resources: definition.resources, key, options });
return message;
},
};
}
/** App language source. Each installation subscribes for exactly its own lifetime. */
export interface PluginLocaleSource {
getLocale(): string;
subscribe(listener: () => void): () => void;
}
export interface PluginI18n {
readonly locale: string;
t<Resource extends TranslationResource, Key extends TranslationKey<Resource>>(
messages: PluginTranslations<Resource>,
key: Key,
...args: TranslationArguments<Resource, Key>
): string;
}
export class PluginTranslationRuntime implements PluginI18n {
private readonly engines = new Map<Resources, i18n>();
private readonly listeners = new Set<() => void>();
private readonly unsubscribe: () => void;
private stopped = false;
constructor(private readonly source: PluginLocaleSource) {
this.unsubscribe = source.subscribe(() => {
for (const listener of this.listeners) listener();
});
}
get locale(): string {
return this.source.getLocale();
}
getSnapshot = (): string => this.locale;
subscribe = (listener: () => void): (() => void) => {
if (this.stopped) throw new Error("Plugin translations have stopped");
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
t<Resource extends TranslationResource, Key extends TranslationKey<Resource>>(
messages: PluginTranslations<Resource>,
key: Key,
...args: TranslationArguments<Resource, Key>
): string {
return this.resolve(messages.message(key, ...args));
}
resolve = (text: PluginText, locale = this.locale): string => {
if (typeof text === "string") return text;
if (this.stopped) throw new Error("Plugin translations have stopped");
const message = messageDefinitions.get(text);
if (!message) throw new Error("Plugin labels must use messages.message()");
let engine = this.engines.get(message.resources);
if (!engine) {
engine = createInstance();
void engine.init({
initAsync: false,
lng: this.locale,
fallbackLng: "en",
resources: Object.fromEntries(
Object.entries(message.resources).map(([language, translation]) => [
language,
{ translation },
]),
),
interpolation: { escapeValue: false },
returnNull: false,
returnObjects: false,
});
this.engines.set(message.resources, engine);
}
return engine.t(message.key, { ...message.options, lng: locale });
};
dispose(): void {
if (this.stopped) return;
this.stopped = true;
this.unsubscribe();
this.listeners.clear();
this.engines.clear();
}
}
export function isPluginMessage(value: unknown): value is PluginMessage {
return (
typeof value === "object" && value !== null && messageDefinitions.has(value as PluginMessage)
);
}
export function requirePluginText(text: unknown): PluginText {
if (typeof text === "string" && text.trim()) return text.trim();
if (isPluginMessage(text)) return text;
throw new Error("Plugin label must be a non-empty string or a translation message");
}
function flatten(resource: TranslationResource, prefix = ""): Map<string, string> {
if (!resource || typeof resource !== "object" || Array.isArray(resource))
throw new Error("Translation resources must be objects of strings");
const result = new Map<string, string>();
for (const [key, value] of Object.entries(resource)) {
if (!key || key.includes(".") || ["__proto__", "constructor", "prototype"].includes(key))
throw new Error(`Invalid translation key: ${key}`);
const path = prefix ? `${prefix}.${key}` : key;
if (typeof value === "string") result.set(path, value);
else for (const entry of flatten(value, path)) result.set(...entry);
}
return result;
}
function pluralBase(key: string): string {
return key.replace(/_(zero|one|two|few|many|other)$/, "");
}
function placeholders(value: string): string[] {
return [
...new Set([...value.matchAll(/{{-?\s*([^},\s]+)(?:[^}]*)}}/g)].map((match) => match[1])),
].sort();
}
function validateResources(resources: Resources): Map<string, string> {
const english = flatten(resources.en);
if (!english.size) throw new Error("Plugin translations require English messages");
for (const [locale, resource] of Object.entries(resources)) {
if (Intl.getCanonicalLocales(locale)[0] !== locale)
throw new Error(`Translation locale must be canonical: ${locale}`);
for (const [key, value] of flatten(resource)) {
const source = english.get(key) ?? english.get(`${pluralBase(key)}_other`);
if (source === undefined) throw new Error(`Unknown translation key in ${locale}: ${key}`);
if (placeholders(value).join(",") !== placeholders(source).join(","))
throw new Error(`Translation placeholders differ in ${locale}: ${key}`);
}
}
return english;
}
const Context = createContext<PluginTranslationRuntime | null>(null);
export function PluginTranslationProvider({
runtime,
children,
}: {
runtime: PluginTranslationRuntime;
children: ReactNode;
}) {
return <Context.Provider value={runtime}>{children}</Context.Provider>;
}
export function usePluginTranslationRuntime(): PluginTranslationRuntime {
const runtime = useContext(Context);
if (!runtime) throw new Error("Plugin translations must run inside a contributed plugin surface");
return runtime;
}
export function useTranslation<Resource extends TranslationResource>(
messages: PluginTranslations<Resource>,
) {
const runtime = usePluginTranslationRuntime();
const locale = useSyncExternalStore(runtime.subscribe, runtime.getSnapshot, runtime.getSnapshot);
const t = useCallback(
<Key extends TranslationKey<Resource>>(
key: Key,
...args: TranslationArguments<Resource, Key>
) => runtime.resolve(messages.message(key, ...args), locale),
[runtime, messages, locale],
);
return { locale, t };
}
+7 -6
View File
@@ -1,3 +1,4 @@
import type { PluginText } from "./text.js";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { 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;
}

Some files were not shown because too many files have changed in this diff Show More