Compare commits

..
Author SHA1 Message Date
Mohamed Boudra d369c9d36b feat(subagents): connect fleet MCP delegation to app and parent completion
Parented creation enters the relationship owner before resource admission. Initial run evidence drives one qualified completion consumer, while ordinary bound conversations own their directory demand and workspace selectors. Preserve manager-owned archive lifecycles and verify managed callbacks through the parent capability.
2026-10-20 17:41:52 +02:00
Mohamed Boudra 12890f8ab0 feat(subagents): retain fleet membership and origin through app navigation
Use the parent ledger to attest membership before the first child snapshot so placement cannot race creation. Bind retained conversations, catalog pushes and actions to their selected origin with separate replica, layout and pending-state owners.
2026-10-20 15:09:50 +02:00
Mohamed Boudra 7c4aa058ff feat(hosts): bind replica and session lifetime to origin authority 2026-10-20 13:23:04 +02:00
Mohamed Boudra 702bc942b7 fix(agents): retain creation identity and dispatch evidence across hosts 2026-10-20 12:15:51 +02:00
Mohamed Boudra b1b5010bd2 feat(agents): persist qualified subagent lifecycle and run evidence 2026-10-20 10:57:13 +02:00
Mohamed Boudra 888b54bb44 feat(hosts): resolve qualified agent addresses through shared runtimes
Keep agent identity and origin authority stable through disconnects, destination changes, and scoped disposal. Share the resolver with trusted daemon consumers and plugins, using ordinary sessions and per-admission permission ceilings. Expose MCP host discovery through that boundary as the first checkpoint toward durable fleet subagents.
2026-10-20 09:32:17 +02:00
Mohamed Boudra 63c3bb8c42 test(e2e): keep transient developer toasts from stalling checks
Concurrent locator handlers can wait for a dismiss button another handler removed. Dismiss existing toast DOM at document lifetime, including its open shadow root, without intercepting app actions.
2026-10-20 08:06:15 +02:00
Mohamed Boudra 569b34263d refactor(hosts): use the ordinary client contract over channel transports
Keep queuing, permission codes and reconnection independent of transport selection. Track submitted request outcomes generically while retaining connection error codes and structured causes.
2026-10-20 07:59:01 +02:00
Mohamed Boudra 71cc853f7b test(hosts): control physical write backpressure across platforms
Hold real TCP writes until assertions finish instead of assuming a paused receiver exhausts every platform receive buffer. Preserve physical send settlement, byte identity, capacity and sibling checks.
2026-10-20 07:59:01 +02:00
Mohamed Boudra f2dfa83e83 refactor(hosts): remove management settings UI from phase one
Keep connected-host management in the CLI and shared SDK while preserving app saved connections and plugin access.
2026-10-20 07:59:01 +02:00
Mohamed Boudra fb4ca20dbd fix(hosts): keep compact row actions clear of host labels
Give compact connected-host rows separate text and action widths so long names and producer labels cannot compete with primary controls.
2026-10-20 07:59:01 +02:00
Mohamed Boudra 4e2aa97a74 fix(hosts): bound encrypted overload and initial acquisition
Check encrypted wire capacity before the async channel converts a resource limit into a physical connection error. Bound initial routed acquisition with the existing client timeout, then enable reconnect after admission.
2026-10-20 07:59:01 +02:00
Mohamed Boudra 5ab9b8dcd4 fix(hosts): share daemon links across isolated routed sessions
Multiplex logical sessions over the monitored daemon connection and wrap original binary bytes in routing headers. Admit channels under their authenticated parent and current grants; preserve scoped delivery and physical send completion.
2026-10-20 07:59:01 +02:00
Mohamed Boudra 4284209025 fix(cli): preserve validated addresses in daemon command hints 2026-10-20 07:59:01 +02:00
Mohamed Boudra 6ae87ec7e2 test(hosts): include relay coverage in existing CI contracts 2026-10-20 07:59:01 +02:00
Mohamed Boudra 630e8301ad fix(hosts): validate safe targets and verify encrypted relay routes 2026-10-20 07:59:01 +02:00
Mohamed Boudra 74bb3e76e4 feat(hosts): connect to saved hosts through a daemon 2026-10-20 07:59:01 +02:00
910 changed files with 28466 additions and 45978 deletions
-1
View File
@@ -41,7 +41,6 @@ server:
desktop:
- "packages/desktop/**"
- "packages/app/src/desktop/**"
- "packages/app/src/**/*.electron.{ts,tsx}"
- "packages/server/src/server/browser-tools/**"
- "packages/app/e2e/support/**"
- "packages/app/*config.{cjs,js,ts}"
+1 -9
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,14 +417,6 @@ 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,6 +6,7 @@ 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/**"
-65
View File
@@ -1,70 +1,5 @@
# Changelog
## 0.13.0-beta.1 - 2026-10-22
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
Connect your daemon to another machine, then ask an agent to delegate work there:
```sh
paseo host add 192.168.1.20:6767 --label studio
paseo host add 'ssh://me@build-box' --label build-box
paseo host add 'relay://...' --label laptop
```
### Added
- Added support for foldable phones such as iPhone Duo, which switch to the wide layout when unfolded ([#7424](https://github.com/getpaseo/paseo/pull/7424))
- Added connected hosts, so agents can create, prompt and monitor agents on other machines and see them in the Subagents track ([#7370](https://github.com/getpaseo/paseo/pull/7370), [#7593](https://github.com/getpaseo/paseo/pull/7593))
- Added paseo.sh in Simplified Chinese at [paseo.sh/zh-CN](https://paseo.sh/zh-CN) ([#7373](https://github.com/getpaseo/paseo/pull/7373))
- Added All changes to the Changes pane as the default, showing committed and uncommitted work since the branch base in one diff ([#7576](https://github.com/getpaseo/paseo/pull/7576))
- Added Implement with Auto mode to Claude plan approvals when Auto was on before Plan ([#7453](https://github.com/getpaseo/paseo/pull/7453))
### Improved
- Changed the Host field on New workspace to always show, with Add host at the end of its menu ([#7580](https://github.com/getpaseo/paseo/pull/7580))
- Replaced the file tab's Preview/Source toggle with a switcher between the file's panels ([#7411](https://github.com/getpaseo/paseo/pull/7411))
- Changed Explorer to use the same tabs and launcher on phones, tablets and desktop ([#7424](https://github.com/getpaseo/paseo/pull/7424))
- Changed Explorer tabs on phones to keep full labels and scroll, with Close moved to the long-press menu ([#7589](https://github.com/getpaseo/paseo/pull/7589))
- Changed usage rows to say whether a percentage is used or remaining, in the app's language ([#7376](https://github.com/getpaseo/paseo/pull/7376) by [@michaelmwu](https://github.com/michaelmwu))
- Changed an agent's report and its finish notification to reach the parent agent as one message ([#7561](https://github.com/getpaseo/paseo/pull/7561))
- Stopped sending workspace setup output to apps that are not showing that workspace's Setup panel ([#7407](https://github.com/getpaseo/paseo/pull/7407), [#7473](https://github.com/getpaseo/paseo/pull/7473))
- Paused status ring animations inside hidden panels on iOS and Android ([#7423](https://github.com/getpaseo/paseo/pull/7423) by [@peterkeen](https://github.com/peterkeen))
- Reduced app startup work by loading only the selected language's translations ([#7410](https://github.com/getpaseo/paseo/pull/7410) by [@colonelpanic8](https://github.com/colonelpanic8))
- Removed repeated workspace layout saves when nothing changed ([#7358](https://github.com/getpaseo/paseo/pull/7358) by [@colonelpanic8](https://github.com/colonelpanic8))
### Plugins
- Added [file panels](https://paseo.sh/docs/plugins/reference#file-panels), so a plugin can open matching files in the file tab, such as a PDF viewer or a rich Markdown editor ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366))
- Added [plugin translations](https://paseo.sh/docs/plugins/reference#runtime-translations), so plugin content and labels follow the app's language ([#7478](https://github.com/getpaseo/paseo/pull/7478))
- Added translated plugin listings through `translations` in `paseo-plugin.json` and `OVERVIEW.zh-CN.md` ([#7373](https://github.com/getpaseo/paseo/pull/7373))
- Added `Markdown` to `@getpaseo/plugin/client/ui` to render Markdown with Paseo's renderer ([#7579](https://github.com/getpaseo/paseo/pull/7579))
- Added inline SVG and component icons to every plugin `icon` ([#7368](https://github.com/getpaseo/paseo/pull/7368))
- Changed every plugin UI import to `@getpaseo/plugin/client/ui`; the previous entry point still works and is deprecated ([#7474](https://github.com/getpaseo/paseo/pull/7474))
- Changed turn-ended hooks to send an empty `timeline`; fetch recent history with `timeline.refetch` instead ([#7477](https://github.com/getpaseo/paseo/pull/7477))
- Fixed notification timeline transformers failing to load with "invalid item type: notification" ([#7582](https://github.com/getpaseo/paseo/pull/7582) by [@Gavinchen92](https://github.com/Gavinchen92))
### Fixed
- Fixed agents showing as running after a daemon restart ([#7573](https://github.com/getpaseo/paseo/pull/7573))
- Fixed Claude system context appearing as an assistant message in imported or restored chats ([#7590](https://github.com/getpaseo/paseo/pull/7590) by [@khevamann](https://github.com/khevamann), [@rogerluan](https://github.com/rogerluan))
- Fixed saved workspace tabs being lost when leaving or reloading the app right after it loads ([#7547](https://github.com/getpaseo/paseo/pull/7547))
- Fixed relative links in Markdown preview resolving from the workspace root instead of the document's folder ([#7538](https://github.com/getpaseo/paseo/pull/7538))
- Fixed Add image failing with "Failed to select image" on Android after changing the font size ([#7546](https://github.com/getpaseo/paseo/pull/7546) by [@RRD-dev](https://github.com/RRD-dev), [@idoyana](https://github.com/idoyana), [@luoph](https://github.com/luoph))
- Fixed popovers, tooltips and context menus on Android opening one status bar below their trigger ([#7589](https://github.com/getpaseo/paseo/pull/7589))
- Fixed the active Explorer tab being invisible in the light theme ([#7589](https://github.com/getpaseo/paseo/pull/7589))
- Fixed Android showing "Host not found" instead of a TLS error for untrusted certificates ([#7429](https://github.com/getpaseo/paseo/pull/7429))
- Fixed the keyboard covering focused Settings fields on Android ([#7362](https://github.com/getpaseo/paseo/pull/7362))
- Fixed Pull or Push appearing twice in the compact Changes menu ([#7355](https://github.com/getpaseo/paseo/pull/7355) by [@az33zy](https://github.com/az33zy))
- Fixed Jump to tab and Jump to workspace saving shortcuts that don't end in a number ([#7365](https://github.com/getpaseo/paseo/pull/7365))
- Fixed `paseo agent archive` reporting `AGENT_NOT_FOUND` for agents beyond the first 200 ([#7535](https://github.com/getpaseo/paseo/pull/7535) by [@L4XB](https://github.com/L4XB), [@hteo1337](https://github.com/hteo1337))
- Fixed archived agents reappearing in MCP `list_agents` after their history was read ([#7532](https://github.com/getpaseo/paseo/pull/7532) by [@thomasvan](https://github.com/thomasvan))
- Fixed detached agents created with `notifyOnFinish` never notifying their creator ([#7528](https://github.com/getpaseo/paseo/pull/7528) by [@thomasvan](https://github.com/thomasvan))
- Fixed OpenCode agents being told `notifyOnFinish` defaults to off ([#7383](https://github.com/getpaseo/paseo/pull/7383) by [@gszep](https://github.com/gszep))
- Fixed Pi subagent rows staying running when a resume is refused ([#7369](https://github.com/getpaseo/paseo/pull/7369))
- Fixed Android source builds downloading a second NDK ([#7403](https://github.com/getpaseo/paseo/pull/7403) by [@dwyanewang](https://github.com/dwyanewang))
- Fixed Nix packages reporting the AGPL license instead of Apache-2.0 ([#7415](https://github.com/getpaseo/paseo/pull/7415))
## 0.12.0 - 2026-10-19
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
-3303
View File
File diff suppressed because it is too large Load Diff
-4
View File
@@ -36,13 +36,11 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
| [docs/menus.md](docs/menus.md) | The menu engine — popover vs sheet, submenu pages, hover intent, when a decision earns a submenu |
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
| [docs/notifications.md](docs/notifications.md) | Notification tap ownership, native replay and acknowledgement, startup handoff, and payload QA |
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
| [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 |
@@ -109,8 +107,6 @@ 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/zh-CN/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/sponsor">成为 Paseo 赞助商</a></td>
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">成为 Paseo 赞助商</a></td>
</tr>
</table>
@@ -80,7 +80,7 @@ Paseo 会运行一个名为 daemon 的本地服务,用来管理你的 coding a
### 桌面 app(推荐)
从 [paseo.sh/download](https://paseo.sh/zh-CN/download) 或 [GitHub releases 页面](https://github.com/getpaseo/paseo/releases)下载。打开 app 后 daemon 会自动启动,不需要再安装其他东西。
从 [paseo.sh/download](https://paseo.sh/download) 或 [GitHub releases 页面](https://github.com/getpaseo/paseo/releases)下载。打开 app 后 daemon 会自动启动,不需要再安装其他东西。
如果要从手机连接,在 Settings 中扫描显示的二维码。
@@ -97,8 +97,8 @@ paseo
完整安装和配置见:
- [文档](https://paseo.sh/zh-CN/docs)
- [配置参考](https://paseo.sh/zh-CN/docs/configuration)
- [文档](https://paseo.sh/docs)
- [配置参考](https://paseo.sh/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/zh-CN/docs/cli)。
更多内容见[完整 CLI 参考](https://paseo.sh/docs/cli)。
## Skills
@@ -164,7 +164,7 @@ npm run typecheck
## 支持 Paseo
Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/zh-CN/sponsor#spot),logo 会展示在本 README 顶部,以及 paseo.sh 的首页和文档中。
Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/sponsor#spot),logo 会展示在本 README 顶部,以及 paseo.sh 的首页和文档中。
## 相关项目
+1 -8
View File
@@ -71,16 +71,9 @@ 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` for explicit reporting or no completion callback.
Agent-originated messages and automatic notifications share a recipient-daemon delivery module. A queued receipt acknowledges admission before provider dispatch, allowing a child to report and finish in the same two-second quiet window. Human prompts dispatch directly. Conflicting explicit interruption or session-mode choices retain separate ordered dispatches.
The pending window is in memory. Clean shutdown flushes it before closing agents; an abrupt crash can lose it. A durable send receipt remains pending until actual dispatch acknowledgment, so a lost window never becomes a completed receipt. Completion waits cover queued admission within their original timeout and cancellation scope.
`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.
Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission.
The permission notification includes the normalized request plus the child and request IDs, so the caller can inspect it and respond without fetching agent status.
A watched child that closes before its finish event also notifies the caller so delegated work cannot disappear silently during archive or workspace teardown.
+23 -65
View File
@@ -61,81 +61,39 @@ 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 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
npm run android:development # Debug build
npm run android:production # Release build
npm run android:clear # Remove generated Android project
```
`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:
For a production-ID release APK that local Android profiling tools can attach to:
```bash
PASEO_PROFILE_BUILD=1 npm run android:production
```
Those markers emit only while a system trace records `sh.paseo` (`perfetto -a sh.paseo ...`);
they do not produce Downloads files.
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
```
## Running on an emulator against a worktree daemon
+10 -16
View File
@@ -29,25 +29,15 @@ principal. An ordinary direct B consumer does not become that producer by supply
The optional `server_info.sourceId` reports this producer identity separately from the authenticated admission principal.
Trusted server plugins retain the authority of their runtime; they are not sandboxed by this feature.
## Delegating agents
A managed child has a qualified `{serverId, agentId}` address on every host. The parent's daemon owns membership and lifecycle intent; a destination's parent pointer is metadata. Workspace comparisons include the server ID, so matching workspace strings on different hosts do not share lifecycle ownership. Preserve the archive policy in [agent lifecycle](agent-lifecycle.md). The first child snapshot carries the owner's ledger projection even while creation is pending, because tab placement runs before creation finishes. Explicit clearing survives list and disk-cache round trips; a parent pointer or label cannot recreate membership on a capable daemon.
Agent MCP discovers destination IDs with `list_hosts`. `create_workspace`, `list_workspaces`,
`create_agent`, `send_agent_prompt`, `get_agent_status`, `get_agent_activity`, and `list_agents`
accept `serverId`; omission selects the calling daemon. Remote creation requires a
destination workspace and explicit provider/model. Caller filesystem defaults apply only on the
calling daemon. Prompting a remote child acknowledges acceptance and uses the existing idle wait for
finish notifications. Remote agent lists have no caller-directory default; pass `cwd` to filter them.
Retained app conversations bind their stores, queries, initialization and layout to origin authority as well as destination. Two routes to B therefore keep separate cached evidence and command capabilities. Provider catalog pushes belong to that same destination binding, including facts shown in the parent's track. Route restore remembers an origin ID, then reacquires current authority; persisted namespaces grant no access.
The parent’s Subagents track shows qualified children across connected hosts. Opening a child uses
the ordinary conversation on the destination's session. The runtime selects among that host's
connection candidates, including direct connections and routes discovered through another daemon.
Adding a direct connection to a discovered host adds another candidate. If no candidate connects,
the destination workspace keeps its existing disconnected or reconnecting behavior.
Durable relationships retain a stable authority reference and creation ceiling, never live credentials or execution capabilities. Recovery must resolve current authority through its owner. Derived runtime sessions cannot authorize their own recovery. Execution-scoped admissions and non-owner durable references without a supported authority resolver fail explicitly; do not recover them through the daemon owner's MCP runtime.
Finish notifications use the existing idle wait, including a pending initial run or an already idle
child. They name the server and agent. A daemon restart or route loss ends an outstanding notification
wait; inspect or wait for the child again rather than resending its prompt. Concurrent prompts gain
no additional turn attribution or delivery guarantee.
Before accepting new writes, the daemon adopts eligible pre-feature local parent labels into authoritative membership. The upgrade records its bounded candidates before qualifying agent metadata, then resumes those candidates after interruption. It never rescans later label edits. Existing membership, including forgotten entries, takes precedence; explicit null or qualified parent pointers do not establish legacy provenance. Adopted entries preserve the old daemon-owned local lifecycle, not an authenticated creator grant, and contain no creation recipe to replay. Recovery still resolves current owner authority within the retained self-only ceiling.
Provider-owned subagents are descriptors and timelines in `ProviderSubagentStore`, not `AgentStorage` records. They retain their provider observation/cancellation path. Internal managed agents and execution-owned records are separate stored/runtime concepts and are excluded from adoption on both sides of the relationship.
## Connections and recovery
@@ -72,6 +62,10 @@ Ordinary non-recoverable mutations are not automatically replayed. Existing ackn
creation recovery retains its request identity and resumes through the ordinary creation lifecycle. Multiplexing changes only the transport: ordinary permission
RPC codes pass through unchanged, and connection/recovery behavior matches direct clients.
Creation cancellation fences a reserved creation identity before admission. An admitted receipt can precede resource readiness and cannot be reported as canceled. Observe its current receipt and later changes through the ordinary SDK instead of replaying an uncertain prompt. Parent archive retains a pending obligation until cancellation or destination lifecycle confirmation; explicit forget removes local membership without claiming the destination changed.
Run journals retain submission and provider-turn identity. Restarted nonterminal evidence is interrupted, and subsequent evidence must match that turn. Idle and reconnect do not prove completion. A storage failure retains observed evidence with pending durability; a later read retries persistence. Notification recovery must preserve checkpoint identity and this durability distinction.
Text session envelopes carry a channel identifier. Binary routing adds an opcode, a bounded UTF-8
channel identifier, and the original terminal or file-transfer bytes. It never converts those bytes to
base64 JSON. The existing direct binary opcodes and 100 MiB message limit remain unchanged; routed
+3 -1
View File
@@ -317,7 +317,7 @@ Paseo tools; otherwise the provider policy applies. Provider and global policy a
session is created, resumed, imported, or reloaded, so configuration changes affect the next
session rather than an already-running one.
`agents.metadataGeneration.providers` selects the models for daemon-side metadata tasks: commit messages, PR text, branch names, and generated titles. An empty list is Automatic; a non-empty list is Manual and only those models are tried. See [public-docs/metadata-generation.md](../public-docs/metadata-generation.md#how-a-model-is-chosen).
`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available.
### Git process limits
@@ -353,6 +353,8 @@ Environment variables override `config.json`:
after changing `config.json`. Environment changes require a daemon restart; the launch environment
remains authoritative during reload.
`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available.
Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model.
Set these to select OpenAI instead of local speech:
+1 -6
View File
@@ -83,12 +83,7 @@ literal `"explorer"` pane id and `explorerPaneIdByWorkspace` key for compatibili
Explorer uses the shared workspace tab row and ordinary tab context menus. Files and Changes
hide their close buttons through the panel manifest; close them from the tab context menu.
Other tabs reveal the close control on hover. Compact layouts show no inline close on any tab,
since a narrow X beside the label is easy to hit by accident; the long-press menu closes it.
Compact tabs also keep their natural width and the rail scrolls as soon as they overflow, while
pointer layouts shrink tabs toward the clickable minimum first. A rail that scrolls never truncates.
The + menu opens compatible panels in the dock, including Agent and terminal profiles. Compact
layouts omit Diff from it because compact Changes shows each file's diff inline.
Other tabs reveal the close control on hover. The + menu opens compatible panels in the dock, including Agent and terminal profiles.
Web also supports dragging agents and terminals into Explorer.
Bulk-close actions apply only to the dock's tabs. Explorer tabs can be reordered and dragged
between compatible panes, but the dock cannot be split or maximized. Selecting an Explorer tab
+9 -16
View File
@@ -104,24 +104,17 @@ Do not collapse this topology with a catch-all, `getId`, or
## Agent Targets
Notifications and agent URLs name a host and agent. Both use
`navigateToAgent()`, which reads the agent's current workspace from the store
or routes through `/h/[serverId]/agent/[agentId]`. The agent route waits for
the named host, demands its directory, and waits for workspace and layout
hydration before opening the tab. Keep the stable agent URL free of a workspace
hint. [Notifications](notifications.md) owns tap delivery and native lifecycle
constraints.
Notifications and agent URLs enter the router with different authoritative
targets.
When a workspace target cannot open yet, `navigateToWorkspace()` records a
pending open in the layout model under that host and workspace key. The owning
workspace screen consumes it once after hydration. Archived-workspace recovery
reads the same pending target, so startup redirects cannot discard it and a
retained screen cannot consume another workspace's request.
- Notifications carry `serverId`, `workspaceId`, and `agentId`. Route them
directly to the workspace with the agent open intent.
- Agent URLs carry only `serverId` and `agentId`. Route them through
`/h/[serverId]/agent/[agentId]`; that route waits for the named host, resolves
the agent's workspace from the host, and then opens the agent there.
Legacy `?open=` links seed this model state at the matched route boundary and
then lose the query parameter. Internal navigation does not use the query as a
handoff. Read matched route params locally; global search params let retained
workspace screens observe requests for other workspaces.
Both paths converge on `navigateToAgent()`. Do not make notification routing
guess a workspace, and do not add a workspace to the stable agent URL format.
## Params
+4 -6
View File
@@ -282,12 +282,10 @@ left = anchorRect.x - hostRect.x;
bottom = hostRect.height - (anchorRect.y - hostRect.y) + offset;
```
Do this before adding any platform offset. The app draws edge-to-edge on Android, so
`measureInWindow`, touch `pageY`, and a full-screen `Modal` all share one coordinate
space that starts at the screen top. Do not add `StatusBar.currentHeight` to an anchor:
React Native 0.86 stopped subtracting the status bar from window coordinates under
edge-to-edge, and the old compensation now lands every menu, tooltip, and context menu
one status bar below its trigger.
Do this before adding any platform offset. If anchor and host are both measured
with `measureInWindow`, Android's status-bar coordinate behavior cancels out.
Only add a status-bar offset when the render surface is not measured in the same
coordinate system. See `tooltip.tsx` for that separate case.
## Gotcha 5 — The two-measurement flash
+1 -1
View File
@@ -1,6 +1,6 @@
# I18n
Paseo client UI translations live in `packages/app/src/i18n`. Website translations are in [website-i18n.md](website-i18n.md).
Paseo client UI translations live in `packages/app/src/i18n`.
## Supported Locales
-71
View File
@@ -1,71 +0,0 @@
# Notifications
Use `notification-open` for OS notification taps. It owns delivery, parsing,
process-local deduplication, startup readiness, and native acknowledgement.
Keep agent and workspace navigation in their existing navigation modules.
Import the pure API from `@/notification-open` and the app-shell component from
`@/notification-open/react`; senders must not load the React routing runtime.
Agent payloads name a host and agent. Ignore their workspace hint when opening:
the agent can move after a notification is sent. Use `navigateToAgent`, which
uses the current store record or the agent route to resolve the workspace from
the host. Terminal payloads name their workspace and use `navigateToWorkspace`.
See [agent targets and deferred opens](expo-router.md#agent-targets) for route
ownership and workspace recovery. Notification generation, presence, and push
eligibility remain daemon concerns.
## Native delivery
Keep the router instance at module scope. Android's `singleTask` Activity can
finish on Back while its JS context remains alive. A React ref is lost when the
root remounts; process-local handled IDs must survive that remount.
Clear `lastNotificationResponse` after dispatch. Reading a launch response does
not acknowledge it. Without clearing it, a later Activity launch can reopen the
old agent.
With expo-notifications 57, Android also replays its pending response list to
new native listeners. Clearing the last response does not drain that list.
Deduplication covers listener registration within one JS context. A development
JS reload creates another context and can replay a cold-launch response; do not
add persisted handled IDs to compensate. Recheck this limitation when upgrading
expo-notifications.
iOS drains pending responses when its delegate first handles them. Push data
read by Expo arrives under `userInfo.body`; simulator payloads must match:
```json
{
"aps": { "alert": { "title": "Agent finished", "body": "Open the agent" }, "sound": "default" },
"body": {
"serverId": "host",
"workspaceId": "workspace",
"agentId": "agent",
"reason": "finished"
}
}
```
An Android FCM launch intent needs `google.message_id` and a JSON `body` extra.
An arbitrary Activity launch with data extras does not exercise Expo's FCM path.
Foreground pushes remain in Notification Center without a banner or sound.
This preserves notifications sent with stale presence information.
## Startup and verification
Wait for the root navigator, host registry, remembered selection, and startup
redirects before handing off a tap. A compact agent route must demand the host's
directory itself; its sidebar can be closed for the entire cold launch.
Verify warm and cold taps, stale and missing workspace hints, two distinct taps,
and Android Back followed by launcher reopen or a different notification tap. Check the focused tab and every
workspace's saved layout, since a foreign pinned tab survives navigation.
Use the daemon's payload shape on both iOS and Android; a browser event alone
does not test native replay or acknowledgement.
Existing foreign pins belong to the reconciliation fix in
[#6900](https://github.com/getpaseo/paseo/issues/6900). Desktop main-process window
selection is tracked separately in
[#6875](https://github.com/getpaseo/paseo/issues/6875).
+12 -41
View File
@@ -86,12 +86,6 @@ 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`,
@@ -246,7 +240,7 @@ Do not put any other code modules in the plugin root.
Shared files import contract helpers and types from `@getpaseo/plugin`. Server handler files import
`PluginHandlerContext` from `@getpaseo/plugin/server`. Client files import Paseo UI from
`@getpaseo/plugin/client/ui`. Its `Icon` draws a Lucide name from the client's installed
`@getpaseo/plugin/client/react-native`. Its `Icon` draws a Lucide name from the client's installed
icon set, or any `PluginIcon` through its `icon` prop; an unknown name or unsafe SVG renders nothing
so it cannot break the plugin surface.
Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in
@@ -263,14 +257,14 @@ Classify every SDK export before adding it. All client entry points and implemen
Zod schemas, and functions that run in both runtimes. A type-only import is still an architectural
dependency; shared types must not refer to React components, hooks, Node APIs, or server contexts.
| Entry | Owns | May depend on |
| ------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------- |
| `@getpaseo/plugin` | Shared data, schemas, RPC/settings definitions, runtime-neutral helpers | Shared code only |
| `@getpaseo/plugin/server` | Server contexts, lifecycle contracts, and CLI process launch | Shared and server code |
| `@getpaseo/plugin/server/provider`, `/server/acp` | Server provider contracts and adapters | Shared and server code |
| `@getpaseo/plugin/client` | Client contribution contexts, hooks, navigation, and UI contribution types | Shared and client code |
| `@getpaseo/plugin/client/ui` | Host-provided UI components | Shared and client code |
| `@getpaseo/plugin/client/host` | App-owned rendering integration; not a plugin-author entry | Shared and client code |
| Entry | Owns | May depend on |
| ---------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------- |
| `@getpaseo/plugin` | Shared data, schemas, RPC/settings definitions, runtime-neutral helpers | Shared code only |
| `@getpaseo/plugin/server` | Server contexts, lifecycle contracts, and CLI process launch | Shared and server code |
| `@getpaseo/plugin/server/provider`, `/server/acp` | Server provider contracts and adapters | Shared and server code |
| `@getpaseo/plugin/client` | Client contribution contexts, hooks, navigation, and UI contribution types | Shared and client code |
| `@getpaseo/plugin/client/react-native`, `/client/ui` | Host-provided UI components | Shared and client code |
| `@getpaseo/plugin/client/host` | App-owned rendering integration; not a plugin-author entry | Shared and client code |
Server code imports shared helpers from the root and server capabilities from `/server`. Client
code imports shared helpers from the root and client capabilities from `/client`. Neither runtime
@@ -562,39 +556,16 @@ one section's order from built-ins, plugin groups, and the section's preference
remembers under its own key. It shares header ids and the `plugin:<pluginId>:<id>` settings key
with new-API items.
## Contribute file panels
Client `addFilePanel` registrations become the body of a file tab. The tab stats the file, picks a
panel, and mounts it with a `PluginFile` handle; the panel reads, shows its own `Loading`, writes,
and resolves conflicts. The host has no bar and no notion of a panel's views: the Markdown and HTML
panels own their Preview and Source views, toolbar and editor buffer, and the tab only shows the
dot a panel sets through `tab.setModified`. Source is the floor for text files no other built-in
claims, never a candidate beside one. Built-in panels (Markdown, HTML, Image, and Source, Paseo's
editor) take the same handle as plugin panels, so a fix to reading, watching, or writing lands in one place:
`packages/app/src/file-panels/file/`. Do not add a host-side read or loading gate for one panel;
make that panel faster instead. Source highlights a large file on native in idle-time slices,
the lines around the opening line first.
The handle checks every write against the version it last read or wrote, so two panels or a panel
and an agent cannot silently overwrite each other. The daemon reports the file's kind (text,
image, binary) with its stat from a bounded sniff, which is how Source claims text files without a
read. See the [public file panel reference](../public-docs/plugins/reference.md#file-panels) for
the author API, selection, `DocumentFrame`, and the version requirement. The examples live in
`plugin-examples/pdf-panel` and `plugin-examples/markdown-panel`.
## Contribute buttons
Header buttons and composer pills share the client-only descriptor and registration lifecycle in
`packages/app/src/plugins/buttons/`. The [public button reference](../public-docs/plugins/reference.md#header-buttons)
owns the author API and placement rules. Keep presentation policy in this module so another
placement can reuse behavior without copying registration or action state. The header overflow
and the compact workspace menu share one builder that turns buttons into menu rows and pages; the
workspace menu reaches it through `usePluginWorkspaceMenu`.
placement can reuse behavior without copying registration or action state.
Native sheets teleport their children. Button surfaces rebuild the installation's SDK, state,
query, and toast providers inside the surface content. A host menu can hold buttons from several
installations, so each of its rows and pages resolves its own installation's runtime. Providers
only around the trigger do not reach those bodies.
query, and toast providers inside the surface content, including overflow pages from different
plugins. Providers only around the trigger do not reach those bodies.
Request observation with `client.paseo.agents.list({ subscribe: {} })` and consume the returned
`subscription` handle. Plain `list()` and agent/workspace directory `.subscribe(handler)` listeners create no daemon
+5 -14
View File
@@ -62,10 +62,9 @@ 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:** 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
**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
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.
@@ -534,14 +533,6 @@ 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`.
- The release agent writes the Chinese entry in the changelog commit, after the English entry is approved. Every beta that rewrites the English entry rewrites the Chinese one too, and promotion renames both. `packages/website/e2e/i18n.spec.ts` fails CI on the release commit when any entry has no current translation.
- 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. 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.
@@ -654,7 +645,7 @@ Every entry covers `previous stable release → release source`: first beta, lat
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
- [ ] Fold this beta into the series' single `CHANGELOG.md` entry (heading `## X.Y.Z-beta.N - YYYY-MM-DD`, previous stable → release source), review it against the changelog policy, get approval, and commit it with its [`CHANGELOG.zh-CN.md` translation](#changelog-translations) before cutting the release
- [ ] Fold this beta into the series' single `CHANGELOG.md` entry (heading `## X.Y.Z-beta.N - YYYY-MM-DD`, previous stable → release source), review it against the changelog policy, get approval, and commit it before cutting the release
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
- [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully
@@ -677,7 +668,7 @@ Every entry covers `previous stable release → release source`: first beta, lat
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
- [ ] Ensure the approved release inputs are committed locally and the git worktree is clean before running any release command
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion renames the series' `## X.Y.Z-beta.N` entry to `## X.Y.Z - YYYY-MM-DD` and re-checks it covers the full release. Commit the matching [`CHANGELOG.zh-CN.md` entry](#changelog-translations) with it
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion renames the series' `## X.Y.Z-beta.N` entry to `## X.Y.Z - YYYY-MM-DD` and re-checks it covers the full release
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
-51
View File
@@ -1,51 +0,0 @@
# 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-DkB70/NH4SriKZ6hMDhI7XfwkqjGfb9e1zC3dxjBaTg=
sha256-7B/Zyo/DaHdd3PVq8Hn1HG+x+wLdZBVxgx/cbtaM/S8=
+28 -31
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.13.0-beta.1",
"version": "0.12.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.13.0-beta.1",
"version": "0.12.0",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -35656,7 +35656,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.13.0-beta.1",
"version": "0.12.0",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37922,12 +37922,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.13.0-beta.1",
"version": "0.12.0",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.13.0-beta.1",
"@getpaseo/protocol": "0.13.0-beta.1",
"@getpaseo/server": "0.13.0-beta.1",
"@getpaseo/client": "0.12.0",
"@getpaseo/protocol": "0.12.0",
"@getpaseo/server": "0.12.0",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -38174,10 +38174,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.13.0-beta.1",
"version": "0.12.0",
"dependencies": {
"@getpaseo/protocol": "0.13.0-beta.1",
"@getpaseo/relay": "0.13.0-beta.1",
"@getpaseo/protocol": "0.12.0",
"@getpaseo/relay": "0.12.0",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
@@ -38189,7 +38189,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.13.0-beta.1",
"version": "0.12.0",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -38433,7 +38433,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.13.0-beta.1",
"version": "0.12.0",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -42085,7 +42085,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.13.0-beta.1",
"version": "0.12.0",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -42114,15 +42114,14 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.13.0-beta.1",
"version": "0.12.0",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"i18next": "^26.3.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.13.0-beta.1",
"@getpaseo/protocol": "0.13.0-beta.1",
"@getpaseo/client": "0.12.0",
"@getpaseo/protocol": "0.12.0",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -42132,8 +42131,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.13.0-beta.1",
"@getpaseo/protocol": "0.13.0-beta.1",
"@getpaseo/client": "0.12.0",
"@getpaseo/protocol": "0.12.0",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -42155,7 +42154,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.13.0-beta.1",
"version": "0.12.0",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -42182,7 +42181,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.13.0-beta.1",
"version": "0.12.0",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -42400,16 +42399,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.13.0-beta.1",
"version": "0.12.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.13.0-beta.1",
"@getpaseo/highlight": "0.13.0-beta.1",
"@getpaseo/plugin": "0.13.0-beta.1",
"@getpaseo/protocol": "0.13.0-beta.1",
"@getpaseo/relay": "0.13.0-beta.1",
"@getpaseo/client": "0.12.0",
"@getpaseo/highlight": "0.12.0",
"@getpaseo/plugin": "0.12.0",
"@getpaseo/protocol": "0.12.0",
"@getpaseo/relay": "0.12.0",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -43330,7 +43329,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.13.0-beta.1",
"version": "0.12.0",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -43340,11 +43339,9 @@
"@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",
@@ -44940,7 +44937,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.13.0-beta.1",
"version": "0.12.0",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+4 -7
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.13.0-beta.1",
"version": "0.12.0",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -41,16 +41,12 @@
"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",
@@ -84,14 +80,15 @@
"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 dev:android",
"android": "npm run android --workspace=@getpaseo/app",
"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 dev:ios",
"ios": "npm run ios --workspace=@getpaseo/app",
"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",
+1 -5
View File
@@ -10,7 +10,6 @@ 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
@@ -182,7 +181,7 @@ export default {
},
],
...buildProfile.fdroidPlugins,
...(isProfileBuild || isWatchdogBuild ? [withAndroidProfileable] : []),
...(isProfileBuild ? [withAndroidProfileable] : []),
],
experiments: {
typedRoutes: true,
@@ -192,9 +191,6 @@ 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",
+6 -144
View File
@@ -262,74 +262,24 @@ test.afterEach(async () => {
}
});
test("Changes opens All changes for a clean checkout", async ({ page }) => {
test("Changes opens the populated committed comparison for a clean checkout", async ({ page }) => {
const workspace = await createWorkspaceWithCommittedDiff();
await openWorkspaceChangesSurface(page, workspace, 90_000);
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText("All changes");
await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText("Committed");
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
});
test("All changes combines committed and pending edits and preserves an unfinished review", async ({
page,
context,
}) => {
const workspace = await createWorkspaceWithNetDiff();
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await configureDiffPresentation(page, { layout: "unified", wrapLines: false });
await openWorkspaceChangesSurface(page, workspace);
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const mode = tree.getByRole("button", { name: "Diff mode", exact: true });
await expect(mode).toContainText("All changes");
await expectNetComparisonFiles(page, ["a-tracked.ts", "b-committed.ts"]);
await expectRenderedAddedLine(page, "committed");
await test.step("an unfinished review survives the first dirty edit", async () => {
await startReviewOnFirstChangedLine(page);
await page
.getByRole("textbox", { name: "Review comment" })
.fill("Unfinished review stays here");
await writeFile(path.join(workspace.repoPath, "c-untracked.txt"), "untracked\n");
await expectNetComparisonFiles(page, ["a-tracked.ts", "b-committed.ts", "c-untracked.txt"]);
await expect(mode).toContainText("All changes");
await expect(page.getByRole("textbox", { name: "Review comment" })).toHaveValue(
"Unfinished review stays here",
);
await cancelInlineReview(page);
});
await test.step("pending edits form a live net patch over the committed edit", async () => {
await writeFile(path.join(workspace.repoPath, "a-tracked.ts"), "working\n");
await expectRenderedAddedLine(page, "working");
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("a-tracked.ts, +1, -1");
});
await test.step("committing the same contents preserves the rendered comparison", async () => {
execFileSync("git", ["add", "--all"], { cwd: workspace.repoPath });
execFileSync("git", ["commit", "-m", "Commit pending changes"], { cwd: workspace.repoPath });
await expect(page.getByTestId("changes-primary-cta")).toContainText("Merge", {
timeout: 30_000,
});
await expect(mode).toContainText("All changes");
await expectNetComparisonFiles(page, ["a-tracked.ts", "b-committed.ts", "c-untracked.txt"]);
await expectRenderedAddedLine(page, "working");
});
await test.step("undoing to the comparison base removes the net change", async () => {
await writeFile(path.join(workspace.repoPath, "a-tracked.ts"), "base\n");
await expectNetComparisonFiles(page, ["b-committed.ts", "c-untracked.txt"]);
await expect(mode).toContainText("All changes");
});
});
test("Changes keeps a manual comparison when checkout dirtiness changes", async ({ page }) => {
test("Changes expires a manual comparison when checkout dirtiness changes", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await openWorkspaceChanges(page, workspace);
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
const mode = tree.getByTestId("changes-diff-status-trigger");
await expect(mode).toContainText("All changes");
await expect(mode).toContainText("Uncommitted");
await mode.click();
await page.getByTestId("changes-diff-mode-committed").click();
@@ -344,23 +294,7 @@ test("Changes keeps a manual comparison when checkout dirtiness changes", async
});
await writeFile(path.join(workspace.repoPath, "new-working-change.txt"), "uncommitted\n");
await expect(tree.getByRole("button", { name: "See uncommitted changes" })).toBeVisible({
timeout: 30_000,
});
await expect(mode).toContainText("Committed");
await selectChangesComparison(page, "Uncommitted");
execFileSync("git", ["add", "--all"], { cwd: workspace.repoPath });
execFileSync("git", ["commit", "-m", "Commit new working change"], { cwd: workspace.repoPath });
await expect(tree.getByText("No changes to display", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(mode).toContainText("Uncommitted");
await writeFile(path.join(workspace.repoPath, "another-working-change.txt"), "pending\n");
await expect(tree.getByText("another-working-change.txt", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(mode).toContainText("Uncommitted");
await expect(mode).toContainText("Uncommitted", { timeout: 30_000 });
});
test("an empty Changes comparison links to the populated comparison", async ({ page }) => {
@@ -699,7 +633,6 @@ test("changes context menus expose folder revert and restore a file after confir
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await selectChangesComparison(page, "Uncommitted");
const tree = changesTree(page);
await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" });
@@ -747,7 +680,6 @@ test("discarding a staged rename restores its source path", async ({ page }) =>
const workspace = await createWorkspaceWithMountedTabDiff({ includeRenamedFile: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await selectChangesComparison(page, "Uncommitted");
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const renamedHeader = diffHeaderForPath(panel, "src/zz-renamed.ts");
@@ -778,7 +710,6 @@ test("discarding an untracked file removes it from the working tree", async ({ p
const workspace = await createWorkspaceWithMountedTabDiff({ includeUntrackedFile: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await selectChangesComparison(page, "Uncommitted");
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const untrackedHeader = diffHeaderForPath(panel, "zz-untracked.txt");
@@ -810,7 +741,6 @@ test("shows a revert error returned by the daemon", async ({ page }) => {
await failNextDiscardRequest(page);
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await selectChangesComparison(page, "Uncommitted");
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
const confirmation = new Promise<void>((resolve) => {
@@ -902,9 +832,6 @@ 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")
@@ -962,8 +889,6 @@ test("compact Explorer renders inline hunks with a saved tree-only desktop prefe
{ key: CHANGES_PREFERENCES_KEY },
);
const explorer = await openCompactChanges(page, workspace);
await explorer.getByRole("button", { name: "Diff mode", exact: true }).click();
await page.getByRole("menuitem", { name: "Uncommitted", exact: true }).click();
await expect(explorer.getByTestId("git-diff-canvas")).toBeVisible();
await expect(explorer.getByTestId("diff-file-0-toggle")).toBeVisible();
await explorer.getByTestId("diff-file-0-toggle").click({ button: "right" });
@@ -1597,64 +1522,6 @@ async function createWorkspaceWithMountedTabDiff(
return { id: createdWorkspace.workspace.id, repoPath: repo.path };
}
async function createWorkspaceWithNetDiff(): Promise<DirtyWorkspace> {
const repo = await createTempGitRepo("changes-net-", {
files: [{ path: "a-tracked.ts", content: "base\n" }],
});
const client = await connectSeedClient();
cleanupTasks.push({
run: async () => {
await client.close();
await repo.cleanup();
},
});
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repo.path });
await writeFile(path.join(repo.path, "a-tracked.ts"), "committed\n");
await writeFile(path.join(repo.path, "b-committed.ts"), "committed only\n");
execFileSync("git", ["add", "--all"], { cwd: repo.path });
execFileSync("git", ["commit", "-m", "Committed changes"], { cwd: repo.path });
const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
if (!created.workspace) throw new Error(created.error ?? "Failed to create net diff workspace");
return { id: created.workspace.id, repoPath: repo.path };
}
async function expectNetComparisonFiles(page: Page, files: string[]): Promise<void> {
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
await expect(tree.locator('[data-testid^="diff-tree-file-"][data-testid$="-name"]')).toHaveText(
files,
);
await expect(panel.getByTestId(/^diff-file-\d+$/)).toHaveCount(files.length);
for (const file of files) {
await expect(diffHeaderForPath(panel, file)).toBeVisible();
}
}
async function expectRenderedAddedLine(page: Page, contents: string): Promise<void> {
await changesTree(page).getByText("a-tracked.ts", { exact: true }).click();
await expect(async () => {
await page.keyboard.press("Escape");
await page.evaluate(() => navigator.clipboard.writeText(""));
const body = page.getByTestId("diff-file-0-body");
const canvas = page.getByTestId("git-diff-canvas");
const bounds = await body.boundingBox();
if (!bounds) throw new Error("Expanded diff body has no bounds");
const fontSize = await canvas.evaluate((element) =>
Number.parseFloat(getComputedStyle(element).fontSize),
);
// Replacement: hunk header, removed line, added line.
await page.mouse.click(bounds.x + 120, bounds.y + Math.round(fontSize * 1.5) * 2.5, {
button: "right",
});
const copyLine = page.getByRole("menuitem", { name: "Copy line", exact: true });
await expect(copyLine).toBeEnabled({ timeout: 1000 });
await copyLine.click();
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()), { timeout: 1000 })
.toBe(contents);
}).toPass({ timeout: 15_000 });
}
async function createWorkspaceWithCommittedDiff(): Promise<DirtyWorkspace> {
const repo = await createTempGitRepo("changes-committed-", {
files: [{ path: "tracked.ts", content: "export const tracked = 1;\n" }],
@@ -1977,14 +1844,9 @@ 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 expect(page.locator("[data-e2e-before-font-family]")).toHaveCount(0);
await sizeField.fill(String(typography.fontSize));
await page.getByLabel("Code font size").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));
@@ -5,7 +5,6 @@ import {
applyCommandCenterAgentControls,
chooseCommandCenterAgentControl,
openCommandCenterForAgent,
searchAgentControls,
expectCommandCenterAgentControlSelected,
expectFocusedAgentControls,
expectWorkspaceAgentConfiguration,
@@ -112,8 +111,8 @@ test.describe("Command Center agent controls", () => {
});
try {
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
await openCommandCenterForAgent(page, workspace);
await searchAgentControls(page, "load");
const input = await openCommandCenterForAgent(page, workspace);
await input.fill("load");
await page.keyboard.press("Enter");
await expectWorkspaceAgentConfiguration(workspace, {
@@ -185,7 +184,7 @@ test.describe("Command Center agent controls", () => {
try {
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
const input = await openCommandCenterForAgent(page, workspace);
await searchAgentControls(page, "loa");
await input.fill("loa");
// Moves the highlight onto a model row, which still matches after the next keystroke.
await page.keyboard.press("ArrowDown");
await input.fill("load");
@@ -854,7 +854,8 @@ async function revealCompactChanges(page: Page) {
await expect(compactReviewSurface(page).getByTestId("diff-file-0-body")).toBeVisible();
}
async function expectReviewEditorBody(page: Page, body: string) {
await expect(page.getByRole("textbox", { name: "Review comment", exact: true })).toHaveValue(
body,
);
const editor = page.getByRole("textbox", { name: "Review comment", exact: true });
// A viewport resize starts the presentation handoff; exactly one editor must remain accessible.
await expect(editor).toHaveCount(1);
await expect(editor).toHaveValue(body);
}
+14 -14
View File
@@ -1,5 +1,5 @@
import { metroTest as test, expect } from "../support/fixtures";
import { hideDeveloperToasts } from "../support/developer-ui.mjs";
import { installDeveloperToastDismissal } from "../support/developer-ui.mjs";
import type { Page } from "@playwright/test";
async function showTransientDeveloperToast(page: Page, shadow: boolean) {
@@ -15,38 +15,38 @@ async function showTransientDeveloperToast(page: Page, shadow: boolean) {
}, shadow);
}
async function openHiddenDeveloperToast(page: Page) {
async function openTransientDeveloperToast(page: Page) {
await page.setContent("<button>Continue</button>");
await showTransientDeveloperToast(page, false);
await hideDeveloperToasts(page);
await installDeveloperToastDismissal(page);
}
async function navigateWithHiddenDeveloperToasts(page: Page) {
await hideDeveloperToasts(page);
async function navigateWithDeveloperToastDismissal(page: Page) {
await installDeveloperToastDismissal(page);
await page.goto("data:text/html,<button>Continue</button>");
await showTransientDeveloperToast(page, true);
}
async function inspectLayoutWithHiddenToast(page: Page) {
async function inspectLayoutWhileToastDisappears(page: Page) {
const action = page.getByRole("button", { name: "Continue", exact: true });
const boxes = await Promise.all(Array.from({ length: 4 }, () => action.boundingBox()));
for (const box of boxes) expect(box).not.toBeNull();
await expect(page.locator("#error-toast")).toBeHidden();
await expect(page.locator("#error-toast").getByRole("button")).toHaveCount(0);
await action.click();
}
test("a hidden developer toast does not stall concurrent layout checks", async ({ page }) => {
test("a disappearing developer toast does not stall concurrent layout checks", async ({ page }) => {
test.setTimeout(10000);
await openHiddenDeveloperToast(page);
await inspectLayoutWithHiddenToast(page);
await openTransientDeveloperToast(page);
await inspectLayoutWhileToastDisappears(page);
});
test("developer toast hiding survives navigation and Expo shadow-root updates", async ({
test("developer toast dismissal survives navigation and observes Expo shadow-root updates", async ({
page,
}) => {
test.setTimeout(10000);
await navigateWithHiddenDeveloperToasts(page);
await inspectLayoutWithHiddenToast(page);
await navigateWithDeveloperToastDismissal(page);
await inspectLayoutWhileToastDisappears(page);
await page.evaluate(() => {
const root = document.getElementById("error-toast")!.shadowRoot!;
const replacement = document.createElement("button");
@@ -54,5 +54,5 @@ test("developer toast hiding survives navigation and Expo shadow-root updates",
replacement.addEventListener("click", () => replacement.remove());
root.append(replacement);
});
await inspectLayoutWithHiddenToast(page);
await inspectLayoutWhileToastDisappears(page);
});
@@ -56,12 +56,45 @@ async function replaceEditorText(page: Page, content: string): Promise<void> {
await editor.type(content);
}
async function replaceFileAfterDeletionWasObserved(input: {
page: Page;
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>;
filePath: string;
relativePath: string;
content: string;
}): Promise<void> {
const { page, gate, filePath, relativePath, content } = input;
// Recreate the observed race without faking file state: the daemon publishes the
// deletion, then a real read succeeds after the replacement while its ready event waits.
await gate.waitForFileSubscription(relativePath);
gate.holdFileReads(relativePath);
await unlink(filePath);
await gate.waitForHeldFileRead();
await expect(fileCallout(page)).toHaveCount(0);
gate.releaseHeldFileRead();
await expectOnlyFileCallout(page, "File deleted on disk");
gate.holdNextReadyFileUpdate(relativePath);
await writeFile(filePath, content, "utf8");
await gate.waitForHeldReadyFileUpdate();
await expect.poll(() => readFile(filePath, "utf8")).toBe(content);
}
async function expectOnlyFileCallout(page: Page, title: string): Promise<void> {
await expect(fileCallout(page)).toHaveCount(1);
await expect(fileCallout(page)).toContainText(title);
await expect(filePane(page).getByText(title, { exact: true })).toHaveCount(1);
}
async function expectCleanReplacementCanReload(page: Page): Promise<void> {
await expectOnlyFileCallout(page, "Changed on disk");
await expect(filePane(page).getByText("File deleted on disk", { exact: true })).toHaveCount(0);
await expect(fileCallout(page).getByRole("button", { name: "Overwrite" })).toHaveCount(0);
await expect(fileCallout(page).getByRole("button", { name: "Reload" })).toBeEnabled();
await fileCallout(page).getByRole("button", { name: "Reload" }).click();
await expect(filePane(page).getByText("After", { exact: true })).toBeVisible();
await expect(fileCallout(page)).toHaveCount(0);
}
async function expectDeletedFileNotice(page: Page): Promise<void> {
await expectOnlyFileCallout(page, "File deleted on disk");
await expect(fileCallout(page)).toContainText("The open copy is preserved.");
@@ -107,7 +140,7 @@ test.describe("Workspace file change conflicts", () => {
relativePath,
content: "const preserved = true;\n",
});
await expect(visibleEditor(page)).toContainText("const preserved = true;");
await gate.waitForFileSubscription(relativePath);
await recordFileCallouts(page);
await restoreFileAfterWatcherObservedTemporaryAbsence({ gate, filePath, relativePath });
@@ -156,80 +189,162 @@ test.describe("Workspace file change conflicts", () => {
relativePath,
content: "const before = true;\n",
});
await expect(visibleEditor(page)).toContainText("const before = true;");
await gate.waitForFileSubscription(relativePath);
await recordFileCallouts(page);
gate.holdNextReadyFileUpdate(relativePath);
gate.holdFileReads(relativePath);
await replaceEditorText(page, "const saved = true;\n");
await expect.poll(() => readFile(filePath, "utf8")).toContain("const saved = true;");
await gate.waitForHeldReadyFileUpdate();
gate.releaseHeldReadyFileUpdate();
await gate.waitForHeldFileRead();
await expect(visibleEditor(page)).toContainText("const saved = true;");
await expect(fileCallout(page)).toHaveCount(0);
await expectNoFileCalloutWasRendered(page);
gate.releaseHeldFileRead();
});
test("a viewed file deleted and written back shows the new file", async ({
test("a clean file replaced on disk offers one working reload action", async ({
page,
withWorkspace,
}) => {
const gate = await installDaemonWebSocketGate(page);
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-clean-replacement-",
relativePath: "inventory.md",
content: "# Before\n",
});
await expect(filePane(page).getByText("Before", { exact: true })).toBeVisible();
await unlink(filePath);
await expectDeletedFileNotice(page);
await expect(filePane(page).getByText("Before", { exact: true })).toBeVisible();
await writeFile(filePath, "# After\n", "utf8");
await expect(filePane(page).getByText("After", { exact: true })).toBeVisible();
await expect(fileCallout(page)).toHaveCount(0);
await replaceFileAfterDeletionWasObserved({
page,
gate,
filePath,
relativePath: "inventory.md",
content: "# After\n",
});
gate.releaseHeldReadyFileUpdate();
await expectCleanReplacementCanReload(page);
});
test("a deleted file keeps the open copy with one notice and no resolution actions", async ({
test("a deleted file shows one explanatory notice and no resolution actions", async ({
page,
withWorkspace,
}) => {
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-deleted-",
relativePath: "deleted.ts",
content: "const present = true;\n",
relativePath: "deleted.md",
content: "# Present\n",
});
await expect(visibleEditor(page)).toContainText("const present = true;");
await expect(filePane(page).getByText("Present", { exact: true })).toBeVisible();
await recordFileCallouts(page);
await unlink(filePath);
await expectDeletedFileNotice(page);
await expectFileCalloutWasRendered(page, "File deleted on disk");
await expect(visibleEditor(page)).toContainText("const present = true;");
});
test("a path that stops being a file keeps the open copy and follows the file back", async ({
test("a file check error offers a working retry", async ({ page, withWorkspace }) => {
const gate = await installDaemonWebSocketGate(page);
const relativePath = "retry.md";
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-check-retry-",
relativePath,
content: "# Before\n",
});
await gate.waitForFileSubscription(relativePath);
await expect(filePane(page).getByText("Before", { exact: true })).toBeVisible();
await unlink(filePath);
await mkdir(filePath);
await expectOnlyFileCallout(page, "Couldn't check file on disk");
await expect(fileCallout(page).getByRole("button", { name: "Retry" })).toBeEnabled();
await expect(fileCallout(page).getByRole("button", { name: "Reload" })).toHaveCount(0);
await expect(fileCallout(page).getByRole("button", { name: "Overwrite" })).toHaveCount(0);
gate.holdNextReadyFileUpdate(relativePath);
await rm(filePath, { recursive: true });
await writeFile(filePath, "# After\n", "utf8");
await gate.waitForHeldReadyFileUpdate();
gate.holdFileReads(relativePath);
await fileCallout(page).getByRole("button", { name: "Retry" }).click();
await gate.waitForHeldFileRead();
await expect(fileCallout(page).getByRole("button", { name: "Retry" })).toBeDisabled();
gate.releaseHeldFileRead();
await expectCleanReplacementCanReload(page);
gate.releaseHeldReadyFileUpdate();
});
test("retry clears a transient error when the file is unchanged", async ({
page,
withWorkspace,
}) => {
const gate = await installDaemonWebSocketGate(page);
const relativePath = "retry.ts";
const relativePath = "unchanged.md";
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-check-error-",
prefix: "file-check-unchanged-",
relativePath,
content: "const before = true;\n",
content: "# Unchanged\n",
});
await expect(visibleEditor(page)).toContainText("const before = true;");
const parkedPath = `${filePath}.parked`;
await gate.waitForFileSubscription(relativePath);
await expect(filePane(page).getByText("Unchanged", { exact: true })).toBeVisible();
await rename(filePath, parkedPath);
await mkdir(filePath);
await expectOnlyFileCallout(page, "Couldn't check file on disk");
gate.holdNextReadyFileUpdate(relativePath);
await rm(filePath, { recursive: true });
await rename(parkedPath, filePath);
await gate.waitForHeldReadyFileUpdate();
await fileCallout(page).getByRole("button", { name: "Retry" }).click();
await expect(fileCallout(page)).toHaveCount(0);
await expect(filePane(page).getByText("Changed on disk", { exact: true })).toHaveCount(0);
gate.releaseHeldReadyFileUpdate();
});
test("a read-only file error preserves its message and offers retry", async ({
page,
withWorkspace,
}) => {
const gate = await installDaemonWebSocketGate(page);
const relativePath = "artifact.bin";
const binaryContent = new Uint8Array([0, 1, 2, 3, 255]);
const filePath = await openTrackedFile(page, withWorkspace, {
prefix: "file-read-only-retry-",
relativePath,
content: binaryContent,
});
await gate.waitForFileSubscription(relativePath);
await expect(
filePane(page).getByText("Binary preview unavailable", { exact: true }),
).toBeVisible();
await unlink(filePath);
await mkdir(filePath);
await gate.waitForFileUpdate(relativePath, "error");
await expect(visibleEditor(page)).toContainText("const before = true;");
await expect(
filePane(page).getByText("Requested path is not a file", { exact: true }),
).toBeVisible();
await expect(filePane(page).getByRole("button", { name: "Retry" })).toBeEnabled();
gate.holdNextReadyFileUpdate(relativePath);
await rm(filePath, { recursive: true });
await writeFile(filePath, "const after = true;\n", "utf8");
await writeFile(filePath, binaryContent);
await gate.waitForHeldReadyFileUpdate();
gate.holdFileReads(relativePath);
await filePane(page).getByRole("button", { name: "Retry" }).click();
await gate.waitForHeldFileRead();
await expect(
filePane(page).getByText("Requested path is not a file", { exact: true }),
).toHaveCount(0);
gate.releaseHeldFileRead();
await expect(visibleEditor(page)).toContainText("const after = true;");
await expect(fileCallout(page)).toHaveCount(0);
await expect(
filePane(page).getByText("Binary preview unavailable", { exact: true }),
).toBeVisible();
await expect(
filePane(page).getByText("Requested path is not a file", { exact: true }),
).toHaveCount(0);
gate.releaseHeldReadyFileUpdate();
});
test("a changed file with local edits offers overwrite and a working reload", async ({
+7 -40
View File
@@ -1,6 +1,5 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import {
expandFolder,
@@ -72,23 +71,6 @@ async function selectFileView(page: Page, view: "Preview" | "Source"): Promise<v
await expect(option).toHaveAttribute("aria-selected", "true");
}
async function expectAbove(upper: Locator, lower: Locator): Promise<void> {
const [upperBox, lowerBox] = await Promise.all([upper.boundingBox(), lower.boundingBox()]);
expect(upperBox).not.toBeNull();
expect(lowerBox).not.toBeNull();
expect(upperBox!.y + upperBox!.height).toBeLessThanOrEqual(lowerBox!.y);
}
/** How many times the app has read the file's contents from the daemon. */
function fileReadCount(
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>,
fileName: string,
): number {
return gate
.getClientRequests("file_explorer_request")
.filter((request) => request.mode === "file" && String(request.path).endsWith(fileName)).length;
}
async function expectMarkdownImageLoaded(page: Page, name: string): Promise<void> {
const image = page.getByRole("button", { name, exact: true });
await expect(image).toBeVisible();
@@ -146,7 +128,7 @@ test.describe("CodeMirror workspace file editing", () => {
await returnToAssistant(page, session.agentId);
await openAssistantMarkdownLink(page, "Missing capture");
await expectFileTabOpen(page, ".dev/tablet-review/missing.png");
await expect(page.getByText("File not found", { exact: true })).toBeVisible();
await expect(page.getByText(/ENOENT/)).toBeVisible();
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
});
} finally {
@@ -335,7 +317,11 @@ test.describe("CodeMirror workspace file editing", () => {
title: "Unsupported large source",
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
});
await writeFile(path.join(session.cwd, "too-large.txt"), "x".repeat(51 * 1024 * 1024), "utf8");
await writeFile(
path.join(session.cwd, "too-large.txt"),
Buffer.alloc(51 * 1024 * 1024),
"utf8",
);
try {
await openAgentRoute(page, session);
@@ -598,11 +584,6 @@ test.describe("CodeMirror workspace file editing", () => {
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await expect(page.getByLabel(/File size/)).toBeVisible();
await expect(page.getByLabel(/lines/)).toBeVisible();
await expect(page.getByTestId("file-preview-mode")).toHaveCount(0);
await expectAbove(
page.getByTestId("file-panel-bar").filter({ visible: true }),
page.getByTestId("file-source-editor").filter({ visible: true }),
);
await replaceEditorText(page, "const autosaved = 2;\n");
await expect(page.getByTestId("workspace-tab-modified-file_source.ts")).toBeVisible();
@@ -704,7 +685,6 @@ test.describe("CodeMirror workspace file editing", () => {
withWorkspace,
}) => {
test.setTimeout(90_000);
const gate = await installDaemonWebSocketGate(page);
const workspace = await withWorkspace({ prefix: "file-editing-preview-" });
const markdownPath = path.join(workspace.repoPath, "notes.md");
const imagePath = path.join(workspace.repoPath, "pixel.png");
@@ -714,30 +694,17 @@ test.describe("CodeMirror workspace file editing", () => {
await openWorkspaceFile(page, "notes.md");
const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true });
const toolbar = page.getByTestId("file-panel-bar").filter({ visible: true });
await expect(visibleFilePane.getByText("First heading", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Preview", exact: true })).toBeVisible();
await expect(toolbar.getByLabel(/^File size/)).toBeVisible();
await expect(toolbar.getByLabel("2 lines")).toBeVisible();
await writeFile(markdownPath, "# Updated heading\n", "utf8");
await expect(visibleFilePane.getByText("Updated heading", { exact: true })).toBeVisible();
// Source shows the same read in the editor; the file is not read again.
const reads = fileReadCount(gate, "notes.md");
await selectFileView(page, "Source");
await expect(page.getByTestId("file-source-editor")).toBeVisible();
await expect(editor(page)).toContainText("# Updated heading");
await expect(toolbar.getByLabel("Line 1, column 1")).toBeVisible();
expect(fileReadCount(gate, "notes.md")).toBe(reads);
// Preview shows the edits made in Source.
await replaceEditorText(page, "# Saved from source\n");
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeVisible();
await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n");
await selectFileView(page, "Preview");
await expect(visibleFilePane.getByText("Saved from source", { exact: true })).toBeVisible();
await expect(toolbar.getByLabel("Line 1, column 1")).toHaveCount(0);
await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n");
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).not.toBeVisible();
await openWorkspaceFile(page, "pixel.png");
const image = visibleFilePane.locator("img");
@@ -1,47 +0,0 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import {
expandFolder,
openFileExplorer,
openFileFromExplorer,
} from "../support/helpers/file-explorer";
async function openNestedGuide(page: Page): Promise<void> {
await openFileExplorer(page);
await expandFolder(page, "docs");
await openFileFromExplorer(page, "guide.md");
}
async function followDetailsLink(page: Page): Promise<void> {
await page
.getByTestId("markdown-preview-frame")
.getByRole("link", { name: "Details", exact: true })
.click();
}
test("Markdown preview resolves a relative link from the document directory", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "markdown-relative-links-" });
await mkdir(path.join(workspace.repoPath, "docs/guide"), { recursive: true });
await writeFile(
path.join(workspace.repoPath, "docs/guide.md"),
"# Guide\n\n[Details](guide/details.md)\n",
);
await writeFile(
path.join(workspace.repoPath, "docs/guide/details.md"),
"# Nested details destination\n",
);
await workspace.navigateTo();
await openNestedGuide(page);
await followDetailsLink(page);
await expect(
page
.getByTestId("markdown-preview-frame")
.getByText("Nested details destination", { exact: true }),
).toBeVisible();
});
@@ -95,7 +95,7 @@ test.describe("New workspace entry points", () => {
}
});
test("the New Workspace screen shows the host selector with Add host when there is only one host", async ({
test("the New Workspace screen hides the host selector when there is only one host", async ({
page,
}) => {
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-single-host-" });
@@ -120,12 +120,7 @@ test.describe("New workspace entry points", () => {
await expect(page.getByTestId("new-workspace-project-picker-trigger")).toBeVisible({
timeout: 30_000,
});
const hostTrigger = page.getByTestId("host-picker-trigger");
await expect(hostTrigger).toContainText("localhost", { timeout: 30_000 });
await hostTrigger.click();
await expect(page.getByTestId("new-workspace-host-picker-add-host")).toBeVisible();
await page.getByTestId("new-workspace-host-picker-add-host").click();
await expect(page).toHaveURL(/\/settings\/general\?addHost=/);
await expect(page.getByTestId("host-picker-trigger")).toHaveCount(0);
} finally {
await seeded.cleanup();
}
@@ -1,24 +1,15 @@
import { verifyPluginToolTranslations } from "../support/helpers/plugin-translations";
import { test } from "../support/fixtures";
import { withButtonShowcase } from "../support/helpers/plugin-buttons";
test("plugin header and composer buttons share actions, menus, content, and updates", async ({
page,
}, info) => {
test.setTimeout(240_000);
await withButtonShowcase(page, info, async (buttons) => {
}) => {
test.setTimeout(180_000);
await withButtonShowcase(page, async (buttons) => {
await buttons.verifyObservationLifetime();
await buttons.openWideMenusAndPopovers();
await buttons.runAndUpdateActions();
await buttons.openCompactSheets();
await buttons.useCompactWorkspaceMenu();
await buttons.hideAndUnloadOpenButtons();
});
});
test("plugin tooltips and menu labels follow app language with a panel draft open", async ({
page,
}, info) => {
test.setTimeout(180_000);
await verifyPluginToolTranslations(page, info);
});
@@ -1,265 +0,0 @@
import { execFileSync } from "node:child_process";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import type { Frame, TestInfo } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { copyPluginExample } from "../support/helpers/plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { seedWorkspace } from "../support/helpers/seed-client";
import { gotoWorkspace } from "../support/helpers/launcher";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import {
openFileExplorer,
openFileFromExplorer,
expectFileTabOpen,
} from "../support/helpers/file-explorer";
// Full Chromium includes the PDF viewer; the default headless shell does not.
test.use({ channel: "chromium" });
function samplePdf() {
const stream = "BT /F1 24 Tf 72 720 Td (Paseo PDF panel example) Tj ET";
const objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`,
];
let pdf = "%PDF-1.4\n%\u0000\u00ff\n";
const offsets = objects.map((object, index) => {
const offset = Buffer.byteLength(pdf);
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
return offset;
});
const xref = Buffer.byteLength(pdf);
pdf += `xref\n0 6\n0000000000 65535 f \n${offsets.map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`).join("")}`;
pdf += `trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
return pdf;
}
async function openFile(page: Page, name: string) {
await openFileFromExplorer(page, name);
await expectFileTabOpen(page, name);
}
const WIDE = { width: 1280, height: 900 };
const COMPACT = { width: 390, height: 844 };
async function capture(page: Page, testInfo: TestInfo, name: string) {
const file = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path: file });
await testInfo.attach(name, { path: file, contentType: "image/png" });
}
/** Captures the current state wide, then compact once `ready` holds there, and returns to wide. */
async function captureWideAndCompact(
page: Page,
testInfo: TestInfo,
name: string,
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();
}
/** Turns on vim keybindings without discarding settings the test writes later. */
async function enableVimKeybindings(page: Page) {
await page.addInitScript(() => {
const key = "@paseo:app-settings";
const settings = JSON.parse(localStorage.getItem(key) ?? "{}");
localStorage.setItem(key, JSON.stringify({ ...settings, vimKeybindings: true }));
});
}
async function chooseRichMarkdown(page: Page) {
await page.getByRole("radio", { name: "Rich Markdown — markdown-panel", exact: true }).click();
await page.getByRole("checkbox", { name: "Don't ask me again", exact: true }).click();
await page.getByRole("button", { name: "Use", exact: true }).click();
}
async function replaceMarkdown(page: Page, text: string) {
await page.getByRole("textbox", { name: "Rich Markdown editor", exact: true }).fill(text);
}
async function openWithFromTabMenu(page: Page, name: string, option: string) {
await page.getByTestId(`workspace-tab-file_${name}`).first().click({ button: "right" });
await page.getByRole("menuitem", { name: "Open with…" }).hover();
await page.getByRole("menuitem", { name: option }).click();
}
async function selectFileView(page: Page, view: "Preview" | "Source") {
const option = page
.getByTestId("file-preview-mode")
.filter({ visible: true })
.getByRole("button", { name: view, exact: true });
await option.click();
await expect(option).toHaveAttribute("aria-selected", "true");
}
/** The browser's PDF viewer in the mounted frame; a remounted pane leaves old frames detached. */
async function pdfViewer(page: Page): Promise<Frame> {
const viewerUrl = /^chrome-extension:\/\/[^/]+\/index\.html$/;
const find = () =>
page.frames().find((frame) => !frame.isDetached() && viewerUrl.test(frame.url())) ?? null;
await expect.poll(() => find() !== null).toBe(true);
return find()!;
}
async function expectPdfViewerReady(page: Page) {
// A narrow viewer collapses its toolbar to Print and More actions.
const viewer = await pdfViewer(page);
await expect(viewer.getByRole("button", { name: "Print", exact: true })).toBeEnabled();
}
async function expectMarkdownEditor(page: Page, text: string) {
const editor = page.getByRole("textbox", { name: "Rich Markdown editor", exact: true });
await expect(editor).toBeVisible();
await expect(editor).toContainText(text);
await expect(editor).toBeInViewport({ ratio: 1 });
}
test("directory examples mount the PDF frame and write Markdown through the file handle", async ({
page,
}, testInfo) => {
test.setTimeout(180_000);
const pdf = await copyPluginExample("pdf-panel");
const markdown = await copyPluginExample("markdown-panel");
execFileSync(
process.platform === "win32" ? "npm.cmd" : "npm",
["install", "--ignore-scripts", "--no-audit", "--no-fund"],
{ cwd: markdown.directory, stdio: "pipe" },
);
const workspace = await seedWorkspace({
repoPrefix: "file-panel-examples-",
repo: {
files: [
{
path: "notes.md",
content: "# Markdown panel example\n\nEdit **Markdown** through Paseo.\n",
},
{ path: "second.md", content: "# Remembered panel\n" },
{ path: "report.pdf", content: samplePdf() },
],
},
});
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(pdf.directory);
await client.installDirectoryPlugin(markdown.directory);
await enableVimKeybindings(page);
await page.setViewportSize(WIDE);
const gate = await installDaemonWebSocketGate(page);
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await openFile(page, "report.pdf");
await expect(page.getByTitle("Document preview", { exact: true })).toHaveAttribute(
"src",
/^blob:/,
);
await expect(page.getByTitle("Document preview", { exact: true })).not.toHaveAttribute(
"sandbox",
);
const documentUrl = await page
.getByTitle("Document preview", { exact: true })
.getAttribute("src");
expect(
await page.evaluate(
async (url) => (await fetch(url!)).blob().then((blob) => blob.type),
documentUrl,
),
).toBe("application/pdf");
await expectPdfViewerReady(page);
await expect(
(await pdfViewer(page)).getByRole("textbox", { name: "Page number", exact: true }),
).toHaveValue("1");
await captureWideAndCompact(page, testInfo, "pdf-example", async () => {
await expect(page.getByTitle("Document preview", { exact: true })).toBeInViewport();
await expectPdfViewerReady(page);
});
await openFile(page, "notes.md");
await expect(page.getByRole("radio", { name: "Markdown — Paseo", exact: true })).toBeChecked();
await expect(
page.getByRole("radio", { name: "Rich Markdown — markdown-panel", exact: true }),
).toBeVisible();
await expect(page.getByRole("radio")).toHaveCount(2);
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,
});
});
await chooseRichMarkdown(page);
await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toContainText(
"Markdown panel example",
);
gate.holdNextClientRequest("fs.file.write.request");
await replaceMarkdown(page, "# Edited by Rich Markdown\n\nSaved by the panel.\n");
await gate.waitForHeldClientRequest();
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeVisible();
await capture(page, testInfo, "markdown-example-dirty-wide");
gate.releaseHeldClientRequest();
await expect
.poll(() => readFile(path.join(workspace.repoPath, "notes.md"), "utf8"))
.toBe("# Edited by Rich Markdown\n\nSaved by the panel.\n");
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeHidden();
await captureWideAndCompact(page, testInfo, "markdown-example-rich-markdown", async () => {
await expectMarkdownEditor(page, "Edited by Rich Markdown");
});
// "Open with…" switches to Paseo's Markdown panel; its Source view is the editor, with the
// cursor and vim mode in the panel's toolbar.
await openWithFromTabMenu(page, "notes.md", "Markdown — Paseo");
await expect(page.getByRole("heading", { name: "Edited by Rich Markdown" })).toBeVisible();
await selectFileView(page, "Source");
const source = page.getByTestId("file-source-editor").filter({ visible: true });
await expect(source.locator(".cm-content")).toContainText("Edited by Rich Markdown");
await source.locator(".cm-line").nth(2).click();
const bar = page.getByTestId("file-panel-bar").filter({ visible: true });
await expect(bar.getByText("NORMAL", { exact: true })).toBeVisible();
await expect(bar.getByText(/^Ln 3, Col \d+$/)).toBeVisible();
await capture(page, testInfo, "markdown-example-source-wide");
await openWithFromTabMenu(page, "notes.md", "Rich Markdown — markdown-panel");
await writeFile(path.join(workspace.repoPath, "notes.md"), "# External update\n");
await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toHaveText(
"# External update",
);
await replaceMarkdown(page, "# Local edit\n");
await writeFile(path.join(workspace.repoPath, "notes.md"), "# Conflicting external edit\n");
await expect(page.getByTestId("rich-markdown-conflict")).toBeVisible();
await capture(page, testInfo, "markdown-example-conflict-wide");
await page.getByRole("button", { name: "Overwrite", exact: true }).click();
await expect
.poll(() => readFile(path.join(workspace.repoPath, "notes.md"), "utf8"))
.toBe("# Local edit\n");
await openFile(page, "second.md");
await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toContainText(
"Remembered panel",
);
await expect(page.getByText("Open .md files with", { exact: true })).toBeHidden();
await page.setViewportSize(COMPACT);
await expectMarkdownEditor(page, "Remembered panel");
} finally {
await client.removePlugin("pdf-panel");
await client.removePlugin("markdown-panel");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
await client.close();
await workspace.cleanup();
await pdf.cleanup();
await markdown.cleanup();
}
});
@@ -1,171 +0,0 @@
import type { TestInfo } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { seedWorkspace } from "../support/helpers/seed-client";
import { gotoWorkspace } from "../support/helpers/launcher";
import { expectFileTabOpen, openFileExplorer } from "../support/helpers/file-explorer";
import {
BUILTIN_MARKDOWN_OPTION,
MARKDOWN_PANEL_ID,
PLUGIN_MARKDOWN_OPTION,
expectBuiltinMarkdown,
expectPluginMarkdown,
filePanelPicker,
installMarkdownPanelPlugin,
openMarkdown,
storedFilePanelAssociations,
} from "../support/helpers/markdown-file-panel-plugin";
async function choosePanel(page: Page, option: string, options: { remember: boolean }) {
await page.getByRole("radio", { name: option, exact: true }).click();
if (options.remember) {
await page.getByRole("checkbox", { name: "Don't ask me again", exact: true }).click();
}
await page.getByRole("button", { name: "Use", exact: true }).click();
await expect(filePanelPicker(page)).toBeHidden();
}
async function openWithFromTabMenu(page: Page, name: string, option: string) {
await page.getByTestId(`workspace-tab-file_${name}`).first().click({ button: "right" });
await page.getByRole("menuitem", { name: "Open with…" }).hover();
await page.getByRole("menuitem", { name: option }).click();
}
async function toggleAlwaysUseFromTabMenu(page: Page, name: string) {
await openWithFromTabMenu(page, name, "Always use for .md");
}
/** The built-in panel's toolbar control between its Preview and Source views. */
function viewControl(page: Page) {
return page.getByTestId("file-preview-mode").filter({ visible: true });
}
async function selectFileView(page: Page, view: "Preview" | "Source") {
const option = viewControl(page).getByRole("button", { name: view, exact: true });
await option.click();
await expect(option).toHaveAttribute("aria-selected", "true");
}
async function expectNoOpenWith(page: Page, name: string) {
await page.getByTestId(`workspace-tab-file_${name}`).first().click({ button: "right" });
await expect(page.getByRole("menuitem", { name: "Close", exact: true })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "Open with…" })).toHaveCount(0);
await page.keyboard.press("Escape");
}
async function capture(page: Page, testInfo: TestInfo, name: string) {
const file = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path: file });
await testInfo.attach(name, { path: file, contentType: "image/png" });
}
test("opens Markdown with the built-in panel and its Preview and Source without asking", async ({
page,
}, testInfo) => {
const workspace = await seedWorkspace({
repoPrefix: "file-panel-builtins-",
repo: { files: [{ path: "notes.md", content: "# notes heading\n" }] },
});
try {
await page.setViewportSize({ width: 1280, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await openMarkdown(page, "notes.md");
await expectBuiltinMarkdown(page, "notes heading");
await expect(viewControl(page).getByRole("button")).toHaveText(["Preview", "Source"]);
await expect(filePanelPicker(page)).toBeHidden();
await expectNoOpenWith(page, "notes.md");
await capture(page, testInfo, "file-panel-builtins-wide");
await selectFileView(page, "Source");
await expect(page.getByTestId("file-source-editor").filter({ visible: true })).toBeVisible();
await selectFileView(page, "Preview");
await expectBuiltinMarkdown(page, "notes heading");
await page.setViewportSize({ width: 390, height: 844 });
await expectBuiltinMarkdown(page, "notes heading");
await expect(
viewControl(page).getByRole("button", { name: "Source", exact: true }),
).toBeInViewport({ ratio: 1 });
await expect(filePanelPicker(page)).toBeHidden();
await capture(page, testInfo, "file-panel-builtins-compact");
} finally {
await workspace.cleanup();
}
});
test("asks once which panel opens Markdown and remembers it when told to", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const workspace = await seedWorkspace({
repoPrefix: "file-panel-picker-",
repo: {
files: ["first", "second", "third", "fourth"].map((name) => ({
path: `${name}.md`,
content: `# ${name} heading\n`,
})),
},
});
const plugin = await installMarkdownPanelPlugin();
try {
await page.setViewportSize({ width: 1280, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
// First open: the built-in renders under the picker.
await openMarkdown(page, "first.md");
await expect(filePanelPicker(page)).toBeVisible();
await expect(page.getByRole("radio", { name: BUILTIN_MARKDOWN_OPTION })).toBeChecked();
await expect(page.getByRole("radio", { name: PLUGIN_MARKDOWN_OPTION })).not.toBeChecked();
await expect(page.getByRole("radio")).toHaveCount(2);
await expectBuiltinMarkdown(page, "first heading");
await capture(page, testInfo, "file-panel-picker-wide");
// Don't ask me again: the plugin renders and later opens use it silently.
await choosePanel(page, PLUGIN_MARKDOWN_OPTION, { remember: true });
await expectPluginMarkdown(page, "first.md");
expect(await storedFilePanelAssociations(page)).toEqual({ "ext:md": MARKDOWN_PANEL_ID });
await openMarkdown(page, "second.md");
await expectPluginMarkdown(page, "second.md");
await expect(filePanelPicker(page)).toBeHidden();
// Forgetting from the tab menu keeps the tab on the plugin.
await toggleAlwaysUseFromTabMenu(page, "second.md");
await expect.poll(() => storedFilePanelAssociations(page)).toEqual({});
await expectPluginMarkdown(page, "second.md");
// An unremembered choice applies to this tab only; the next open asks again.
await openMarkdown(page, "third.md");
await expect(filePanelPicker(page)).toBeVisible();
await choosePanel(page, PLUGIN_MARKDOWN_OPTION, { remember: false });
await expectPluginMarkdown(page, "third.md");
expect(await storedFilePanelAssociations(page)).toEqual({});
// "Open with…" switches the open tab back to the built-in in place, with its own views.
await openWithFromTabMenu(page, "third.md", BUILTIN_MARKDOWN_OPTION);
await expectBuiltinMarkdown(page, "third heading");
await expectFileTabOpen(page, "third.md");
await selectFileView(page, "Source");
await expect(page.getByTestId("file-source-editor").filter({ visible: true })).toBeVisible();
await selectFileView(page, "Preview");
await expectBuiltinMarkdown(page, "third heading");
// Dismissing keeps the built-in; reopening the tab after a reload asks again.
await openMarkdown(page, "fourth.md");
await expect(filePanelPicker(page)).toBeVisible();
await page.keyboard.press("Escape");
await expect(filePanelPicker(page)).toBeHidden();
await expectBuiltinMarkdown(page, "fourth heading");
await page.setViewportSize({ width: 390, height: 844 });
await page.reload();
await expect(filePanelPicker(page)).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole("button", { name: "Use", exact: true })).toBeInViewport({
ratio: 1,
});
await capture(page, testInfo, "file-panel-picker-compact");
} finally {
await plugin.uninstall();
await workspace.cleanup();
}
});
@@ -1,150 +0,0 @@
import type { TestInfo } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { openSettings } from "../support/helpers/app";
import { openFileExplorer } from "../support/helpers/file-explorer";
import { gotoWorkspace } from "../support/helpers/launcher";
import {
BUILTIN_MARKDOWN_OPTION,
MARKDOWN_PANEL_ID,
PLUGIN_MARKDOWN_OPTION,
expectPluginMarkdown,
filePanelPicker,
installMarkdownPanelPlugin,
openMarkdown,
storedFilePanelAssociations,
} from "../support/helpers/markdown-file-panel-plugin";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openSettingsSection } from "../support/helpers/settings";
const ASK_EVERY_TIME = "Ask every time";
const SOURCE_OPTION = "Source — Paseo";
function markdownRow(page: Page) {
return page.getByTestId("file-panel-settings-row-ext:md");
}
function markdownDropdown(page: Page, value: string) {
return markdownRow(page).getByRole("button", { name: `.md: ${value}`, exact: true });
}
function askAgain(page: Page) {
return markdownRow(page).getByRole("button", { name: "Ask again for .md", exact: true });
}
async function openFilesSettings(page: Page) {
await openSettings(page);
await openSettingsSection(page, "files");
await expect(page.getByTestId("file-panel-settings")).toBeVisible();
}
async function capture(page: Page, testInfo: TestInfo, name: string) {
const file = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path: file });
await testInfo.attach(name, { path: file, contentType: "image/png" });
}
async function expectMenuOpen(page: Page) {
await expect(page.getByTestId("file-panel-settings-menu")).toHaveCSS("opacity", "1");
}
test("Settings > Files chooses which panel opens .md and asks again on request", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const workspace = await seedWorkspace({
repoPrefix: "file-panel-settings-",
repo: { files: [{ path: "notes.md", content: "# notes heading\n" }] },
});
const plugin = await installMarkdownPanelPlugin();
try {
await page.setViewportSize({ width: 1280, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await openFilesSettings(page);
// Nothing remembered: the row asks every time and has nothing to forget.
await expect(markdownDropdown(page, ASK_EVERY_TIME)).toBeVisible({ timeout: 30_000 });
await expect(askAgain(page)).toBeDisabled();
await capture(page, testInfo, "file-panel-settings-wide");
await markdownDropdown(page, ASK_EVERY_TIME).click();
await expect(page.getByRole("menuitem", { name: BUILTIN_MARKDOWN_OPTION })).toBeVisible();
await expect(page.getByRole("menuitem", { name: PLUGIN_MARKDOWN_OPTION })).toBeVisible();
await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toHaveCount(0);
await expectMenuOpen(page);
await capture(page, testInfo, "file-panel-settings-wide-menu");
await page.getByRole("menuitem", { name: PLUGIN_MARKDOWN_OPTION }).click();
await expect(markdownDropdown(page, PLUGIN_MARKDOWN_OPTION)).toBeVisible();
await expect
.poll(() => storedFilePanelAssociations(page))
.toEqual({
"ext:md": MARKDOWN_PANEL_ID,
});
await capture(page, testInfo, "file-panel-settings-wide-chosen");
// The next open uses the plugin without asking.
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await openMarkdown(page, "notes.md");
await expectPluginMarkdown(page, "notes.md");
await expect(filePanelPicker(page)).toBeHidden();
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/settings/files");
await expect(markdownDropdown(page, PLUGIN_MARKDOWN_OPTION)).toBeVisible({ timeout: 30_000 });
await expect(askAgain(page)).toBeInViewport({ ratio: 1 });
await capture(page, testInfo, "file-panel-settings-compact");
await markdownDropdown(page, PLUGIN_MARKDOWN_OPTION).click();
await expect(page.getByRole("menuitem", { name: BUILTIN_MARKDOWN_OPTION })).toBeVisible();
await expectMenuOpen(page);
await capture(page, testInfo, "file-panel-settings-compact-menu");
await page.keyboard.press("Escape");
await expect(page.getByRole("menuitem", { name: BUILTIN_MARKDOWN_OPTION })).toBeHidden();
// Ask again forgets the choice; the next open asks.
await askAgain(page).click();
await expect(markdownDropdown(page, ASK_EVERY_TIME)).toBeVisible();
await expect.poll(() => storedFilePanelAssociations(page)).toEqual({});
await page.setViewportSize({ width: 1280, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await expect(filePanelPicker(page)).toBeVisible({ timeout: 30_000 });
} finally {
await plugin.uninstall();
await workspace.cleanup();
}
});
test("Settings > Files explains where file panels come from when no plugin adds one", async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto("/settings/files");
const empty = page.getByTestId("file-panel-settings-empty");
await expect(empty).toHaveText("File panels appear here when a plugin adds one", {
timeout: 30_000,
});
await capture(page, testInfo, "file-panel-settings-empty-wide");
await page.setViewportSize({ width: 390, height: 844 });
await expect(empty).toBeVisible();
await capture(page, testInfo, "file-panel-settings-empty-compact");
});
test("Settings > Files offers Source in place of a panel no host has, and forgets it", async ({
page,
}) => {
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ filePanelAssociations: { "ext:pdf": "plugin:none/none" } }),
);
});
await page.goto("/settings/files");
const row = page.getByTestId("file-panel-settings-row-ext:pdf");
const dropdown = row.getByRole("button", { name: ".pdf: Not on a connected host" });
await expect(dropdown).toBeEnabled({ timeout: 30_000 });
await dropdown.click();
await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeHidden();
await row.getByRole("button", { name: "Ask again for .pdf", exact: true }).click();
await expect(page.getByTestId("file-panel-settings-empty")).toBeVisible();
});
@@ -1,236 +0,0 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { seedWorkspace } from "../support/helpers/seed-client";
import { gotoWorkspace } from "../support/helpers/launcher";
import {
openFileExplorer,
openFileFromExplorer,
expectFileTabOpen,
} from "../support/helpers/file-explorer";
const PLUGIN_ID = "file-panel-e2e";
const PDF = Buffer.from("%PDF-1.4\n%\x00\xff\n1 0 obj<</Type/Catalog>>endobj\n%%EOF", "binary");
const SOURCE = `import React, { useEffect, useState } from "react";
import { Text, View, Pressable } from "react-native";
import {
PluginFileConflictError,
useFileBytes,
useFileUrl,
usePaseo,
useWorkspace,
} from "@getpaseo/plugin/client";
import { useQueryClient } from "@tanstack/react-query";
import { DocumentFrame, Loading } from "@getpaseo/plugin/client/ui";
function Pdf({ file, workspaceId }) {
usePaseo();
useQueryClient();
const workspace = useWorkspace(workspaceId, value => ({ id: value.id }));
const bytes = useFileBytes(file);
const url = useFileUrl(file);
const [urlBytes, setUrlBytes] = useState(-1);
const href = url.status === "ready" ? url.url : null;
useEffect(() => {
if (href) fetch(href).then(response => response.arrayBuffer()).then(body => setUrlBytes(body.byteLength));
}, [href]);
if (bytes.status !== "ready" || !href) return <Loading />;
return <View style={{ flex: 1 }}>
<Text>PDF plugin: {file.name}</Text>
<Text>Path {file.path}</Text>
<Text>Bytes {bytes.bytes.length}; URL bytes {urlBytes}</Text>
<Text>Workspace {workspace?.id}</Text>
<DocumentFrame url={href} testID="plugin-pdf-frame" />
</View>;
}
// Reads once and writes on demand, so a change on disk since that read rejects the write.
function Markdown({ file, tab }) {
const [text, setText] = useState(null);
const [status, setStatus] = useState("");
useEffect(() => { file.readText().then(setText); }, [file]);
const save = (next) => {
tab.setModified(true);
file.writeText(next).then(
() => { tab.setModified(false); setText(next); },
(error) => setStatus(error instanceof PluginFileConflictError ? "Write rejected: changed on disk" : String(error)),
);
};
if (text === null) return <Loading />;
return <View>
<Text>Plugin Markdown: {text}</Text>
<Pressable accessibilityRole="button" onPress={() => save("# Saved by plugin\\n")}>
<Text>Edit Markdown through plugin</Text>
</Pressable>
<Text>{status}</Text>
</View>;
}
export default function contribute(client) {
client.addFilePanel({ id: "pdf", title: "PDF", icon: "FileText",
files: { extensions: ["pdf", "txt"] }, Component: Pdf });
client.addFilePanel({ id: "markdown", title: "Plugin Markdown", icon: "FileText",
files: { extensions: ["md"] }, Component: Markdown });
return () => {};
}`;
async function openWorkspaceFile(page: Page, name: string) {
await openFileFromExplorer(page, name);
await expectFileTabOpen(page, name);
}
async function associateMarkdownWithPlugin(page: Page) {
await page.evaluate(() => {
const key = "@paseo:app-settings";
const settings = JSON.parse(localStorage.getItem(key) ?? "{}");
settings.filePanelAssociations = { "ext:md": "plugin:file-panel-e2e/markdown" };
localStorage.setItem(key, JSON.stringify(settings));
});
await page.reload();
}
async function editMarkdownThroughPlugin(page: Page) {
await page.getByRole("button", { name: "Edit Markdown through plugin", exact: true }).click();
}
/** Opens the tab with Paseo's Markdown panel from "Open with…" and shows its Source view. */
async function openBuiltinMarkdownSource(page: Page, name: string) {
await page.getByTestId(`workspace-tab-file_${name}`).first().click({ button: "right" });
await page.getByRole("menuitem", { name: "Open with…" }).hover();
await page.getByRole("menuitem", { name: "Markdown — Paseo" }).click();
await page
.getByTestId("file-preview-mode")
.filter({ visible: true })
.getByRole("button", { name: "Source", exact: true })
.click();
}
test("directory plugins render PDF bytes and write Markdown through the file handle", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const directory = await mkdtemp(path.join(tmpdir(), "paseo-file-panels-plugin-"));
const workspace = await seedWorkspace({
repoPrefix: "file-panels-",
repo: {
files: [
{ path: "notes.md", content: "# Built-in Markdown\n" },
{ path: "bytes.txt", content: "Bytes panel text document" },
],
},
});
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previousConfig = await client.getDaemonConfig();
await writeFile(path.join(workspace.repoPath, "report.pdf"), PDF);
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), SOURCE);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await page.setViewportSize({ width: 1280, height: 900 });
const gate = await installDaemonWebSocketGate(page);
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await openWorkspaceFile(page, "report.pdf");
await expect(page.getByText("PDF plugin: report.pdf", { exact: true })).toBeVisible();
await expect(
page.getByText(`Bytes ${PDF.length}; URL bytes ${PDF.length}`, { exact: true }),
).toBeVisible();
await expect(
page.getByText(`Workspace ${workspace.workspaceId}`, { exact: true }),
).toBeVisible();
await expect(page.getByTitle("Document preview", { exact: true })).toHaveAttribute(
"src",
/^blob:/,
);
await expect(
page.getByText(`Path ${path.join(workspace.repoPath, "report.pdf")}`, { exact: true }),
).toBeVisible();
const pdfScreenshot = testInfo.outputPath("plugin-pdf-web.png");
await page.screenshot({ path: pdfScreenshot });
await testInfo.attach("plugin-pdf-web", { path: pdfScreenshot, contentType: "image/png" });
// A plugin panel claiming a text file competes with Source, so Paseo asks over the plugin.
await openWorkspaceFile(page, "bytes.txt");
await expect(page.getByText("Open .txt files with", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByText("Open .txt files with", { exact: true })).toBeHidden();
await expect(page.getByText("PDF plugin: bytes.txt", { exact: true })).toBeVisible();
await expect(page.getByTitle("Document preview", { exact: true })).toBeVisible();
await expect(page.getByTitle("Document preview", { exact: true })).toHaveAttribute(
"sandbox",
"",
);
await expect(
page
.frameLocator('[title="Document preview"]')
.getByText("Bytes panel text document", { exact: true }),
).toBeVisible();
await openWorkspaceFile(page, "notes.md");
await expect(page.getByText("Open .md files with", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByText("Open .md files with", { exact: true })).toBeHidden();
await expect(
page.getByRole("heading", { name: "Built-in Markdown", exact: true }),
).toBeVisible();
await expect(page.getByRole("button", { name: "Edit Markdown through plugin" })).toHaveCount(0);
await associateMarkdownWithPlugin(page);
await expect(
page.getByText("Plugin Markdown: # Built-in Markdown", { exact: true }),
).toBeVisible();
gate.holdNextClientRequest("fs.file.write.request");
await editMarkdownThroughPlugin(page);
await gate.waitForHeldClientRequest();
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeVisible();
gate.releaseHeldClientRequest();
await expect(
page.getByText("Plugin Markdown: # Saved by plugin", { exact: true }),
).toBeVisible();
await expect
.poll(() => readFile(path.join(workspace.repoPath, "notes.md"), "utf8"))
.toBe("# Saved by plugin\n");
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeHidden();
// The disk changed since the plugin's last read or write: the write is rejected, not applied.
await writeFile(path.join(workspace.repoPath, "notes.md"), "# Changed outside\n");
await editMarkdownThroughPlugin(page);
await expect(page.getByText("Write rejected: changed on disk", { exact: true })).toBeVisible();
expect(await readFile(path.join(workspace.repoPath, "notes.md"), "utf8")).toBe(
"# Changed outside\n",
);
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeVisible();
let closePrompt = "";
page.once("dialog", async (dialog) => {
closePrompt = dialog.message();
await dialog.dismiss();
});
await page
.getByTestId("workspace-tab-file_notes.md")
.filter({ visible: true })
.first()
.click({ button: "right" });
await page.getByRole("menuitem", { name: "Close", exact: true }).click();
await expect.poll(() => closePrompt).toContain("discard");
await openBuiltinMarkdownSource(page, "notes.md");
await expect(
page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content"),
).toHaveText("# Changed outside");
const markdownScreenshot = testInfo.outputPath("plugin-markdown-source-web.png");
await page.screenshot({ path: markdownScreenshot });
await testInfo.attach("plugin-markdown-source-web", {
path: markdownScreenshot,
contentType: "image/png",
});
} finally {
await client.removePlugin(PLUGIN_ID);
await client.patchDaemonConfig({
pluginsEnabled: previousConfig.config.pluginsEnabled ?? false,
});
await client.close();
await workspace.cleanup();
await rm(directory, { recursive: true, force: true });
}
});
@@ -1,4 +1,3 @@
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";
@@ -10,7 +9,7 @@ import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspac
const PLUGIN_ID = "plugin-host-ui-e2e";
const PLUGIN_SOURCE = `import { usePaseo } from "@getpaseo/plugin/client";
import { Icon, Modal, useToast, Markdown } from "@getpaseo/plugin/client/ui";
import { Icon, Modal, useToast } from "@getpaseo/plugin/client/react-native";
import { useQueryClient } from "@tanstack/react-query";
import React, { useState } from "react";
import { Pressable, Text, View } from "react-native";
@@ -27,9 +26,6 @@ function ModalBody({ onSaved }) {
return <View>
<Text>Plugin modal contexts ready</Text>
<Markdown>{"## Plugin report"}</Markdown>
<Markdown>{"Checks **passed**. [Documentation](https://paseo.sh/docs/plugins)"}</Markdown>
<Markdown>{""}</Markdown>
<Pressable accessibilityRole="button" onPress={save}>
<Text>Save issue</Text>
</Pressable>
@@ -95,20 +91,10 @@ async function openPluginModal(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open plugin modal", exact: true }).click();
}
async function expectPluginMarkdown(page: Page): Promise<void> {
await expect(page.getByRole("heading", { name: "Plugin report" })).toBeVisible();
await expect(page.getByText("Checks passed.", { exact: false })).toBeVisible();
await expect(page.getByRole("link", { name: "Documentation", exact: true })).toHaveAttribute(
"href",
"https://paseo.sh/docs/plugins",
);
}
async function expectCenteredPluginDialog(page: Page): Promise<void> {
const dialog = page.getByRole("dialog");
await expect(dialog.getByText("Edit plugin issue", { exact: true })).toBeVisible();
await expect(dialog.getByText("Plugin modal contexts ready", { exact: true })).toBeVisible();
await expectPluginMarkdown(page);
}
async function closeCenteredPluginDialog(page: Page): Promise<void> {
@@ -120,7 +106,6 @@ async function closeCenteredPluginDialog(page: Page): Promise<void> {
async function expectCompactPluginSheet(page: Page): Promise<void> {
await expect(page.getByText("Edit plugin issue", { exact: true })).toBeVisible();
await expect(page.getByText("Plugin modal contexts ready", { exact: true })).toBeVisible();
await expectPluginMarkdown(page);
}
async function savePluginIssue(page: Page): Promise<void> {
@@ -181,10 +166,3 @@ 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,7 +1,6 @@
import { test } from "../support/fixtures";
import {
withTimelinePlugin,
viewInlineThinkingExample,
requestPluginTimeline,
interactWithStreamingCard,
expectWholeCompletedCard,
@@ -34,9 +33,3 @@ test("Overview preserves both consecutive tool plugin cards", async ({ page }, i
await expectBothConsecutiveTools(page);
});
});
test("inline-thinking example renders a streamed thought through host UI", async ({
page,
}, info) => {
await viewInlineThinkingExample(page, info);
});
@@ -36,7 +36,7 @@ function pluginClientSource(input: {
}): string {
return `import React, { useRef } from "react";
import { Pressable, Text, View } from "react-native";
import { Icon } from "@getpaseo/plugin/client/ui";
import { Icon } from "@getpaseo/plugin/client/react-native";
import { useAgent, useWorkspace, openExternalUrl } from "@getpaseo/plugin/client";
import { ExternalLink } from "@getpaseo/plugin/client/ui";
import { recordComposerOpen } from "./shared/rpc";
@@ -1,5 +1,4 @@
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 {
@@ -32,28 +31,6 @@ 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()] });
@@ -6,7 +6,6 @@ import {
seedMockAgentWorkspace,
type MockAgentWorkspace,
} from "../support/helpers/mock-agent";
import { expectOpenTabCount } from "../support/helpers/sheet";
import { getServerId } from "../support/helpers/server-id";
import { closeMobileAgentSidebar, openMobileAgentSidebar } from "../support/helpers/sidebar";
@@ -228,45 +227,3 @@ test("manual unread survives leaving the current workspace on compact layout", a
await markUnreadThenResumeChat(page, workspaces.subject.workspaceId);
await completeTurnAndLeaveOnCompact(page, workspaces);
});
async function reopenMarkedWorkspaceAfterBackgroundTurn(page: Page, workspace: MockAgentWorkspace) {
const route = page.url();
// End this app session before the turn, so its cached transcript cannot contain the new prompt.
await page.goto("about:blank");
await workspace.client.sendAgentMessage(
workspace.agentId,
"A turn completed while the app was closed.",
);
await workspace.client.waitForFinish(workspace.agentId, 20_000);
// Reopen the ordinary workspace route, without an agent open intent that would repair focus.
await page.goto(route);
await expect(page.getByRole("button", { name: /Switch tabs/ })).toBeVisible();
}
async function expectRestoredCompactChat(page: Page) {
await expectOpenTabCount(page, 1);
await expect(
page.getByText("A turn completed while the app was closed.", { exact: true }),
).toBeVisible();
}
test("reopening a marked unread workspace restores only its agent tab and current history on compact layout", async ({
page,
workspaces,
}) => {
test.setTimeout(120_000);
await test.step("open a finished chat with one main tab", async () => {
await openCompactWorkspace(page, workspaces.subject);
await closeMobileAgentSidebar(page);
await expectOpenTabCount(page, 1);
await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible();
});
await test.step("mark the current workspace unread, then close and reopen the app", async () => {
await openMobileAgentSidebar(page);
await markAsUnread(page, workspaces.subject.workspaceId);
await reopenMarkedWorkspaceAfterBackgroundTurn(page, workspaces.subject);
});
await test.step("restore the main tab without Explorer entries and load its latest history", async () => {
await expectRestoredCompactChat(page);
});
});
@@ -1,17 +1,23 @@
import { buildSeededHost } from "../support/helpers/daemon-registry";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { submitMessage } from "../support/helpers/composer";
import { openSessions, clickSessionRow } from "../support/helpers/archive-tab";
import {
seedFleetSubagents,
delegateFleetThroughMcp,
observeFleetRouteOwnership,
readFleetCompletion,
connectFleetDirectHost,
openFleetParentTrack,
readFleetAuthorityState,
expectFleetRouteUnavailable,
} from "../support/helpers/subagents";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { seedWorkspace } from "../support/helpers/seed-client";
import {
delegateFleetThroughMcp,
expectManagedSubagentArchived,
openSubagentsTrack,
seedParentWithSubagent,
} from "../support/helpers/subagents";
import { openSubagentsTrack, seedParentWithSubagent } from "../support/helpers/subagents";
test.use({ e2eInjectPaseoTools: true });
@@ -157,187 +163,165 @@ test("a preferred chat file link still opens beside its right-pane agent", async
}
});
test("MCP delegates to self and B and opens both qualified children in ordinary conversations", async ({
test("a fleet child retains its origin beside direct access through collision, catalog push, reload and revocation", async ({
page,
}) => {
const fleet = await delegateFleetThroughMcp();
let appSockets = 0;
let routeOpens = 0;
const activeDaemonSockets = new Set<unknown>();
const observationRequests: Record<string, number> = {};
page.on("websocket", (socket) => {
appSockets++;
if (new URL(socket.url()).pathname === "/ws") {
activeDaemonSockets.add(socket);
socket.on("close", () => activeDaemonSockets.delete(socket));
}
socket.on("framesent", ({ payload }) => {
if (typeof payload !== "string" || !payload.startsWith("{")) return;
if (String(payload).includes('"hosts.route.open.request"')) routeOpens++;
const outer = JSON.parse(String(payload));
const request = outer.type === "session" ? outer.message : null;
if (
request?.type &&
(request.subscribe !== undefined ||
request.observe === true ||
request.type.endsWith("set_subscription.request"))
test.setTimeout(180_000);
const fleet = await seedFleetSubagents();
try {
const a = fleet.parent.serverId;
const b = fleet.destination.serverId;
await connectFleetDirectHost(page, fleet);
await openFleetParentTrack(page);
const rows = page.getByTestId(`subagents-track-row-${fleet.remote.id}`);
await expect(rows).toHaveCount(2);
const remote = rows.filter({ hasText: "Fleet child on B" });
await expect(remote).toBeVisible();
await expect(remote.getByTestId(`subagents-track-row-facts-${fleet.remote.id}`)).toHaveText(
"Ten second stream · Low · Load test",
);
await expect
.poll(async () => (await readFleetAuthorityState(page, a, b)).direct.status)
.toBe("online");
await expect
.poll(async () => (await readFleetAuthorityState(page, a, b)).routed.catalogVersion)
.toBeGreaterThan(0);
const beforePush = await readFleetAuthorityState(page, a, b);
await fleet.direct.providers.refresh({ cwd: fleet.workspace.repoPath });
await expect
.poll(async () => (await readFleetAuthorityState(page, a, b)).routed.catalogVersion)
.toBeGreaterThan(beforePush.routed.catalogVersion);
await remote.click();
await expect(page).toHaveURL(
(url) => url.pathname.includes(`/h/${b}/workspace/`) && url.searchParams.get("origin") === a,
);
await submitMessage(page, "ROUTED_FLEET_PROMPT");
await expect
.poll(async () => (await fleet.remote.refresh())?.agent.lastUserMessageAt)
.not.toBeNull();
expect((await fleet.local.refresh())?.agent.lastUserMessageAt).toBeNull();
const routedUrl = page.url();
await page.reload();
await expect(page).toHaveURL((url) => url.searchParams.get("origin") === a);
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
await fleet.remote.waitForFinish();
// Reopen direct B through ordinary history, then retain it beside the routed conversation.
await openSessions(page);
await clickSessionRow(page, "Fleet child on B");
await expect(page).toHaveURL(
(url) => url.pathname.includes(`/h/${b}/workspace/`) && !url.searchParams.has("origin"),
);
await openFleetParentTrack(page);
await page
.getByTestId(`subagents-track-row-${fleet.remote.id}`)
.filter({ hasText: "Fleet child on B" })
.click();
await expect
.poll(async () => (await readFleetAuthorityState(page, a, b)).direct.status)
.toBe("online");
await fleet.disable();
await expect
.poll(async () => (await readFleetAuthorityState(page, a, b)).routed.status)
.not.toBe("online");
const withdrawn = await readFleetAuthorityState(page, a, b);
expect(withdrawn.direct.status).toBe("online");
await fleet.direct.providers.refresh({ cwd: fleet.workspace.repoPath });
await expect
.poll(async () => (await readFleetAuthorityState(page, a, b)).direct.catalogVersion)
.toBeGreaterThan(withdrawn.direct.catalogVersion);
expect((await readFleetAuthorityState(page, a, b)).routed.catalogVersion).toBe(
withdrawn.routed.catalogVersion,
);
await page.reload();
await expect(page).toHaveURL((url) => url.searchParams.get("origin") === a);
expect((await fleet.local.refresh())?.agent.lastUserMessageAt).toBeNull();
// Direct B remains a separate usable authority; its newer evidence cannot refill A's withdrawn scope.
await page.goto(buildHostAgentDetailRoute(b, fleet.remote.id, fleet.workspace.workspaceId));
await submitMessage(page, "DIRECT_ONLY_AFTER_REVOCATION");
await expect(page.getByText("DIRECT_ONLY_AFTER_REVOCATION", { exact: true })).toBeVisible();
await page.goto(routedUrl);
await expect(page).toHaveURL((url) => url.searchParams.get("origin") === a);
await expectFleetRouteUnavailable(page, a, b);
await expect(page.getByText("DIRECT_ONLY_AFTER_REVOCATION", { exact: true })).toHaveCount(0);
const beforeRecovery = (await fleet.direct.agents.ref(fleet.remote.id).refresh())?.agent
.lastUserMessageAt;
await fleet.enable();
await expect
.poll(async () => (await readFleetAuthorityState(page, a, b)).routed.status)
.toBe("online");
await expect(page).toHaveURL((url) => url.searchParams.get("origin") === a);
await expect(page.getByText("DIRECT_ONLY_AFTER_REVOCATION", { exact: true })).toBeVisible();
await submitMessage(page, "AFTER_REENABLE_THROUGH_A");
await expect
.poll(
async () =>
(await fleet.direct.agents.ref(fleet.remote.id).refresh())?.agent.lastUserMessageAt,
)
observationRequests[request.type] = (observationRequests[request.type] ?? 0) + 1;
});
});
.not.toBe(beforeRecovery);
expect((await fleet.local.refresh())?.agent.lastUserMessageAt).toBeNull();
} finally {
await fleet.close();
}
});
test("an agent delegates through MCP to self and B, opens both ordinary conversations and receives correlated completion", async ({
page,
}) => {
test.setTimeout(180_000);
await page.setViewportSize({ width: 1400, height: 900 });
const counts = observeFleetRouteOwnership(page);
const fleet = await delegateFleetThroughMcp();
try {
await openAgentRoute(page, {
workspaceId: fleet.workspace.workspaceId,
agentId: fleet.parent.id,
});
console.log("Fleet ordinary parent ownership", await counts());
await openSubagentsTrack(page);
await expect(page.getByTestId(`subagents-track-row-${fleet.local.agentId}`)).toContainText(
await expect(page.getByTestId(`subagents-track-row-${fleet.local.id}`)).toContainText(
"MCP child on A",
);
await expect(page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`)).toContainText(
await expect(page.getByTestId(`subagents-track-row-${fleet.remote.id}`)).toContainText(
"MCP child on B",
);
await expect(page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`)).toContainText(
await expect(page.getByTestId(`subagents-track-row-${fleet.remote.id}`)).toContainText(
"Fleet B",
);
await page.getByTestId(`subagents-track-row-${fleet.local.agentId}`).click();
await expect(page.getByTestId(`workspace-tab-agent_${fleet.local.agentId}`)).toBeVisible();
await submitMessage(page, "LOCAL_UI_TARGET");
await expect(page.getByText("LOCAL_UI_TARGET", { exact: true })).toBeVisible();
expect((await fleet.b.fetchAgent(fleet.local.agentId))?.agent.lastUserMessageAt).toBeNull();
await page.getByTestId(`subagents-track-row-${fleet.local.id}`).click();
await expect(page).toHaveURL((url) =>
url.pathname.includes(`/h/${fleet.local.serverId}/workspace/`),
);
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
await fleet.expectCompletion(fleet.local);
await submitMessage(page, "UI_MCP_FLEET_LOCAL");
await expect(page.getByText("UI_MCP_FLEET_LOCAL", { exact: true })).toBeVisible();
expect(
(await fleet.direct.agents.ref(fleet.local.id).refresh())?.agent.lastUserMessageAt,
).toBeNull();
await page.getByTestId(`workspace-tab-agent_${fleet.parent.id}`).click();
await openSubagentsTrack(page);
await page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`).click();
await expect(page).toHaveURL((url) =>
url.pathname.includes(`/h/${fleet.remote.serverId}/workspace/`),
await page.getByTestId(`subagents-track-row-${fleet.remote.id}`).click();
await expect(page).toHaveURL(
(url) =>
url.pathname.includes(`/h/${fleet.remote.serverId}/workspace/`) &&
url.searchParams.get("origin") === fleet.parent.serverId,
);
await submitMessage(page, "REMOTE_UI_TARGET");
await expect(page.getByText("REMOTE_UI_TARGET", { exact: true })).toBeVisible();
expect((await fleet.a.fetchAgent(fleet.remote.agentId))?.agent.lastUserMessageAt).toBeNull();
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
console.log("Fleet destination conversation ownership", await counts());
await fleet.expectCompletion(fleet.remote);
await submitMessage(page, "UI_MCP_FLEET_REMOTE");
await expect(page.getByText("UI_MCP_FLEET_REMOTE", { exact: true })).toBeVisible();
expect(
(await fleet.origin.agents.ref(fleet.remote.id).refresh())?.agent.lastUserMessageAt,
).toBeNull();
await fleet.expectCompletion(fleet.local);
await fleet.expectCompletion(fleet.remote);
await openAgentRoute(page, {
workspaceId: fleet.workspace.workspaceId,
agentId: fleet.parent.id,
});
await expect(
page.getByRole("button", { name: /Agent finished.*MCP child on A/ }),
).toBeVisible();
await expect(
page.getByRole("button", { name: /Agent finished.*MCP child on B/ }),
).toBeVisible();
const owners = await page.evaluate(() => {
const runtime = Reflect.get(globalThis, "__paseoHostRuntimeStore") as {
getHosts(): Array<{ serverId: string }>;
getSnapshot(
serverId: string,
): { connectionStatus: string; clientGeneration: number } | null;
};
return runtime.getHosts().map((host) => {
const snapshot = runtime.getSnapshot(host.serverId);
return {
serverId: host.serverId,
connectionStatus: snapshot?.connectionStatus,
clientGeneration: snapshot?.clientGeneration,
};
});
});
console.log("Fleet ordinary route counts", {
appSockets,
activeDaemonSockets: activeDaemonSockets.size,
routeOpens,
owners,
observationRequests,
});
} finally {
await fleet.close();
}
});
async function openFleetParentWithDirectB(
page: Page,
fleet: Awaited<ReturnType<typeof delegateFleetThroughMcp>>,
) {
await page.goto("/", { waitUntil: "commit" });
const host = buildSeededHost({
serverId: fleet.remote.serverId,
endpoint: `127.0.0.1:${fleet.destination.port}`,
label: "Fleet B",
nowIso: new Date().toISOString(),
});
await page.evaluate((extraHost) => {
localStorage.setItem("@paseo:e2e-extra-hosts", JSON.stringify([extraHost]));
}, host);
await openAgentRoute(page, {
workspaceId: fleet.workspace.workspaceId,
agentId: fleet.parent.id,
});
await openSubagentsTrack(page);
await expect(page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`)).toContainText(
"MCP child on B",
);
}
async function openFleetChild(
page: Page,
fleet: Awaited<ReturnType<typeof delegateFleetThroughMcp>>,
) {
await page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`).click();
await expect(page).toHaveURL(
(url) => url.pathname === `/h/${fleet.remote.serverId}/workspace/${fleet.remote.workspaceId}`,
);
}
test("a directly connected B opens and archives its MCP child from A's track", async ({ page }) => {
const fleet = await delegateFleetThroughMcp();
try {
await openFleetParentWithDirectB(page, fleet);
await openFleetChild(page, fleet);
await expect(page.getByRole("button", { name: "MCP child on B", exact: true })).toBeVisible();
await submitMessage(page, "DIRECT_B_TARGET");
await expect(page.getByText("DIRECT_B_TARGET", { exact: true })).toBeVisible();
expect((await fleet.a.fetchAgent(fleet.remote.agentId))?.agent.lastUserMessageAt).toBeNull();
await openFleetParentWithDirectB(page, fleet);
await page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`).hover();
page.once("dialog", (dialog) => {
void dialog.accept();
});
await page.getByRole("button", { name: "Archive MCP child on B", exact: true }).click();
await expectManagedSubagentArchived({ client: fleet.b }, fleet.remote.agentId);
await expect(page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`)).toHaveCount(0);
expect((await fleet.a.fetchAgent(fleet.remote.agentId))?.agent.archivedAt).toBeNull();
await expect(page.getByText(/already connected through another route/)).toHaveCount(0);
} finally {
await fleet.close();
}
});
test("an unavailable B child opens its cached workspace with existing reconnecting behavior", async ({
page,
}) => {
const fleet = await delegateFleetThroughMcp();
try {
await openFleetParentWithDirectB(page, fleet);
await fleet.destination.close();
await expect
.poll(() =>
page.evaluate((serverId) => {
const runtime = Reflect.get(globalThis, "__paseoHostRuntimeStore") as {
getSnapshot(id: string): { serverId: string; connectionStatus: string } | null;
};
return runtime.getSnapshot(serverId);
}, fleet.remote.serverId),
)
.toMatchObject({
serverId: fleet.remote.serverId,
connectionStatus: expect.stringMatching(/^(offline|connecting|error)$/),
});
await openFleetChild(page, fleet);
await expect(page.getByTestId("workspace-header-title").filter({ visible: true })).toHaveText(
"B workspace",
);
await expect(page.getByRole("button", { name: "MCP child on B", exact: true })).toBeVisible();
await expect(page.getByText(/already connected through another route/)).toHaveCount(0);
await readFleetCompletion(page, fleet.local);
await readFleetCompletion(page, fleet.remote);
} finally {
await fleet.close();
}
@@ -404,7 +404,7 @@ async function fetchWorkspaceStatuses(
}
test.describe("Workspace model restart regressions", () => {
test("browser-created same-cwd workspace preserves repaired restart status and migrated tab ownership", async ({
test("browser-created same-cwd workspace preserves restarted agent status and migrated tab ownership", async ({
page,
baseURL,
}) => {
@@ -423,13 +423,9 @@ test.describe("Workspace model restart regressions", () => {
.toMatchObject({
id: LEGACY_AGENT_ID,
workspaceId: seeded.workspaceA,
status: "closed",
status: "running",
});
await expect
.poll(() => fetchWorkspaceStatuses(client, [seeded.workspaceA, seeded.workspaceB]))
.toEqual({ [seeded.workspaceA]: "done", [seeded.workspaceB]: "done" });
await page.goto(buildHostWorkspaceRoute(serverId, seeded.workspaceA));
await waitForSidebarHydration(page);
await expectWorkspaceRowDoesNotShowIndicator(page, {
@@ -476,11 +472,15 @@ test.describe("Workspace model restart regressions", () => {
]),
)
.toMatchObject({
[seeded.workspaceA]: "done",
[seeded.workspaceB]: "done",
[createdWorkspaceId]: "done",
});
// The restarted provider session may settle while the browser creates the sibling. Its
// initial running status is asserted above; this phase verifies that ownership never moves.
const workspaceStatuses = await fetchWorkspaceStatuses(client, [seeded.workspaceA]);
expect(["running", "done"]).toContain(workspaceStatuses[seeded.workspaceA]);
await expectWorkspaceRowDoesNotShowIndicator(page, {
serverId,
workspaceId: seeded.workspaceB,
@@ -11,7 +11,6 @@ import {
expectWorkspaceTabVisible,
openWorkspaceWithAgents,
} from "../support/helpers/archive-tab";
import { expectAgentAbsentFromWorkspaceLayout } from "../support/helpers/workspace-layout";
import { clickNewChat } from "../support/helpers/launcher";
import { expectComposerVisible } from "../support/helpers/composer";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
@@ -94,20 +93,10 @@ async function closeFirstVisibleDraftTab(page: Page): Promise<void> {
await closeButton.first().click();
}
async function openWorkspaceTabUrl(page: Page, route: string): Promise<void> {
await page.evaluate((url) => {
window.history.pushState(null, "", url);
window.dispatchEvent(new PopStateEvent("popstate"));
}, route);
await expectAppRoute(page, route.split("?")[0]);
}
test.describe("Workspace navigation regression", () => {
test.describe.configure({ timeout: 240_000 });
test("keeps an offline notification on the agent route until the host connects", async ({
page,
}) => {
test("opens a notification's workspace on a different offline host", async ({ page }) => {
const target = {
serverId: "notification-offline-host",
workspaceId: "notification-workspace",
@@ -134,33 +123,14 @@ test.describe("Workspace navigation regression", () => {
);
}, target);
await expectAppRoute(page, buildHostAgentDetailRoute(target.serverId, target.agentId), {
timeout: 30_000,
});
await expect(page.getByTestId("agent-route-waiting-for-host")).toBeVisible();
await expect(page.getByText(/Notification Host/).first()).toBeVisible();
});
test("cold agent targets hydrate their workspace without opening the compact sidebar", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "notification-cold-compact-" });
try {
const agent = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Cold notification agent",
});
await page.setViewportSize({ width: 390, height: 844 });
await gotoAppShell(page);
await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id));
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), workspace.workspaceId), {
timeout: 30_000,
});
await expectComposerVisible(page);
} finally {
await workspace.cleanup();
}
await expectAppRoute(
page,
buildHostWorkspaceOpenRoute(target.serverId, target.workspaceId, `agent:${target.agentId}`),
{ timeout: 30_000 },
);
await expect(page.getByText("Connecting", { exact: true })).toBeVisible();
await expect(page.getByText("Notification Host", { exact: true })).toBeVisible();
await expect(page.getByText("Add a project", { exact: true })).toHaveCount(0);
});
test("shows the New launcher after returning from settings and closing the last agent draft tab", async ({
@@ -351,7 +321,7 @@ test.describe("Workspace navigation regression", () => {
}
});
test("a stale notification workspace preserves retained workspace selection and tabs", async ({
test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({
page,
}) => {
const serverId = getServerId();
@@ -465,7 +435,7 @@ test.describe("Workspace navigation regression", () => {
}),
);
},
{ agentId: secondAgent.id, serverId, workspaceId: firstWorkspace.workspaceId },
{ agentId: secondAgent.id, serverId, workspaceId: secondWorkspace.workspaceId },
);
await waitForWorkspaceTabsVisible(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
@@ -493,22 +463,6 @@ test.describe("Workspace navigation regression", () => {
await expect(secondDeckEntry).toBeHidden();
await expectWorkspaceDeckEntryCount(page, 2);
await expectAgentAbsentFromWorkspaceLayout(page, {
serverId,
workspaceId: firstWorkspace.workspaceId,
agentId: secondAgent.id,
});
await openWorkspaceTabUrl(
page,
buildHostWorkspaceOpenRoute(serverId, firstWorkspace.workspaceId, `agent:${firstAgent.id}`),
);
await expectWorkspaceTabVisible(page, firstAgent.id);
await expectAgentAbsentFromWorkspaceLayout(page, {
serverId,
workspaceId: secondWorkspace.workspaceId,
agentId: firstAgent.id,
});
await page.reload();
await waitForSidebarHydration(page);
await waitForWorkspaceTabsVisible(page);
@@ -2,7 +2,6 @@ import { test, expect } from "../support/fixtures";
import { createTempGitRepo } from "../support/helpers/workspace";
import {
connectWorkspaceSetupClient,
setupCommandOutput,
createWorkspaceThroughDaemon,
openWorkspaceScriptsMenu,
seedProjectForWorkspaceSetup,
@@ -34,9 +33,7 @@ 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" &&
setupCommandOutput(payload.detail).includes("setup complete"),
(payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
@@ -18,7 +18,6 @@ import {
} from "../support/helpers/terminal-perf";
import {
connectWorkspaceSetupClient,
setupCommandOutput,
createWorkspaceThroughDaemon,
expectSetupPanel,
openHomeWithProject,
@@ -93,8 +92,7 @@ test.describe("Workspace setup streaming", () => {
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
payload.status === "completed" && payload.detail.log.includes("setup complete"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
@@ -172,19 +170,16 @@ test.describe("Workspace setup streaming", () => {
await seedProjectForWorkspaceSetup(client, repo.path);
const initialRunning = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "running" && payload.detail.commands.length === 0,
(payload) => payload.status === "running" && payload.detail.log === "",
);
const runningWithOutput = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "running" &&
setupCommandOutput(payload.detail).includes("starting setup"),
(payload) => payload.status === "running" && payload.detail.log.includes("starting setup"),
);
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
payload.status === "completed" && payload.detail.log.includes("setup complete"),
);
await createWorkspaceThroughDaemon(client, {
@@ -197,8 +192,8 @@ test.describe("Workspace setup streaming", () => {
const completedPayload = await completed;
expect(initialPayload.detail.log).toBe("");
expect(setupCommandOutput(runningPayload.detail)).toContain("starting setup");
expect(setupCommandOutput(completedPayload.detail)).toContain("setup complete");
expect(runningPayload.detail.log).toContain("starting setup");
expect(completedPayload.detail.log).toContain("setup complete");
expect(completedPayload.error).toBeNull();
} finally {
await client.close();
@@ -220,9 +215,7 @@ test.describe("Workspace setup streaming", () => {
await seedProjectForWorkspaceSetup(client, repo.path);
const failed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "failed" &&
setupCommandOutput(payload.detail).includes("setup failed"),
(payload) => payload.status === "failed" && payload.detail.log.includes("setup failed"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
@@ -231,8 +224,8 @@ test.describe("Workspace setup streaming", () => {
});
const failedPayload = await failed;
expect(setupCommandOutput(failedPayload.detail)).toContain("starting setup");
expect(setupCommandOutput(failedPayload.detail)).toContain("setup failed");
expect(failedPayload.detail.log).toContain("starting setup");
expect(failedPayload.detail.log).toContain("setup failed");
expect(failedPayload.error).toMatch(/failed/i);
await openHomeWithProject(page, repo.path);
@@ -338,8 +331,7 @@ test.describe("Workspace setup streaming", () => {
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
payload.status === "completed" && payload.detail.log.includes("setup complete"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
@@ -385,8 +377,7 @@ test.describe("Workspace setup streaming", () => {
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" &&
setupCommandOutput(payload.detail).includes("setup complete"),
payload.status === "completed" && payload.detail.log.includes("setup complete"),
);
const result = await client.createPaseoWorktree({
+25 -13
View File
@@ -1,15 +1,27 @@
const HIDE_DEVELOPER_TOASTS = "#error-toast { display: none !important; }";
/** @param {import('@playwright/test').Page} page */
export async function hideDeveloperToasts(page) {
// Developer chrome must not cover app actions or move the test's pointer/focus.
// Console errors remain in the runner's logs. Hiding the toast, instead of clicking it away,
// leaves no race with a toast that closes or is replaced between matching and clicking.
await page.addInitScript((css) => {
const style = document.createElement("style");
style.textContent = css;
document.addEventListener("DOMContentLoaded", () => document.head.append(style));
}, HIDE_DEVELOPER_TOASTS);
// A page attached after it loaded (Electron over CDP) runs no init script until it navigates.
await page.addStyleTag({ content: HIDE_DEVELOPER_TOASTS });
export async function installDeveloperToastDismissal(page) {
// Keep developer chrome out of app actions without pointer/focus changes.
// Locator handlers can deadlock when concurrent checks dismiss the same toast.
const install = () => {
let observedRoot;
const options = { childList: true, subtree: true };
const dismiss = () => {
const toast = document.getElementById("error-toast");
const root = toast?.shadowRoot ?? toast;
if (root && root !== observedRoot) {
observer.disconnect();
observer.observe(document, options);
observer.observe(root, options);
observedRoot = root;
}
// Expo's toast root contains dismiss buttons; its overlay has a different ID.
for (const button of root?.querySelectorAll("button") ?? [])
button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
};
const observer = new MutationObserver(dismiss);
observer.observe(document, options);
dismiss();
};
await page.addInitScript(install);
await page.evaluate(install);
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { test as base, expect, type Page } from "@playwright/test";
import { hideDeveloperToasts } from "./developer-ui.mjs";
import { installDeveloperToastDismissal } from "./developer-ui.mjs";
import { startOutdatedDaemon, type OutdatedDaemon } from "./helpers/daemon-update";
import { startE2EWorker } from "./helpers/e2e-worker";
import { getE2EDaemonPort } from "./helpers/daemon-port";
@@ -131,7 +131,7 @@ const test = daemonTest.extend<{
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
});
await hideDeveloperToasts(page);
await installDeveloperToastDismissal(page);
const entries: string[] = [];
@@ -1,3 +1,4 @@
import { inspectSessionWireRequest } from "./session-wire";
import type { Page, WebSocketRoute } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
@@ -16,11 +17,7 @@ interface SendAgentMessageRequest {
function readSendRequest(message: WebSocketMessage): SendAgentMessageRequest | null {
if (typeof message !== "string") return null;
try {
const envelope = JSON.parse(message) as {
type?: unknown;
message?: Record<string, unknown>;
};
const request = envelope.type === "session" ? envelope.message : null;
const request = inspectSessionWireRequest(message)?.request;
if (
request?.type !== "send_agent_message_request" ||
typeof request.requestId !== "string" ||
@@ -22,22 +22,6 @@ export async function openCommandCenterForAgent(
return panel.getByTestId("command-center-input");
}
/**
* Types a query that matches both the agent's modes and its models, and waits until rows from both
* groups are listed. The agent's controls register after the panel opens; a key pressed before
* then lands on whatever else matched, such as file search.
*/
export async function searchAgentControls(page: Page, query: string): Promise<void> {
const panel = page.getByTestId("command-center-panel");
await panel.getByTestId("command-center-input").fill(query);
await expect(panel.getByRole("button", { name: /^Mode › / }).first()).toBeVisible({
timeout: 30_000,
});
await expect(panel.getByRole("button", { name: /^Model › / }).first()).toBeVisible({
timeout: 30_000,
});
}
export async function chooseCommandCenterAgentControl(input: {
page: Page;
query: string;
@@ -74,3 +74,12 @@ export async function loadProtocolSchemas(): Promise<typeof import("@getpaseo/pr
).href;
return import(moduleUrl);
}
export async function loadPaseoApiFactory(): Promise<
(typeof import("@getpaseo/client"))["createPaseoApi"]
> {
const moduleUrl = pathToFileURL(
path.resolve(__dirname, "../../../../../packages/client/dist/index.js"),
).href;
return (await import(moduleUrl)).createPaseoApi;
}
@@ -1,3 +1,4 @@
import { inspectSessionWireRequest } from "./session-wire";
import type { Page, WebSocketRoute } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
@@ -71,12 +72,11 @@ function readSessionMessage(message: string | Buffer): ClientRequest | null {
function readClientRequest(message: string | Buffer): ClientRequest | null {
if (typeof message !== "string") return null;
const session = inspectSessionWireRequest(message);
if (session) return session.request;
try {
const envelope = JSON.parse(message) as {
type?: unknown;
message?: ClientRequest;
};
return envelope.type === "session" ? (envelope.message ?? null) : envelope;
const envelope = JSON.parse(message) as ClientRequest;
return envelope.type === "session" ? null : envelope;
} catch {
return null;
}
@@ -1,76 +0,0 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import { pluginRequirements } from "./plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { expectFileTabOpen, openFileFromExplorer } from "./file-explorer";
/** A directory plugin whose "Rich Markdown" file panel claims `.md`, next to the built-in. */
export const MARKDOWN_PANEL_PLUGIN_ID = "file-panel-picker-e2e";
export const MARKDOWN_PANEL_ID = `plugin:${MARKDOWN_PANEL_PLUGIN_ID}/rich-markdown`;
export const BUILTIN_MARKDOWN_OPTION = "Markdown — Paseo";
export const PLUGIN_MARKDOWN_OPTION = `Rich Markdown — ${MARKDOWN_PANEL_PLUGIN_ID}`;
const SOURCE = `import React from "react";
import { Text, View } from "react-native";
function RichMarkdown({ file }) {
return <View><Text>Rich Markdown plugin: {file.name}</Text></View>;
}
export default function contribute(client) {
client.addFilePanel({ id: "rich-markdown", title: "Rich Markdown", icon: "FileType",
files: { extensions: ["md"] }, Component: RichMarkdown });
return () => {};
}`;
/** Enables plugins on the test daemon and installs the plugin; `uninstall` restores both. */
export async function installMarkdownPanelPlugin(): Promise<{ uninstall(): Promise<void> }> {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-markdown-file-panel-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previousConfig = await client.getDaemonConfig();
const uninstall = async () => {
await client.removePlugin(MARKDOWN_PANEL_PLUGIN_ID);
await client.patchDaemonConfig({
pluginsEnabled: previousConfig.config.pluginsEnabled ?? false,
});
await client.close();
await rm(directory, { recursive: true, force: true });
};
try {
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: MARKDOWN_PANEL_PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), SOURCE);
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
} catch (error) {
await uninstall();
throw error;
}
return { uninstall };
}
export function filePanelPicker(page: Page) {
return page.getByText("Open .md files with", { exact: true });
}
export async function openMarkdown(page: Page, name: string): Promise<void> {
await openFileFromExplorer(page, name);
await expectFileTabOpen(page, name);
}
export async function expectBuiltinMarkdown(page: Page, heading: string): Promise<void> {
await expect(page.getByRole("heading", { name: heading, exact: true })).toBeVisible();
}
export async function expectPluginMarkdown(page: Page, name: string): Promise<void> {
await expect(page.getByText(`Rich Markdown plugin: ${name}`, { exact: true })).toBeVisible();
}
export async function storedFilePanelAssociations(page: Page): Promise<unknown> {
return page.evaluate(() => {
const settings = JSON.parse(localStorage.getItem("@paseo:app-settings") ?? "{}");
return settings.filePanelAssociations ?? {};
});
}
@@ -1,17 +1,11 @@
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Locator, TestInfo } from "@playwright/test";
import type { Locator } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
import { buildAgentRoute } from "./mock-agent";
import { openCommandCenter } from "./command-center";
import {
closeCompactWorkspaceMenu,
compactWorkspaceMenu,
goBackInMenu,
openCompactWorkspaceMenu,
} from "./compact-workspace-scripts";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { seedWorkspace } from "./seed-client";
import { pluginRequirements } from "./plugin-fixture";
@@ -66,7 +60,7 @@ export default function contribute(client) {
let finishReview = () => {};
let attempts = 0;
const action = { kind: "action", async onPress() {
if (++attempts % 2 === 1) throw new Error("Deployment failed. Try again.");
if (++attempts === 1) throw new Error("Deployment failed. Try again.");
await new Promise((resolve) => { finish = resolve; });
deploy.update({ label: "Deployed" });
} };
@@ -171,13 +165,6 @@ async function command(page: Page, title: string) {
await expect(panel).not.toBeVisible();
}
/** The command center opens from the sidebar, which compact layouts keep closed. */
async function commandFromCompact(page: Page, title: string) {
await page.setViewportSize(WIDE);
await command(page, title);
await page.setViewportSize(COMPACT);
}
async function expectSurfaceVisible(
page: Page,
subject: Locator = page.getByRole("toolbar", { name: "Workspace actions", exact: true }),
@@ -189,61 +176,6 @@ async function expectDetails(page: Page) {
await expect(page.getByText("All systems operational", { exact: true })).toBeInViewport();
}
function workspaceMenuRow(page: Page, title: string): Locator {
return compactWorkspaceMenu(page).getByRole("menuitem", { name: title, exact: true });
}
async function expectWorkspaceMenuRows(page: Page, titles: readonly string[]) {
const rows = compactWorkspaceMenu(page).getByRole("menuitem");
for (const [index, title] of titles.entries()) {
await expect(rows.nth(index)).toHaveAccessibleName(title);
}
}
async function chooseInWorkspaceMenu(page: Page, title: string) {
await workspaceMenuRow(page, title).click();
}
async function expectWorkspaceMenuClosed(page: Page) {
await expect(compactWorkspaceMenu(page)).toBeHidden({ timeout: 10_000 });
}
async function expectCompactDetails(page: Page) {
const menu = compactWorkspaceMenu(page);
await expect(menu.getByText("All systems operational", { exact: true })).toBeInViewport();
await expect(menu.getByText("Presentation: compact", { exact: true })).toBeVisible();
}
/** Resolves once `subject` renders the same pixels twice in a row: entering animations are done. */
async function expectStill(subject: Locator) {
let previous: Buffer | null = null;
await expect
.poll(
async () => {
const current = await subject.screenshot();
const still = previous !== null && current.equals(previous);
previous = current;
return still;
},
{ intervals: [150] },
)
.toBe(true);
}
/** Attaches the screen once per colour scheme, for review alongside the run. */
async function captureSchemes(page: Page, info: TestInfo, name: string, subject: Locator) {
for (const colorScheme of ["light", "dark"] as const) {
await page.emulateMedia({ colorScheme });
await expectStill(subject);
const file = info.outputPath(`${name}-${colorScheme}.png`);
await info.attach(`${name}-${colorScheme}`, {
body: await page.screenshot({ path: file }),
contentType: "image/png",
});
}
await page.emulateMedia({ colorScheme: "light" });
}
async function expectHeaderTextTruncated(page: Page) {
const title = page.getByTestId("workspace-header-title");
const subtitle = page.getByTestId("workspace-header-subtitle");
@@ -272,12 +204,10 @@ async function expectHeaderTextTruncated(page: Page) {
export async function withButtonShowcase(
page: Page,
info: TestInfo,
run: (buttons: {
openWideMenusAndPopovers(): Promise<void>;
runAndUpdateActions(): Promise<void>;
openCompactSheets(): Promise<void>;
useCompactWorkspaceMenu(): Promise<void>;
hideAndUnloadOpenButtons(): Promise<void>;
verifyObservationLifetime(): Promise<void>;
}) => Promise<void>,
@@ -354,12 +284,6 @@ export async function withButtonShowcase(
.getByTestId("plugin-button-chevron"),
).toHaveCount(0);
await expectSurfaceVisible(page);
await captureSchemes(
page,
info,
"desktop-wide-header",
page.getByRole("toolbar", { name: "Workspace actions", exact: true }),
);
await press(page, "Header checks");
await expect(
page.getByRole("menuitem", { name: "Run checks", exact: true }),
@@ -379,12 +303,6 @@ export async function withButtonShowcase(
await expect(
page.getByRole("menuitem", { name: "Production locked", exact: true }),
).toBeDisabled();
await captureSchemes(
page,
info,
"desktop-overflow",
page.getByRole("menuitem", { name: "Production locked", exact: true }),
);
await expectSurfaceVisible(
page,
page.getByRole("menuitem", { name: "Production locked", exact: true }),
@@ -454,19 +372,7 @@ export async function withButtonShowcase(
await expectSurfaceVisible(page);
await command(page, "Enable example buttons");
await command(page, "Use icon-only header");
await expect(
page.getByRole("button", { name: "Deploy application", exact: true }),
).toHaveText("");
await expectSurfaceVisible(page);
await expect(
page.getByText("Deployment failed. Try again.", { exact: true }),
).toHaveCount(0);
await captureSchemes(
page,
info,
"desktop-icon-only-header",
page.getByRole("toolbar", { name: "Workspace actions", exact: true }),
);
await command(page, "Hide review button");
await expect(page.getByRole("button", { name: "Run review", exact: true })).toHaveCount(
0,
@@ -477,7 +383,7 @@ export async function withButtonShowcase(
);
}),
openCompactSheets: () =>
test.step("compact header leaves plugin actions to the workspace menu while composer triggers open sheets", async () => {
test.step("compact header omits plugin actions while composer triggers open sheets", async () => {
await page.setViewportSize(COMPACT);
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(0);
await expect(
@@ -516,98 +422,6 @@ export async function withButtonShowcase(
);
await choose(page, "Run checks");
}),
useCompactWorkspaceMenu: () =>
test.step("compact workspace menu carries header buttons with their actions, menus, and content", async () => {
await openCompactWorkspaceMenu(page);
await expectWorkspaceMenuRows(page, [
"Deploy application",
"Header checks",
"Header status",
"Open deployment logs",
"Production locked",
"Copy workspace path",
]);
await expect(workspaceMenuRow(page, "Production locked")).toBeDisabled();
// Deploy lost its label to "Use icon-only header" and logs never had one; rows read titles.
await expect(workspaceMenuRow(page, "Deploy application")).toHaveText(
"Deploy application",
);
await expect(workspaceMenuRow(page, "Open deployment logs")).toHaveText(
"Open deployment logs",
);
await captureSchemes(page, info, "compact-root-icon-only", compactWorkspaceMenu(page));
await showcase.setTitle("Compact checks");
await expect(workspaceMenuRow(page, "Deploy application")).toHaveCount(0);
await showcase.setTitle("Button showcase");
await expect(workspaceMenuRow(page, "Deploy application")).toBeVisible();
await chooseInWorkspaceMenu(page, "Deploy application");
await expectWorkspaceMenuClosed(page);
await expect(
page.getByText("Deployment failed. Try again.", { exact: true }),
).toBeVisible();
await openCompactWorkspaceMenu(page);
await chooseInWorkspaceMenu(page, "Deploy application");
await expectWorkspaceMenuClosed(page);
await openCompactWorkspaceMenu(page);
await expect(workspaceMenuRow(page, "Deploy application")).toBeDisabled();
await captureSchemes(page, info, "compact-row-states", compactWorkspaceMenu(page));
await closeCompactWorkspaceMenu(page);
await commandFromCompact(page, "Finish deployment");
await openCompactWorkspaceMenu(page);
await expect(workspaceMenuRow(page, "Deploy application")).toBeEnabled();
await captureSchemes(page, info, "compact-root", compactWorkspaceMenu(page));
await chooseInWorkspaceMenu(page, "Header checks");
await expectWorkspaceMenuRows(page, ["Run checks", "Environment", "Deployment details"]);
await captureSchemes(page, info, "compact-header-checks", compactWorkspaceMenu(page));
await goBackInMenu(page);
await expect(workspaceMenuRow(page, "Header status")).toBeVisible();
await chooseInWorkspaceMenu(page, "Header checks");
await chooseInWorkspaceMenu(page, "Environment");
await expectWorkspaceMenuRows(page, ["Staging", "Production"]);
await expect(workspaceMenuRow(page, "Production")).toBeDisabled();
await captureSchemes(page, info, "compact-environment", compactWorkspaceMenu(page));
await goBackInMenu(page);
await expect(workspaceMenuRow(page, "Run checks")).toBeVisible();
await goBackInMenu(page);
await expect(workspaceMenuRow(page, "Header checks")).toBeVisible();
await chooseInWorkspaceMenu(page, "Header checks");
await chooseInWorkspaceMenu(page, "Deployment details");
await expectCompactDetails(page);
await captureSchemes(
page,
info,
"compact-deployment-details",
compactWorkspaceMenu(page),
);
await goBackInMenu(page);
await expect(workspaceMenuRow(page, "Run checks")).toBeVisible();
await chooseInWorkspaceMenu(page, "Deployment details");
await expectCompactDetails(page);
await press(page, "Close deployment details");
await expectWorkspaceMenuClosed(page);
await openCompactWorkspaceMenu(page);
await chooseInWorkspaceMenu(page, "Header status");
await expectCompactDetails(page);
await captureSchemes(page, info, "compact-header-status", compactWorkspaceMenu(page));
await press(page, "Fail button content");
await expect(
compactWorkspaceMenu(page).getByText("Plugin failed: Button content failed", {
exact: true,
}),
).toBeVisible();
await goBackInMenu(page);
await expect(workspaceMenuRow(page, "Header status")).toBeVisible();
await chooseInWorkspaceMenu(page, "Open deployment logs");
await expectCompactDetails(page);
await captureSchemes(page, info, "compact-logs-popover", compactWorkspaceMenu(page));
await press(page, "Close deployment details");
await expectWorkspaceMenuClosed(page);
}),
hideAndUnloadOpenButtons: () =>
test.step("hiding an open button and unloading a plugin remove its surfaces", async () => {
await page.setViewportSize(WIDE);
@@ -640,12 +454,6 @@ export async function withButtonShowcase(
0,
);
await expect(page.getByText("All systems operational", { exact: true })).toHaveCount(0);
await page.setViewportSize(COMPACT);
await openCompactWorkspaceMenu(page);
await expect(workspaceMenuRow(page, "Header checks")).toHaveCount(0);
await expect(workspaceMenuRow(page, "Header status")).toHaveCount(0);
await closeCompactWorkspaceMenu(page);
await page.setViewportSize(WIDE);
await showcase.setTitle("Healthy app after plugin unload");
await expect(page.getByTestId("workspace-header-title")).toHaveText(
"Healthy app after plugin unload",
@@ -2,7 +2,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, type Page, type TestInfo } from "@playwright/test";
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
import { pluginRequirements } from "./plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
@@ -274,40 +274,3 @@ export async function withPopoverTimelinePlugin(run: () => Promise<void>): Promi
run,
});
}
const THOUGHT =
"Need to find the scroll container, the layout effect that watches for new messages, and any gesture handler that might fight with programmatic scrolling. Probably a ref on the FlatList plus a near-bottom threshold.";
/** Exercises the maintained inline-thinking renderer against real streamed reasoning. */
export async function viewInlineThinkingExample(page: Page, info: TestInfo): Promise<void> {
info.setTimeout(120_000);
const example = await copyPluginExample("inline-thinking");
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const agent = await seedMockAgentWorkspace({
repoPrefix: "inline-thinking-",
title: "Inline thinking example",
model: "one-minute-stream",
});
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(example.directory);
await openAgentRoute(page, agent);
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible();
await requestPluginTimeline(agent);
const thought = page.getByText(/^Need to find the scroll container/);
await expect(thought).toBeVisible({ timeout: 60_000 });
await expect(thought).not.toHaveText(THOUGHT);
await info.attach("inline-thinking-streaming", {
body: await page.screenshot({ path: info.outputPath("inline-thinking-streaming.png") }),
contentType: "image/png",
});
await expect(thought).toHaveText(THOUGHT, { timeout: 30_000 });
} finally {
await client.removePlugin("inline-thinking");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
await client.close();
await agent.cleanup();
await example.cleanup();
}
}
@@ -1,164 +0,0 @@
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();
}
}
@@ -0,0 +1,44 @@
/** Inspect an ordinary session request while preserving any owning transport envelopes. */
export function inspectSessionWireRequest(message: string | Buffer): {
request: Record<string, unknown>;
reply(response: unknown): string;
} | null {
const routes: string[] = [];
let raw = typeof message === "string" ? message : message.toString("utf8");
try {
for (;;) {
const envelope = JSON.parse(raw);
if (envelope?.type !== "session" || !envelope.message || typeof envelope.message !== "object")
return null;
const request: Record<string, unknown> = envelope.message;
if (request.type !== "hosts.route.frame.request") {
return {
request,
reply(response) {
let data = JSON.stringify({ type: "session", message: response });
for (const subscriptionId of routes.toReversed()) {
data = JSON.stringify({
type: "session",
message: {
type: "hosts.route.frame",
payload: { subscriptionId, data, binary: false },
},
});
}
return data;
},
};
}
if (
typeof request.routeId !== "string" ||
typeof request.data !== "string" ||
request.binary !== false
)
return null;
routes.push(request.routeId);
raw = request.data;
}
} catch {
return null;
}
}
@@ -24,7 +24,6 @@ const SECTION_LABELS = {
appearance: "Appearance",
sidebar: "Sidebar",
editor: "Editor",
files: "Files",
terminal: "Terminal",
browser: "Browser",
shortcuts: "Shortcuts",
+317 -118
View File
@@ -1,7 +1,15 @@
import { connectDaemonClient } from "./daemon-client-loader";
import type { HostRuntimeStore } from "@/runtime/host-runtime";
import { addConnectedHostAndReload } from "./hosts";
import { buildSeededHost } from "./daemon-registry";
import { openAgentRoute } from "./mock-agent";
import { openSessions, clickSessionRow } from "./archive-tab";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { randomUUID } from "node:crypto";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectDaemonClient, loadPaseoApiFactory } from "./daemon-client-loader";
import { startIsolatedHostDaemon } from "./isolated-host-daemon";
import { seedWorkspace } from "./seed-client";
import { getE2EDaemonPort } from "./daemon-port";
import { inspectSessionWireRequest } from "./session-wire";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { expect, type Page } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
@@ -170,7 +178,7 @@ export async function expectSubagentRowGone(page: Page, childId: string): Promis
}
export async function expectManagedSubagentUnarchived(
workspace: { client: Pick<SeededWorkspace["client"], "fetchAgent"> },
workspace: Pick<SeededWorkspace, "client">,
childId: string,
): Promise<void> {
await expect(workspace.client.fetchAgent({ agentId: childId })).resolves.toMatchObject({
@@ -179,7 +187,7 @@ export async function expectManagedSubagentUnarchived(
}
export async function expectManagedSubagentArchived(
workspace: { client: Pick<SeededWorkspace["client"], "fetchAgent"> },
workspace: Pick<SeededWorkspace, "client">,
childId: string,
): Promise<void> {
await expect
@@ -255,22 +263,19 @@ export async function rejectNextManagedSubagentArchiveRequest(
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const requestId = archiveRequestId(message, subagentId);
if (requestId && !rejected) {
const intercepted = archiveRequest(message, subagentId);
if (intercepted && !rejected) {
rejected = true;
// A daemon-rejected archive RPC: the request never reaches the daemon, and its normal
// correlated error travels back through the real browser client and mutation rollback.
ws.send(
JSON.stringify({
type: "session",
message: {
type: "rpc_error",
payload: {
requestId,
requestType: "archive_agent_request",
error: "Archive rejected for test",
code: "archive_rejected",
},
intercepted.reply({
type: "rpc_error",
payload: {
requestId: intercepted.request.requestId,
requestType: "archive_agent_request",
error: "Archive rejected for test",
code: "archive_rejected",
},
}),
);
@@ -286,28 +291,18 @@ export async function rejectNextManagedSubagentArchiveRequest(
}
function isArchiveRequestForSubagent(message: WebSocketMessage, subagentId: string): boolean {
return archiveRequestId(message, subagentId) !== null;
return archiveRequest(message, subagentId) !== null;
}
function archiveRequestId(message: WebSocketMessage, subagentId: string): string | null {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
const envelope = JSON.parse(rawMessage) as {
type?: unknown;
message?: { type?: unknown; agentId?: unknown; requestId?: unknown };
};
if (
envelope.type === "session" &&
envelope.message?.type === "archive_agent_request" &&
envelope.message.agentId === subagentId &&
typeof envelope.message.requestId === "string"
) {
return envelope.message.requestId;
}
return null;
} catch {
return null;
}
function archiveRequest(message: WebSocketMessage, subagentId: string) {
const inspected = inspectSessionWireRequest(message);
if (
inspected?.request.type === "archive_agent_request" &&
inspected.request.agentId === subagentId &&
typeof inspected.request.requestId === "string"
)
return inspected;
return null;
}
export async function detachSubagentFromTrack(page: Page, childId: string): Promise<void> {
@@ -325,51 +320,212 @@ export async function detachSubagentFromTrack(page: Page, childId: string): Prom
await detachButton.click();
}
/** The primary children come from the real agent MCP endpoint on A. */
/** Real equal-ID children in equal-ID workspaces, with both route authorities independently live. */
export async function seedFleetSubagents() {
const workspace = await seedWorkspace({ repoPrefix: "fleet-track-" });
const destination = await startIsolatedHostDaemon(`fleet_${randomUUID()}`);
const clients: DaemonClient[] = [];
const apis: ReturnType<Awaited<ReturnType<typeof loadPaseoApiFactory>>>[] = [];
const close = async () => {
await Promise.allSettled(apis.map((api) => api.dispose()));
await Promise.allSettled(clients.map((client) => client.close()));
await destination.close();
await workspace.cleanup();
};
try {
const createApi = await loadPaseoApiFactory();
const aClient = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "fleet-origin" });
clients.push(aClient);
const bClient = await connectDaemonClient<DaemonClient>({
clientIdPrefix: "fleet-destination",
port: destination.port,
});
clients.push(bClient);
const origin = createApi(aClient);
const direct = createApi(bClient);
apis.push(origin, direct);
const registered = await origin.hosts.add({ connection: `127.0.0.1:${destination.port}` });
await expect
.poll(
async () =>
(await origin.hosts.list()).hosts.find(
(host) => host.registrationId === registered.registrationId,
)?.status,
)
.toBe("online");
const destinationWorkspace = await direct.workspaces.create({
workspaceId: workspace.workspaceId,
source: { kind: "directory", path: workspace.repoPath },
});
expect(destinationWorkspace.id).toBe(workspace.workspaceId);
const config = { provider: "mock/ten-second-stream", modeId: "load-test" };
const parent = await origin.workspaces
.ref(workspace.workspaceId)
.agents.create({ title: "Fleet parent", config });
const agentId = randomUUID();
const local = await parent.subagents.create({ agentId, title: "Fleet child on A", config });
const remote = await parent.subagents.create({
agentId,
serverId: destination.serverId,
workspaceId: destinationWorkspace.id,
title: "Fleet child on B",
config,
});
return {
workspace,
destination,
origin,
direct,
parent,
local,
remote,
disable: () => origin.hosts.update(registered.registrationId, { enabled: false }),
enable: () => origin.hosts.update(registered.registrationId, { enabled: true }),
close,
};
} catch (error) {
await close();
throw error;
}
}
export async function connectFleetDirectHost(
page: Page,
fleet: Awaited<ReturnType<typeof seedFleetSubagents>>,
) {
await openAgentRoute(page, {
workspaceId: fleet.workspace.workspaceId,
agentId: fleet.parent.id,
});
await addConnectedHostAndReload(page, {
serverId: fleet.destination.serverId,
port: fleet.destination.port,
label: "Fleet B",
});
await page.evaluate(
(host) => localStorage.setItem("@paseo:e2e-extra-hosts", JSON.stringify([host])),
buildSeededHost({
serverId: fleet.destination.serverId,
endpoint: `127.0.0.1:${fleet.destination.port}`,
nowIso: new Date().toISOString(),
label: "Fleet B",
}),
);
await page.goto(
buildHostAgentDetailRoute(
fleet.destination.serverId,
fleet.remote.id,
fleet.workspace.workspaceId,
),
);
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
}
export async function openFleetParentTrack(page: Page) {
await openSessions(page);
await clickSessionRow(page, "Fleet parent");
await openSubagentsTrack(page);
}
export async function readFleetAuthorityState(
page: Page,
originServerId: string,
serverId: string,
) {
return page.evaluate(
({ originServerId: sourceId, serverId: destinationId }) => {
const runtime = Reflect.get(globalThis, "__paseoHostRuntimeStore") as HostRuntimeStore;
const read = (origin: string) => {
const binding = runtime
.getAuthorityBindings()
.find(
(candidate) =>
candidate.reference.originServerId === origin &&
candidate.reference.serverId === destinationId,
);
const queries =
binding?.queries
.getQueryCache()
.findAll({ queryKey: ["providersSnapshot", destinationId] }) ?? [];
return {
status: binding?.runtime.getSnapshot(destinationId)?.connectionStatus ?? "absent",
hydrated: binding?.sessionStore.getState().sessions[destinationId]?.hasHydratedWorkspaces,
workspaceIds: [
...(binding?.sessionStore.getState().sessions[destinationId]?.workspaces.keys() ?? []),
],
catalogVersion: Math.max(0, ...queries.map((query) => query.state.dataUpdatedAt)),
};
};
return { direct: read(destinationId), routed: read(sourceId) };
},
{ originServerId, serverId },
);
}
export async function expectFleetRouteUnavailable(
page: Page,
originServerId: string,
serverId: string,
) {
await expect
.poll(async () => (await readFleetAuthorityState(page, originServerId, serverId)).routed.status)
.toMatch(/^(offline|error)$/);
const unavailable = page.getByText(/^(Cannot reach .+|.+ is offline)$/).filter({ visible: true });
const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(unavailable.or(composer)).toBeVisible();
if (await composer.isVisible()) {
if (await composer.isEditable()) {
await composer.fill("MUST_NOT_USE_DIRECT_AUTHORITY");
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeDisabled();
} else await expect(composer).toBeDisabled();
} else
await expect(page.getByRole("button", { name: "Send message", exact: true })).toHaveCount(0);
}
/** Primary children are created by the agent's real MCP endpoint, never the seed SDK. */
export async function delegateFleetThroughMcp() {
const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
const { StreamableHTTPClientTransport } =
await import("@modelcontextprotocol/sdk/client/streamableHttp.js");
const { getE2EDaemonPort } = await import("./daemon-port");
const { z } = await import("zod");
type DaemonClient = import("@getpaseo/client/internal/daemon-client").DaemonClient;
const workspace = await seedWorkspace({ repoPrefix: "fleet-mcp-" });
const destination = await startIsolatedHostDaemon(`fleet_${crypto.randomUUID()}`);
const a = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "fleet-seed-A" });
const b = await connectDaemonClient<DaemonClient>({
clientIdPrefix: "fleet-observe-B",
const workspace = await seedWorkspace({ repoPrefix: "mcp-fleet-journey-" });
const destination = await startIsolatedHostDaemon(`fleet_${randomUUID()}`);
const createApi = await loadPaseoApiFactory();
const aClient = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "mcp-fleet-origin" });
const bClient = await connectDaemonClient<DaemonClient>({
clientIdPrefix: "mcp-fleet-observer",
port: destination.port,
});
const serverId = a.getLastServerInfoMessage()!.serverId;
const mcp = new Client({ name: "fleet-parent", version: "1" });
const origin = createApi(aClient);
const direct = createApi(bClient);
const mcp = new Client({ name: "fleet-delegating-agent", version: "1.0.0" });
const close = async () => {
await mcp.close();
await Promise.allSettled([a.close(), b.close()]);
await Promise.allSettled([origin.dispose(), direct.dispose()]);
await Promise.allSettled([aClient.close(), bClient.close()]);
await destination.close();
await workspace.cleanup();
};
const call = async (name: string, args: Record<string, unknown> = {}) => {
const response = await mcp.callTool({ name, arguments: args });
expect(response, name).not.toMatchObject({ isError: true });
return z.record(z.string(), z.unknown()).parse(response.structuredContent);
const result = await mcp.callTool({ name, arguments: args });
expect(result, `${name} result`).not.toMatchObject({ isError: true });
return z.record(z.string(), z.unknown()).parse(result.structuredContent);
};
try {
const parent = await a.createAgent({
provider: "mock",
model: "e2e-fast-stream",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Fleet parent",
modeId: "load-test",
});
const registration = await a.addHost({
const config = { provider: "mock/ten-second-stream", modeId: "load-test" };
const parent = await origin.workspaces
.ref(workspace.workspaceId)
.agents.create({ title: "Fleet MCP parent", config });
const registered = await origin.hosts.add({
connection: `127.0.0.1:${destination.port}`,
label: "Fleet B",
});
await expect
.poll(
async () =>
(await a.listHosts()).hosts.find(
(row) => row.registrationId === registration.registrationId,
(await origin.hosts.list()).hosts.find(
(host) => host.registrationId === registered.registrationId,
)?.status,
)
.toBe("online");
@@ -378,7 +534,8 @@ export async function delegateFleetThroughMcp() {
new URL(`http://127.0.0.1:${getE2EDaemonPort()}/mcp/agents?callerAgentId=${parent.id}`),
),
);
expect(await call("list_hosts")).toMatchObject({
const hosts = await call("list_hosts");
expect(hosts).toMatchObject({
hosts: expect.arrayContaining([
expect.objectContaining({ serverId: destination.serverId, label: "Fleet B" }),
]),
@@ -387,83 +544,83 @@ export async function delegateFleetThroughMcp() {
serverId: destination.serverId,
isolation: "local",
path: workspace.repoPath,
title: "B workspace",
title: "Fleet B workspace",
});
const targetWorkspaceId = z.string().parse(target.workspaceId);
expect(await call("list_workspaces", { serverId: destination.serverId })).toMatchObject({
workspaces: expect.arrayContaining([
expect.objectContaining({ workspaceId: target.workspaceId }),
expect.objectContaining({ workspaceId: targetWorkspaceId }),
]),
});
const createChild = async (targetServerId: string, workspaceId: string, title: string) => {
const createChild = async (serverId: string, workspaceId: string, title: string) => {
const result = await call("create_agent", {
serverId: targetServerId,
serverId,
workspaceId,
title,
provider: "mock/e2e-fast-stream",
settings: { modeId: "load-test" },
initialPrompt: `Finish ${title}`,
provider: config.provider,
settings: { modeId: config.modeId },
initialPrompt: `Complete ${title}`,
notifyOnFinish: true,
});
return {
serverId: targetServerId,
agentId: z.string().parse(result.agentId),
workspaceId,
title,
};
const agentId = z.string().parse(result.agentId);
const submissionId = z.string().parse(result.submissionId);
expect(await call("get_agent_status", { serverId, agentId })).toMatchObject({ serverId });
expect(await call("get_agent_activity", { serverId, agentId })).toMatchObject({ serverId });
expect(await call("list_agents", { serverId })).toMatchObject({
serverId,
agents: expect.arrayContaining([expect.objectContaining({ id: agentId })]),
});
return { id: agentId, serverId, workspaceId, submissionId, title };
};
const local = await createChild(serverId, workspace.workspaceId, "MCP child on A");
const remote = await createChild(
destination.serverId,
z.string().parse(target.workspaceId),
"MCP child on B",
const local = await createChild(parent.serverId, workspace.workspaceId, "MCP child on A");
const remote = await createChild(destination.serverId, targetWorkspaceId, "MCP child on B");
// Opposite-host collisions are unrelated fixture agents. Neither primary child is seeded.
await direct.agents.create({
agentId: local.id,
cwd: workspace.repoPath,
title: "Unrelated collision on B",
config,
});
await origin.agents.create({
agentId: remote.id,
cwd: workspace.repoPath,
title: "Unrelated collision on A",
config,
});
expect((await parent.subagents.list()).rows.map((row) => row.child)).toEqual(
expect.arrayContaining([
{ serverId: local.serverId, agentId: local.id },
{ serverId: remote.serverId, agentId: remote.id },
]),
);
// Existing SDK reservation creates unrelated equal-ID fixtures; primary children remain MCP-created.
await b.createAgent({
agentId: local.agentId,
provider: "mock",
model: "e2e-fast-stream",
cwd: workspace.repoPath,
workspaceId: String(target.workspaceId),
title: "Unrelated on B",
modeId: "load-test",
});
await a.createAgent({
agentId: remote.agentId,
provider: "mock",
model: "e2e-fast-stream",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Unrelated on A",
modeId: "load-test",
});
return {
workspace,
destination,
parent,
serverId,
local,
remote,
a,
b,
origin,
direct,
close,
async expectCompletion(child: typeof local) {
await expect
.poll(async () => {
const timeline = await a.fetchAgentTimeline(parent.id, {
direction: "tail",
limit: 100,
});
return timeline.entries
.flatMap(({ item }) =>
item.type === "tool_call" && item.agentMessage ? [item.agentMessage] : [],
)
.filter(
(message) =>
message.event === "finished" &&
message.sender.id === child.agentId &&
message.text.includes(`Server: ${child.serverId}`),
).length;
})
.toBe(1);
.poll(
async () => {
const timeline = await parent.timeline.refetch();
return timeline.entries
.flatMap(({ item }) =>
item.type === "tool_call" && item.agentMessage ? [item.agentMessage] : [],
)
.filter(
(message) =>
message.event === "finished" &&
message.sender.address?.serverId === child.serverId &&
message.text.includes(child.submissionId),
);
},
{ timeout: 30000 },
)
.toHaveLength(1);
},
};
} catch (error) {
@@ -471,3 +628,45 @@ export async function delegateFleetThroughMcp() {
throw error;
}
}
export async function readFleetCompletion(
page: Page,
child: { title: string; submissionId: string },
) {
await expect(
page.getByRole("button", { name: new RegExp(`Agent finished.*${child.title}`) }),
).toBeVisible();
}
/** Records observed requests and runtime-owned sessions without opening extra scopes. */
export function observeFleetRouteOwnership(page: Page) {
const requests: Record<string, number> = {};
let sockets = 0;
page.on("websocket", (socket) => {
sockets++;
socket.on("framesent", ({ payload }) => {
const request = inspectSessionWireRequest(payload)?.request;
if (!request || typeof request.type !== "string") return;
if (request.subscribe !== undefined || request.type === "hosts.route.open.request")
requests[request.type] = (requests[request.type] ?? 0) + 1;
});
});
return async () => ({
sockets,
cumulativeRequests: { ...requests },
bindings: await page.evaluate(() => {
const registry = Reflect.get(globalThis, "__paseoHostRuntimeStore") as HostRuntimeStore;
return registry.getAuthorityBindings().map((binding) => {
const id = binding.reference.serverId;
const session = binding.sessionStore.getState().sessions[id];
return {
reference: binding.reference,
status: binding.runtime.getSnapshot(id)?.connectionStatus,
session: session?.client !== null && session?.client !== undefined,
workspaceReplica: session?.hasHydratedWorkspaces === true,
timelineReplica: session?.viewedTimelineSync !== undefined,
};
});
}),
});
}
@@ -68,8 +68,8 @@ export async function navigateToTerminal(
const workspaceRoute = buildTerminalWorkspaceUrl(input.workspaceId, input.terminalId);
await page.goto(workspaceRoute);
// The route seeds the legacy URL target into the pending-open model, then
// removes the query. Tab readiness below waits for that intent to be consumed.
// The workspace layout consumes `?open=...`, returns null during the effect,
// then replaces the URL with the clean workspace route after preparing the tab.
// On CI, Expo Router's rootNavigationState may take time to initialize,
// so we allow a generous timeout here.
const cleanWorkspaceRoute = buildWorkspaceUrl(input.workspaceId);
@@ -86,8 +86,7 @@ export async function navigateToTerminal(
await terminalTab.waitFor({ state: "visible", timeout: 30_000 });
await terminalTab.click();
// Inactive terminal tabs retain their surfaces; interact with the selected tab.
const terminalSurface = page.getByTestId("terminal-surface").filter({ visible: true });
const terminalSurface = page.locator('[data-testid="terminal-surface"]');
await terminalSurface.waitFor({ state: "visible", timeout: 15_000 });
// Wait for loading overlay to disappear (terminal attached)
@@ -1,4 +1,4 @@
import { expect, type Page } from "@playwright/test";
import type { Page } from "@playwright/test";
import { z } from "zod";
import { WorkspaceLayoutPersistedStateSchema } from "../../../src/stores/workspace-layout-storage";
import {
@@ -61,26 +61,3 @@ export async function seedCorruptedWorkspaceLayout(page: Page, workspaceId: stri
JSON.stringify(stored),
);
}
export async function expectAgentAbsentFromWorkspaceLayout(
page: Page,
input: { serverId: string; workspaceId: string; agentId: string },
): Promise<void> {
await expect
.poll(async () => {
const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state"));
if (!raw) throw new Error("Workspace layout was not persisted");
const stored = z
.object({ state: WorkspaceLayoutPersistedStateSchema })
.parse(JSON.parse(raw));
const key = `${input.serverId}:${input.workspaceId}`;
const layout = stored.state.layoutByWorkspace[key];
const pins = stored.state.pinnedAgentIdsByWorkspace?.[key] ?? [];
// The persisted tree is validated above; inspect all panes including hidden ones.
return {
containsAgent: JSON.stringify(layout).includes(input.agentId),
pinned: pins.includes(input.agentId),
};
})
.toEqual({ containsAgent: false, pinned: false });
}
@@ -376,7 +376,3 @@ 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,8 +9,6 @@ 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,7 +5,6 @@ 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"),
@@ -17,7 +16,6 @@ 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
@@ -77,14 +75,6 @@ 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);
}
@@ -7,7 +7,6 @@
#include <stdexcept>
#include <unordered_map>
#include "include/ports/SkFontMgr_android.h"
#include "include/ports/SkFontScanner_FreeType.h"
#include "modules/skunicode/include/SkUnicode.h"
namespace {
@@ -87,7 +86,7 @@ extern "C" JNIEXPORT jdoubleArray JNICALL METHOD(prepareDocument)(
const auto fontFamily = SkUnicode::convertUtf16ToUtf8(copyString(env, family));
const double copyMs = elapsed(copyStart);
const auto layoutStart = Clock::now();
paseo::diff::prepare(*document, SkFontMgr_New_Android(nullptr, SkFontScanner_Make_FreeType()), fontFamily.c_str(), size, job->cancelled);
paseo::diff::prepare(*document, SkFontMgr_New_Android(nullptr), fontFamily.c_str(), size, job->cancelled);
const double layoutMs = elapsed(layoutStart);
{
std::lock_guard lock(mutex);
@@ -13,7 +13,6 @@
#include "modules/skparagraph/include/Paragraph.h"
#include "modules/skparagraph/include/ParagraphBuilder.h"
#include "modules/skunicode/include/SkUnicode.h"
#include "modules/skunicode/include/SkUnicode_icu.h"
namespace paseo::diff {
namespace {
@@ -179,7 +178,7 @@ class Measurer {
return it->second;
}
std::unique_ptr<para::Paragraph> shape(const std::u16string& text) {
auto builder = para::ParagraphBuilder::make(style_, fonts_, SkUnicodes::ICU::Make());
auto builder = para::ParagraphBuilder::make(style_, fonts_);
builder->pushStyle(textStyle_);
const auto utf8 = SkUnicode::convertUtf16ToUtf8(text);
builder->addText(utf8.c_str(), utf8.size());
@@ -13,7 +13,3 @@ useDefaultAndroidSdkVersions()
android {
namespace "sh.paseo.trace"
}
dependencies {
testImplementation "junit:junit:4.13.2"
}
@@ -1,77 +0,0 @@
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()
}
}
@@ -1,36 +0,0 @@
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) }
}
@@ -1,38 +0,0 @@
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,23 +5,9 @@ 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))
}
@@ -1,61 +0,0 @@
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)
}
}
}
@@ -1,40 +0,0 @@
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)
}
}
@@ -1,59 +0,0 @@
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)
}
}
+5 -10
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.13.0-beta.1",
"version": "0.12.0",
"private": true,
"main": "index.ts",
"scripts": {
@@ -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 dev:android",
"android:development": "npm run dev:android",
"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: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 run dev:ios",
"ios": "npm --prefix ../.. run build:client && expo run: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,12 +36,7 @@
"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",
"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"
"build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs"
},
"dependencies": {
"@codemirror/commands": "6.10.4",
@@ -1,4 +1,3 @@
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";
@@ -62,8 +61,6 @@ 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,
+126 -12
View File
@@ -1,3 +1,4 @@
import { HostAuthoritySessions } from "@/runtime/host-authority-sessions";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useIsExplorerSidebarOpen } from "@/workspace-tabs/explorer-sidebar";
@@ -7,6 +8,7 @@ import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { PortalProvider } from "@gorhom/portal";
import { LucideProvider } from "lucide-react-native";
import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications";
import { Stack, useNavigationContainerRef, usePathname, useRouter } from "expo-router";
import {
createContext,
@@ -15,6 +17,7 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
@@ -49,7 +52,11 @@ import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-short
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
import { HostConfirmationSheet } from "@/hosts/host-confirmation-sheet";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
import {
getIsElectronRuntime,
HEADER_INNER_HEIGHT,
useIsCompactFormFactor,
} from "@/constants/layout";
import {
canDesktopAppSidebarShare,
resolveDesktopAppChromeLayout,
@@ -91,6 +98,7 @@ import { KeyboardShiftProvider } from "@/keyboard/shift";
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
import { useOpenProject } from "@/hooks/use-open-project";
import { useAppSettings } from "@/hooks/use-settings";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useOpenAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelsProvider, useIsMobilePanelActive } from "@/mobile-panels/provider";
import {
@@ -125,9 +133,14 @@ import {
parseHostWorkspaceRouteFromPathname,
parseServerIdFromPathname,
} from "@/utils/host-routes";
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { PluginCatalogSync } from "@/plugins";
import { NotificationOpenRouter } from "@/notification-open/react";
import { ensureOsNotificationPermission } from "@/utils/os-notifications";
import {
ensureOsNotificationPermission,
WEB_NOTIFICATION_CLICK_EVENT,
type WebNotificationClickDetail,
} from "@/utils/os-notifications";
polyfillCrypto();
@@ -147,6 +160,114 @@ const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
startupBlocker: { kind: "none" },
});
function PushNotificationRouter() {
const router = useRouter();
const lastHandledIdRef = useRef<string | null>(null);
const openNotification = useStableEvent((data: Record<string, unknown> | undefined) => {
const target = resolveNotificationTarget(data);
const serverId = target.serverId;
const workspaceId = target.workspaceId;
const agentId = target.agentId;
if (serverId && workspaceId && agentId) {
navigateToAgent({ serverId, workspaceId, agentId, pin: true });
return;
}
router.navigate(buildNotificationRoute(data));
});
useEffect(() => {
if (isWeb) {
let removeDesktopNotificationListener: (() => void) | null = null;
let cancelled = false;
if (getIsElectronRuntime()) {
void ensureOsNotificationPermission();
const unlistenResult = getDesktopHost()?.events?.on?.(
"notification-click",
(payload: unknown) => {
const data =
typeof payload === "object" &&
payload !== null &&
"data" in payload &&
typeof (payload as { data?: unknown }).data === "object" &&
(payload as { data?: unknown }).data !== null
? (payload as { data: Record<string, unknown> }).data
: undefined;
openNotification(data);
},
);
void Promise.resolve(unlistenResult).then((unlisten) => {
if (typeof unlisten !== "function") {
return;
}
if (cancelled) {
unlisten();
return;
}
removeDesktopNotificationListener = unlisten;
return;
});
}
const openFromWebClick = (event: Event) => {
const customEvent = event as CustomEvent<WebNotificationClickDetail>;
event.preventDefault();
openNotification(customEvent.detail?.data);
};
window.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
return () => {
cancelled = true;
removeDesktopNotificationListener?.();
window.removeEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
};
}
Notifications.setNotificationHandler({
handleNotification: async () => ({
// When the app is open, don't show OS banners.
shouldShowAlert: false,
shouldShowBanner: false,
shouldShowList: false,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});
const openFromResponse = (response: Notifications.NotificationResponse) => {
const identifier = response.notification.request.identifier;
if (lastHandledIdRef.current === identifier) {
return;
}
lastHandledIdRef.current = identifier;
const data = response.notification.request.content.data as
| Record<string, unknown>
| undefined;
openNotification(data);
};
const subscription = Notifications.addNotificationResponseReceivedListener(openFromResponse);
void Notifications.getLastNotificationResponseAsync().then((response) => {
if (response) {
openFromResponse(response);
}
return;
});
return () => {
subscription.remove();
};
}, [openNotification]);
return null;
}
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
const client = useHostRuntimeClient(daemon.serverId);
@@ -193,6 +314,7 @@ function HostSessionManager() {
return (
<>
<HostAuthoritySessions />
{hosts.map((daemon) => (
<ManagedDaemonSession key={daemon.serverId} daemon={daemon} />
))}
@@ -825,9 +947,6 @@ function WorkspaceRouteNavigationBridge() {
}
function AppShell() {
useEffect(() => {
if (getDesktopHost()) void ensureOsNotificationPermission();
}, []);
return (
<MobilePanelsProvider>
<HorizontalScrollProvider>
@@ -842,15 +961,10 @@ function AppShell() {
);
}
function NotificationOpenBridge() {
const { startupBlocker } = useHostRuntimeBootstrapState();
return <NotificationOpenRouter startupBlocked={startupBlocker.kind !== "none"} />;
}
function RuntimeProviders({ children }: { children: ReactNode }) {
return (
<HostRuntimeBootstrapProvider>
<NotificationOpenBridge />
<PushNotificationRouter />
<SidebarCalloutProvider>
<ProvidersWrapper>{children}</ProvidersWrapper>
</SidebarCalloutProvider>
@@ -19,13 +19,20 @@ export default function HostRouteLayout() {
}
function KnownHostRoute() {
const params = useLocalSearchParams<{ serverId?: string | string[] }>();
const params = useLocalSearchParams<{
serverId?: string | string[];
origin?: string | string[];
}>();
const hosts = useHosts();
const hostRegistryStatus = useHostRegistryStatus();
const bootstrapState = useHostRuntimeBootstrapState();
const routeServerId = typeof params.serverId === "string" ? params.serverId : null;
const startupRoute = resolveStartupRoute({
route: { kind: "host", serverId: routeServerId },
route: {
kind: "host",
serverId: routeServerId,
...(typeof params.origin === "string" ? { originServerId: params.origin } : {}),
},
startupBlocker: bootstrapState.startupBlocker,
hostRegistryStatus,
hosts,
@@ -1,27 +1,39 @@
import { HostAuthorityRoute } from "@/runtime/host-authority-route";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { useFetchQuery } from "@/data/query";
import { resolveAgentRoute, type AgentRouteLookup } from "@/navigation/agent-route-resolution";
import { AgentRouteResolutionView } from "@/navigation/agent-route-resolution-view";
import { useHasHydratedWorkspaces } from "@/stores/session-store-hooks";
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
import { useSessionStore } from "@/stores/session-store";
import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
import { useHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
import {
buildHostRootRoute,
buildSettingsHostRoute,
withHostRouteAuthority,
} from "@/utils/host-routes";
import { toErrorMessage } from "@/utils/error-messages";
import { navigateToAgent } from "@/utils/navigate-to-agent";
export default function HostAgentReadyRoute() {
const { serverId, origin } = useLocalSearchParams<{ serverId?: string; origin?: string }>();
const reference = useMemo(
() => ({ originServerId: origin ?? serverId ?? "", serverId: serverId ?? "" }),
[origin, serverId],
);
if (!serverId) return null;
return (
<HostRouteBootstrapBoundary>
<HostAgentReadyRouteContent />
<HostAuthorityRoute reference={reference}>
<HostAgentReadyRouteContent />
</HostAuthorityRoute>
</HostRouteBootstrapBoundary>
);
}
function HostAgentReadyRouteContent() {
const router = useRouter();
const runtime = useHostRuntimeStore();
const params = useLocalSearchParams<{
serverId?: string;
agentId?: string;
@@ -42,10 +54,8 @@ function HostAgentReadyRouteContent() {
});
useEffect(() => {
if (!serverId || !agentId || agentWorkspaceId) return;
void getHostRuntimeStore()
.prepareAgentRoute(serverId, agentId)
.catch(() => undefined);
}, [agentId, agentWorkspaceId, serverId]);
void runtime.prepareAgentRoute(serverId, agentId).catch(() => undefined);
}, [agentId, agentWorkspaceId, serverId, runtime]);
const shouldLookupAgent = Boolean(
serverId && agentId && client && connectionStatus === "online" && !agentWorkspaceId,
);
@@ -85,16 +95,7 @@ function HostAgentReadyRouteContent() {
lookupQuery.isSuccess,
shouldLookupAgent,
]);
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
const hasHydratedLayout = useWorkspaceLayoutStoreHydrated();
useEffect(() => {
if (!serverId) return;
// A compact app can open this route before its sidebar has demanded the directory.
return getHostRuntimeStore().acquireDirectoryDemand(serverId);
}, [serverId]);
const resolution = resolveAgentRoute({
hasHydratedWorkspaces,
hasHydratedLayout,
serverId,
agentId,
cachedWorkspaceId: agentWorkspaceId,
@@ -117,11 +118,23 @@ function HostAgentReadyRouteContent() {
handledNavigationRef.current = navigationKey;
if (resolution.kind === "resolved") {
navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId, pin: true });
navigateToAgent({
serverId,
agentId,
workspaceId: resolution.workspaceId,
authority: runtime.authorityFor(serverId),
});
return;
}
router.replace(resolution.kind === "invalid" ? ("/" as Href) : buildHostRootRoute(serverId));
}, [agentId, resolution, router, serverId]);
router.replace(
resolution.kind === "invalid"
? ("/" as Href)
: (withHostRouteAuthority(
buildHostRootRoute(serverId),
runtime.authorityFor(serverId),
) as Href),
);
}, [agentId, resolution, router, serverId, runtime]);
const handleRetry = useCallback(() => {
if (resolution.kind === "lookupError") {
@@ -129,21 +142,28 @@ function HostAgentReadyRouteContent() {
return;
}
if (serverId) {
void getHostRuntimeStore().runProbeCycleNow(serverId);
void runtime.runProbeCycleNow(serverId);
}
}, [lookupQuery, resolution.kind, serverId]);
}, [lookupQuery, resolution.kind, serverId, runtime]);
const handleManageHost = useCallback(() => {
if (serverId) {
router.push(buildSettingsHostRoute(serverId));
router.push(buildSettingsHostRoute(runtime.authorityFor(serverId).originServerId));
}
}, [router, serverId]);
}, [router, serverId, runtime]);
const handleBack = useCallback(() => {
if (router.canGoBack()) {
router.back();
return;
}
router.replace(serverId ? buildHostRootRoute(serverId) : ("/" as Href));
}, [router, serverId]);
router.replace(
serverId
? (withHostRouteAuthority(
buildHostRootRoute(serverId),
runtime.authorityFor(serverId),
) as Href)
: ("/" as Href),
);
}, [router, serverId, runtime]);
if (
resolution.kind === "waitingForHost" ||
+31 -4
View File
@@ -1,4 +1,10 @@
import { Redirect } from "expo-router";
import { useMemo } from "react";
import { HostAuthorityRoute } from "@/runtime/host-authority-route";
import {
workspaceSelectionAuthority,
type ActiveWorkspaceSelection,
} from "@/stores/last-workspace-selection";
import { Redirect, useLocalSearchParams } from "expo-router";
import { useHostRouteServerId } from "@/navigation/host-route-context";
import {
resolveHostIndexRoute,
@@ -13,10 +19,31 @@ import {
export default function HostIndexRoute() {
const serverId = useHostRouteServerId();
const workspaceSelection = useLastWorkspaceSelection();
const remembered = useLastWorkspaceSelection();
const { origin } = useLocalSearchParams<{ origin?: string }>();
const selection = useMemo(
() =>
remembered?.serverId === serverId
? { ...remembered, ...(origin ? { authority: { originServerId: origin } } : {}) }
: null,
[remembered, serverId, origin],
);
if (!selection) return <HostIndexContent workspaceSelection={null} />;
return (
<HostAuthorityRoute reference={workspaceSelectionAuthority(selection)}>
<HostIndexContent workspaceSelection={selection} />
</HostAuthorityRoute>
);
}
function HostIndexContent({
workspaceSelection,
}: {
workspaceSelection: ActiveWorkspaceSelection | null;
}) {
const serverId = useHostRouteServerId();
const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated();
const workspaceSelectionWorkspaceId =
workspaceSelection?.serverId === serverId ? workspaceSelection.workspaceId : null;
const workspaceSelectionWorkspaceId = workspaceSelection?.workspaceId ?? null;
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
const workspaceSelectionExists = useWorkspaceExists(serverId, workspaceSelectionWorkspaceId);
@@ -1,7 +1,9 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react";
import { HostAuthorityRoute } from "@/runtime/host-authority-route";
import { workspaceSelectionAuthority } from "@/stores/last-workspace-selection";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useNavigation } from "expo-router/react-navigation";
import { StyleSheet, View } from "react-native";
import { useLocalSearchParams } from "expo-router";
import { useGlobalSearchParams, useRootNavigationState } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { RetainedPanel } from "@/components/retained-panel";
import {
@@ -11,7 +13,7 @@ import {
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
import {
areRetainedWorkspaceSelectionListsEqual,
areWorkspaceSelectionsEqual,
@@ -24,16 +26,12 @@ import {
resolveWorkspaceDeckEntries,
shouldKeepWorkspaceDeckEntryMounted,
} from "@/screens/workspace/workspace-deck-retention";
import {
decodeWorkspaceIdFromPathSegment,
parseWorkspaceOpenIntent,
type WorkspaceOpenIntent,
} from "@/utils/host-routes";
import { parseWorkspaceOpenIntent, type WorkspaceOpenIntent } from "@/utils/host-routes";
import {
replaceBrowserRouteWithCanonicalHostWorkspaceRoute,
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit,
} from "@/utils/host-route-browser";
import { prepareWorkspaceTab } from "@/utils/prepare-workspace-tab";
import { useWorkspaceTabNavigation } from "@/workspace-tabs/navigation";
import { isNative, isWeb } from "@/constants/platform";
import { RenderProfile } from "@/utils/render-profiler";
@@ -64,8 +62,6 @@ function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarge
return { kind: "draft", draftId: openIntent.draftId };
}
// COMPAT(workspace-open-url): added in v0.12.0, remove after 2027-01-22.
// Bookmarks and older clients still send ?open=; internal navigation uses pending model state.
function stripOpenSearchParamFromBrowserUrl() {
if (!isWeb || typeof window === "undefined") {
return;
@@ -83,9 +79,7 @@ function clearConsumedOpenIntent(input: {
}) {
input.navigation.setParams({ open: undefined });
if (isWeb) {
// Expo commits the incoming browser URL after setParams. Strip the consumed legacy input
// after that commit so it cannot be restored into the address bar and replayed on reload.
window.setTimeout(stripOpenSearchParamFromBrowserUrl, 0);
stripOpenSearchParamFromBrowserUrl();
}
}
@@ -98,18 +92,41 @@ export default function HostWorkspaceIndexRoute() {
}
function HostWorkspaceRouteContent() {
const { open } = useGlobalSearchParams<{ open?: string | string[] }>();
return (
<WorkspaceDeck
recoveryRequested={parseWorkspaceOpenIntent(getParamValue(open))?.kind === "agent"}
/>
);
}
function WorkspaceOpenIntentBoundary({
selection,
active,
children,
}: {
selection: ActiveWorkspaceSelection;
active: boolean;
children: React.ReactNode;
}) {
const { prepareWorkspaceTab } = useWorkspaceTabNavigation();
const navigation = useNavigation();
const params = useLocalSearchParams<{
serverId?: string | string[];
workspaceId?: string | string[];
const rootNavigationState = useRootNavigationState();
const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated();
const consumedIntentRef = useRef<string | null>(null);
const [intentConsumed, setIntentConsumed] = useState(false);
const globalParams = useGlobalSearchParams<{
open?: string | string[];
}>();
const serverId = getParamValue(params.serverId);
const workspaceValue = getParamValue(params.workspaceId);
const workspaceId = workspaceValue
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
: "";
const openValue = getParamValue(params.open);
const { serverId, workspaceId } = selection;
const openValue = active ? getParamValue(globalParams.open) : "";
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
const workspaceExists = useWorkspaceExists(serverId, workspaceId);
const openIntent = useMemo(() => parseWorkspaceOpenIntent(openValue), [openValue]);
const isAgentOpenIntent = openIntent?.kind === "agent";
const isOpenIntentWaitingForWorkspace = Boolean(
isAgentOpenIntent && (!hasHydratedWorkspaces || !workspaceExists),
);
useEffect(() => {
if (!serverId || !workspaceId) {
return;
@@ -117,31 +134,75 @@ function HostWorkspaceRouteContent() {
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit();
}, [serverId, workspaceId]);
useLayoutEffect(() => {
if (!openValue || !serverId || !workspaceId) return;
const openIntent = parseWorkspaceOpenIntent(openValue);
if (openIntent) {
prepareWorkspaceTab(
{
serverId,
workspaceId,
target: getOpenIntentTarget(openIntent),
pin: openIntent.kind === "agent",
},
{ openTab: useWorkspaceLayoutStore.getState().deferOpenTab },
);
useEffect(() => {
if (!openValue) {
return;
}
if (!rootNavigationState?.key) {
return;
}
if (!hasHydratedWorkspaceLayoutStore) {
return;
}
if (isOpenIntentWaitingForWorkspace) {
return;
}
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
if (consumedIntentRef.current === consumptionKey) {
clearConsumedOpenIntent({
navigation: navigation as unknown as {
setParams: (params: { open?: string | undefined }) => void;
},
});
setIntentConsumed(true);
return;
}
consumedIntentRef.current = consumptionKey;
if (openIntent) {
prepareWorkspaceTab({
serverId,
workspaceId,
target: getOpenIntentTarget(openIntent),
pin: openIntent.kind === "agent",
});
}
// Expo Router's replace ignores query-param-only changes (findDivergentState
// skips search params). Strip ?open from the browser URL directly so the
// address bar reflects the clean workspace route.
clearConsumedOpenIntent({
navigation: navigation as unknown as {
setParams: (params: { open?: string | undefined }) => void;
},
});
}, [navigation, openValue, serverId, workspaceId]);
return <WorkspaceDeck />;
setIntentConsumed(true);
}, [
hasHydratedWorkspaceLayoutStore,
isOpenIntentWaitingForWorkspace,
navigation,
openIntent,
openValue,
rootNavigationState?.key,
prepareWorkspaceTab,
serverId,
workspaceId,
]);
if (
openValue &&
!isOpenIntentWaitingForWorkspace &&
(!intentConsumed || !hasHydratedWorkspaceLayoutStore)
) {
return null;
}
return children;
}
function WorkspaceDeck() {
function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
const activeSelection = useActiveWorkspaceSelection();
const [retainedSelections, setRetainedSelections] = useState<RetainedWorkspaceSelection[]>(() =>
activeSelection ? [{ selection: activeSelection, inactiveSince: null }] : [],
@@ -212,6 +273,7 @@ function WorkspaceDeck() {
key={getWorkspaceSelectionKey(selection)}
selection={selection}
active={active}
recoveryRequested={recoveryRequested}
onUnmountInactive={unmountWorkspaceSelection}
/>
);
@@ -221,13 +283,28 @@ function WorkspaceDeck() {
);
}
function WorkspaceDeckEntry({
function WorkspaceDeckEntry(props: {
selection: ActiveWorkspaceSelection;
active: boolean;
recoveryRequested: boolean;
onUnmountInactive: (selection: ActiveWorkspaceSelection) => void;
}) {
return (
<HostAuthorityRoute reference={workspaceSelectionAuthority(props.selection)}>
<WorkspaceDeckEntryContent {...props} />
</HostAuthorityRoute>
);
}
function WorkspaceDeckEntryContent({
selection,
active,
recoveryRequested,
onUnmountInactive,
}: {
selection: ActiveWorkspaceSelection;
active: boolean;
recoveryRequested: boolean;
onUnmountInactive: (selection: ActiveWorkspaceSelection) => void;
}) {
const hasHydratedWorkspaces = useHasHydratedWorkspaces(selection.serverId);
@@ -253,11 +330,14 @@ function WorkspaceDeckEntry({
active={active}
testID={`workspace-deck-entry-${selection.serverId}:${selection.workspaceId}`}
>
<WorkspaceScreen
serverId={selection.serverId}
workspaceId={selection.workspaceId}
isRouteFocused={active}
/>
<WorkspaceOpenIntentBoundary selection={selection} active={active}>
<WorkspaceScreen
serverId={selection.serverId}
workspaceId={selection.workspaceId}
isRouteFocused={active}
recoveryRequested={active && recoveryRequested}
/>
</WorkspaceOpenIntentBoundary>
</RetainedPanel>
);
}
+8 -4
View File
@@ -39,10 +39,14 @@ export default function Index() {
hosts,
anyOnlineHostServerId,
workspaceSelection,
workspaceSelectionStatus: resolveWorkspaceSelectionStatus({
hasHydratedWorkspaces: hasHydratedWorkspaceSelectionHost,
workspaceExists: workspaceSelectionExists,
}),
// COMPAT(unboundStartupSelection): added in v0.12, remove after 2027-04-20.
// Qualified restore reacquires its origin at the host boundary before proving absence.
workspaceSelectionStatus: workspaceSelection?.authority
? "unknown"
: resolveWorkspaceSelectionStatus({
hasHydratedWorkspaces: hasHydratedWorkspaceSelectionHost,
workspaceExists: workspaceSelectionExists,
}),
isWorkspaceSelectionLoaded,
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
});
@@ -23,8 +23,6 @@ const INLINE_PAREN_LINE_SUFFIX = /^(.+?)\(([0-9]+)(?:,[0-9]+)?(?:-([0-9]+)(?:,[0
const INLINE_WORD_LINE_SUFFIX = /^(.+?)\s+lines?\s+([0-9]+)(?:-([0-9]+))?$/i;
export interface AssistantHrefParseOptions {
workspaceRoot?: string;
/** Authored document links resolve from this directory, independently of the workspace. */
relativePathBase?: string;
/** Authored links identify a file; only inferred text needs filename heuristics. */
explicitLink?: boolean;
/**
@@ -283,7 +281,6 @@ export function parseAssistantFileLink(
workspaceRoot: options.workspaceRoot,
decodeHref: options.decodeHref,
explicitLink: options.explicitLink,
relativePathBase: options.relativePathBase,
});
if (relativeTarget) {
return relativeTarget;
@@ -398,15 +395,6 @@ function parseWorkspaceRelativeFileLink(
};
}
const documentBase = options.explicitLink && normalizePathInput(options.relativePathBase);
if (documentBase) {
return {
raw: value,
path: resolveDocumentRelativePath(parsed.path, documentBase),
...parsed.lines,
};
}
const workspaceRoot = normalizePathInput(options.workspaceRoot);
if (!workspaceRoot) {
return null;
@@ -650,24 +638,6 @@ function isDomainLikePathSegment(segment: string): boolean {
return /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/.test(segment);
}
function resolveDocumentRelativePath(relativePath: string, baseDirectory: string): string {
const prefix = baseDirectory.startsWith("/") ? "/" : "";
const segments = baseDirectory.split("/").filter(Boolean);
for (const segment of relativePath.split("/")) {
const last = segments.at(-1) ?? "";
if (!segment || segment === ".") continue;
if (segment !== "..") {
segments.push(segment);
} else if (last && last !== "~" && last !== ".." && !/^[A-Za-z]:$/.test(last)) {
segments.pop();
} else if (!prefix && !/^[A-Za-z]:$/.test(last)) {
segments.push("..");
}
}
const resolved = prefix + segments.join("/");
return /^[A-Za-z]:$/.test(resolved) ? `${resolved}/` : resolved;
}
function resolveRelativePathUnderRoot(pathValue: string, workspaceRoot: string): string | null {
const normalizedPath = normalizePathToken(pathValue);
if (!normalizedPath || isAbsolutePath(normalizedPath)) {
@@ -22,7 +22,6 @@ export interface AssistantFileLinkResolverConfig {
client?: AssistantFileLinkDaemonClient | null;
serverId?: string;
workspaceRoot?: string;
relativePathBase?: string;
onOpenWorkspaceFile?: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
toast?: ToastApi | null;
}
@@ -43,7 +42,6 @@ export function AssistantFileLinkResolverProvider({
client,
serverId,
workspaceRoot,
relativePathBase,
onOpenWorkspaceFile,
toast,
children,
@@ -52,18 +50,10 @@ export function AssistantFileLinkResolverProvider({
client,
serverId,
workspaceRoot,
relativePathBase,
onOpenWorkspaceFile,
toast,
});
configRef.current = {
client,
serverId,
workspaceRoot,
relativePathBase,
onOpenWorkspaceFile,
toast,
};
configRef.current = { client, serverId, workspaceRoot, onOpenWorkspaceFile, toast };
const getDirectorySuggestions = useCallback<GetDirectorySuggestions>(async (input) => {
const activeClient = configRef.current.client;
@@ -18,50 +18,6 @@ const CONTEXT: AssistantFileLinkContext = {
workspaceRoot: "/Users/test/project",
};
it.each([
["guide/details.md", "/repo/docs/guide/details.md"],
["../README.md", "/repo/README.md"],
["guide/details%20page.md", "/repo/docs/guide/details page.md"],
])("resolves authored href %s from its document directory", (href, expected) => {
expect(
classifyForResolution({ href }, { workspaceRoot: "/repo", relativePathBase: "/repo/docs" }),
).toMatchObject({ kind: "resolved", value: { kind: "file", target: { path: expected } } });
});
it.each([
["C:/repo/docs", "../README.md", "C:/repo/README.md"],
["C:/repo/docs", "../../", "C:/"],
["~/.paseo/docs", "../guide.md", "~/.paseo/guide.md"],
["/repo/docs", "/other/file.md", "/other/file.md"],
["/repo/docs", "file:///other/file.md", "/other/file.md"],
["/repo/docs", "guide/details.md#L3", "/repo/docs/guide/details.md"],
])(
"resolves %s with href %s without changing absolute destinations",
(relativePathBase, href, expected) => {
expect(
classifyForResolution({ href }, { workspaceRoot: "/repo", relativePathBase }),
).toMatchObject({
kind: "resolved",
value: { kind: "file", target: { path: expected } },
});
},
);
it("keeps inferred paths and chat links rooted at the workspace", () => {
const context = { workspaceRoot: "/repo", relativePathBase: "/repo/docs" };
expect(classifyForResolution({ href: "src/file.ts", markup: "linkify" }, context)).toMatchObject({
value: { kind: "file", target: { path: "/repo/src/file.ts" } },
});
expect(
classifyForResolution({ href: "guide/details.md" }, { workspaceRoot: "/repo" }),
).toMatchObject({
value: { kind: "file", target: { path: "/repo/guide/details.md" } },
});
expect(classifyForResolution({ href: "https://example.com/docs" }, context)).toMatchObject({
value: { kind: "external", url: "https://example.com/docs" },
});
});
it.each(["docs/guides", "docs/guides/", "docs/%E6%8C%87%E5%8D%97"])(
"resolves an explicit extensionless folder href: %s",
(href) => {
@@ -17,7 +17,6 @@ export interface AssistantFileLinkSource {
export interface AssistantFileLinkContext {
workspaceRoot?: string;
relativePathBase?: string;
}
export interface DirectorySuggestionEntry {
@@ -119,7 +118,6 @@ export function classifyForResolution(
workspaceRoot: context.workspaceRoot,
decodeHref: tokenInfo.fromHref,
explicitLink: source.sourceType !== "inline-code" && !isLinkifiedSource(source),
relativePathBase: context.relativePathBase,
});
if (!classification) {
return { kind: "resolved", value: { kind: "ignored" } };
@@ -15,7 +15,6 @@ import {
fetchDaemonResolution,
type AssistantFileLinkResolution,
type AssistantFileLinkSource,
type AssistantFileLinkContext,
} from "./resolver";
export interface UseFileLinkResult {
@@ -55,15 +54,13 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult
const stableSource = useStableSource(source);
const activeConfig = context.configRef.current;
const workspaceRoot = activeConfig.workspaceRoot;
const relativePathBase = activeConfig.relativePathBase;
const serverId = activeConfig.serverId;
const resolution = useMemo(
() =>
classifyForResolution(stableSource, {
workspaceRoot,
relativePathBase,
}),
[stableSource, workspaceRoot, relativePathBase],
[stableSource, workspaceRoot],
);
const queryKey = useMemo(
() =>
@@ -157,12 +154,12 @@ export function useAssistantFileLinkActions(): AssistantFileLinkActions {
);
const canOpen = useCallback(
(source: AssistantFileLinkSource) =>
canOpenAssistantFileLink(source, context.configRef.current),
canOpenAssistantFileLink(source, context.configRef.current.workspaceRoot),
[context.configRef],
);
const canResolveFile = useCallback(
(source: AssistantFileLinkSource) =>
canResolveAssistantFileLinkToFile(source, context.configRef.current),
canResolveAssistantFileLinkToFile(source, context.configRef.current.workspaceRoot),
[context.configRef],
);
@@ -179,7 +176,6 @@ function openAssistantFileLink(input: {
const capturedConfig = input.context.configRef.current;
const capturedResolution = classifyForResolution(input.source, {
workspaceRoot: capturedConfig.workspaceRoot,
relativePathBase: capturedConfig.relativePathBase,
});
if (capturedResolution.kind === "resolved") {
@@ -237,17 +233,17 @@ function openAssistantFileLink(input: {
function canOpenAssistantFileLink(
source: AssistantFileLinkSource,
context: AssistantFileLinkContext,
workspaceRoot: string | undefined,
): boolean {
const resolution = classifyForResolution(source, context);
const resolution = classifyForResolution(source, { workspaceRoot });
return resolution.kind === "needsLookup" || resolution.value.kind !== "ignored";
}
function canResolveAssistantFileLinkToFile(
source: AssistantFileLinkSource,
context: AssistantFileLinkContext,
workspaceRoot: string | undefined,
): boolean {
const resolution = classifyForResolution(source, context);
const resolution = classifyForResolution(source, { workspaceRoot });
return resolution.kind === "needsLookup" || resolution.value.kind === "file";
}
@@ -50,7 +50,8 @@ function createAgent(overrides: Partial<Agent> = {}): Agent {
options: [{ id: "high", label: "High" }],
},
],
parentAgentId: null,
parent: null,
managedRelationship: null,
labels: {},
...overrides,
turn: overrides.turn ?? { phase: "idle", cancellationRequestId: null },
@@ -1,59 +0,0 @@
import { X } from "lucide-react-native";
import type { StyleProp, ViewStyle } from "react-native";
import { withUnistyles } from "react-native-unistyles";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import {
IconButtonChromeProvider,
iconButtonChromeGlyphSize,
mutedIconColorMapping,
} from "@/components/ui/icon-button-chrome";
import type { ShortcutKey } from "@/utils/format-shortcut";
const ThemedX = withUnistyles(X);
const NO_SHORTCUT: ShortcutKey[] = [];
interface SidebarCloseButtonProps {
label: string;
onPress: () => void;
testID: string;
nativeID?: string;
accessibilityState?: { expanded: boolean };
style?: StyleProp<ViewStyle>;
}
/**
* The X that closes a compact sidebar overlay. It is the compact header's icon button, so the
* sidebar's close reads like the header it covers; every compact sidebar uses it.
*/
export function SidebarCloseButton({
label,
onPress,
testID,
nativeID,
accessibilityState,
style,
}: SidebarCloseButtonProps) {
return (
<IconButtonChromeProvider value="header-island">
<HeaderToggleButton
onPress={onPress}
tooltipLabel={label}
tooltipKeys={NO_SHORTCUT}
tooltipSide="bottom"
style={style}
testID={testID}
nativeID={nativeID}
accessible
accessibilityRole="button"
accessibilityLabel={label}
accessibilityState={accessibilityState}
>
<ThemedX
size={iconButtonChromeGlyphSize("large")}
strokeWidth={1.5}
uniProps={mutedIconColorMapping}
/>
</HeaderToggleButton>
</IconButtonChromeProvider>
);
}
+40 -11
View File
@@ -5,6 +5,7 @@ import {
GitBranch,
Server,
Settings,
X,
type LucideIcon,
} from "lucide-react-native";
import { useTranslation } from "react-i18next";
@@ -20,9 +21,14 @@ import { Gesture } from "react-native-gesture-handler";
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { SidebarCloseButton } from "@/components/headers/sidebar-close-button";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import {
IconButtonChromeProvider,
iconButtonChromeGlyphSize,
mutedIconColorMapping,
} from "@/components/ui/icon-button-chrome";
import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout";
import {
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
@@ -62,6 +68,7 @@ import { usePanelStore } from "@/stores/panel-store";
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
import { openHostOverview } from "@/navigation/settings-navigation";
import {
@@ -74,6 +81,8 @@ import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
const ThemedX = withUnistyles(X);
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
const DEV_BUILD_LABEL = process.env.EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL?.trim() || null;
@@ -546,15 +555,7 @@ function MobileSidebar({
}, [closeSidebar]);
const closeButton = useMemo(
() => (
<SidebarCloseButton
label={labels.closeSidebar}
onPress={closeSidebar}
testID="sidebar-close"
nativeID="sidebar-close"
style={styles.mobileCloseButton}
/>
),
() => <SidebarCloseButton label={labels.closeSidebar} onPress={closeSidebar} />,
[labels.closeSidebar, closeSidebar],
);
@@ -620,6 +621,34 @@ function MobileSidebar({
);
}
const NO_SHORTCUT: ShortcutKey[] = [];
/** The compact header's icon button, so the sidebar's close reads like the header it covers. */
function SidebarCloseButton({ label, onPress }: { label: string; onPress: () => void }) {
return (
<IconButtonChromeProvider value="header-island">
<HeaderToggleButton
onPress={onPress}
tooltipLabel={label}
tooltipKeys={NO_SHORTCUT}
tooltipSide="bottom"
style={styles.mobileCloseButton}
testID="sidebar-close"
nativeID="sidebar-close"
accessible
accessibilityRole="button"
accessibilityLabel={label}
>
<ThemedX
size={iconButtonChromeGlyphSize("large")}
strokeWidth={1.5}
uniProps={mutedIconColorMapping}
/>
</HeaderToggleButton>
</IconButtonChromeProvider>
);
}
function DesktopSidebar({
theme,
workspaceGroups,
@@ -5,9 +5,9 @@ import { useToast } from "@/contexts/toast-context";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { RewindMode } from "./use-rewind-capabilities";
import { useRewindComposerRestore } from "./composer-restore";
import { useSessionStore } from "@/stores/session-store";
import { useSessionStoreApi } from "@/stores/session-store";
import { shouldRestoreComposerForRewindMode } from "./rewind-mode";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useHostRuntimeStore } from "@/runtime/host-runtime";
interface UseRewindAgentMutationInput {
serverId?: string;
@@ -26,6 +26,8 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
isPending: boolean;
} {
const toast = useToast();
const sessionStore = useSessionStoreApi();
const runtime = useHostRuntimeStore();
const { t } = useTranslation();
const composerRestore = useRewindComposerRestore();
const { isPending, mutateAsync } = useMutation({
@@ -36,12 +38,10 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
await input.client.rewindAgent(input.agentId, input.messageId, mode);
if (mode !== "files") {
const cursor = input.serverId
? useSessionStore
.getState()
.sessions[input.serverId]?.agentTimelineCursor.get(input.agentId)
? sessionStore.getState().sessions[input.serverId]?.agentTimelineCursor.get(input.agentId)
: undefined;
if (!input.serverId) throw new Error(t("common.errors.daemonClientUnavailable"));
await getHostRuntimeStore().fetchAgentTimeline(input.serverId, input.agentId, {
await runtime.fetchAgentTimeline(input.serverId, input.agentId, {
direction: "tail",
projection: "projected",
...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}),
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef } from "react";
import type { GestureResponderEvent } from "react-native";
import { Platform, StatusBar, type GestureResponderEvent } from "react-native";
import * as Haptics from "expo-haptics";
import { isWeb as platformIsWeb } from "@/constants/platform";
import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
@@ -36,9 +36,10 @@ export function useLongPressDragInteraction(input: {
if (!menuController || !touchStartRef.current) {
return;
}
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
menuController.setAnchorRect({
x: touchStartRef.current.x,
y: touchStartRef.current.y,
y: touchStartRef.current.y + statusBarHeight,
width: 0,
height: 0,
});
@@ -1,4 +1,4 @@
import { useMemo, type ReactNode } from "react";
import React, { useMemo, type ReactNode } from "react";
import {
View,
Text,
@@ -186,6 +186,48 @@ 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,
@@ -636,7 +678,17 @@ function buildDetailSections(
<ShellDetailSection key="shell" command={detail.command} output={detail.output} ds={ds} />,
];
}
if (detail.type === "worktree_setup") return [];
if (detail.type === "worktree_setup") {
return [
<WorktreeSetupDetailSection
key="worktree-setup"
log={detail.log}
branchName={detail.branchName}
worktreePath={detail.worktreePath}
ds={ds}
/>,
];
}
if (detail.type === "sub_agent") {
return [
<SubAgentDetailSection
@@ -6,6 +6,8 @@ import {
type Ref,
} from "react";
import {
Platform,
StatusBar,
View,
type GestureResponderEvent,
type PressableProps,
@@ -131,9 +133,10 @@ export function ContextMenuTrigger({
const point = coerceEventPoint(event);
if (!point) return;
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
ctx.setAnchorRect({
x: point.pageX,
y: point.pageY,
y: point.pageY + statusBarHeight,
width: 0,
height: 0,
});
@@ -14,6 +14,7 @@ import {
Modal,
Platform,
Pressable,
StatusBar,
View,
type StyleProp,
type ViewStyle,
@@ -161,13 +162,16 @@ export function useAnchoredPosition({
return undefined;
}
// Capture status bar height synchronously before async measurement, so it cannot change
// or read back null between the measure call and its resolution.
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
let cancelled = false;
// The app draws edge-to-edge, so measureInWindow and the full-screen Modal share one
// coordinate space that starts at the screen top; no status bar offset applies.
void measureElement(anchorRef.current).then((rect) => {
if (cancelled) return undefined;
setTriggerRect(rect);
// On Android with statusBarTranslucent, measureInWindow returns coordinates relative to
// below the status bar while Modal content starts at the screen top.
setTriggerRect({ ...rect, y: rect.y + statusBarHeight });
return undefined;
});
+3 -1
View File
@@ -18,6 +18,7 @@ import {
Platform,
Modal,
Pressable,
StatusBar,
View,
type PressableProps,
type StyleProp,
@@ -473,10 +474,11 @@ export function TooltipContent({
return () => {};
}
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
let cancelled = false;
void measureElement(ctx.triggerRef.current).then((rect) => {
if (!cancelled) setTriggerRect(rect);
if (!cancelled) setTriggerRect({ ...rect, y: rect.y + statusBarHeight });
return undefined;
});
+1 -1
View File
@@ -54,7 +54,7 @@ export interface ComposerSendClient {
images: Array<{ data: string; mimeType: string }>;
attachments: ReturnType<typeof splitComposerAttachmentsForSubmit>["attachments"];
},
) => Promise<unknown>;
) => Promise<void>;
uploadFile: (input: { fileName: string; mimeType: string; bytes: Uint8Array }) => Promise<{
requestId: string;
file: {
+14 -4
View File
@@ -5,9 +5,13 @@ import {
resolveComposerAttachmentSubmitFormat,
splitComposerAttachmentsForSubmit,
} from "@/composer/attachments/submit";
import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store";
import {
isActiveCreateFlowForDraft,
useCreateFlowStore,
useCreateFlowStoreApi,
} from "@/stores/create-flow-store";
import { handoffCreatedAgentMessageSubmission } from "@/composer/submission/writer";
import { useSessionStore } from "@/stores/session-store";
import { useSessionStoreApi } from "@/stores/session-store";
import {
createUserMessage,
type StreamItem,
@@ -122,6 +126,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
onCreateError,
}: UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult>) {
const { t } = useTranslation();
const sessionStore = useSessionStoreApi();
const flowStore = useCreateFlowStoreApi();
const [localMachine, dispatch] = useReducer(
reducer<TDraftAgent>,
initialAttempt,
@@ -235,6 +241,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
images: attempt.images,
attachments: attempt.attachments,
}),
sessionStore,
);
markPendingCreateLifecycle({ draftId, lifecycle: "sent" });
}
@@ -255,6 +262,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
},
[
createRequest,
sessionStore,
draftId,
getPendingServerId,
markPendingCreateLifecycle,
@@ -268,7 +276,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
const handleCreateFromInput = useCallback(
async ({ text, attachments, cwd }: SubmitContext) => {
const existing = useCreateFlowStore.getState().pendingByDraftId[draftId];
const existing = flowStore.getState().pendingByDraftId[draftId];
if (
isSubmitting ||
isActiveCreateFlowForDraft({ pending: existing, serverId: getPendingServerId(), draftId })
@@ -285,7 +293,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
throw error;
}
const supportsForgeSearch =
useSessionStore.getState().sessions[pendingServerId]?.serverInfo?.features?.forgeSearch ===
sessionStore.getState().sessions[pendingServerId]?.serverInfo?.features?.forgeSearch ===
true;
const wirePayload = splitComposerAttachmentsForSubmit(attachments, {
format: resolveComposerAttachmentSubmitFormat({
@@ -342,6 +350,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
draftId,
getPendingServerId,
isSubmitting,
flowStore,
sessionStore,
onCreateStart,
runCreateAttempt,
setPendingCreateAttempt,
@@ -243,7 +243,8 @@ function buildDraftAgentSnapshot(input: {
model,
features: composerState.agentControls.features,
thinkingOptionId,
parentAgentId: null,
parent: null,
managedRelationship: null,
labels: {},
};
}
+31 -27
View File
@@ -6,7 +6,7 @@ import {
import type { ComposerTextSource } from "./text-source";
import { createStore, type StoreApi } from "zustand/vanilla";
import { useStore } from "zustand";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useHostRuntimeStore } from "@/runtime/host-runtime";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
View,
@@ -55,7 +55,11 @@ import {
} from "@/composer/agent-controls";
import { ContextWindowMeter } from "@/components/context-window-meter";
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store";
import {
selectAgentTurnPresentation,
useSessionStore,
useSessionStoreApi,
} from "@/stores/session-store";
import { useFilePicker } from "@/hooks/use-file-picker";
import { useFileDrop } from "@/components/file-drop/use-file-drop";
import type { DroppedItem } from "@/components/file-drop/types";
@@ -315,7 +319,6 @@ interface PendingFileAttachment {
}
interface RenderAttachmentTrayArgs {
serverId: string;
selectedAttachments: ComposerAttachment[];
pendingFiles: PendingFileAttachment[];
isComposerLocked: boolean;
@@ -332,7 +335,6 @@ interface RenderAttachmentTrayArgs {
function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | null {
const {
serverId,
selectedAttachments,
pendingFiles,
isComposerLocked,
@@ -345,7 +347,6 @@ function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | nu
<View style={styles.attachmentTray} testID="composer-attachment-tray">
{selectedAttachments.map((attachment, index) =>
renderComposerAttachmentPill({
serverId,
attachment,
index,
disabled: isComposerLocked,
@@ -396,7 +397,6 @@ function renderQueueTrack(args: RenderQueueTrackArgs): ReactElement | null {
}
interface RenderComposerAttachmentPillArgs {
serverId: string;
attachment: ComposerAttachment;
index: number;
disabled: boolean;
@@ -457,7 +457,6 @@ 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}
@@ -1269,6 +1268,8 @@ function ComposerContentImpl({
const mode = resolveComposerInputMode(inputMode);
const { t } = useTranslation();
const buttonIconSize = resolveComposerButtonIconSize();
const sessionStore = useSessionStoreApi();
const runtime = useHostRuntimeStore();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId);
@@ -1565,19 +1566,27 @@ function ComposerContentImpl({
supportsForgeAttachments: supportsForgeSearch,
}),
encodeImages,
submission: createMessageSubmissionWriter(serverId),
submission: createMessageSubmissionWriter(serverId, sessionStore),
activeTurnBehavior,
activeTurnId:
activeTurnBehavior === "steer"
? (selectAgentTurnPresentation(
useSessionStore.getState().sessions[serverId],
sessionStore.getState().sessions[serverId],
targetAgentId,
).turnId ?? undefined)
: undefined,
});
onAttentionPromptSend?.();
};
}, [appSettings.sendBehavior, client, onAttentionPromptSend, serverId, supportsForgeSearch, t]);
}, [
appSettings.sendBehavior,
client,
onAttentionPromptSend,
serverId,
sessionStore,
supportsForgeSearch,
t,
]);
useEffect(() => {
onSubmitMessageRef.current = onSubmitMessage;
@@ -1608,10 +1617,10 @@ function ComposerContentImpl({
const queueWriter = useMemo<QueueWriter>(
() => ({
read: (id) => useSessionStore.getState().sessions[serverId]?.queuedMessages?.get(id) ?? [],
read: (id) => sessionStore.getState().sessions[serverId]?.queuedMessages?.get(id) ?? [],
write: (updater) => setQueuedMessages(serverId, updater),
}),
[serverId, setQueuedMessages],
[serverId, sessionStore, setQueuedMessages],
);
const queueMessage = useCallback(
@@ -1880,26 +1889,23 @@ function ComposerContentImpl({
const handleCancelAgent = useCallback(() => {
const targetAgentId = agentIdRef.current;
const cancellation = cancelComposerAgent({
client,
client: client
? { cancelAgent: (id) => runtime.cancelAgent({ serverId, agentId: id }) }
: null,
agentId: targetAgentId,
isAgentRunning,
isCancellingAgent,
isConnected,
});
if (!cancellation) return;
const requestId = getHostRuntimeStore().beginAgentCancellation(serverId, targetAgentId);
void cancellation
.catch((error) => {
const message = resolveErrorMessage(error);
if (message && message.trim().length > 0) {
toastErrorRef.current(message);
}
})
.finally(() => {
getHostRuntimeStore().settleAgentCancellation(serverId, targetAgentId, requestId);
});
void cancellation.catch((error) => {
const message = resolveErrorMessage(error);
if (message && message.trim().length > 0) {
toastErrorRef.current(message);
}
});
messageInputRef.current?.focus();
}, [client, isAgentRunning, isCancellingAgent, isConnected, serverId]);
}, [client, runtime, isAgentRunning, isCancellingAgent, isConnected, serverId]);
const focusMessageInputForKeyboardAction = useCallback(() => {
focusMessageInputWithPlatformStrategy(messageInputRef);
@@ -2300,7 +2306,6 @@ function ComposerContentImpl({
const attachmentTray = useMemo(
() =>
renderAttachmentTray({
serverId,
selectedAttachments,
pendingFiles,
isComposerLocked,
@@ -2320,7 +2325,6 @@ function ComposerContentImpl({
handleOpenAttachment,
handleRemoveAttachment,
isComposerLocked,
serverId,
selectedAttachments,
pendingFiles,
t,

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