mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-21 08:42:39 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d70bf0645 | ||
|
|
fbd90cb88d | ||
|
|
a891155b82 | ||
|
|
006fd053ac | ||
|
|
2545250b2f | ||
|
|
30464cf234 | ||
|
|
98d34a3dc1 | ||
|
|
77453388e4 | ||
|
|
bd15e40e94 |
@@ -3,8 +3,8 @@ name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# Every base branch, so stacked PRs and PRs into `next` get the same checks.
|
||||
pull_request:
|
||||
branches: [main]
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -417,6 +417,14 @@ jobs:
|
||||
- name: Run app unit tests
|
||||
run: npm run test --workspace=@getpaseo/app
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Run Android module tests
|
||||
run: npm run test:android --workspace=@getpaseo/app
|
||||
|
||||
playwright-1:
|
||||
name: playwright (shard 1/8)
|
||||
needs: changes
|
||||
|
||||
@@ -6,7 +6,6 @@ on:
|
||||
# Packaging leaves ordinary PR CI, but a PR that changes what gets packaged
|
||||
# or how it is smoked still proves the real artifacts before merge.
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "packages/desktop/**"
|
||||
- "plugins/**"
|
||||
|
||||
+3238
File diff suppressed because it is too large
Load Diff
@@ -41,12 +41,12 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers |
|
||||
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||
| [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, native surfaces, and attachment sources |
|
||||
| [docs/website-i18n.md](docs/website-i18n.md) | Website languages: locale URLs, translated Markdown pairs, `{#id}` anchors, Chinese style |
|
||||
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
|
||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||
| [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging |
|
||||
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
|
||||
| [docs/connected-hosts.md](docs/connected-hosts.md) | Daemon-owned host registrations, routed SDK scopes, identity and authority boundaries |
|
||||
| [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority |
|
||||
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||
| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
|
||||
@@ -107,6 +107,8 @@ npm run format:check # Check formatting without writing
|
||||
|
||||
Repo dev commands use checkout-local state by default. In this checkout, `PASEO_HOME` resolves to `.dev/paseo-home`, and `npm run cli -- ...` targets that same dev home automatically. The packaged desktop app and production-style daemon keep using `~/.paseo` on port `6767`.
|
||||
|
||||
When Mo requests an APK, use `npm run build:apk:arm64` for an ARM64 build: writes `artifacts/paseo-dogfood-arm64.apk` with diagnostics without starting a device; `npm run build:apk` builds all four Android architectures.
|
||||
|
||||
See [docs/development.md](docs/development.md) for full setup, build sync requirements, and debugging.
|
||||
|
||||
## Release branches
|
||||
|
||||
+7
-7
@@ -59,8 +59,8 @@
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="33%"><a href="https://hoplite.sh/?utm_source=paseo&utm_medium=readme&utm_campaign=sponsor"><picture><source media="(prefers-color-scheme: dark)" srcset="packages/website/public/sponsors/hoplite-on-dark.svg"><img src="packages/website/public/sponsors/hoplite-on-light.svg" alt="Hoplite" height="40"></picture></a><br><sub>Cloud coding agents</sub></td>
|
||||
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">成为 Paseo 赞助商</a></td>
|
||||
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">成为 Paseo 赞助商</a></td>
|
||||
<td align="center" width="33%"><a href="https://paseo.sh/zh-CN/sponsor">成为 Paseo 赞助商</a></td>
|
||||
<td align="center" width="33%"><a href="https://paseo.sh/zh-CN/sponsor">成为 Paseo 赞助商</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -80,7 +80,7 @@ Paseo 会运行一个名为 daemon 的本地服务,用来管理你的 coding a
|
||||
|
||||
### 桌面 app(推荐)
|
||||
|
||||
从 [paseo.sh/download](https://paseo.sh/download) 或 [GitHub releases 页面](https://github.com/getpaseo/paseo/releases)下载。打开 app 后 daemon 会自动启动,不需要再安装其他东西。
|
||||
从 [paseo.sh/download](https://paseo.sh/zh-CN/download) 或 [GitHub releases 页面](https://github.com/getpaseo/paseo/releases)下载。打开 app 后 daemon 会自动启动,不需要再安装其他东西。
|
||||
|
||||
如果要从手机连接,在 Settings 中扫描显示的二维码。
|
||||
|
||||
@@ -97,8 +97,8 @@ paseo
|
||||
|
||||
完整安装和配置见:
|
||||
|
||||
- [文档](https://paseo.sh/docs)
|
||||
- [配置参考](https://paseo.sh/docs/configuration)
|
||||
- [文档](https://paseo.sh/zh-CN/docs)
|
||||
- [配置参考](https://paseo.sh/zh-CN/docs/configuration)
|
||||
|
||||
## CLI
|
||||
|
||||
@@ -116,7 +116,7 @@ paseo send abc123 "also add tests" # 发送后续任务
|
||||
paseo --host workstation.local:6767 run "run the full test suite"
|
||||
```
|
||||
|
||||
更多内容见[完整 CLI 参考](https://paseo.sh/docs/cli)。
|
||||
更多内容见[完整 CLI 参考](https://paseo.sh/zh-CN/docs/cli)。
|
||||
|
||||
## Skills
|
||||
|
||||
@@ -164,7 +164,7 @@ npm run typecheck
|
||||
|
||||
## 支持 Paseo
|
||||
|
||||
Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/sponsor#spot),logo 会展示在本 README 顶部,以及 paseo.sh 的首页和文档中。
|
||||
Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/zh-CN/sponsor#spot),logo 会展示在本 README 顶部,以及 paseo.sh 的首页和文档中。
|
||||
|
||||
## 相关项目
|
||||
|
||||
|
||||
@@ -71,14 +71,11 @@ 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.
|
||||
|
||||
+65
-23
@@ -61,39 +61,81 @@ Gradle auto-fetches the platform/build-tools it needs once licenses are accepted
|
||||
|
||||
## Local build + install
|
||||
|
||||
The locked `expo-modules-core@57.0.23` needs the patch installed by `npm install`.
|
||||
It backports
|
||||
[Expo's Activity-result launcher fix](https://github.com/expo/expo/pull/49634): changing
|
||||
font size or another unhandled configuration can otherwise disable image selection
|
||||
until the app process restarts. Rebuild the native app after changing this patch;
|
||||
a Metro reload cannot update it. Remove the patch when the installed
|
||||
`expo-modules-core` includes the fix.
|
||||
|
||||
Run its native regression tests from `packages/app/android` after prebuild:
|
||||
|
||||
```bash
|
||||
./gradlew :expo-modules-core:testDebugUnitTest \
|
||||
--tests expo.modules.kotlin.activityresult.AppContextActivityResultRegistryRecreationTest \
|
||||
--no-daemon --max-workers=1 -Dorg.gradle.parallel=false
|
||||
```
|
||||
|
||||
From repo root:
|
||||
|
||||
```bash
|
||||
npm run android:development # Debug build
|
||||
npm run android:production # Release build
|
||||
npm run android:clear # Remove generated Android project
|
||||
npm run build:apk # Universal dogfood APK: 32/64-bit ARM and x86
|
||||
npm run build:apk:arm64 # Smaller dogfood APK for ARM64 phones
|
||||
npm run dev:android # Build/install the debug app and start development
|
||||
npm run dev:ios # Build/install the iOS development app
|
||||
```
|
||||
|
||||
For a production-ID release APK that local Android profiling tools can attach to:
|
||||
`build:apk` writes `artifacts/paseo-dogfood-universal.apk`; `build:apk:arm64` writes
|
||||
`artifacts/paseo-dogfood-arm64.apk`. Neither command launches or installs on a device. Both enable
|
||||
`PASEO_WATCHDOG_BUILD=1`, the production React profiling renderer, and Android's shell-profileable
|
||||
flag. These builds keep the production package ID (`sh.paseo`) and release optimizations. Use this APK for
|
||||
personal dogfooding. Android system-trace spans require the separate `PASEO_PROFILE_BUILD=1` flag;
|
||||
watchdog recording does not send duplicate spans to that sink by default. Normal store/release
|
||||
workflows leave diagnostics disabled.
|
||||
|
||||
The older `android`, `android:development`, and `ios` commands remain aliases for development.
|
||||
`android:production` still builds and installs a release through Expo; use `build:apk` when you
|
||||
need an artifact. `android:clear` removes the generated Android project.
|
||||
|
||||
Run `npm run test:android --workspace=@getpaseo/app` to prebuild Android and run native module unit tests. The app CI job runs this command with the Android toolchain.
|
||||
|
||||
### Diagnostic logs without ADB
|
||||
|
||||
In a dogfood build, open Android's Files app → Downloads → **Paseo diagnostics** and share all
|
||||
retained `.jsonl` files. No app screen, storage permission, or attached debugger is required.
|
||||
The recorder retains eight files of about 5 MiB each across reconnects and app restarts. Rotation
|
||||
replaces the oldest files, so collect them soon after reproducing a freeze. Uninstall/reinstall
|
||||
can leave older Downloads files that the new installation cannot manage.
|
||||
|
||||
Each file identifies its build and session. Events include application-transport frame sizes and
|
||||
message/request IDs, connection transitions, parse/validation/dispatch spans, named React subtree
|
||||
render timings, and foreground JS heartbeat gaps. Payloads, passwords, and message contents
|
||||
are excluded; connection identifiers include host addresses. Each size has a unit: **bytes** when
|
||||
already available, otherwise **UTF-16 code units** for text. These are
|
||||
client transport observations, after relay decryption where applicable, not packet arrival times.
|
||||
|
||||
The native recorder flags a heartbeat overdue by more than 500 ms (250 ms expected interval).
|
||||
A heartbeat gap bounds the unresponsive interval; it is not an exact blocked-stack duration.
|
||||
Backgrounding suspends detection. `js.stall.started` can be written while JS remains blocked;
|
||||
`js.stall.recovered` records the gap when JS resumes. Recovery can appear without a start event
|
||||
when JS resumes before the next native sampling tick. Process death leaves earlier files intact,
|
||||
but queued/unflushed records can be lost. A missing recovery event alone does not prove a crash.
|
||||
|
||||
`react.render.slow` records subtree rendering taking at least 16 ms; one-second summaries include
|
||||
faster renders. Nested profiler timings overlap: don't add parent and child times. React's render
|
||||
measurements do not measure native layout/paint or the complete commit phase. `recorder.dropped`
|
||||
means the bounded queue overflowed; missing events in that interval must not be treated as absence
|
||||
of activity. Storage failure stops recording rather than blocking the app.
|
||||
|
||||
For an ordinary release APK with system trace markers only:
|
||||
|
||||
```bash
|
||||
PASEO_PROFILE_BUILD=1 npm run android:production
|
||||
```
|
||||
|
||||
This keeps the `sh.paseo` package id, release Hermes bundle, and release optimizations. It adds
|
||||
`<profileable android:shell="true" />` and enables local Android trace markers for workspace mounts
|
||||
and daemon WebSocket traffic. The markers contain message types and sizes, never payload contents,
|
||||
and emit only while a system trace records the `sh.paseo` app (`perfetto -a sh.paseo ...`).
|
||||
|
||||
Or from `packages/app`:
|
||||
|
||||
```bash
|
||||
# Debug
|
||||
npx cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive
|
||||
npx cross-env APP_VARIANT=development expo run:android --variant=debug
|
||||
|
||||
# Release
|
||||
npx cross-env APP_VARIANT=production expo prebuild --platform android --clean --non-interactive
|
||||
npx cross-env APP_VARIANT=production expo run:android --variant=release
|
||||
|
||||
# Clear generated Android project
|
||||
rm -rf android
|
||||
```
|
||||
Those markers emit only while a system trace records `sh.paseo` (`perfetto -a sh.paseo ...`);
|
||||
they do not produce Downloads files.
|
||||
|
||||
## Running on an emulator against a worktree daemon
|
||||
|
||||
|
||||
@@ -4,9 +4,6 @@ Paseo is a client-server system for monitoring and controlling local AI coding a
|
||||
|
||||
Your code never leaves your machine. Paseo is local-first.
|
||||
|
||||
Daemon-owned outbound connections and routed API scope ownership are described in
|
||||
[Connected hosts](connected-hosts.md).
|
||||
|
||||
## System overview
|
||||
|
||||
```
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
# Connected hosts
|
||||
|
||||
A saved host belongs to the daemon that stores it. The app's saved connections remain a separate list.
|
||||
Connect A to B through `paseo --host A host` or `paseo.hosts`. Consumers select B by its learned `serverId`; management uses the stable
|
||||
`registrationId` created before B is reachable.
|
||||
|
||||
## Ownership and authority
|
||||
|
||||
Credentials live in A's `hosts.json`, written atomically with mode `0600` on POSIX systems. Discovery returns a safe
|
||||
target description. Replacement inputs never come back through a read API. Keep this file private
|
||||
when collecting diagnostics or backing up daemon state.
|
||||
|
||||
The authenticated producer owns its source key. Repeating an unchanged add returns the saved entry,
|
||||
including a user's disabled state. Changed input requires an explicit update. Display labels and
|
||||
access can be changed by users with `tunnel.manage`; connection and metadata changes require the
|
||||
producer or `access.manage`. Removing a producer-managed entry allows that producer to add it again.
|
||||
Disabling retains the opt-out.
|
||||
|
||||
Each enabled registration owns one physical daemon link, shared by status monitoring and every
|
||||
routed API scope. Each scope owns an isolated virtual session on B. A derives its identity from the
|
||||
admitted origin session, producer, and logical SDK scope. B admits virtual sessions under the actual
|
||||
physical link principal, then intersects forwarded caller grants with that link's current authority.
|
||||
Changing or resuming a parent's grants closes its existing channels; reconnect re-admits demand with
|
||||
current authority. A routed API cannot select a second hop, and saving a host grants no reverse access.
|
||||
|
||||
A producer that registers a host through a routed API is qualified on B as
|
||||
`route:<B principal>:<A serverId>:<A producer>`, with each component percent-encoded. This namespace belongs to the authenticated link
|
||||
principal. An ordinary direct B consumer does not become that producer by supplying a source key.
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Connections and recovery
|
||||
|
||||
The shared Node connector owns the target grammar and each connection attempt, including a fresh
|
||||
SSH process. SSH reaches an already running daemon. The shared client connection lifecycle owns
|
||||
physical reconnect and keepalive; the registry retains configuration, identity acceptance, and recovery
|
||||
status. Unreachable registrations remain saved. Identity is pinned when discovered. Self and duplicate
|
||||
registrations remain inspectable; a changed destination requires explicit identity replacement.
|
||||
|
||||
Both daemons advertise `connectedHosts`; the shared link additionally requires `hostMultiplexing`.
|
||||
A completes capability and pinned-identity checks on each physical recovery before admitting channels. There is no local fallback. An older daemon continues to serve existing local
|
||||
operations; requesting this feature gives update guidance.
|
||||
|
||||
Disposing a routed API releases its observations and session. It does not remove the saved host or
|
||||
affect sibling API scopes. Disposing the origin cascades to its derived scopes. Observations restore
|
||||
through the usual owned-subscription contract. The ordinary client queues new requests while connecting
|
||||
within its existing deadline and rejects requests while disconnected. Connection loss clears old
|
||||
waiters and queued sends. Submitted requests with no response fail with `DaemonConnectionError`
|
||||
(`DAEMON_CONNECTION_LOST`), optional `outcome: "unknown"` and known `serverId`. Inspect the destination
|
||||
before retrying a mutation.
|
||||
Ordinary non-recoverable mutations are not automatically replayed. Existing acknowledged/idempotent
|
||||
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.
|
||||
|
||||
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
|
||||
payloads reserve space for the routing header inside that limit. Relay encryption retains its own
|
||||
existing frame limit.
|
||||
|
||||
Host links cap saved registrations, simultaneous channels, handshake lifetime, frames, physical
|
||||
output, and aggregate queued bytes. Capacity checks use the actual escaped outer JSON or binary
|
||||
frame size. Awaitable output completes when the physical send completes. An overflowing channel
|
||||
closes without closing siblings or the saved link. Routed sessions release resources on disconnect;
|
||||
subscription demand is rebuilt by its owning client on reconnect.
|
||||
|
||||
See the [SDK reference](../public-docs/sdk/reference.md#clienthosts),
|
||||
[CLI reference](../public-docs/cli.md#connected-hosts), and
|
||||
[plugin reference](../public-docs/plugins/reference.md#connected-hosts).
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# I18n
|
||||
|
||||
Paseo client UI translations live in `packages/app/src/i18n`.
|
||||
Paseo client UI translations live in `packages/app/src/i18n`. Website translations are in [website-i18n.md](website-i18n.md).
|
||||
|
||||
## Supported Locales
|
||||
|
||||
|
||||
@@ -31,10 +31,6 @@ A pairing invitation is neither. It is an expiring, single-use exchange that cre
|
||||
|
||||
Agents and terminals use workspace authority. Both can execute code and mutate the workspace, so separate write permissions would claim an isolation boundary the daemon cannot enforce.
|
||||
|
||||
Connected-host management uses `tunnel.manage`; source-owned connection and metadata replacement
|
||||
requires the producer or `access.manage`. Routed operations retain both caller and link authority,
|
||||
see [Connected hosts](connected-hosts.md#ownership-and-authority).
|
||||
|
||||
Owner, operator, and viewer are UI presets expanded into explicit permissions. Do not persist them as roles. Adding a permission must not silently widen an existing principal.
|
||||
|
||||
Permissions are additive allows. Missing authority denies the operation. Do not add deny precedence.
|
||||
|
||||
@@ -86,6 +86,12 @@ plugin before compiling and starting from disk. A failed reload stays failed; Pa
|
||||
the old code. Use `enable`, `disable`, and `remove` to manage one plugin. Removing a directory source
|
||||
never deletes it. The global `pluginsEnabled` switch remains available.
|
||||
|
||||
## Client translations
|
||||
|
||||
Plugin runtime translations follow the connected app, so never send a daemon locale through RPCs or add plugin resources to the app catalog. The installation owns the translation engine; native portal bridges must carry it alongside RPC and client-state contexts. Keep host label resolution at presentation time so language changes preserve the installation lifetime.
|
||||
|
||||
The author contract is in [runtime translations](../public-docs/plugins/reference.md#runtime-translations).
|
||||
|
||||
## Built-in plugins
|
||||
|
||||
Built-in plugins ship from `plugins/<id>/`, with `paseo-plugin.json`, `index.server.ts`,
|
||||
|
||||
+12
-3
@@ -62,9 +62,10 @@ release. This applies to both beta and stable releases.
|
||||
the changelog. Retarget remaining PRs based on `next` to `main` and delete the integrated
|
||||
`next`. Create it fresh when needed again.
|
||||
|
||||
**Setup still needed:** CI, Docker, and Nix PR checks currently target only `main`,
|
||||
and GitHub permits only squash merges. Enable checks and required-check protection
|
||||
for `next`, CI on its pushes, and merge commits for the integration PR. Handle PR
|
||||
**Setup still needed:** PR checks run for every base branch, but CI, Docker, and Nix
|
||||
push runs target only `main`, and GitHub permits only squash merges. Enable
|
||||
required-check protection for `next`, CI on its pushes, and merge commits for the
|
||||
integration PR. Handle PR
|
||||
base changes (`edited` events) so retargeting runs checks against the new base;
|
||||
GitHub's default PR events do not cover this. Deployment triggers stay unchanged.
|
||||
|
||||
@@ -533,6 +534,14 @@ No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or
|
||||
|
||||
Older prerelease bodies on GitHub keep the notes they were synced with; `Release Notes Sync` only writes the release whose tag matches the top entry.
|
||||
|
||||
## Changelog translations
|
||||
|
||||
`CHANGELOG.zh-CN.md` holds the Chinese notes for `paseo.sh/zh-CN/changelog`. Only the website reads it; the app's **What's new** sheet, GitHub release bodies and F-Droid read `CHANGELOG.md`.
|
||||
|
||||
- Translations never gate a release. Translate a new entry in a follow-up PR, or in the changelog commit when you can.
|
||||
- A translated entry uses the English heading unchanged: `## X.Y.Z - YYYY-MM-DD`. The website uses it only while the version and date both match the English entry. Otherwise it shows that release whole in English under a notice, so a beta rewritten in place drops its old translation until the new notes are translated. Delete Chinese entries whose English entry was renamed.
|
||||
- Keep the English sections, bullets and links one to one. Section titles: Added 新增, Improved 改进, Changed 变更, Fixed 修复, Security 安全, Breaking 破坏性变更, Plugins 插件, Notes 说明. Wording follows [website-i18n.md](website-i18n.md#chinese-style).
|
||||
|
||||
## Changelog ownership
|
||||
|
||||
- **The agent running the release writes the changelog entry — beta or stable.** The release context and final wording stay with that agent.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Website translations
|
||||
|
||||
paseo.sh is served in English and Simplified Chinese (`zh-CN`). This doc covers the website in `packages/website`. App UI translations are in [i18n.md](i18n.md).
|
||||
|
||||
## URLs own the language
|
||||
|
||||
- English pages keep their unprefixed paths. Every other language lives under its tag: `/docs/cli` and `/zh-CN/docs/cli` are the same page. `/en/...` is not a page.
|
||||
- The server renders the language in the URL. There is no browser-language detection or redirect, so a shared link always opens in the language it was shared in.
|
||||
- The language selector moves to the same page in the other language and keeps the query and hash. Every internal link on a page carries the page's language, so the choice sticks while the reader navigates. An explicit English URL always renders English.
|
||||
- The choice is not stored. A stored choice could only take effect by redirecting unprefixed URLs, which are the English pages. First-party links into the site carry the reader's language instead: `README.zh-CN.md` links to `/zh-CN/...`.
|
||||
- Routes live under `src/routes/{-$locale}/`. Build internal links with `localizePath` or `useLocalizePath` from `~/i18n`. Links inside Markdown are localized by `DocsMarkdown`. Files, `.md` sources, `llms.txt` and API endpoints keep one path for every language (`isPagePath`).
|
||||
- Give each page's `head` the language-neutral path through `pageMeta` (`src/meta.ts`). It writes the self canonical, the `hreflang` alternates with English as `x-default`, and `og:locale`. The sitemap in `vite.config.ts` lists the same alternates.
|
||||
|
||||
## Where copy lives
|
||||
|
||||
| Content | English | Chinese |
|
||||
| ---------------- | -------------------------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| Interface copy | `src/i18n/resources/en.json` | `src/i18n/resources/zh-CN.json` |
|
||||
| Docs | `public-docs/<page>.md` | `public-docs/<page>.zh-CN.md` |
|
||||
| Blog posts | `posts/<slug>.md` | `posts/<slug>.zh-CN.md` |
|
||||
| Comparison pages | `src/content/alternatives/<slug>.md` | `src/content/alternatives/<slug>.zh-CN.md` |
|
||||
| Legal pages | `legal/<page>.md` | `legal/<page>.zh-CN.md` |
|
||||
| Changelog | `CHANGELOG.md` | `CHANGELOG.zh-CN.md`, see [release.md](release.md#changelog-translations) |
|
||||
| Plugin listings | the plugin's `paseo-plugin.json` and `OVERVIEW.md` | `translations` and `OVERVIEW.zh-CN.md`, see `public-docs/plugins/publishing.md` |
|
||||
|
||||
`en.json` types the keys, so typecheck fails when `zh-CN.json` misses one. A translation file is grouped with its English original by name (`src/markdown-content.ts`). A translation without an English original fails the build.
|
||||
|
||||
Every first-party page has a translation. The changelog and plugin listings are the exceptions: a release or listing without one renders whole in English under a notice. Pages never mix languages paragraph by paragraph. Navigation order, categories and slugs always come from the English file's frontmatter.
|
||||
|
||||
## Editing a translated doc
|
||||
|
||||
Edit the English file and its `.zh-CN.md` in the same PR, and add the `.zh-CN.md` with any new page. Code blocks, commands, flags, config keys and links are copied verbatim, so code-only changes are a copy. Never delete a translation to get around an edit, and never leave one that describes behavior the English no longer has.
|
||||
|
||||
Translated headings pin the English anchor with a trailing `{#id}`, so `/zh-CN/docs/cli#running-agents` lands on the same section as the English link:
|
||||
|
||||
```md
|
||||
## 运行 Agent {#running-agents}
|
||||
|
||||
:::example[回复 Slack 提及 {#answer-a-slack-mention}]
|
||||
```
|
||||
|
||||
When you rename an English heading, change the `{#id}` in the translation to the new slug. `src/content-translations.test.ts` fails when a docs, blog, comparison or legal page has no translation, and when a translation's headings differ from the English in count, level, order or anchor. It cannot tell whether the prose under a heading is current. The `.md` source endpoints serve files verbatim, so `{#id}` shows there as written.
|
||||
|
||||
## Chinese style
|
||||
|
||||
- Keep code, commands, file paths, IDs, brand and product names, and command output in English.
|
||||
- Keep these terms in English: Agent, worktree, daemon, Provider, PR, diff, Composer, Hub, workflow, heartbeat, slug.
|
||||
- Translate these: workspace 工作区, host 主机, relay 中继, plugin 插件, skill 技能, review 审查, schedule 定时任务, orchestration 编排, configuration 配置, prompt 提示词, subagent 子 Agent.
|
||||
- Quote app UI labels with the app's Chinese wording from `packages/app/src/i18n/resources/zh-CN.ts`, such as **设置 → 插件**. Labels the app has not translated stay in English, as do labels in Hub, GitHub, Slack and Discord. Add the English label in parentheses only where readers search for it, such as an error message.
|
||||
- Put a space between Chinese and Latin text or numbers: `启动 Agent`, `共 3 个`.
|
||||
- Bold that ends in full-width punctuation does not render when Chinese text follows it directly: `**注意:**这里` shows the asterisks. Move the punctuation outside the bold (`**注意**:这里`).
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-7B/Zyo/DaHdd3PVq8Hn1HG+x+wLdZBVxgx/cbtaM/S8=
|
||||
sha256-Et1ofRqkqFoGETnbeqXuy2RerFT0kkk5F+VwDXak+fk=
|
||||
|
||||
Generated
+3
-1
@@ -38178,7 +38178,6 @@
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.12.0",
|
||||
"@getpaseo/relay": "0.12.0",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -42117,6 +42116,7 @@
|
||||
"version": "0.12.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"i18next": "^26.3.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -43339,9 +43339,11 @@
|
||||
"@tanstack/react-start": "^1.168.26",
|
||||
"framer-motion": "^12.35.2",
|
||||
"github-slugger": "^2.0.0",
|
||||
"i18next": "^26.3.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"react": "^19.1.4",
|
||||
"react-dom": "^19.1.4",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-markdown": "^10.1.0",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-pretty-code": "^0.14.3",
|
||||
|
||||
+6
-3
@@ -41,12 +41,16 @@
|
||||
"dev:server:watch": "concurrently --kill-others --names protocol,client,server --prefix-colors yellow,blue,cyan \"npm run watch:protocol\" \"npm run watch:client\" \"npm run dev:server:raw\"",
|
||||
"dev:server:raw": "npm run dev --workspace=@getpaseo/server",
|
||||
"dev:app": "cross-env PASEO_LISTEN=127.0.0.1:6768 EXPO_PORT=8081 ./scripts/dev-app.sh",
|
||||
"dev:android": "npm run dev:android --workspace=@getpaseo/app",
|
||||
"dev:ios": "npm run dev:ios --workspace=@getpaseo/app",
|
||||
"test:e2e:mobile": "./scripts/test-mobile-agent-device.sh",
|
||||
"test:e2e:composer-keyboard:android": "./packages/app/e2e/mobile/composer-keyboard/android.sh",
|
||||
"dev:website": "npm run dev --workspace=@getpaseo/website",
|
||||
"postinstall": "node scripts/postinstall-patches.mjs",
|
||||
"prepare": "lefthook install --force",
|
||||
"build": "npm run build --workspaces --if-present",
|
||||
"build:apk": "node scripts/build-apk.mjs",
|
||||
"build:apk:arm64": "node scripts/build-apk.mjs arm64",
|
||||
"build:highlight": "npm run build --workspace=@getpaseo/highlight",
|
||||
"build:highlight:clean": "npm run build:clean --workspace=@getpaseo/highlight",
|
||||
"build:plugin": "npm run build --workspace=@getpaseo/plugin",
|
||||
@@ -80,15 +84,14 @@
|
||||
"acp:version-drift:check": "node scripts/check-acp-catalog-version-drift.mjs --fail-on-drift",
|
||||
"acp:version-drift:update": "node scripts/check-acp-catalog-version-drift.mjs --update",
|
||||
"start": "npm run start --workspace=@getpaseo/server",
|
||||
"android": "npm run android --workspace=@getpaseo/app",
|
||||
"android": "npm run dev:android",
|
||||
"android:development": "npm run android:development --workspace=@getpaseo/app",
|
||||
"android:production": "npm run android:production --workspace=@getpaseo/app",
|
||||
"android:release": "npm run android:production --workspace=@getpaseo/app",
|
||||
"android:clear": "npm run android:clear --workspace=@getpaseo/app",
|
||||
"android:clean": "npm run android:clean --workspace=@getpaseo/app",
|
||||
"fdroid:changelogs": "node scripts/sync-fdroid-changelogs.mjs",
|
||||
"fdroid:changelogs:check": "node scripts/sync-fdroid-changelogs.mjs --check",
|
||||
"ios": "npm run ios --workspace=@getpaseo/app",
|
||||
"ios": "npm run dev:ios",
|
||||
"web": "npm run web --workspace=@getpaseo/app",
|
||||
"dev:desktop": "cross-env PASEO_LISTEN=127.0.0.1:6768 npm run dev --workspace=@getpaseo/desktop",
|
||||
"dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop",
|
||||
|
||||
@@ -10,6 +10,7 @@ const withAndroidScroll = require("./modules/paseo-scroll/app.plugin");
|
||||
const { getNativeReleaseVersion } = require("./native-release-version");
|
||||
const appVariant = process.env.APP_VARIANT ?? "production";
|
||||
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
|
||||
const isWatchdogBuild = process.env.PASEO_WATCHDOG_BUILD === "1";
|
||||
const isProfileBuild = process.env.PASEO_PROFILE_BUILD === "1";
|
||||
|
||||
const buildProfile = isFdroidBuild
|
||||
@@ -181,7 +182,7 @@ export default {
|
||||
},
|
||||
],
|
||||
...buildProfile.fdroidPlugins,
|
||||
...(isProfileBuild ? [withAndroidProfileable] : []),
|
||||
...(isProfileBuild || isWatchdogBuild ? [withAndroidProfileable] : []),
|
||||
],
|
||||
experiments: {
|
||||
typedRoutes: true,
|
||||
@@ -191,6 +192,9 @@ export default {
|
||||
extra: {
|
||||
fdroidBuild: isFdroidBuild,
|
||||
profileBuild: isProfileBuild,
|
||||
watchdogBuild: isWatchdogBuild,
|
||||
diagnosticRevision: process.env.PASEO_DIAGNOSTIC_REVISION,
|
||||
diagnosticBuildTime: process.env.PASEO_DIAGNOSTIC_BUILD_TIME,
|
||||
router: {},
|
||||
eas: {
|
||||
projectId: "0e7f65ce-0367-46c8-a238-2b65963d235a",
|
||||
|
||||
@@ -832,6 +832,9 @@ test("compact Changes keeps its actions compact and menu-only", async ({ page })
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
await page.setViewportSize({ width: 480, height: 900 });
|
||||
// The shell survives the breakpoint, so the wide Explorer can still be on screen right after the
|
||||
// resize. Check for the compact tab only once the shell reports the compact layout.
|
||||
await expect(page.getByTestId("compact-shell")).toBeVisible();
|
||||
|
||||
const compactChangesTab = page
|
||||
.getByTestId("workspace-tab-changes_tree")
|
||||
@@ -1844,9 +1847,14 @@ async function changeCodeTypographyFromSettings(
|
||||
await page.getByTestId("sidebar-settings").click();
|
||||
await expect(page).toHaveURL(new RegExp(`${buildSettingsSectionRoute("general")}|/settings$`));
|
||||
await page.getByRole("button", { name: "Appearance" }).click();
|
||||
// A committed font change remounts the screen (AppearanceStyleBoundary), and the new form starts
|
||||
// from the stored size. Edit the size only in the remounted form, or the remount drops it.
|
||||
const sizeField = page.getByLabel("Code font size");
|
||||
await sizeField.evaluate((field) => field.setAttribute("data-e2e-before-font-family", ""));
|
||||
await page.getByLabel("Code font family").fill(typography.fontFamily);
|
||||
await page.getByLabel("Code font family").press("Enter");
|
||||
await page.getByLabel("Code font size").fill(String(typography.fontSize));
|
||||
await expect(page.locator("[data-e2e-before-font-family]")).toHaveCount(0);
|
||||
await sizeField.fill(String(typography.fontSize));
|
||||
await page.getByLabel("Code font size").press("Enter");
|
||||
await expect(page.getByLabel("Code font family")).toHaveValue(typography.fontFamily);
|
||||
await expect(page.getByLabel("Code font size")).toHaveValue(String(typography.fontSize));
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { metroTest as test, expect } from "../support/fixtures";
|
||||
import { hideDeveloperToasts } from "../support/developer-ui.mjs";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
async function showTransientDeveloperToast(page: Page, shadow: boolean) {
|
||||
await page.evaluate((useShadow) => {
|
||||
const toast = document.createElement("div");
|
||||
toast.id = "error-toast";
|
||||
document.body.append(toast);
|
||||
const root = useShadow ? toast.attachShadow({ mode: "open" }) : toast;
|
||||
const button = document.createElement("button");
|
||||
button.style.cssText = "width:32px;height:32px";
|
||||
button.addEventListener("click", () => button.remove());
|
||||
root.append(button);
|
||||
}, shadow);
|
||||
}
|
||||
|
||||
async function openTransientDeveloperToast(page: Page) {
|
||||
await page.setContent("<button>Continue</button>");
|
||||
await showTransientDeveloperToast(page, false);
|
||||
await hideDeveloperToasts(page);
|
||||
}
|
||||
|
||||
async function navigateWithDeveloperToastDismissal(page: Page) {
|
||||
await hideDeveloperToasts(page);
|
||||
await page.goto("data:text/html,<button>Continue</button>");
|
||||
await showTransientDeveloperToast(page, true);
|
||||
}
|
||||
|
||||
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 action.click();
|
||||
}
|
||||
|
||||
test("a disappearing developer toast does not stall concurrent layout checks", async ({ page }) => {
|
||||
test.setTimeout(10000);
|
||||
await openTransientDeveloperToast(page);
|
||||
await inspectLayoutWhileToastDisappears(page);
|
||||
});
|
||||
|
||||
test("developer toast dismissal survives navigation and observes Expo shadow-root updates", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(10000);
|
||||
await navigateWithDeveloperToastDismissal(page);
|
||||
await inspectLayoutWhileToastDisappears(page);
|
||||
await page.evaluate(() => {
|
||||
const root = document.getElementById("error-toast")!.shadowRoot!;
|
||||
const replacement = document.createElement("button");
|
||||
replacement.style.cssText = "width:32px;height:32px";
|
||||
replacement.addEventListener("click", () => replacement.remove());
|
||||
root.append(replacement);
|
||||
});
|
||||
await inspectLayoutWhileToastDisappears(page);
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { verifyPluginToolTranslations } from "../support/helpers/plugin-translations";
|
||||
import { test } from "../support/fixtures";
|
||||
import { withButtonShowcase } from "../support/helpers/plugin-buttons";
|
||||
|
||||
@@ -13,3 +14,10 @@ test("plugin header and composer buttons share actions, menus, content, and upda
|
||||
await buttons.hideAndUnloadOpenButtons();
|
||||
});
|
||||
});
|
||||
|
||||
test("plugin tooltips and menu labels follow app language with a panel draft open", async ({
|
||||
page,
|
||||
}, info) => {
|
||||
test.setTimeout(180_000);
|
||||
await verifyPluginToolTranslations(page, info);
|
||||
});
|
||||
|
||||
@@ -60,10 +60,14 @@ async function captureWideAndCompact(
|
||||
ready: () => Promise<void>,
|
||||
) {
|
||||
await capture(page, testInfo, `${name}-wide`);
|
||||
// The shell survives the breakpoint, so the previous layout's copy can still be on screen right
|
||||
// after a resize. Check `ready` only once the shell reports the new layout.
|
||||
await page.setViewportSize(COMPACT);
|
||||
await expect(page.getByTestId("compact-shell")).toBeVisible();
|
||||
await ready();
|
||||
await capture(page, testInfo, `${name}-compact`);
|
||||
await page.setViewportSize(WIDE);
|
||||
await expect(page.getByTestId("wide-shell")).toBeVisible();
|
||||
await ready();
|
||||
}
|
||||
|
||||
@@ -195,6 +199,9 @@ test("directory examples mount the PDF frame and write Markdown through the file
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("radio", { name: "Source — Paseo", exact: true })).toBeVisible();
|
||||
await captureWideAndCompact(page, testInfo, "markdown-example-picker", async () => {
|
||||
// The previous layout's picker animates out (the sheet slides down, the dialog fades), so
|
||||
// both pickers are mounted for a moment after a resize.
|
||||
await expect(page.getByRole("button", { name: "Use", exact: true })).toHaveCount(1);
|
||||
await expect(page.getByRole("button", { name: "Use", exact: true })).toBeInViewport({
|
||||
ratio: 1,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { verifyPluginTranslations } from "../support/helpers/plugin-translations";
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -166,3 +167,10 @@ async function playPluginAudio(page: Page, button: string, result: string) {
|
||||
await page.getByRole("button", { name: button, exact: true }).click();
|
||||
await expect(page.getByText(result, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
test("plugin content and host labels follow app language without losing an open draft", async ({
|
||||
page,
|
||||
}, info) => {
|
||||
test.setTimeout(180_000);
|
||||
await verifyPluginTranslations(page, info);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { openAddProjectFlow, addProjectFlow } from "../support/helpers/add-project-flow";
|
||||
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||
import {
|
||||
@@ -31,6 +32,28 @@ import {
|
||||
setSidebarNavItemVisible,
|
||||
} from "../support/helpers/sidebar-nav-settings";
|
||||
|
||||
async function showDeveloperToast(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
document.getElementById("error-toast")?.remove();
|
||||
const toast = document.createElement("div");
|
||||
toast.id = "error-toast";
|
||||
toast.style.cssText = "position:fixed;inset:0;z-index:2147483647;background:white";
|
||||
// Expo renders its unnamed dismiss button in an open shadow root.
|
||||
const button = document.createElement("button");
|
||||
button.style.cssText = "width:24px;height:24px";
|
||||
button.addEventListener("click", () => toast.remove());
|
||||
toast.attachShadow({ mode: "open" }).appendChild(button);
|
||||
document.body.appendChild(toast);
|
||||
});
|
||||
}
|
||||
|
||||
test("developer toasts leave concurrent sidebar layout checks uninterrupted", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await showDeveloperToast(page);
|
||||
await expectSidebarOrder(page, ["new-workspace", "history", "search", "schedules"]);
|
||||
await expect(page.locator("#error-toast")).toBeHidden();
|
||||
});
|
||||
|
||||
test("fixed footer line keeps its five icons, Help and Settings at the end", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
import { buildSeededHost } from "../support/helpers/daemon-registry";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
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";
|
||||
|
||||
test.use({ e2eInjectPaseoTools: true });
|
||||
import { openSubagentsTrack, seedParentWithSubagent } from "../support/helpers/subagents";
|
||||
|
||||
const SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
@@ -156,189 +147,3 @@ test("a preferred chat file link still opens beside its right-pane agent", async
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("MCP delegates to self and B and opens both qualified children in ordinary conversations", 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"))
|
||||
)
|
||||
observationRequests[request.type] = (observationRequests[request.type] ?? 0) + 1;
|
||||
});
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: fleet.workspace.workspaceId,
|
||||
agentId: fleet.parent.id,
|
||||
});
|
||||
await openSubagentsTrack(page);
|
||||
await expect(page.getByTestId(`subagents-track-row-${fleet.local.agentId}`)).toContainText(
|
||||
"MCP child on A",
|
||||
);
|
||||
await expect(page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`)).toContainText(
|
||||
"MCP child on B",
|
||||
);
|
||||
await expect(page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`)).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(`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 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 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);
|
||||
} finally {
|
||||
await fleet.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { test, expect } from "../support/fixtures";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import {
|
||||
connectWorkspaceSetupClient,
|
||||
setupCommandOutput,
|
||||
createWorkspaceThroughDaemon,
|
||||
openWorkspaceScriptsMenu,
|
||||
seedProjectForWorkspaceSetup,
|
||||
@@ -33,7 +34,9 @@ test("scripts menu resizes when a service row grows after launch", async ({ page
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const completed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||
(payload) =>
|
||||
payload.status === "completed" &&
|
||||
setupCommandOutput(payload.detail).includes("setup complete"),
|
||||
);
|
||||
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "../support/helpers/terminal-perf";
|
||||
import {
|
||||
connectWorkspaceSetupClient,
|
||||
setupCommandOutput,
|
||||
createWorkspaceThroughDaemon,
|
||||
expectSetupPanel,
|
||||
openHomeWithProject,
|
||||
@@ -92,7 +93,8 @@ test.describe("Workspace setup streaming", () => {
|
||||
const completed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) =>
|
||||
payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||
payload.status === "completed" &&
|
||||
setupCommandOutput(payload.detail).includes("setup complete"),
|
||||
);
|
||||
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
@@ -170,16 +172,19 @@ test.describe("Workspace setup streaming", () => {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const initialRunning = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) => payload.status === "running" && payload.detail.log === "",
|
||||
(payload) => payload.status === "running" && payload.detail.commands.length === 0,
|
||||
);
|
||||
const runningWithOutput = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) => payload.status === "running" && payload.detail.log.includes("starting setup"),
|
||||
(payload) =>
|
||||
payload.status === "running" &&
|
||||
setupCommandOutput(payload.detail).includes("starting setup"),
|
||||
);
|
||||
const completed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) =>
|
||||
payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||
payload.status === "completed" &&
|
||||
setupCommandOutput(payload.detail).includes("setup complete"),
|
||||
);
|
||||
|
||||
await createWorkspaceThroughDaemon(client, {
|
||||
@@ -192,8 +197,8 @@ test.describe("Workspace setup streaming", () => {
|
||||
const completedPayload = await completed;
|
||||
|
||||
expect(initialPayload.detail.log).toBe("");
|
||||
expect(runningPayload.detail.log).toContain("starting setup");
|
||||
expect(completedPayload.detail.log).toContain("setup complete");
|
||||
expect(setupCommandOutput(runningPayload.detail)).toContain("starting setup");
|
||||
expect(setupCommandOutput(completedPayload.detail)).toContain("setup complete");
|
||||
expect(completedPayload.error).toBeNull();
|
||||
} finally {
|
||||
await client.close();
|
||||
@@ -215,7 +220,9 @@ test.describe("Workspace setup streaming", () => {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const failed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) => payload.status === "failed" && payload.detail.log.includes("setup failed"),
|
||||
(payload) =>
|
||||
payload.status === "failed" &&
|
||||
setupCommandOutput(payload.detail).includes("setup failed"),
|
||||
);
|
||||
|
||||
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||
@@ -224,8 +231,8 @@ test.describe("Workspace setup streaming", () => {
|
||||
});
|
||||
|
||||
const failedPayload = await failed;
|
||||
expect(failedPayload.detail.log).toContain("starting setup");
|
||||
expect(failedPayload.detail.log).toContain("setup failed");
|
||||
expect(setupCommandOutput(failedPayload.detail)).toContain("starting setup");
|
||||
expect(setupCommandOutput(failedPayload.detail)).toContain("setup failed");
|
||||
expect(failedPayload.error).toMatch(/failed/i);
|
||||
|
||||
await openHomeWithProject(page, repo.path);
|
||||
@@ -331,7 +338,8 @@ test.describe("Workspace setup streaming", () => {
|
||||
const completed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) =>
|
||||
payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||
payload.status === "completed" &&
|
||||
setupCommandOutput(payload.detail).includes("setup complete"),
|
||||
);
|
||||
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
@@ -377,7 +385,8 @@ test.describe("Workspace setup streaming", () => {
|
||||
const completed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) =>
|
||||
payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||
payload.status === "completed" &&
|
||||
setupCommandOutput(payload.detail).includes("setup complete"),
|
||||
);
|
||||
|
||||
const result = await client.createPaseoWorktree({
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { hideDeveloperToasts } from "../developer-ui.mjs";
|
||||
import { seedWorkspace } from "./seed-client";
|
||||
import { buildAgentRoute } from "./mock-agent";
|
||||
import { openCommandCenter } from "./command-center";
|
||||
import type { TestInfo, BrowserContext } from "@playwright/test";
|
||||
import { expect, type Page } from "../fixtures";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { copyPluginExample } from "./plugin-fixture";
|
||||
|
||||
async function chooseChinese(page: Page) {
|
||||
await openSettings(page);
|
||||
await page.getByRole("button", { name: "System", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "简体中文 - Simplified Chinese", exact: true }).click();
|
||||
await page.goBack();
|
||||
}
|
||||
|
||||
async function chooseEnglish(page: Page) {
|
||||
await page.getByTestId("sidebar-settings").click();
|
||||
await page.getByRole("button", { name: "简体中文", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "English - 英语", exact: true }).click();
|
||||
await page.goBack();
|
||||
}
|
||||
|
||||
async function capture(page: Page, info: TestInfo, name: string) {
|
||||
const file = info.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path: file, animations: "disabled" });
|
||||
await info.attach(name, { path: file, contentType: "image/png" });
|
||||
}
|
||||
|
||||
export async function verifyPluginTranslations(page: Page, info: TestInfo) {
|
||||
const example = await copyPluginExample("translations");
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const config = await client.getDaemonConfig();
|
||||
const browser = page.context().browser();
|
||||
if (!browser) throw new Error("Translation isolation requires a browser");
|
||||
let secondContext: BrowserContext | undefined;
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(example.directory);
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
await gotoAppShell(page);
|
||||
await page.getByRole("button", { name: "Workspace notes", exact: true }).click();
|
||||
await expect(page.getByText("App language: en", { exact: true })).toBeVisible();
|
||||
await page
|
||||
.getByRole("textbox", { name: "Your draft", exact: true })
|
||||
.fill("Remember the language switch");
|
||||
await capture(page, info, "01-english-draft");
|
||||
await page.getByRole("button", { name: "Preview draft", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Close preview", exact: true })).toBeVisible();
|
||||
await capture(page, info, "02-english-preview");
|
||||
await page.getByRole("button", { name: "Close preview", exact: true }).click();
|
||||
secondContext = await browser.newContext({
|
||||
storageState: await page.context().storageState(),
|
||||
viewport: { width: 1440, height: 1000 },
|
||||
});
|
||||
const second = await secondContext.newPage();
|
||||
await hideDeveloperToasts(second);
|
||||
await second.goto(page.url());
|
||||
await expect(second.getByText("App language: en", { exact: true })).toBeVisible();
|
||||
await chooseChinese(second);
|
||||
await expect(second.getByText("应用语言:zh-CN", { exact: true })).toBeVisible();
|
||||
await second
|
||||
.getByRole("textbox", { name: "你的草稿", exact: true })
|
||||
.fill("A separate client draft");
|
||||
await expect(page.getByText("App language: en", { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("plugin-surface-title")).toHaveText("Workspace notes");
|
||||
await capture(second, info, "03-independent-chinese-client");
|
||||
await chooseChinese(page);
|
||||
await expect(page.getByRole("textbox", { name: "你的草稿", exact: true })).toHaveValue(
|
||||
"Remember the language switch",
|
||||
);
|
||||
await expect(page.getByTestId("plugin-surface-title")).toHaveText("工作区笔记");
|
||||
await expect(page.getByText("应用语言:zh-CN", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("This English-only message demonstrates fallback.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await capture(page, info, "04-chinese-draft-preserved");
|
||||
await page.getByRole("button", { name: "预览草稿", exact: true }).click();
|
||||
await expect(page.getByText("草稿预览", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "关闭预览", exact: true })).toBeVisible();
|
||||
await capture(page, info, "05-chinese-preview");
|
||||
await page.getByRole("button", { name: "关闭预览", exact: true }).click();
|
||||
await chooseEnglish(page);
|
||||
await expect(page.getByRole("textbox", { name: "Your draft", exact: true })).toHaveValue(
|
||||
"Remember the language switch",
|
||||
);
|
||||
await expect(page.getByTestId("plugin-surface-title")).toHaveText("Workspace notes");
|
||||
await expect(second.getByRole("textbox", { name: "你的草稿", exact: true })).toHaveValue(
|
||||
"A separate client draft",
|
||||
);
|
||||
await capture(page, info, "06-return-to-english-draft-preserved");
|
||||
await page.getByRole("button", { name: "Add note", exact: true }).click();
|
||||
await expect(page.getByText("1 note", { exact: true })).toBeVisible();
|
||||
await page.getByRole("textbox", { name: "Your draft", exact: true }).fill("Second note");
|
||||
await page.getByRole("button", { name: "Add note", exact: true }).click();
|
||||
await expect(page.getByText("2 notes", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await secondContext?.close().catch(() => undefined);
|
||||
await client.removePlugin("translation-notes").catch(() => undefined);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await example.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyPluginToolTranslations(page: Page, info: TestInfo) {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "translation-tools-",
|
||||
title: "Notes translation QA",
|
||||
});
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Notes",
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const config = await client.getDaemonConfig();
|
||||
const example = await copyPluginExample("translations");
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(example.directory);
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
await gotoAppShell(page);
|
||||
await page.goto(buildAgentRoute(workspace.workspaceId, agent.id));
|
||||
const commandCenter = await openCommandCenter(page);
|
||||
await commandCenter.getByTestId("command-center-input").fill("Show notes tools");
|
||||
await commandCenter.getByRole("button", { name: "Show notes tools", exact: true }).click();
|
||||
const tools = page
|
||||
.getByRole("toolbar", { name: "Workspace actions", exact: true })
|
||||
.getByRole("button", { name: "Notes tools", exact: true });
|
||||
await tools.hover();
|
||||
await expect(page.getByRole("tooltip")).toContainText("Notes tools");
|
||||
await capture(page, info, "07-english-tooltip");
|
||||
await tools.click();
|
||||
await page.getByRole("menuitem", { name: "Open notes panel", exact: true }).click();
|
||||
await page
|
||||
.getByRole("textbox", { name: "Your draft", exact: true })
|
||||
.fill("A workspace panel draft");
|
||||
await chooseChinese(page);
|
||||
await expect(page.getByRole("textbox", { name: "你的草稿", exact: true })).toHaveValue(
|
||||
"A workspace panel draft",
|
||||
);
|
||||
const translatedTools = page
|
||||
.getByRole("toolbar")
|
||||
.getByRole("button", { name: "笔记工具", exact: true });
|
||||
await translatedTools.hover();
|
||||
await expect(page.getByRole("tooltip")).toContainText("笔记工具");
|
||||
await capture(page, info, "08-chinese-tooltip-and-panel-draft");
|
||||
await translatedTools.click();
|
||||
await expect(page.getByRole("menuitem", { name: "打开笔记面板", exact: true })).toBeVisible();
|
||||
await capture(page, info, "09-chinese-menu");
|
||||
await page.keyboard.press("Escape");
|
||||
} finally {
|
||||
await client.removePlugin("translation-notes").catch(() => undefined);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await example.cleanup();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,3 @@
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
import { startIsolatedHostDaemon } from "./isolated-host-daemon";
|
||||
import { seedWorkspace } from "./seed-client";
|
||||
import { getE2EDaemonPort } from "./daemon-port";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
@@ -170,7 +166,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 +175,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
|
||||
@@ -324,150 +320,3 @@ export async function detachSubagentFromTrack(page: Page, childId: string): Prom
|
||||
await expect(detachButton).toBeVisible({ timeout: 30_000 });
|
||||
await detachButton.click();
|
||||
}
|
||||
|
||||
/** The primary children come from the real agent MCP endpoint on A. */
|
||||
export async function delegateFleetThroughMcp() {
|
||||
const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
|
||||
const { StreamableHTTPClientTransport } =
|
||||
await import("@modelcontextprotocol/sdk/client/streamableHttp.js");
|
||||
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",
|
||||
port: destination.port,
|
||||
});
|
||||
const serverId = a.getLastServerInfoMessage()!.serverId;
|
||||
const mcp = new Client({ name: "fleet-parent", version: "1" });
|
||||
const close = async () => {
|
||||
await mcp.close();
|
||||
await Promise.allSettled([a.close(), b.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);
|
||||
};
|
||||
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({
|
||||
connection: `127.0.0.1:${destination.port}`,
|
||||
label: "Fleet B",
|
||||
});
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await a.listHosts()).hosts.find(
|
||||
(row) => row.registrationId === registration.registrationId,
|
||||
)?.status,
|
||||
)
|
||||
.toBe("online");
|
||||
await mcp.connect(
|
||||
new StreamableHTTPClientTransport(
|
||||
new URL(`http://127.0.0.1:${getE2EDaemonPort()}/mcp/agents?callerAgentId=${parent.id}`),
|
||||
),
|
||||
);
|
||||
expect(await call("list_hosts")).toMatchObject({
|
||||
hosts: expect.arrayContaining([
|
||||
expect.objectContaining({ serverId: destination.serverId, label: "Fleet B" }),
|
||||
]),
|
||||
});
|
||||
const target = await call("create_workspace", {
|
||||
serverId: destination.serverId,
|
||||
isolation: "local",
|
||||
path: workspace.repoPath,
|
||||
title: "B workspace",
|
||||
});
|
||||
expect(await call("list_workspaces", { serverId: destination.serverId })).toMatchObject({
|
||||
workspaces: expect.arrayContaining([
|
||||
expect.objectContaining({ workspaceId: target.workspaceId }),
|
||||
]),
|
||||
});
|
||||
const createChild = async (targetServerId: string, workspaceId: string, title: string) => {
|
||||
const result = await call("create_agent", {
|
||||
serverId: targetServerId,
|
||||
workspaceId,
|
||||
title,
|
||||
provider: "mock/e2e-fast-stream",
|
||||
settings: { modeId: "load-test" },
|
||||
initialPrompt: `Finish ${title}`,
|
||||
});
|
||||
return {
|
||||
serverId: targetServerId,
|
||||
agentId: z.string().parse(result.agentId),
|
||||
workspaceId,
|
||||
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",
|
||||
);
|
||||
// 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,
|
||||
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);
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,3 +376,7 @@ export function observeSetupPanelTraffic(page: Page) {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setupCommandOutput(detail: { commands: Array<{ log: string }> }): string {
|
||||
return detail.commands.map((command) => command.log).join("\n");
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import "@expo/metro-runtime";
|
||||
// oxlint-disable-next-line import/no-unassigned-import -- Preserve Expo's entry side effects.
|
||||
import "expo-router/build/fast-refresh";
|
||||
import { renderRootComponent } from "expo-router/build/renderRootComponent";
|
||||
import { startWatchdog } from "./src/watchdog";
|
||||
import { RootApp } from "./src/root-app";
|
||||
|
||||
startWatchdog();
|
||||
renderRootComponent(RootApp);
|
||||
|
||||
@@ -5,6 +5,7 @@ const path = require("path");
|
||||
const projectRoot = __dirname;
|
||||
const appNodeModulesRoot = path.resolve(projectRoot, "node_modules");
|
||||
const appSrcRoot = path.resolve(projectRoot, "src");
|
||||
const isWatchdogBuild = process.env.PASEO_WATCHDOG_BUILD === "1";
|
||||
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
|
||||
const fdroidModuleOverrides = {
|
||||
"expo-camera": path.resolve(appSrcRoot, "fdroid/expo-camera.tsx"),
|
||||
@@ -16,6 +17,7 @@ const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
|
||||
.toLowerCase();
|
||||
|
||||
const config = getDefaultConfig(projectRoot);
|
||||
config.cacheVersion = isWatchdogBuild ? "watchdog" : "standard";
|
||||
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
|
||||
|
||||
// Keep app exports deterministic across dev machines and CI. Metro's Watchman
|
||||
@@ -75,6 +77,14 @@ function resolveWithCustomWebOverlay(context, moduleName, platform) {
|
||||
}
|
||||
|
||||
config.resolver.resolveRequest = (context, moduleName, platform) => {
|
||||
if (
|
||||
isWatchdogBuild &&
|
||||
platform === "android" &&
|
||||
/[/\\]Renderer[/\\]shims[/\\]/.test(context.originModulePath) &&
|
||||
/implementations\/(ReactFabric|ReactNativeRenderer)-prod$/.test(moduleName)
|
||||
) {
|
||||
return defaultResolveRequest(context, moduleName.replace(/-prod$/, "-profiling"), platform);
|
||||
}
|
||||
if (isFdroidBuild && platform === "android" && fdroidModuleOverrides[moduleName]) {
|
||||
return resolveWithCustomWebOverlay(context, fdroidModuleOverrides[moduleName], platform);
|
||||
}
|
||||
|
||||
@@ -13,3 +13,7 @@ useDefaultAndroidSdkVersions()
|
||||
android {
|
||||
namespace "sh.paseo.trace"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation "junit:junit:4.13.2"
|
||||
}
|
||||
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package sh.paseo.trace
|
||||
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import org.json.JSONObject
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ArrayBlockingQueue
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/** All serialization and storage belongs to the worker, never the JS/UI threads. */
|
||||
internal class DiagnosticRecorder(private val context: Context, private val metadata: Map<String, String>) {
|
||||
private data class Event(val name: String, val data: Map<String, String>, val monoMs: Long = SystemClock.elapsedRealtime(), val wallMs: Long = System.currentTimeMillis())
|
||||
private val queue = ArrayBlockingQueue<Event>(2048)
|
||||
private val dropped = AtomicLong()
|
||||
private val worker = Executors.newSingleThreadScheduledExecutor()
|
||||
private val session = UUID.randomUUID().toString()
|
||||
@Volatile private var failed = false
|
||||
private val watchdog = HeartbeatWatchdog(SystemClock.elapsedRealtime(), ::record)
|
||||
|
||||
private val log = RotatingLog(DownloadsLogStorage(context, session), { part ->
|
||||
serialize(Event("file.start", metadata + mapOf("schema" to "1", "part" to "$part")))
|
||||
}, { error ->
|
||||
failed = true
|
||||
queue.clear()
|
||||
Log.e("PaseoDiagnostics", "Diagnostic recording stopped: storage unavailable", error)
|
||||
})
|
||||
|
||||
init {
|
||||
record("session.start", metadata + mapOf("session" to session, "schema" to "1", "heartbeatMs" to "${HeartbeatTiming.intervalMs}", "stallThresholdMs" to "${HeartbeatTiming.overdueMs}"))
|
||||
worker.scheduleWithFixedDelay({ drain() }, 0, HeartbeatTiming.intervalMs, TimeUnit.MILLISECONDS)
|
||||
}
|
||||
|
||||
fun record(name: String, data: Map<String, String>) {
|
||||
if (failed) return
|
||||
// Limit native memory even if an instrumentation caller accidentally supplies large fields.
|
||||
val bounded = data.entries.take(24).associate { it.key.take(80) to it.value.take(256) }
|
||||
if (!queue.offer(Event(name.take(120), bounded))) dropped.incrementAndGet()
|
||||
}
|
||||
|
||||
fun heartbeat() = watchdog.heartbeat(SystemClock.elapsedRealtime())
|
||||
fun active(value: Boolean) = watchdog.active(value, SystemClock.elapsedRealtime())
|
||||
|
||||
private fun drain() {
|
||||
if (failed) return
|
||||
watchdog.check(SystemClock.elapsedRealtime())
|
||||
val lost = dropped.getAndSet(0)
|
||||
if (lost > 0) write(Event("recorder.dropped", mapOf("count" to "$lost")))
|
||||
var remaining = 2048
|
||||
while (remaining-- > 0 && !failed) {
|
||||
val event = queue.poll() ?: break
|
||||
write(event)
|
||||
}
|
||||
log.finishBatch()
|
||||
}
|
||||
|
||||
private fun write(event: Event) = log.write(serialize(event))
|
||||
|
||||
private fun serialize(event: Event): ByteArray = (JSONObject().apply {
|
||||
put("event", event.name)
|
||||
put("session", session)
|
||||
put("wallMs", event.wallMs)
|
||||
put("monoMs", event.monoMs)
|
||||
put("data", JSONObject(event.data))
|
||||
}.toString() + "\n").toByteArray(Charsets.UTF_8)
|
||||
|
||||
fun close() {
|
||||
worker.execute {
|
||||
record("session.end", emptyMap())
|
||||
drain()
|
||||
log.finishBatch()
|
||||
}
|
||||
worker.shutdown()
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package sh.paseo.trace
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import java.io.OutputStream
|
||||
|
||||
internal class DownloadsLogStorage(private val context: Context, private val session: String) : LogStorage {
|
||||
private val resolver = context.contentResolver
|
||||
private val collection = MediaStore.Downloads.EXTERNAL_CONTENT_URI
|
||||
private val directory = "Download/Paseo diagnostics/"
|
||||
|
||||
override fun create(part: Int): String {
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, "paseo-diagnostics-${System.currentTimeMillis()}-${session.take(8)}-$part.jsonl")
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, "application/x-ndjson")
|
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, directory)
|
||||
}
|
||||
return checkNotNull(resolver.insert(collection, values)).toString()
|
||||
}
|
||||
|
||||
override fun append(id: String): OutputStream =
|
||||
checkNotNull(resolver.openOutputStream(Uri.parse(id), "wa")).buffered(64 * 1024)
|
||||
|
||||
override fun newest(): List<String> = buildList {
|
||||
resolver.query(collection, arrayOf(MediaStore.MediaColumns._ID),
|
||||
"${MediaStore.MediaColumns.RELATIVE_PATH} = ? AND ${MediaStore.MediaColumns.OWNER_PACKAGE_NAME} = ?",
|
||||
arrayOf(directory, context.packageName), "${MediaStore.MediaColumns._ID} DESC")?.use { cursor ->
|
||||
while (cursor.moveToNext()) add(ContentUris.withAppendedId(collection, cursor.getLong(0)).toString())
|
||||
}
|
||||
}
|
||||
|
||||
override fun delete(id: String) { resolver.delete(Uri.parse(id), null, null) }
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package sh.paseo.trace
|
||||
|
||||
internal object HeartbeatTiming {
|
||||
const val intervalMs = 250L
|
||||
const val overdueMs = 500L
|
||||
const val gapMs = intervalMs + overdueMs
|
||||
}
|
||||
|
||||
/** Monotonic time is supplied by the owner, so suspension and recovery are deterministic. */
|
||||
internal class HeartbeatWatchdog(startMs: Long, private val emit: (String, Map<String, String>) -> Unit) {
|
||||
private var foreground = true
|
||||
private var lastHeartbeat = startMs
|
||||
private var overdue = false
|
||||
|
||||
@Synchronized fun heartbeat(now: Long) {
|
||||
if (foreground && now - lastHeartbeat > HeartbeatTiming.gapMs) {
|
||||
emit("js.stall.recovered", mapOf("heartbeatGapMs" to "${now - lastHeartbeat}", "overdueMs" to "${now - lastHeartbeat - HeartbeatTiming.intervalMs}"))
|
||||
}
|
||||
lastHeartbeat = now
|
||||
overdue = false
|
||||
}
|
||||
|
||||
@Synchronized fun active(value: Boolean, now: Long) {
|
||||
if (foreground == value) return
|
||||
emit("app.lifecycle", mapOf("state" to if (value) "foreground" else "background", "stallOpen" to "$overdue"))
|
||||
foreground = value
|
||||
lastHeartbeat = now
|
||||
overdue = false
|
||||
}
|
||||
|
||||
@Synchronized fun check(now: Long) {
|
||||
val gap = now - lastHeartbeat
|
||||
if (foreground && gap > HeartbeatTiming.gapMs && !overdue) {
|
||||
emit("js.stall.started", mapOf("heartbeatGapMs" to "$gap", "lastHeartbeatMonoMs" to "$lastHeartbeat"))
|
||||
overdue = true
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -5,9 +5,23 @@ import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
|
||||
class PaseoNativeTraceModule : Module() {
|
||||
private var recorder: DiagnosticRecorder? = null
|
||||
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("PaseoNativeTrace")
|
||||
|
||||
Function("startRecorder") { metadata: Map<String, String> ->
|
||||
if (recorder == null) {
|
||||
appContext.reactContext?.applicationContext?.let { recorder = DiagnosticRecorder(it, metadata) }
|
||||
}
|
||||
HeartbeatTiming.intervalMs
|
||||
}
|
||||
Function("record") { name: String, data: Map<String, String> -> recorder?.record(name, data) }
|
||||
Function("heartbeat") { recorder?.heartbeat() }
|
||||
OnActivityEntersForeground { recorder?.active(true) }
|
||||
OnActivityEntersBackground { recorder?.active(false) }
|
||||
OnDestroy { recorder?.close(); recorder = null }
|
||||
|
||||
Function("beginSection") { name: String ->
|
||||
Trace.beginSection(name.take(127))
|
||||
}
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package sh.paseo.trace
|
||||
|
||||
import java.io.OutputStream
|
||||
|
||||
/** Storage supplies installation-owned files, newest first. */
|
||||
internal interface LogStorage {
|
||||
fun create(part: Int): String
|
||||
fun append(id: String): OutputStream
|
||||
fun newest(): List<String>
|
||||
fun delete(id: String)
|
||||
}
|
||||
|
||||
/** Owns file identity, bounded retention and the terminal storage-failure state. */
|
||||
internal class RotatingLog(
|
||||
private val storage: LogStorage,
|
||||
private val header: (Int) -> ByteArray,
|
||||
private val onFailure: (Exception) -> Unit,
|
||||
private val maxBytes: Int = 5 * 1024 * 1024,
|
||||
) {
|
||||
private var current: String? = null
|
||||
private var output: OutputStream? = null
|
||||
private var size = 0
|
||||
private var part = 0
|
||||
var failed = false
|
||||
private set
|
||||
|
||||
fun write(bytes: ByteArray) = attempt {
|
||||
if (current == null || size + bytes.size > maxBytes) {
|
||||
finishBatch()
|
||||
if (failed) return@attempt
|
||||
val nextPart = part++
|
||||
current = storage.create(nextPart)
|
||||
output = storage.append(checkNotNull(current))
|
||||
val first = header(nextPart)
|
||||
output!!.write(first)
|
||||
size = first.size
|
||||
storage.newest().drop(8).forEach(storage::delete)
|
||||
}
|
||||
if (output == null) output = storage.append(checkNotNull(current))
|
||||
output!!.write(bytes)
|
||||
size += bytes.size
|
||||
}
|
||||
|
||||
// Closing publishes size and modification time in Android Files.
|
||||
fun finishBatch() = attempt {
|
||||
output?.close()
|
||||
output = null
|
||||
}
|
||||
|
||||
private fun attempt(action: () -> Unit) {
|
||||
if (failed) return
|
||||
try {
|
||||
action()
|
||||
} catch (error: Exception) {
|
||||
failed = true
|
||||
runCatching { output?.close() }
|
||||
output = null
|
||||
onFailure(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package sh.paseo.trace
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class HeartbeatWatchdogTest {
|
||||
@Test fun recordsOneStartAndRecoveryWithMeasuredGap() {
|
||||
val events = mutableListOf<Pair<String, Map<String, String>>>()
|
||||
val watchdog = HeartbeatWatchdog(0) { name, fields -> events.add(name to fields) }
|
||||
watchdog.heartbeat(250)
|
||||
watchdog.check(1000)
|
||||
assertEquals(emptyList<Pair<String, Map<String, String>>>(), events)
|
||||
watchdog.check(1001)
|
||||
watchdog.check(2000)
|
||||
watchdog.heartbeat(2250)
|
||||
assertEquals(listOf(
|
||||
"js.stall.started" to mapOf("heartbeatGapMs" to "751", "lastHeartbeatMonoMs" to "250"),
|
||||
"js.stall.recovered" to mapOf("heartbeatGapMs" to "2000", "overdueMs" to "1750")
|
||||
), events)
|
||||
}
|
||||
|
||||
@Test fun backgroundAndResumeDoNotLookLikeStalls() {
|
||||
val events = mutableListOf<String>()
|
||||
val watchdog = HeartbeatWatchdog(0) { name, _ -> events.add(name) }
|
||||
watchdog.active(false, 100)
|
||||
watchdog.check(300000)
|
||||
watchdog.heartbeat(300100)
|
||||
watchdog.active(true, 400000)
|
||||
watchdog.heartbeat(400250)
|
||||
watchdog.check(400500)
|
||||
assertEquals(listOf("app.lifecycle", "app.lifecycle"), events)
|
||||
}
|
||||
|
||||
@Test fun recoveryIsReportedEvenIfNativeSamplingMissedTheGap() {
|
||||
val events = mutableListOf<String>()
|
||||
val watchdog = HeartbeatWatchdog(0) { name, _ -> events.add(name) }
|
||||
watchdog.heartbeat(900)
|
||||
assertEquals(listOf("js.stall.recovered"), events)
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package sh.paseo.trace
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class RotatingLogTest {
|
||||
private class MemoryStorage : LogStorage {
|
||||
val files = linkedMapOf<String, ByteArrayOutputStream>()
|
||||
var fail = false
|
||||
var opens = 0
|
||||
override fun create(part: Int): String {
|
||||
val id = "part-$part"
|
||||
files[id] = ByteArrayOutputStream()
|
||||
return id
|
||||
}
|
||||
override fun append(id: String): OutputStream {
|
||||
opens++
|
||||
if (fail) throw IOException("disk full")
|
||||
return files.getValue(id)
|
||||
}
|
||||
override fun newest() = files.keys.reversed()
|
||||
override fun delete(id: String) { files.remove(id) }
|
||||
}
|
||||
|
||||
@Test fun rotatesAtByteLimitAndKeepsEightIncludingCurrent() {
|
||||
val storage = MemoryStorage()
|
||||
val log = RotatingLog(storage, { "header:$it\n".toByteArray() }, { throw it }, 20)
|
||||
repeat(10) { log.write("123456789012".toByteArray()); log.finishBatch() }
|
||||
assertEquals((2..9).map { "part-$it" }, storage.files.keys.toList())
|
||||
for (part in 2..9) assertEquals("header:$part\n123456789012", storage.files.getValue("part-$part").toString("UTF-8"))
|
||||
assertFalse(log.failed)
|
||||
}
|
||||
|
||||
@Test fun reopeningBatchAppendsWithoutRepeatingHeaderAndExactLimitFits() {
|
||||
val storage = MemoryStorage()
|
||||
val log = RotatingLog(storage, { "H".toByteArray() }, { throw it }, 5)
|
||||
log.write("12".toByteArray())
|
||||
log.finishBatch()
|
||||
log.write("34".toByteArray())
|
||||
assertEquals(listOf("part-0"), storage.files.keys.toList())
|
||||
assertEquals("H1234", storage.files.getValue("part-0").toString("UTF-8"))
|
||||
log.write("5".toByteArray())
|
||||
assertEquals("H5", storage.files.getValue("part-1").toString("UTF-8"))
|
||||
}
|
||||
|
||||
@Test fun storageFailureStopsRecordingWithoutRetryStorm() {
|
||||
val storage = MemoryStorage()
|
||||
var failures = 0
|
||||
val log = RotatingLog(storage, { byteArrayOf() }, { failures++ })
|
||||
storage.fail = true
|
||||
repeat(100) { log.write(byteArrayOf(1)); log.finishBatch() }
|
||||
assertTrue(log.failed)
|
||||
assertEquals(1, failures)
|
||||
assertEquals(1, storage.opens)
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,12 @@
|
||||
"start:expo": "cross-env APP_VARIANT=development expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"eas-build-post-install": "npm --prefix ../.. run build:app-deps && npm run build:terminal-webview",
|
||||
"android": "npm run android:development",
|
||||
"android:development": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug",
|
||||
"android": "npm run dev:android",
|
||||
"android:development": "npm run dev:android",
|
||||
"android:production": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=production expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=production expo run:android --variant=release",
|
||||
"android:release": "npm run android:production",
|
||||
"android:clear": "node -e \"require('node:fs').rmSync('android', { recursive: true, force: true })\"",
|
||||
"ios": "npm --prefix ../.. run build:client && expo run:ios",
|
||||
"ios": "npm run dev:ios",
|
||||
"ios:release": "npm --prefix ../.. run build:client && expo run:ios --configuration Release",
|
||||
"web": "npm --prefix ../.. run build:client && concurrently --kill-others --names protocol,client,expo --prefix-colors yellow,blue,magenta \"npm --prefix ../.. run watch:protocol\" \"npm --prefix ../.. run watch:client\" \"npm run web:expo\"",
|
||||
"web:expo": "expo start --web",
|
||||
@@ -36,7 +36,12 @@
|
||||
"profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs",
|
||||
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
|
||||
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs",
|
||||
"build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs"
|
||||
"build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs",
|
||||
"dev:android": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug",
|
||||
"dev:ios": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo run:ios",
|
||||
"build:apk": "npm --prefix ../.. run build:apk",
|
||||
"build:apk:arm64": "npm --prefix ../.. run build:apk:arm64",
|
||||
"test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest --no-daemon --max-workers=2 -Dorg.gradle.parallel=false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host";
|
||||
import { runPluginClientBundle, type PluginClientRuntime } from "@/plugins/evaluate";
|
||||
import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/timeline/model";
|
||||
import type { InstalledPlugin } from "@/plugins/types";
|
||||
@@ -61,6 +62,8 @@ function installProbe(
|
||||
serverId: "host-1",
|
||||
clientBundle,
|
||||
lifetime: new AbortController(),
|
||||
locale: "en",
|
||||
i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }),
|
||||
queryClient: new QueryClient(),
|
||||
paseo: {} as InstalledPlugin["paseo"],
|
||||
invoke: async () => undefined,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, type ReactNode } from "react";
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -186,48 +186,6 @@ function ShellDetailSection({ command, output, ds }: ShellDetailProps) {
|
||||
);
|
||||
}
|
||||
|
||||
interface WorktreeSetupDetailProps {
|
||||
log: string;
|
||||
branchName: string;
|
||||
worktreePath: string;
|
||||
ds: DetailStyles;
|
||||
}
|
||||
|
||||
function WorktreeSetupDetailSection({
|
||||
log,
|
||||
branchName,
|
||||
worktreePath,
|
||||
ds,
|
||||
}: WorktreeSetupDetailProps) {
|
||||
const setupLog = log.replace(/^\n+/, "");
|
||||
const hasLog = setupLog.length > 0;
|
||||
return (
|
||||
<View style={ds.sectionFillStyle}>
|
||||
<View style={ds.codeBlockFillStyle}>
|
||||
<ScrollView
|
||||
style={ds.codeVerticalScrollStyle}
|
||||
contentContainerStyle={styles.codeVerticalContent}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
contentContainerStyle={styles.codeHorizontalContent}
|
||||
>
|
||||
<View style={styles.codeLine} dataSet={CODE_SURFACE_DATASET}>
|
||||
<Text selectable style={styles.scrollText}>
|
||||
{hasLog ? setupLog : `Preparing worktree ${branchName} at ${worktreePath}`}
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSubAgentFallbackHeader(
|
||||
subAgentType: string | null | undefined,
|
||||
description: string | null | undefined,
|
||||
@@ -678,17 +636,7 @@ function buildDetailSections(
|
||||
<ShellDetailSection key="shell" command={detail.command} output={detail.output} ds={ds} />,
|
||||
];
|
||||
}
|
||||
if (detail.type === "worktree_setup") {
|
||||
return [
|
||||
<WorktreeSetupDetailSection
|
||||
key="worktree-setup"
|
||||
log={detail.log}
|
||||
branchName={detail.branchName}
|
||||
worktreePath={detail.worktreePath}
|
||||
ds={ds}
|
||||
/>,
|
||||
];
|
||||
}
|
||||
if (detail.type === "worktree_setup") return [];
|
||||
if (detail.type === "sub_agent") {
|
||||
return [
|
||||
<SubAgentDetailSection
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -315,6 +315,7 @@ interface PendingFileAttachment {
|
||||
}
|
||||
|
||||
interface RenderAttachmentTrayArgs {
|
||||
serverId: string;
|
||||
selectedAttachments: ComposerAttachment[];
|
||||
pendingFiles: PendingFileAttachment[];
|
||||
isComposerLocked: boolean;
|
||||
@@ -331,6 +332,7 @@ interface RenderAttachmentTrayArgs {
|
||||
|
||||
function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | null {
|
||||
const {
|
||||
serverId,
|
||||
selectedAttachments,
|
||||
pendingFiles,
|
||||
isComposerLocked,
|
||||
@@ -343,6 +345,7 @@ function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | nu
|
||||
<View style={styles.attachmentTray} testID="composer-attachment-tray">
|
||||
{selectedAttachments.map((attachment, index) =>
|
||||
renderComposerAttachmentPill({
|
||||
serverId,
|
||||
attachment,
|
||||
index,
|
||||
disabled: isComposerLocked,
|
||||
@@ -393,6 +396,7 @@ function renderQueueTrack(args: RenderQueueTrackArgs): ReactElement | null {
|
||||
}
|
||||
|
||||
interface RenderComposerAttachmentPillArgs {
|
||||
serverId: string;
|
||||
attachment: ComposerAttachment;
|
||||
index: number;
|
||||
disabled: boolean;
|
||||
@@ -453,6 +457,7 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R
|
||||
if (attachment.kind === "plugin_resource") {
|
||||
return (
|
||||
<PluginResourceAttachmentPill
|
||||
serverId={args.serverId}
|
||||
key={`${attachment.pluginId}:${attachment.sourceId}:${attachment.item.id}`}
|
||||
attachment={attachment}
|
||||
index={index}
|
||||
@@ -2295,6 +2300,7 @@ function ComposerContentImpl({
|
||||
const attachmentTray = useMemo(
|
||||
() =>
|
||||
renderAttachmentTray({
|
||||
serverId,
|
||||
selectedAttachments,
|
||||
pendingFiles,
|
||||
isComposerLocked,
|
||||
@@ -2314,6 +2320,7 @@ function ComposerContentImpl({
|
||||
handleOpenAttachment,
|
||||
handleRemoveAttachment,
|
||||
isComposerLocked,
|
||||
serverId,
|
||||
selectedAttachments,
|
||||
pendingFiles,
|
||||
t,
|
||||
|
||||
@@ -10,7 +10,6 @@ function connectionListen(connection: HostConnection): string | null {
|
||||
case "directPipe":
|
||||
return `pipe://${connection.path}`;
|
||||
case "remoteSsh":
|
||||
case "routed":
|
||||
case "relay":
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export function filePanelIcon(panel: FilePanel): PluginIcon {
|
||||
export function filePanelTitle(t: TFunction, panel: FilePanel): string {
|
||||
return panel.builtin
|
||||
? t(`panels.file.filePanels.builtin.${BUILTIN_PRESENTATION[panel.id].title}`)
|
||||
: panel.contribution.title;
|
||||
: panel.plugin.i18n.resolve(panel.contribution.title, panel.plugin.locale);
|
||||
}
|
||||
|
||||
/** "Markdown — Paseo", "Rich Markdown — markdown-panel". */
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import type {
|
||||
PluginFile,
|
||||
PluginFileLocation,
|
||||
PluginFilePanelContribution,
|
||||
PluginFileTab,
|
||||
} from "@getpaseo/plugin/client";
|
||||
import type { PluginFile, PluginFileLocation, PluginFileTab } from "@getpaseo/plugin/client";
|
||||
import { useInstalledPlugins } from "@/plugins/registry";
|
||||
import type { InstalledPlugin } from "@/plugins/types";
|
||||
import type { PluginFilePanelContribution, InstalledPlugin } from "@/plugins/types";
|
||||
import { filePanelCatalog, type RenderingFilePanel, type SourceFilePanel } from "./catalog";
|
||||
|
||||
export interface PluginFilePanel {
|
||||
|
||||
@@ -367,11 +367,6 @@ function useArchiveAgentPendingQuery() {
|
||||
});
|
||||
}
|
||||
|
||||
export function usePendingArchiveAgentKeys(): ReadonlySet<string> {
|
||||
const pendingQuery = useArchiveAgentPendingQuery();
|
||||
return useMemo(() => new Set(Object.keys(pendingQuery.data ?? {})), [pendingQuery.data]);
|
||||
}
|
||||
|
||||
export function usePendingArchiveAgentIds(serverId: string): ReadonlySet<string> {
|
||||
const pendingQuery = useArchiveAgentPendingQuery();
|
||||
return useMemo(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { AgentAddress } from "@getpaseo/protocol/addresses";
|
||||
import { memo, useCallback, type ReactElement } from "react";
|
||||
import { WorkspaceDiffStatPill } from "@/composer/diff-stat-pill";
|
||||
import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat";
|
||||
@@ -57,8 +56,8 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
const canDetachSubagents = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.agentDetach === true,
|
||||
);
|
||||
const archiveSubagent = useArchiveSubagent();
|
||||
const detachSubagent = useDetachSubagent();
|
||||
const archiveSubagent = useArchiveSubagent({ serverId });
|
||||
const detachSubagent = useDetachSubagent({ serverId });
|
||||
const openSubagentTarget = useCallback(
|
||||
(target: WorkspaceTabTarget) => {
|
||||
openPreferredWorkspaceTarget({
|
||||
@@ -72,15 +71,12 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
[openInSidePane, tabId, workspaceKey],
|
||||
);
|
||||
const handleOpenSubagent = useCallback(
|
||||
({ serverId: targetServerId, agentId: subagentId }: AgentAddress) => {
|
||||
const session = useSessionStore.getState().sessions[targetServerId];
|
||||
(subagentId: string) => {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const agent = session?.agents.get(subagentId) ?? session?.agentDetails.get(subagentId);
|
||||
// A subagent in another workspace lives in that workspace's tabs.
|
||||
if (
|
||||
targetServerId !== serverId ||
|
||||
(agent?.workspaceId && agent.workspaceId !== workspaceId)
|
||||
) {
|
||||
navigateToAgent({ serverId: targetServerId, agentId: subagentId });
|
||||
if (agent?.workspaceId && agent.workspaceId !== workspaceId) {
|
||||
navigateToAgent({ serverId, agentId: subagentId });
|
||||
return;
|
||||
}
|
||||
openSubagentTarget({ kind: "agent", agentId: subagentId });
|
||||
|
||||
@@ -183,32 +183,19 @@ function resolveSetupStatusLabel(
|
||||
return labels.waiting;
|
||||
}
|
||||
|
||||
function resolveCommandLog(
|
||||
command: SetupCommand,
|
||||
autoExpandIndex: number | null,
|
||||
log: string,
|
||||
): string {
|
||||
if ("log" in command && typeof command.log === "string") {
|
||||
return command.log;
|
||||
}
|
||||
if (command.index === autoExpandIndex) return log;
|
||||
return "";
|
||||
}
|
||||
|
||||
interface BuildCommandRowPropsArgs {
|
||||
command: SetupCommand;
|
||||
autoExpandIndex: number | null;
|
||||
log: string;
|
||||
expandedIndices: Set<number>;
|
||||
manuallyCollapsed: Set<number>;
|
||||
snapshotError: string | null | undefined;
|
||||
}
|
||||
|
||||
function buildCommandRowState(args: BuildCommandRowPropsArgs) {
|
||||
const { command, autoExpandIndex, log, expandedIndices, manuallyCollapsed, snapshotError } = args;
|
||||
const { command, autoExpandIndex, expandedIndices, manuallyCollapsed, snapshotError } = args;
|
||||
const isExpanded = expandedIndices.has(command.index);
|
||||
const hasError = command.status === "failed" && Boolean(snapshotError);
|
||||
const commandLog = resolveCommandLog(command, autoExpandIndex, log);
|
||||
const commandLog = command.log;
|
||||
const hasLog = commandLog.trim().length > 0;
|
||||
const isExpandable = command.status !== "running" || hasLog || hasError;
|
||||
const isAutoExpanded = command.index === autoExpandIndex && !manuallyCollapsed.has(command.index);
|
||||
@@ -285,9 +272,7 @@ function SetupPanel() {
|
||||
);
|
||||
|
||||
const commands = snapshot?.detail.commands ?? EMPTY_COMMANDS;
|
||||
const log = snapshot?.detail.log ?? "";
|
||||
const hasNoSetupCommands =
|
||||
snapshot?.status === "completed" && commands.length === 0 && log.trim().length === 0;
|
||||
const hasNoSetupCommands = snapshot?.status === "completed" && commands.length === 0;
|
||||
const isWaiting = !snapshot || (snapshot.status === "running" && commands.length === 0);
|
||||
|
||||
const { expandedIndices, manuallyCollapsed, toggleExpanded } = useExpandedSetupCommands();
|
||||
@@ -323,7 +308,6 @@ function SetupPanel() {
|
||||
workspaceId={target.workspaceId}
|
||||
snapshot={snapshot}
|
||||
commands={commands}
|
||||
log={log}
|
||||
isWaiting={isWaiting}
|
||||
hasNoSetupCommands={hasNoSetupCommands}
|
||||
autoExpandIndex={autoExpandIndex}
|
||||
@@ -341,7 +325,6 @@ interface SetupPanelBodyProps {
|
||||
workspaceId: string;
|
||||
snapshot: WorkspaceSetupSnapshot | null;
|
||||
commands: SetupCommand[];
|
||||
log: string;
|
||||
isWaiting: boolean;
|
||||
hasNoSetupCommands: boolean;
|
||||
autoExpandIndex: number | null;
|
||||
@@ -352,7 +335,7 @@ interface SetupPanelBodyProps {
|
||||
|
||||
function SetupPanelBody(props: SetupPanelBodyProps) {
|
||||
const { t } = useTranslation();
|
||||
const { snapshot, commands, log } = props;
|
||||
const { snapshot, commands } = props;
|
||||
if (snapshot?.status === "blocked" && snapshot.blockedSource) {
|
||||
return (
|
||||
<BlockedSetupNotice
|
||||
@@ -389,7 +372,6 @@ function SetupPanelBody(props: SetupPanelBodyProps) {
|
||||
const rowState = buildCommandRowState({
|
||||
command,
|
||||
autoExpandIndex: props.autoExpandIndex,
|
||||
log,
|
||||
expandedIndices: props.expandedIndices,
|
||||
manuallyCollapsed: props.manuallyCollapsed,
|
||||
snapshotError: snapshot?.error,
|
||||
@@ -404,7 +386,6 @@ function SetupPanelBody(props: SetupPanelBodyProps) {
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<StandaloneLogView commands={commands} log={log} />
|
||||
<TopLevelSetupError snapshotError={snapshot?.error ?? null} commands={commands} />
|
||||
</View>
|
||||
);
|
||||
@@ -523,25 +504,6 @@ function SetupCommandChevron({ showDetail }: { showDetail: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
function StandaloneLogView({ commands, log }: { commands: SetupCommand[]; log: string }) {
|
||||
const { t } = useTranslation();
|
||||
if (commands.length !== 0 || log.trim().length === 0) return null;
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.logScroll}
|
||||
contentContainerStyle={styles.logScrollContent}
|
||||
showsVerticalScrollIndicator
|
||||
testID="workspace-setup-log"
|
||||
accessible
|
||||
accessibilityLabel={t("workspace.setup.accessibility.log")}
|
||||
>
|
||||
<Text selectable dataSet={CODE_SURFACE_DATASET} style={styles.logText}>
|
||||
{log}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
function TopLevelSetupError({
|
||||
snapshotError,
|
||||
commands,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { watchdog } from "@/watchdog";
|
||||
import type { DaemonClientTrace } from "@getpaseo/client/internal/daemon-client";
|
||||
import { requireOptionalNativeModule } from "expo-modules-core";
|
||||
import { isProfileBuild } from "@/constants/build-profile";
|
||||
@@ -9,17 +10,14 @@ interface PaseoNativeTraceModule {
|
||||
|
||||
const traceModule = requireOptionalNativeModule<PaseoNativeTraceModule>("PaseoNativeTrace");
|
||||
|
||||
export const nativePerformanceTrace: DaemonClientTrace = {
|
||||
isEnabled() {
|
||||
return isProfileBuild && traceModule !== null;
|
||||
},
|
||||
beginSection(name, args) {
|
||||
traceModule?.beginSection(formatSectionName(name, args));
|
||||
},
|
||||
endSection() {
|
||||
traceModule?.endSection();
|
||||
},
|
||||
};
|
||||
const nativeSink: DaemonClientTrace | null =
|
||||
isProfileBuild && traceModule
|
||||
? {
|
||||
isEnabled: () => true,
|
||||
beginSection: (name, args) => traceModule.beginSection(formatSectionName(name, args)),
|
||||
endSection: () => traceModule.endSection(),
|
||||
}
|
||||
: null;
|
||||
|
||||
function formatSectionName(name: string, args?: Record<string, string>): string {
|
||||
if (!args) {
|
||||
@@ -29,10 +27,29 @@ function formatSectionName(name: string, args?: Record<string, string>): string
|
||||
return `${name}|${fields.join(";")}`;
|
||||
}
|
||||
|
||||
const instantSinks: ((name: string, args?: Record<string, string>) => void)[] = [];
|
||||
if (watchdog) instantSinks.push(watchdog.record);
|
||||
if (nativeSink)
|
||||
instantSinks.push((name, args) => {
|
||||
nativeSink.beginSection(name, args);
|
||||
nativeSink.endSection();
|
||||
});
|
||||
export function traceInstant(name: string, args?: Record<string, string>): void {
|
||||
if (!nativePerformanceTrace.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
nativePerformanceTrace.beginSection(name, args);
|
||||
nativePerformanceTrace.endSection();
|
||||
for (const write of instantSinks) write(name, args);
|
||||
}
|
||||
|
||||
/** Each client owns its section stack; overlapping hosts never share span identity. */
|
||||
export function createPerformanceTrace(context: Record<string, string>): DaemonClientTrace {
|
||||
const sinks = [watchdog?.trace(context), nativeSink].filter(
|
||||
(sink): sink is DaemonClientTrace => sink != null,
|
||||
);
|
||||
return {
|
||||
isEnabled: () => sinks.length > 0,
|
||||
beginSection(name, args) {
|
||||
for (const sink of sinks) sink.beginSection(name, args);
|
||||
},
|
||||
endSection() {
|
||||
for (const sink of sinks) sink.endSection();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,11 +2,8 @@ import { useCallback, useMemo, useState, type ReactElement, type RefObject } fro
|
||||
import { View } from "react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type {
|
||||
PluginAttachmentItem,
|
||||
PluginAttachmentSourceContribution,
|
||||
PluginSerializableIcon,
|
||||
} from "@getpaseo/plugin";
|
||||
import type { PluginAttachmentItem, PluginSerializableIcon } from "@getpaseo/plugin";
|
||||
import type { PluginAttachmentSourceContribution } from "../types";
|
||||
import { searchPluginAttachments } from "@getpaseo/plugin/client/host";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { AttachmentMenuItem } from "@/composer/input/input";
|
||||
@@ -136,7 +133,7 @@ export function usePluginAttachmentPicker(
|
||||
{
|
||||
pluginId: active.plugin.id,
|
||||
sourceId: active.source.id,
|
||||
sourceTitle: active.source.title,
|
||||
sourceTitle: active.plugin.i18n.resolve(active.source.title, active.plugin.locale),
|
||||
sourceIcon: active.source.icon,
|
||||
},
|
||||
item,
|
||||
@@ -148,9 +145,9 @@ export function usePluginAttachmentPicker(
|
||||
);
|
||||
const menuItems = useMemo(
|
||||
() =>
|
||||
sources.map(({ key, source }) => ({
|
||||
sources.map(({ key, source, plugin }) => ({
|
||||
id: `plugin:${key}`,
|
||||
label: `Attach ${source.title}`,
|
||||
label: `Attach ${plugin.i18n.resolve(source.title, plugin.locale)}`,
|
||||
icon: <ThemedSourceIcon icon={source.icon} uniProps={iconColorMapping} />,
|
||||
onSelect: () => setActiveKey(key),
|
||||
})),
|
||||
@@ -165,8 +162,11 @@ export function usePluginAttachmentPicker(
|
||||
value=""
|
||||
onSelect={handleSelect}
|
||||
searchable
|
||||
searchPlaceholder={active.source.searchPlaceholder}
|
||||
title={active.source.pickerTitle}
|
||||
searchPlaceholder={active.plugin.i18n.resolve(
|
||||
active.source.searchPlaceholder,
|
||||
active.plugin.locale,
|
||||
)}
|
||||
title={active.plugin.i18n.resolve(active.source.pickerTitle, active.plugin.locale)}
|
||||
open
|
||||
onOpenChange={handleOpenChange}
|
||||
onSearchQueryChange={setQuery}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useInstalledPlugin } from "../registry";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { AttachmentLabel, AttachmentPill } from "@/components/attachment-pill";
|
||||
@@ -16,6 +17,7 @@ function ResourceIcon({ icon, color = "" }: { icon: PluginSerializableIcon; colo
|
||||
const ThemedResourceIcon = withUnistyles(ResourceIcon);
|
||||
|
||||
interface PluginResourceAttachmentPillProps {
|
||||
serverId: string;
|
||||
attachment: PluginResourceComposerAttachment;
|
||||
index: number;
|
||||
disabled: boolean;
|
||||
@@ -26,6 +28,7 @@ interface PluginResourceAttachmentPillProps {
|
||||
}
|
||||
|
||||
export function PluginResourceAttachmentPill({
|
||||
serverId,
|
||||
attachment,
|
||||
index,
|
||||
disabled,
|
||||
@@ -34,6 +37,13 @@ export function PluginResourceAttachmentPill({
|
||||
openLabel,
|
||||
removeLabel,
|
||||
}: PluginResourceAttachmentPillProps) {
|
||||
const plugin = useInstalledPlugin(serverId, attachment.pluginId);
|
||||
const source = plugin?.attachmentSources.find(
|
||||
(candidate) => candidate.id === attachment.sourceId,
|
||||
);
|
||||
// Keep the saved label when the source is no longer installed.
|
||||
const sourceTitle =
|
||||
plugin && source ? plugin.i18n.resolve(source.title, plugin.locale) : attachment.sourceTitle;
|
||||
const handleOpen = useCallback(() => onOpen(attachment), [attachment, onOpen]);
|
||||
const handleRemove = useCallback(() => onRemove(index), [index, onRemove]);
|
||||
const icon = useMemo(
|
||||
@@ -45,14 +55,14 @@ export function PluginResourceAttachmentPill({
|
||||
testID="composer-plugin-resource-attachment-pill"
|
||||
onOpen={handleOpen}
|
||||
onRemove={handleRemove}
|
||||
openAccessibilityLabel={openLabel(attachment.sourceTitle, attachment.item.identifier)}
|
||||
removeAccessibilityLabel={removeLabel(attachment.sourceTitle, attachment.item.identifier)}
|
||||
openAccessibilityLabel={openLabel(sourceTitle, attachment.item.identifier)}
|
||||
removeAccessibilityLabel={removeLabel(sourceTitle, attachment.item.identifier)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<AttachmentLabel
|
||||
icon={icon}
|
||||
title={attachment.item.title}
|
||||
subtitle={`${attachment.sourceTitle} ${attachment.item.identifier}`}
|
||||
subtitle={`${sourceTitle} ${attachment.item.identifier}`}
|
||||
/>
|
||||
</AttachmentPill>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { defineTranslations } from "@getpaseo/plugin/client";
|
||||
import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PluginButton } from "@getpaseo/plugin/client";
|
||||
@@ -7,6 +9,8 @@ import { PluginButtonStore, buttonMatches } from "./model";
|
||||
function installation(): InstalledPlugin {
|
||||
return {
|
||||
lifetime: new AbortController(),
|
||||
locale: "en",
|
||||
i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }),
|
||||
id: "review",
|
||||
serverId: "host-a",
|
||||
clientBundle: "bundle",
|
||||
@@ -173,3 +177,80 @@ describe("plugin buttons", () => {
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("updates translated tooltips and nested menu labels without closing open content", () => {
|
||||
let locale = "en";
|
||||
const listeners = new Set<() => void>();
|
||||
const plugin = installation();
|
||||
plugin.i18n.dispose();
|
||||
plugin.i18n = new PluginTranslationRuntime({
|
||||
getLocale: () => locale,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
});
|
||||
const translations = defineTranslations({
|
||||
resources: {
|
||||
en: { title: "Notes", menu: "Open notes" },
|
||||
"zh-CN": { title: "笔记", menu: "打开笔记" },
|
||||
},
|
||||
});
|
||||
const buttons = store();
|
||||
const registration = buttons.addHeaderButton(plugin, {
|
||||
id: "notes",
|
||||
workspaceId: "workspace",
|
||||
button: {
|
||||
title: translations.message("title"),
|
||||
icon: "Scan",
|
||||
behavior: {
|
||||
kind: "menu",
|
||||
items: [
|
||||
{
|
||||
kind: "item",
|
||||
id: "open",
|
||||
title: translations.message("menu"),
|
||||
behavior: { kind: "action", onPress() {} },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const original = buttons.getSnapshot()[0];
|
||||
buttons.setOpen(original.key, true);
|
||||
locale = "zh-CN";
|
||||
for (const listener of listeners) listener();
|
||||
const translated = buttons.getSnapshot()[0];
|
||||
expect(translated.key).toBe(original.key);
|
||||
expect(translated.open).toBe(true);
|
||||
expect(translated.button.title).toBe("笔记");
|
||||
expect(translated.button.behavior).toMatchObject({
|
||||
kind: "menu",
|
||||
items: [{ title: "打开笔记" }],
|
||||
});
|
||||
registration.update({ disabled: false });
|
||||
locale = "en";
|
||||
for (const listener of listeners) listener();
|
||||
expect(buttons.getSnapshot()[0].button.title).toBe("Notes");
|
||||
registration.remove();
|
||||
plugin.i18n.dispose();
|
||||
expect(listeners.size).toBe(0);
|
||||
expect(buttons.getSnapshot()).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the previous descriptor after rejecting an invalid update", () => {
|
||||
const buttons = store();
|
||||
const plugin = installation();
|
||||
const registration = buttons.addHeaderButton(plugin, {
|
||||
id: "review",
|
||||
workspaceId: "workspace",
|
||||
button: button(),
|
||||
});
|
||||
expect(() => registration.update({ title: "" })).toThrow("Plugin button needs title");
|
||||
expect(() => registration.update({ disabled: true })).not.toThrow();
|
||||
expect(buttons.getSnapshot()[0].button.title).toBe("Review");
|
||||
registration.remove();
|
||||
plugin.i18n.dispose();
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type {
|
||||
PluginText,
|
||||
PluginButtonBehavior,
|
||||
PluginButton,
|
||||
PluginButtonContext,
|
||||
PluginButtonRegistration,
|
||||
PluginHeaderButtonContribution,
|
||||
@@ -27,7 +29,7 @@ export interface RegisteredPluginButton {
|
||||
}
|
||||
|
||||
function resolveAction(
|
||||
behavior: PluginButtonBehavior,
|
||||
behavior: PluginButtonBehavior<PluginText>,
|
||||
path: readonly string[],
|
||||
): (() => void | Promise<void>) | null {
|
||||
if (path.length === 0) return behavior.kind === "action" ? behavior.onPress : null;
|
||||
@@ -55,8 +57,8 @@ export class PluginButtonStore {
|
||||
|
||||
addHeaderButton(
|
||||
installation: InstalledPlugin,
|
||||
input: PluginHeaderButtonContribution,
|
||||
): PluginButtonRegistration {
|
||||
input: PluginHeaderButtonContribution<PluginText>,
|
||||
): PluginButtonRegistration<PluginText> {
|
||||
return this.add(installation, input, "header", {
|
||||
context: "workspace",
|
||||
workspaceId: input.workspaceId.trim(),
|
||||
@@ -65,8 +67,8 @@ export class PluginButtonStore {
|
||||
|
||||
addComposerPill(
|
||||
installation: InstalledPlugin,
|
||||
input: PluginComposerPillContribution,
|
||||
): PluginButtonRegistration {
|
||||
input: PluginComposerPillContribution<PluginText>,
|
||||
): PluginButtonRegistration<PluginText> {
|
||||
if (!input.agentId.trim()) throw new Error("Plugin composer pill needs an agent");
|
||||
return this.add(installation, input, "composer", {
|
||||
context: "agent",
|
||||
@@ -77,10 +79,10 @@ export class PluginButtonStore {
|
||||
|
||||
private add(
|
||||
installation: InstalledPlugin,
|
||||
input: PluginHeaderButtonContribution,
|
||||
input: PluginHeaderButtonContribution<PluginText>,
|
||||
placement: ButtonPlacement,
|
||||
context: PluginButtonContext,
|
||||
): PluginButtonRegistration {
|
||||
): PluginButtonRegistration<PluginText> {
|
||||
const id = requireButtonId(input.id);
|
||||
if (!context.workspaceId.trim()) throw new Error("Plugin button needs a workspace");
|
||||
const duplicate = this.entries.some(
|
||||
@@ -94,7 +96,8 @@ export class PluginButtonStore {
|
||||
);
|
||||
if (duplicate) throw new Error(`Duplicate plugin button: ${id}`);
|
||||
const key = this.nextKey++;
|
||||
const initialButton = validateButton(input.button, this.validation);
|
||||
let sourceButton: PluginButton<PluginText> = input.button;
|
||||
const initialButton = validateButton(sourceButton, this.validation, installation.i18n.resolve);
|
||||
this.publish([
|
||||
...this.entries,
|
||||
{
|
||||
@@ -108,15 +111,23 @@ export class PluginButtonStore {
|
||||
open: false,
|
||||
},
|
||||
]);
|
||||
const unsubscribe = installation.i18n.subscribe(() => {
|
||||
this.replace(key, {
|
||||
button: validateButton(sourceButton, this.validation, installation.i18n.resolve),
|
||||
});
|
||||
});
|
||||
return {
|
||||
update: (patch) => {
|
||||
const entry = this.entries.find((candidate) => candidate.key === key);
|
||||
if (!entry) return;
|
||||
const button = validateButton({ ...entry.button, ...patch }, this.validation);
|
||||
const nextSource = { ...sourceButton, ...patch };
|
||||
const button = validateButton(nextSource, this.validation, installation.i18n.resolve);
|
||||
sourceButton = nextSource;
|
||||
const close = !button.visible || button.disabled || patch.behavior !== undefined;
|
||||
this.replace(key, { button, open: close ? false : entry.open });
|
||||
},
|
||||
remove: () => {
|
||||
unsubscribe();
|
||||
const next = this.entries.filter((entry) => entry.key !== key);
|
||||
if (next.length !== this.entries.length) this.publish(next);
|
||||
},
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import type { PluginButton, PluginButtonBehavior, PluginButtonIcon } from "@getpaseo/plugin/client";
|
||||
import type { PluginText } from "@getpaseo/plugin/client";
|
||||
import type {
|
||||
PluginButton as SDKPluginButton,
|
||||
PluginButtonBehavior as SDKPluginButtonBehavior,
|
||||
PluginButtonIcon,
|
||||
} from "@getpaseo/plugin/client";
|
||||
|
||||
type PluginButton = SDKPluginButton<PluginText>;
|
||||
type PluginButtonBehavior = SDKPluginButtonBehavior<PluginText>;
|
||||
|
||||
export interface ButtonValidation {
|
||||
/** Returns the normalized icon, or throws naming why the icon is unusable. */
|
||||
@@ -18,7 +26,12 @@ function optionalBoolean(value: boolean | undefined, fallback: boolean): boolean
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireText(value: string, field: string): string {
|
||||
function requireText(
|
||||
value: PluginText,
|
||||
field: string,
|
||||
resolve: (text: PluginText) => string,
|
||||
): string {
|
||||
value = resolve(value);
|
||||
if (typeof value !== "string" || !value.trim()) throw new Error(`Plugin button needs ${field}`);
|
||||
return value.trim();
|
||||
}
|
||||
@@ -33,7 +46,8 @@ function isComponent(value: unknown): boolean {
|
||||
function validateBehavior(
|
||||
behavior: PluginButtonBehavior,
|
||||
validation: ButtonValidation,
|
||||
): PluginButtonBehavior {
|
||||
resolve: (text: PluginText) => string,
|
||||
): ResolvedPluginButtonBehavior {
|
||||
switch (behavior.kind) {
|
||||
case "action":
|
||||
if (typeof behavior.onPress !== "function")
|
||||
@@ -55,10 +69,10 @@ function validateBehavior(
|
||||
return {
|
||||
...item,
|
||||
icon: item.icon === undefined ? undefined : validation.parseIcon(item.icon),
|
||||
title: requireText(item.title, "menu item title"),
|
||||
title: requireText(item.title, "menu item title", resolve),
|
||||
visible: optionalBoolean(item.visible, true),
|
||||
disabled: optionalBoolean(item.disabled, false),
|
||||
behavior: validateBehavior(item.behavior, validation),
|
||||
behavior: validateBehavior(item.behavior, validation, resolve),
|
||||
};
|
||||
}),
|
||||
};
|
||||
@@ -68,23 +82,40 @@ function validateBehavior(
|
||||
}
|
||||
}
|
||||
|
||||
export type ResolvedPluginButton = PluginButton & {
|
||||
export type ResolvedPluginButtonBehavior =
|
||||
| Exclude<PluginButtonBehavior, { kind: "menu" }>
|
||||
| { kind: "menu"; items: readonly ResolvedPluginButtonMenuEntry[] };
|
||||
export type ResolvedPluginButtonMenuEntry =
|
||||
| { kind: "separator"; id: string }
|
||||
| {
|
||||
kind: "item";
|
||||
id: string;
|
||||
title: string;
|
||||
icon?: PluginButtonIcon;
|
||||
visible?: boolean;
|
||||
disabled?: boolean;
|
||||
behavior: ResolvedPluginButtonBehavior;
|
||||
};
|
||||
export type ResolvedPluginButton = Omit<PluginButton, "title" | "label" | "behavior"> & {
|
||||
title: string;
|
||||
visible: boolean;
|
||||
disabled: boolean;
|
||||
label: string | undefined;
|
||||
behavior: ResolvedPluginButtonBehavior;
|
||||
};
|
||||
|
||||
export function validateButton(
|
||||
button: PluginButton,
|
||||
validation: ButtonValidation,
|
||||
resolve: (text: PluginText) => string,
|
||||
): ResolvedPluginButton {
|
||||
return {
|
||||
...button,
|
||||
icon: validation.parseIcon(button.icon),
|
||||
title: requireText(button.title, "title"),
|
||||
label: button.label === undefined ? undefined : requireText(button.label, "label"),
|
||||
title: requireText(button.title, "title", resolve),
|
||||
label: button.label === undefined ? undefined : requireText(button.label, "label", resolve),
|
||||
visible: optionalBoolean(button.visible, true),
|
||||
disabled: optionalBoolean(button.disabled, false),
|
||||
behavior: validateBehavior(button.behavior, validation),
|
||||
behavior: validateBehavior(button.behavior, validation, resolve),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type {
|
||||
PluginButtonBehavior,
|
||||
PluginButtonIcon,
|
||||
PluginButtonMenuEntry,
|
||||
PluginHostProps,
|
||||
} from "@getpaseo/plugin/client";
|
||||
ResolvedPluginButtonBehavior as PluginButtonBehavior,
|
||||
ResolvedPluginButtonMenuEntry as PluginButtonMenuEntry,
|
||||
} from "./validation";
|
||||
import type { PluginButtonIcon, PluginHostProps } from "@getpaseo/plugin/client";
|
||||
import type { PluginTheme } from "@getpaseo/plugin";
|
||||
import { useCallback, useMemo, useSyncExternalStore, type ReactNode } from "react";
|
||||
import { Pressable, Text, View, useWindowDimensions } from "react-native";
|
||||
|
||||
@@ -24,6 +24,7 @@ export function createPluginClientRuntime(
|
||||
);
|
||||
return {
|
||||
...capabilities,
|
||||
i18n: installation.i18n,
|
||||
playAudio: createPlayAudio(audio, installation.lifetime.signal),
|
||||
hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal),
|
||||
addComposerPill(contribution) {
|
||||
|
||||
@@ -36,8 +36,8 @@ export function usePluginClientSlashCommands(input: {
|
||||
{
|
||||
pluginId: plugin.id,
|
||||
name: contribution.name,
|
||||
description: contribution.description,
|
||||
argumentHint: contribution.argumentHint,
|
||||
description: plugin.i18n.resolve(contribution.description, plugin.locale),
|
||||
argumentHint: plugin.i18n.resolve(contribution.argumentHint, plugin.locale),
|
||||
async run(args: string) {
|
||||
const context =
|
||||
contribution.context === "agent"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { createPaseoApi, type PaseoApi } from "@getpaseo/client";
|
||||
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
@@ -61,6 +62,8 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
|
||||
serverId: "host-1",
|
||||
clientBundle: "bundle",
|
||||
lifetime: new AbortController(),
|
||||
locale: "en",
|
||||
i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }),
|
||||
paseo: createPaseoApi(
|
||||
new DaemonClient({
|
||||
url: "ws://127.0.0.1:1",
|
||||
|
||||
@@ -81,7 +81,7 @@ export function buildPluginCommandCenterContributions(
|
||||
visibility: "always",
|
||||
presentation: {
|
||||
kind: "action",
|
||||
title: item.title,
|
||||
title: plugin.i18n.resolve(item.title, plugin.locale),
|
||||
sectionTitle: plugin.id,
|
||||
icon: getCommandCenterIcon(pluginIconComponent(item.icon)),
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { requirePluginText } from "@getpaseo/plugin/client/host";
|
||||
import type { createPluginHosts } from "./hosts";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import * as pluginUiRuntime from "./react-native/ui";
|
||||
@@ -11,27 +12,29 @@ import * as ReactNative from "react-native";
|
||||
// eslint-disable-next-line no-restricted-imports -- plugin bundles receive TanStack's real runtime, not Paseo's query wrappers.
|
||||
import * as ReactQuery from "@tanstack/react-query";
|
||||
import * as Zod from "zod";
|
||||
import { type PluginCleanup } from "@getpaseo/plugin";
|
||||
import {
|
||||
type PluginAttachmentSourceContribution,
|
||||
type PluginCleanup,
|
||||
type PluginThemeContribution,
|
||||
} from "@getpaseo/plugin";
|
||||
import {
|
||||
type PluginCommandCenterItemContribution,
|
||||
type PluginClientContext,
|
||||
type PluginClientSlashCommandContribution,
|
||||
type PluginSidebarContribution,
|
||||
type PluginScreenContribution,
|
||||
type PluginScreenProps,
|
||||
type PluginScreenTitle,
|
||||
type PluginSidebarItemContribution,
|
||||
type PluginText,
|
||||
type PluginSurfaceProps,
|
||||
type PluginTimelineRendererContribution,
|
||||
type PluginTimelineTransformerContribution,
|
||||
type PluginWorkspacePanelContribution,
|
||||
type PluginButtonRegistration,
|
||||
} from "@getpaseo/plugin/client";
|
||||
import type { EvaluatedPlugin, PluginSidebarSection } from "./types";
|
||||
import type {
|
||||
EvaluatedPlugin,
|
||||
PluginSidebarSection,
|
||||
PluginAttachmentSourceContribution,
|
||||
PluginThemeContribution,
|
||||
PluginCommandCenterItemContribution,
|
||||
PluginClientSlashCommandContribution,
|
||||
PluginSidebarContribution,
|
||||
PluginScreenContribution,
|
||||
PluginSidebarItemContribution,
|
||||
PluginWorkspacePanelContribution,
|
||||
} from "./types";
|
||||
import type { ComponentType } from "react";
|
||||
import { parsePluginIcon, parseSerializablePluginIcon } from "./icons";
|
||||
import { parsePluginThemeContribution } from "./themes";
|
||||
@@ -75,6 +78,10 @@ function isComponentType(value: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizePluginText(text: import("@getpaseo/plugin/client").PluginText) {
|
||||
return typeof text === "string" ? text.trim() : requirePluginText(text);
|
||||
}
|
||||
|
||||
function requireId(value: string, label: string): string {
|
||||
const id = value.trim();
|
||||
if (!CONTRIBUTION_ID.test(id)) throw new Error(`Invalid ${label}: ${value}`);
|
||||
@@ -82,14 +89,17 @@ function requireId(value: string, label: string): string {
|
||||
}
|
||||
|
||||
/** A non-empty string, or a function of the screen's params resolved when the header renders. */
|
||||
function requireScreenTitle(screenId: string, title: unknown): PluginScreenTitle {
|
||||
if (typeof title === "function") return title as PluginScreenTitle;
|
||||
if (typeof title === "string" && title.trim()) return title.trim();
|
||||
function requireScreenTitle(screenId: string, title: unknown): PluginScreenTitle<PluginText> {
|
||||
if (typeof title === "function") return title as PluginScreenTitle<PluginText>;
|
||||
try {
|
||||
return requirePluginText(title);
|
||||
} catch {}
|
||||
throw new Error(`Screen ${screenId} needs a title: a non-empty string or a function of params`);
|
||||
}
|
||||
|
||||
export type PluginClientRuntime = Pick<
|
||||
PluginClientContext,
|
||||
| "i18n"
|
||||
| "playAudio"
|
||||
| "paseo"
|
||||
| "rpc"
|
||||
@@ -138,7 +148,9 @@ export function runPluginClientBundle(
|
||||
const removals = new Set<PluginCleanup>();
|
||||
let setupComplete = false;
|
||||
let stopped = false;
|
||||
function trackButton(registration: PluginButtonRegistration): PluginButtonRegistration {
|
||||
function trackButton(
|
||||
registration: PluginButtonRegistration<PluginText>,
|
||||
): PluginButtonRegistration<PluginText> {
|
||||
let active = true;
|
||||
const remove = () => {
|
||||
if (!active) return;
|
||||
@@ -181,7 +193,7 @@ export function runPluginClientBundle(
|
||||
const normalizedId = requireId(contribution.id, `sidebar ${section} item id`);
|
||||
if (ids.has(normalizedId))
|
||||
throw new Error(`Duplicate sidebar ${section} item: ${normalizedId}`);
|
||||
const title = contribution.title.trim();
|
||||
const title = normalizePluginText(contribution.title);
|
||||
if (!title) throw new Error(`Sidebar item ${normalizedId} has no title`);
|
||||
if (!isComponentType(contribution.Component)) {
|
||||
throw new Error(`Sidebar item ${normalizedId} is not a component`);
|
||||
@@ -199,7 +211,8 @@ export function runPluginClientBundle(
|
||||
const screenId = requireId(contribution.id, "settings screen id");
|
||||
if (settingsScreenIds.has(screenId))
|
||||
throw new Error(`Duplicate settings screen: ${screenId}`);
|
||||
if (!contribution.title.trim()) throw new Error(`Invalid settings screen: ${screenId}`);
|
||||
if (!normalizePluginText(contribution.title))
|
||||
throw new Error(`Invalid settings screen: ${screenId}`);
|
||||
const icon = parsePluginIcon(`Settings screen ${screenId}`, contribution.icon);
|
||||
settingsScreenIds.add(screenId);
|
||||
return register(collector.settingsScreens, { ...contribution, id: screenId, icon }, () =>
|
||||
@@ -244,7 +257,7 @@ export function runPluginClientBundle(
|
||||
const normalizedId = requireId(contribution.id, "sidebar item id");
|
||||
const legacy = {
|
||||
id: normalizedId,
|
||||
title: contribution.title.trim(),
|
||||
title: normalizePluginText(contribution.title),
|
||||
icon: parsePluginIcon(`Sidebar item ${normalizedId}`, contribution.icon),
|
||||
surface: requireId(contribution.surface, "sidebar surface id"),
|
||||
};
|
||||
@@ -258,7 +271,7 @@ export function runPluginClientBundle(
|
||||
addFilePanel(contribution) {
|
||||
const panelId = requireId(contribution.id, "file panel id");
|
||||
if (filePanelIds.has(panelId)) throw new Error(`Duplicate file panel: ${panelId}`);
|
||||
const title = contribution.title.trim();
|
||||
const title = normalizePluginText(contribution.title);
|
||||
if (!title) throw new Error(`File panel ${panelId} has no title`);
|
||||
const icon = parsePluginIcon(`File panel ${panelId}`, contribution.icon);
|
||||
const files = contribution.files;
|
||||
@@ -304,7 +317,7 @@ export function runPluginClientBundle(
|
||||
if (workspacePanelIds.has(normalizedId)) {
|
||||
throw new Error(`Duplicate workspace panel: ${normalizedId}`);
|
||||
}
|
||||
const title = contribution.title.trim();
|
||||
const title = normalizePluginText(contribution.title);
|
||||
if (!title) throw new Error(`Workspace panel ${normalizedId} has no title`);
|
||||
const icon = parsePluginIcon(`Workspace panel ${normalizedId}`, contribution.icon);
|
||||
if (contribution.context !== "workspace" && contribution.context !== "agent") {
|
||||
@@ -332,7 +345,7 @@ export function runPluginClientBundle(
|
||||
if (commandCenterItemIds.has(normalizedId)) {
|
||||
throw new Error(`Duplicate Command Center item: ${normalizedId}`);
|
||||
}
|
||||
const title = contribution.title.trim();
|
||||
const title = normalizePluginText(contribution.title);
|
||||
if (!title) throw new Error(`Command Center item ${normalizedId} has no title`);
|
||||
const icon = parsePluginIcon(`Command Center item ${normalizedId}`, contribution.icon);
|
||||
if (
|
||||
@@ -363,7 +376,7 @@ export function runPluginClientBundle(
|
||||
if (clientSlashCommandNames.has(name)) {
|
||||
throw new Error(`Duplicate client slash command: ${name}`);
|
||||
}
|
||||
const description = contribution.description.trim();
|
||||
const description = normalizePluginText(contribution.description);
|
||||
if (!description) throw new Error(`Client slash command ${name} has no description`);
|
||||
if (contribution.context !== "workspace" && contribution.context !== "agent") {
|
||||
throw new Error(`Client slash command ${name} has invalid context`);
|
||||
@@ -378,7 +391,7 @@ export function runPluginClientBundle(
|
||||
...contribution,
|
||||
name,
|
||||
description,
|
||||
argumentHint: contribution.argumentHint.trim(),
|
||||
argumentHint: normalizePluginText(contribution.argumentHint),
|
||||
},
|
||||
() => clientSlashCommandNames.delete(name),
|
||||
);
|
||||
@@ -388,9 +401,9 @@ export function runPluginClientBundle(
|
||||
if (attachmentSourceIds.has(normalizedId)) {
|
||||
throw new Error(`Duplicate attachment source: ${normalizedId}`);
|
||||
}
|
||||
const title = contribution.title.trim();
|
||||
const pickerTitle = contribution.pickerTitle.trim();
|
||||
const searchPlaceholder = contribution.searchPlaceholder.trim();
|
||||
const title = normalizePluginText(contribution.title);
|
||||
const pickerTitle = normalizePluginText(contribution.pickerTitle);
|
||||
const searchPlaceholder = normalizePluginText(contribution.searchPlaceholder);
|
||||
const method = contribution.search.name.trim();
|
||||
if (!title) throw new Error(`Attachment source ${normalizedId} has no title`);
|
||||
const icon = parseSerializablePluginIcon(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host";
|
||||
import { usePaseo, useRpc } from "@getpaseo/plugin/client";
|
||||
import { defineRpc } from "@getpaseo/plugin";
|
||||
import type { PaseoApi } from "@getpaseo/client";
|
||||
@@ -21,6 +22,8 @@ function installation(invoked: string[]): InstalledPlugin {
|
||||
serverId: "host",
|
||||
clientBundle: "",
|
||||
lifetime: new AbortController(),
|
||||
locale: "en",
|
||||
i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }),
|
||||
paseo: { dispose: async () => undefined } as PaseoApi,
|
||||
invoke: async (method) => {
|
||||
invoked.push(method);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { PaseoApiProvider, PluginRpcProvider } from "@getpaseo/plugin/client/host";
|
||||
import {
|
||||
PaseoApiProvider,
|
||||
PluginRpcProvider,
|
||||
PluginTranslationProvider,
|
||||
} from "@getpaseo/plugin/client/host";
|
||||
import React, { type ReactNode } from "react";
|
||||
import type { InstalledPlugin } from "./types";
|
||||
|
||||
@@ -14,7 +18,9 @@ export function PluginInstallationProvider({
|
||||
return (
|
||||
<QueryClientProvider client={plugin.queryClient}>
|
||||
<PaseoApiProvider paseo={plugin.paseo}>
|
||||
<PluginRpcProvider invoke={plugin.invoke}>{children}</PluginRpcProvider>
|
||||
<PluginRpcProvider invoke={plugin.invoke}>
|
||||
<PluginTranslationProvider runtime={plugin.i18n}>{children}</PluginTranslationProvider>
|
||||
</PluginRpcProvider>
|
||||
</PaseoApiProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { filePanelTitle } from "@/file-panels/choice/presentation";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { createPluginHosts } from "./hosts";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { PaseoApi } from "@getpaseo/client";
|
||||
@@ -26,9 +28,15 @@ function observingClient() {
|
||||
return { client, open };
|
||||
}
|
||||
|
||||
function registry() {
|
||||
function registry(
|
||||
localeSource: import("@getpaseo/plugin/client/host").PluginLocaleSource = {
|
||||
getLocale: () => "en",
|
||||
subscribe: () => () => {},
|
||||
},
|
||||
) {
|
||||
const setupClients = new Map<string, PaseoApi>();
|
||||
const plugins = new PluginRegistry({
|
||||
localeSource,
|
||||
version: "0.8.0",
|
||||
createRuntime: (installation) => {
|
||||
setupClients.set(installation.id, installation.paseo);
|
||||
@@ -43,6 +51,7 @@ function registry() {
|
||||
installation.lifetime.signal,
|
||||
),
|
||||
paseo: installation.paseo,
|
||||
i18n: installation.i18n,
|
||||
rpc: async () => {
|
||||
throw new Error("Unexpected plugin RPC");
|
||||
},
|
||||
@@ -145,3 +154,74 @@ test("an invalid async client entry is disposed and its rejected continuation is
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(h.plugins.getSnapshot()).toEqual([]);
|
||||
});
|
||||
|
||||
test("locale snapshots preserve installation state and observations, then release locale subscriptions", async () => {
|
||||
let locale = "en";
|
||||
const listeners = new Set<() => void>();
|
||||
const h = registry({
|
||||
getLocale: () => locale,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
});
|
||||
const translated = {
|
||||
...catalog("notes"),
|
||||
clientBundle: `(function(require) {
|
||||
const {defineTranslations} = require("@getpaseo/plugin/client");
|
||||
const messages = defineTranslations({resources:{en:{title:"Notes"},"zh-CN":{title:"笔记"}}});
|
||||
return {default(plugin) {
|
||||
plugin.paseo.observeEvents(["notes"]);
|
||||
plugin.addScreen({id:"notes",title:messages.message("title"),Component:function Notes(){return null;}});
|
||||
plugin.addFilePanel({id:"notes",title:messages.message("title"),icon:"FileText",files:{extensions:["notes"]},Component:function NotesFile(){return null;}});
|
||||
return function(){};
|
||||
}};
|
||||
})`,
|
||||
};
|
||||
h.plugins.installCatalog("host", [translated], { client: h.client, audio });
|
||||
expect(h.plugins.getEvaluationError("host", "notes")).toBeUndefined();
|
||||
const [before] = h.plugins.getSnapshot();
|
||||
function fileTitle(plugin: typeof before) {
|
||||
const contribution = plugin.filePanels[0];
|
||||
return filePanelTitle(i18n.t, {
|
||||
id: "plugin:notes/notes",
|
||||
builtin: false,
|
||||
files: contribution.files,
|
||||
plugin,
|
||||
contribution,
|
||||
});
|
||||
}
|
||||
expect(fileTitle(before)).toBe("Notes");
|
||||
expect(before.locale).toBe("en");
|
||||
expect(
|
||||
before.i18n.resolve(
|
||||
before.surfaces[0].title as import("@getpaseo/plugin/client").PluginText,
|
||||
before.locale,
|
||||
),
|
||||
).toBe("Notes");
|
||||
locale = "zh-CN";
|
||||
for (const listener of listeners) listener();
|
||||
const [after] = h.plugins.getSnapshot();
|
||||
expect(after.locale).toBe("zh-CN");
|
||||
expect(fileTitle(after)).toBe("笔记");
|
||||
expect(fileTitle(before)).toBe("Notes");
|
||||
expect(after.filePanels[0].Component).toBe(before.filePanels[0].Component);
|
||||
expect(
|
||||
after.i18n.resolve(
|
||||
after.surfaces[0].title as import("@getpaseo/plugin/client").PluginText,
|
||||
after.locale,
|
||||
),
|
||||
).toBe("笔记");
|
||||
expect(after.lifetime).toBe(before.lifetime);
|
||||
expect(after.queryClient).toBe(before.queryClient);
|
||||
expect(after.paseo).toBe(before.paseo);
|
||||
expect(after.surfaces[0].Component).toBe(before.surfaces[0].Component);
|
||||
expect(h.open).toEqual(new Set(["notes"]));
|
||||
h.plugins.installCatalog("host", [translated], { client: h.client, audio });
|
||||
expect(h.plugins.getSnapshot()[0].i18n).toBe(before.i18n);
|
||||
h.plugins.removeHost("host");
|
||||
expect(listeners.size).toBe(0);
|
||||
await expect.poll(() => h.open.size).toBe(0);
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ function registry(version: string) {
|
||||
let starts = 0;
|
||||
let cleanups = 0;
|
||||
const result = new PluginRegistry({
|
||||
localeSource: { getLocale: () => "en", subscribe: () => () => {} },
|
||||
version,
|
||||
createRuntime(installation) {
|
||||
starts++;
|
||||
@@ -25,6 +26,7 @@ function registry(version: string) {
|
||||
installation.lifetime.signal,
|
||||
),
|
||||
paseo: installation.paseo,
|
||||
i18n: installation.i18n,
|
||||
rpc: async () => {
|
||||
throw new Error("No RPC in this plugin");
|
||||
},
|
||||
@@ -100,6 +102,26 @@ it("unloads on a requirement-only edit and recovers after correction", () => {
|
||||
expect(result.getEvaluationError("host", "example")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects the unreleased translation API on 0.12.0 before evaluating its imports", () => {
|
||||
const { result, starts } = registry("0.12.0");
|
||||
result.installCatalog(
|
||||
"host",
|
||||
[
|
||||
{
|
||||
id: "translated",
|
||||
requirements: { paseo: ">=0.12.1" },
|
||||
clientBundle: `(function(require) { require("@getpaseo/plugin/client").defineTranslations({ resources: { en: { title: "Notes" } } }); throw new Error("unsupported API evaluated"); })`,
|
||||
},
|
||||
],
|
||||
{ client, audio },
|
||||
);
|
||||
expect(starts()).toBe(0);
|
||||
expect(result.getSnapshot()).toEqual([]);
|
||||
expect(result.getEvaluationError("host", "translated")).toBe(
|
||||
'Plugin "translated" requires Paseo >=0.12.1. Your app is 0.12.0. Use a compatible plugin version or update the app.',
|
||||
);
|
||||
});
|
||||
|
||||
it("skips file-panel bundles on older apps and installs them on the required app", () => {
|
||||
const entry = {
|
||||
id: "files",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import type { AudioEngine } from "@/audio";
|
||||
import { useMemo, useSyncExternalStore } from "react";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
@@ -20,6 +22,7 @@ export class PluginRegistry {
|
||||
|
||||
constructor(
|
||||
private readonly dependencies: {
|
||||
localeSource: import("@getpaseo/plugin/client/host").PluginLocaleSource;
|
||||
version: string | null;
|
||||
createRuntime: typeof createPluginClientRuntime;
|
||||
},
|
||||
@@ -81,6 +84,8 @@ export class PluginRegistry {
|
||||
const client = options.client;
|
||||
installation = {
|
||||
lifetime: new AbortController(),
|
||||
locale: this.dependencies.localeSource.getLocale(),
|
||||
i18n: new PluginTranslationRuntime(this.dependencies.localeSource),
|
||||
paseo: createPaseoApi(client),
|
||||
invoke: (method, input) => client.invokePluginRpc(entry.id, method, input),
|
||||
id: entry.id,
|
||||
@@ -102,6 +107,10 @@ export class PluginRegistry {
|
||||
timelineTransformers: [],
|
||||
timelineRenderers: [],
|
||||
};
|
||||
const unsubscribeTranslations = installation.i18n.subscribe(() => this.publish());
|
||||
installation.lifetime.signal.addEventListener("abort", unsubscribeTranslations, {
|
||||
once: true,
|
||||
});
|
||||
const runtime = this.dependencies.createRuntime(installation, options.audio);
|
||||
const evaluated = runPluginClientBundle(entry.id, entry.clientBundle, runtime, () =>
|
||||
this.publish(),
|
||||
@@ -121,6 +130,7 @@ export class PluginRegistry {
|
||||
return [installation];
|
||||
} catch (error) {
|
||||
installation?.lifetime.abort();
|
||||
installation?.i18n.dispose();
|
||||
void installation?.paseo
|
||||
.dispose()
|
||||
.catch((failure) => console.warn(`[Plugins] API cleanup failed for ${key}`, failure));
|
||||
@@ -136,7 +146,13 @@ export class PluginRegistry {
|
||||
}
|
||||
}
|
||||
this.byHost.set(serverId, installed);
|
||||
this.publish();
|
||||
if (
|
||||
installed.length !== catalog.length ||
|
||||
installed.length !== previous.length ||
|
||||
installed.some((plugin, index) => plugin !== previous[index])
|
||||
) {
|
||||
this.publish();
|
||||
}
|
||||
const installedTimelineBundles = installed
|
||||
.filter((plugin) => plugin.timelineTransformers.length > 0)
|
||||
.map((plugin) => `${plugin.id}\0${plugin.clientBundle}`);
|
||||
@@ -161,6 +177,7 @@ export class PluginRegistry {
|
||||
if (this.disposed.has(plugin)) return;
|
||||
this.disposed.add(plugin);
|
||||
plugin.lifetime.abort();
|
||||
plugin.i18n.dispose();
|
||||
plugin.queryClient.clear();
|
||||
try {
|
||||
void Promise.resolve(plugin.cleanup()).catch((error) => {
|
||||
@@ -174,6 +191,9 @@ export class PluginRegistry {
|
||||
private publish(): void {
|
||||
this.snapshot = [...this.byHost.values()]
|
||||
.flat()
|
||||
// A new presentation snapshot invalidates label selectors without replacing the installation lifetime.
|
||||
// eslint-disable-next-line no-map-spread
|
||||
.map((plugin) => ({ ...plugin, locale: plugin.i18n.locale }))
|
||||
.sort((left, right) =>
|
||||
`${left.serverId}/${left.id}`.localeCompare(`${right.serverId}/${right.id}`),
|
||||
);
|
||||
@@ -182,6 +202,15 @@ export class PluginRegistry {
|
||||
}
|
||||
|
||||
export const pluginRegistry = new PluginRegistry({
|
||||
localeSource: {
|
||||
getLocale: () => i18n.resolvedLanguage ?? i18n.language,
|
||||
subscribe: (listener) => {
|
||||
i18n.on("languageChanged", listener);
|
||||
return () => {
|
||||
i18n.off("languageChanged", listener);
|
||||
};
|
||||
},
|
||||
},
|
||||
version: resolveAppVersion(),
|
||||
createRuntime: createPluginClientRuntime,
|
||||
});
|
||||
|
||||
@@ -57,7 +57,7 @@ export function PluginSettingsMenuItems({
|
||||
serverId={serverId}
|
||||
pluginId={pluginId}
|
||||
screenId={screen.id}
|
||||
title={screen.title}
|
||||
title={plugin.i18n.resolve(screen.title, plugin.locale)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { InstalledPlugin } from "./types";
|
||||
@@ -20,6 +21,8 @@ function installed(
|
||||
serverId,
|
||||
clientBundle: serverId,
|
||||
lifetime: new AbortController(),
|
||||
locale: "en",
|
||||
i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }),
|
||||
queryClient: new QueryClient(),
|
||||
paseo: {} as InstalledPlugin["paseo"],
|
||||
invoke: async () => undefined,
|
||||
|
||||
@@ -64,7 +64,7 @@ export function groupPluginSidebarItems(
|
||||
key,
|
||||
pluginId: plugin.id,
|
||||
contributionId: item.id,
|
||||
title: item.title,
|
||||
title: plugin.i18n.resolve(item.title, plugin.locale),
|
||||
icon: item.icon,
|
||||
targets: [{ plugin, item }],
|
||||
});
|
||||
@@ -80,7 +80,7 @@ export function groupPluginSidebarItems(
|
||||
key,
|
||||
pluginId: plugin.id,
|
||||
contributionId: item.id,
|
||||
title: item.title,
|
||||
title: plugin.i18n.resolve(item.title, plugin.locale),
|
||||
targets: [{ plugin, item }],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ function SidebarItemContent({
|
||||
const frame = useMemo<SidebarItemFrame>(
|
||||
() => ({
|
||||
section,
|
||||
title: item.title,
|
||||
title: plugin.i18n.resolve(item.title, plugin.locale),
|
||||
testID:
|
||||
section === "header"
|
||||
? `plugin-sidebar-${plugin.id}-${item.id}`
|
||||
@@ -214,7 +214,7 @@ function SidebarItemContent({
|
||||
offerAnchor,
|
||||
releaseAnchor,
|
||||
}),
|
||||
[anchorTo, item.id, item.title, offerAnchor, plugin.id, releaseAnchor, section],
|
||||
[anchorTo, item.id, item.title, offerAnchor, plugin, releaseAnchor, section],
|
||||
);
|
||||
const itemProps = useMemo<PluginSidebarItemProps>(
|
||||
() => ({ ...hostProps, currentScreen, openScreen, openPopover }),
|
||||
@@ -228,7 +228,7 @@ function SidebarItemContent({
|
||||
{Popover ? (
|
||||
<SidebarPopoverSurface
|
||||
section={section}
|
||||
title={item.title}
|
||||
title={plugin.i18n.resolve(item.title, plugin.locale)}
|
||||
testID={`${frame.testID}-popover`}
|
||||
>
|
||||
<SurfaceErrorBoundary installation={plugin} Surface={Popover}>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { InstalledPlugin } from "./types";
|
||||
@@ -20,6 +21,8 @@ function installation(
|
||||
serverId,
|
||||
clientBundle: serverId,
|
||||
lifetime: new AbortController(),
|
||||
locale: "en",
|
||||
i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }),
|
||||
queryClient: new QueryClient(),
|
||||
paseo: {} as InstalledPlugin["paseo"],
|
||||
invoke: async () => undefined,
|
||||
@@ -178,6 +181,8 @@ describe("parsePluginOpenScreenInput", () => {
|
||||
});
|
||||
|
||||
describe("resolvePluginScreenTitle", () => {
|
||||
const resolve = new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} })
|
||||
.resolve;
|
||||
const Component = () => null;
|
||||
const botNames: Record<string, string> = { "bot-1": "Bot 1", "bot-2": "Bot 2" };
|
||||
const bot = {
|
||||
@@ -187,18 +192,18 @@ describe("resolvePluginScreenTitle", () => {
|
||||
};
|
||||
|
||||
it("uses a string title as is", () => {
|
||||
expect(resolvePluginScreenTitle({ id: "deploys", title: "Deploys", Component }, null, {})).toBe(
|
||||
"Deploys",
|
||||
);
|
||||
expect(
|
||||
resolvePluginScreenTitle({ id: "deploys", title: "Deploys", Component }, null, {}, resolve),
|
||||
).toBe("Deploys");
|
||||
});
|
||||
|
||||
it("derives a function title from the params it is given", () => {
|
||||
expect(resolvePluginScreenTitle(bot, null, { botId: "bot-2" })).toBe("Bot 2");
|
||||
expect(resolvePluginScreenTitle(bot, null, { botId: "bot-1" })).toBe("Bot 1");
|
||||
expect(resolvePluginScreenTitle(bot, null, { botId: "bot-2" }, resolve)).toBe("Bot 2");
|
||||
expect(resolvePluginScreenTitle(bot, null, { botId: "bot-1" }, resolve)).toBe("Bot 1");
|
||||
});
|
||||
|
||||
it("falls back to the screen id when the function returns empty or throws", () => {
|
||||
expect(resolvePluginScreenTitle(bot, null, { botId: "missing" })).toBe("bot");
|
||||
expect(resolvePluginScreenTitle(bot, null, { botId: "missing" }, resolve)).toBe("bot");
|
||||
const throwing = {
|
||||
id: "bot",
|
||||
title: () => {
|
||||
@@ -206,13 +211,13 @@ describe("resolvePluginScreenTitle", () => {
|
||||
},
|
||||
Component,
|
||||
};
|
||||
expect(resolvePluginScreenTitle(throwing, null, {})).toBe("bot");
|
||||
expect(resolvePluginScreenTitle(throwing, null, {}, resolve)).toBe("bot");
|
||||
});
|
||||
|
||||
it("uses the title of a legacy item that points at the screen", () => {
|
||||
const legacy = { id: "entry", title: "Legacy entry", icon: "Server", surface: "main" };
|
||||
expect(resolvePluginScreenTitle({ id: "main", title: "main", Component }, legacy, {})).toBe(
|
||||
"Legacy entry",
|
||||
);
|
||||
expect(
|
||||
resolvePluginScreenTitle({ id: "main", title: "main", Component }, legacy, {}, resolve),
|
||||
).toBe("Legacy entry");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,11 +42,12 @@ export function resolvePluginScreenTitle(
|
||||
screen: InstalledPlugin["surfaces"][number],
|
||||
legacySidebarItem: InstalledPlugin["legacySidebarItems"][number] | null,
|
||||
params: PluginScreenParams,
|
||||
resolve: (text: import("@getpaseo/plugin/client").PluginText) => string,
|
||||
): string {
|
||||
if (legacySidebarItem) return legacySidebarItem.title;
|
||||
if (typeof screen.title === "string") return screen.title;
|
||||
if (legacySidebarItem) return resolve(legacySidebarItem.title);
|
||||
if (typeof screen.title !== "function") return resolve(screen.title);
|
||||
try {
|
||||
const title = screen.title(params);
|
||||
const title = resolve(screen.title(params));
|
||||
if (typeof title === "string" && title.trim()) return title.trim();
|
||||
} catch (error) {
|
||||
console.warn(`[Plugins] Screen ${screen.id} title failed`, error);
|
||||
|
||||
@@ -149,13 +149,14 @@ export function PluginSurfaceScreen() {
|
||||
const serverIds = getPluginSurfaceContributionServerIds(installations, pluginId, identity);
|
||||
return hosts.filter((candidate) => serverIds.includes(candidate.serverId));
|
||||
}, [hosts, identity, installations, pluginId]);
|
||||
const title = useMemo(
|
||||
() =>
|
||||
surface
|
||||
? resolvePluginScreenTitle(surface, sidebarItem, params)
|
||||
: (sidebarItem?.title ?? (pluginId || "Plugin")),
|
||||
[params, pluginId, sidebarItem, surface],
|
||||
);
|
||||
const title = useMemo(() => {
|
||||
if (surface && plugin)
|
||||
return resolvePluginScreenTitle(surface, sidebarItem, params, (text) =>
|
||||
plugin.i18n.resolve(text, plugin.locale),
|
||||
);
|
||||
if (sidebarItem && plugin) return plugin.i18n.resolve(sidebarItem.title, plugin.locale);
|
||||
return pluginId || "Plugin";
|
||||
}, [params, plugin, pluginId, sidebarItem, surface]);
|
||||
const icon = sidebarItem?.icon ?? null;
|
||||
const close = useCallback(() => {
|
||||
if (router.canGoBack()) router.back();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import type { PluginThemeContribution } from "@getpaseo/plugin";
|
||||
import { describe, expect, it } from "vitest";
|
||||
@@ -50,6 +51,8 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install
|
||||
serverId,
|
||||
clientBundle: serverId,
|
||||
lifetime: new AbortController(),
|
||||
locale: "en",
|
||||
i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }),
|
||||
queryClient: new QueryClient(),
|
||||
paseo: {} as InstalledPlugin["paseo"],
|
||||
invoke: async () => undefined,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { PluginMessage } from "@getpaseo/plugin/client";
|
||||
import { isPluginMessage } from "@getpaseo/plugin/client/host";
|
||||
import { useMemo } from "react";
|
||||
import { z } from "zod";
|
||||
import type { PluginThemeContribution } from "@getpaseo/plugin";
|
||||
import type { PluginThemeContribution } from "../types";
|
||||
import { useHostFeatureMap } from "@/runtime/host-features";
|
||||
import {
|
||||
buildDarkSemanticColors,
|
||||
@@ -24,7 +26,7 @@ const hexColorSchema = z
|
||||
|
||||
const contributionSchema: z.ZodType<PluginThemeContribution> = z.strictObject({
|
||||
id: z.string(),
|
||||
name: z.string().trim().min(1).max(60),
|
||||
name: z.union([z.string().trim().min(1).max(60), z.custom<PluginMessage>(isPluginMessage)]),
|
||||
appearance: z.enum(["light", "dark"]),
|
||||
colors: z.strictObject({
|
||||
background: hexColorSchema,
|
||||
@@ -49,6 +51,7 @@ export interface PluginThemeOption {
|
||||
interface PluginThemeTarget {
|
||||
serverId: string;
|
||||
contribution: PluginThemeContribution;
|
||||
plugin: InstalledPlugin;
|
||||
}
|
||||
|
||||
export function parsePluginThemeContribution(value: unknown): PluginThemeContribution {
|
||||
@@ -133,7 +136,7 @@ export function collectPluginThemes(
|
||||
if (!supportedHosts.has(plugin.serverId)) continue;
|
||||
for (const contribution of plugin.themes) {
|
||||
const id = `${plugin.id}/theme/${contribution.id}`;
|
||||
const target = { serverId: plugin.serverId, contribution };
|
||||
const target = { serverId: plugin.serverId, contribution, plugin };
|
||||
const targets = targetsById.get(id);
|
||||
if (targets) targets.push(target);
|
||||
else targetsById.set(id, [target]);
|
||||
@@ -145,7 +148,7 @@ export function collectPluginThemes(
|
||||
return {
|
||||
id,
|
||||
serverId: target.serverId,
|
||||
name: target.contribution.name,
|
||||
name: target.plugin.i18n.resolve(target.contribution.name, target.plugin.locale),
|
||||
swatch: target.contribution.colors.background,
|
||||
theme: buildPluginTheme(target.contribution),
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
@@ -12,6 +13,8 @@ function plugin(input: {
|
||||
serverId: "host-1",
|
||||
clientBundle: "bundle",
|
||||
lifetime: new AbortController(),
|
||||
locale: "en",
|
||||
i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }),
|
||||
queryClient: new QueryClient(),
|
||||
paseo: {} as InstalledPlugin["paseo"],
|
||||
invoke: async () => undefined,
|
||||
|
||||
@@ -1,26 +1,39 @@
|
||||
import type { PluginText } from "@getpaseo/plugin/client";
|
||||
import type { PaseoApi } from "@getpaseo/client";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import type { PluginRequirements } from "@getpaseo/protocol/messages";
|
||||
import type {
|
||||
PluginAttachmentSourceContribution,
|
||||
PluginAttachmentSourceContribution as SDKPluginAttachmentSourceContribution,
|
||||
PluginCleanup,
|
||||
PluginThemeContribution,
|
||||
PluginThemeContribution as SDKPluginThemeContribution,
|
||||
} from "@getpaseo/plugin";
|
||||
import type {
|
||||
PluginFilePanelContribution,
|
||||
PluginCommandCenterItemContribution,
|
||||
PluginClientSlashCommandContribution,
|
||||
PluginComposerPillContribution,
|
||||
PluginSidebarContribution,
|
||||
PluginSidebarItemContribution,
|
||||
PluginScreenContribution,
|
||||
PluginSettingsScreenContribution,
|
||||
PluginFilePanelContribution as SDKPluginFilePanelContribution,
|
||||
PluginCommandCenterItemContribution as SDKPluginCommandCenterItemContribution,
|
||||
PluginClientSlashCommandContribution as SDKPluginClientSlashCommandContribution,
|
||||
PluginComposerPillContribution as SDKPluginComposerPillContribution,
|
||||
PluginSidebarContribution as SDKPluginSidebarContribution,
|
||||
PluginSidebarItemContribution as SDKPluginSidebarItemContribution,
|
||||
PluginScreenContribution as SDKPluginScreenContribution,
|
||||
PluginSettingsScreenContribution as SDKPluginSettingsScreenContribution,
|
||||
PluginTimelineRendererContribution,
|
||||
PluginTimelineTransformerContribution,
|
||||
PluginPanelLocation,
|
||||
PluginWorkspacePanelContribution,
|
||||
PluginWorkspacePanelContribution as SDKPluginWorkspacePanelContribution,
|
||||
} from "@getpaseo/plugin/client";
|
||||
|
||||
type PluginFilePanelContribution = SDKPluginFilePanelContribution<PluginText>;
|
||||
type PluginAttachmentSourceContribution = SDKPluginAttachmentSourceContribution<PluginText>;
|
||||
type PluginThemeContribution = SDKPluginThemeContribution<PluginText>;
|
||||
type PluginCommandCenterItemContribution = SDKPluginCommandCenterItemContribution<PluginText>;
|
||||
type PluginClientSlashCommandContribution = SDKPluginClientSlashCommandContribution<PluginText>;
|
||||
type PluginComposerPillContribution = SDKPluginComposerPillContribution<PluginText>;
|
||||
type PluginSidebarContribution = SDKPluginSidebarContribution<PluginText>;
|
||||
type PluginSidebarItemContribution = SDKPluginSidebarItemContribution<PluginText>;
|
||||
type PluginScreenContribution = SDKPluginScreenContribution<PluginText>;
|
||||
type PluginSettingsScreenContribution = SDKPluginSettingsScreenContribution<PluginText>;
|
||||
type PluginWorkspacePanelContribution = SDKPluginWorkspacePanelContribution<PluginText>;
|
||||
|
||||
export type PluginSidebarSection = "header" | "footer";
|
||||
|
||||
export type EvaluatedPluginWorkspacePanelContribution = PluginWorkspacePanelContribution & {
|
||||
@@ -48,6 +61,9 @@ export interface EvaluatedPlugin {
|
||||
|
||||
export interface InstalledPlugin extends EvaluatedPlugin {
|
||||
lifetime: AbortController;
|
||||
/** Immutable presentation locale, refreshed without replacing the installation lifetime. */
|
||||
locale: string;
|
||||
i18n: import("@getpaseo/plugin/client/host").PluginTranslationRuntime;
|
||||
/** The plugin's one Paseo client, `usePaseo()` in every surface; disposed at teardown. */
|
||||
paseo: PaseoApi;
|
||||
/** Calls one of the plugin's server RPC methods on its host. */
|
||||
@@ -59,6 +75,7 @@ export interface InstalledPlugin extends EvaluatedPlugin {
|
||||
}
|
||||
|
||||
export type {
|
||||
PluginFilePanelContribution,
|
||||
PluginAttachmentSourceContribution,
|
||||
PluginCommandCenterItemContribution,
|
||||
PluginClientSlashCommandContribution,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type {
|
||||
PluginPanelLocation,
|
||||
PluginWorkspacePanelContribution,
|
||||
} from "@getpaseo/plugin/client";
|
||||
import type { PluginWorkspacePanelContribution } from "../types";
|
||||
import type { PluginPanelLocation } from "@getpaseo/plugin/client";
|
||||
import type { PaneHost } from "@/panels/panel-manifest";
|
||||
import { panelSupportsHost } from "@/panels/panel-manifest";
|
||||
import type { PluginWorkspaceTabTarget, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
|
||||
@@ -140,9 +140,9 @@ function usePluginPanelDescriptor(
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: panel.title,
|
||||
label: plugin!.i18n.resolve(panel.title, plugin!.locale),
|
||||
subtitle: target.pluginId,
|
||||
tooltip: panel.title,
|
||||
tooltip: plugin!.i18n.resolve(panel.title, plugin!.locale),
|
||||
titleState: "ready",
|
||||
icon: pluginIconComponent(panel.icon),
|
||||
statusBucket: null,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
@@ -9,6 +10,8 @@ function installed(): InstalledPlugin {
|
||||
serverId: "host-1",
|
||||
clientBundle: "bundle",
|
||||
lifetime: new AbortController(),
|
||||
locale: "en",
|
||||
i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }),
|
||||
queryClient: new QueryClient(),
|
||||
paseo: {} as InstalledPlugin["paseo"],
|
||||
invoke: async () => undefined,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PluginWorkspacePanelContribution } from "@getpaseo/plugin/client";
|
||||
import type { PluginWorkspacePanelContribution } from "../types";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
import type { PluginWorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
import { useCallback, useState, type ComponentProps } from "react";
|
||||
import { ExpoRoot } from "expo-router";
|
||||
import Head from "expo-router/head";
|
||||
@@ -13,7 +14,11 @@ const { ctx: context } = require("expo-router/_ctx") as {
|
||||
};
|
||||
|
||||
export function RootApp() {
|
||||
return <RootRouter context={context} />;
|
||||
return (
|
||||
<RenderProfile id="App">
|
||||
<RootRouter context={context} />
|
||||
</RenderProfile>
|
||||
);
|
||||
}
|
||||
|
||||
export function RootRouter({ context: routes }: Pick<ComponentProps<typeof ExpoRoot>, "context">) {
|
||||
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
FetchAgentsOptions,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import { DaemonAuthenticationError } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { HostDirectory } from "@getpaseo/protocol/hosts";
|
||||
import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer";
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
|
||||
@@ -136,7 +135,6 @@ class FakeDaemonClient {
|
||||
if (response) await response;
|
||||
const failure = this.sendAgentMessageFailures.shift();
|
||||
if (failure) throw failure;
|
||||
return {};
|
||||
}
|
||||
|
||||
async waitForSentMessages(count: number): Promise<void> {
|
||||
@@ -159,24 +157,10 @@ class FakeDaemonClient {
|
||||
}
|
||||
}
|
||||
|
||||
get isConnected(): boolean {
|
||||
return this.state.status === "connected";
|
||||
}
|
||||
|
||||
getConnectionState(): ConnectionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
public connectedHosts = false;
|
||||
public hostDirectory: Promise<HostDirectory> = Promise.resolve({
|
||||
self: { serverId: "srv_test", label: "test" },
|
||||
hosts: [],
|
||||
});
|
||||
|
||||
observeHosts() {
|
||||
return subscriptionFixture(this.hostDirectory, () => () => {});
|
||||
}
|
||||
|
||||
public ownedSubscriptions = true;
|
||||
private daemonVersion: string | null = "0.8.0";
|
||||
|
||||
@@ -187,10 +171,7 @@ class FakeDaemonClient {
|
||||
serverId: "srv_test",
|
||||
hostname: "test",
|
||||
version: this.daemonVersion,
|
||||
features: {
|
||||
ownedSubscriptions: this.ownedSubscriptions,
|
||||
connectedHosts: this.connectedHosts,
|
||||
},
|
||||
features: { ownedSubscriptions: this.ownedSubscriptions },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1677,103 +1658,6 @@ describe("HostRuntimeController", () => {
|
||||
});
|
||||
|
||||
describe("HostRuntimeStore", () => {
|
||||
it.each([false, true])(
|
||||
"keeps B and C distinct with routed and direct candidates (B direct first: %s)",
|
||||
async (directFirst) => {
|
||||
const directory = new Deferred<HostDirectory>();
|
||||
const origin = new FakeDaemonClient();
|
||||
origin.setConnectionState({ status: "connected" });
|
||||
origin.connectedHosts = true;
|
||||
origin.hostDirectory = directory.promise;
|
||||
const store = new HostRuntimeStore({
|
||||
storage: createMemoryHostRuntimeStorage(),
|
||||
deps: {
|
||||
createClient: ({ host }) =>
|
||||
(host.serverId === "srv_a"
|
||||
? origin
|
||||
: new FakeDaemonClient()) as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host }) => ({
|
||||
client: (host.serverId === "srv_a"
|
||||
? origin
|
||||
: makeConnectedProbeClient(5)) as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label,
|
||||
}),
|
||||
getClientId: async () => "cid_fleet_candidates",
|
||||
},
|
||||
});
|
||||
const addDirectB = () =>
|
||||
store.upsertDirectConnection({
|
||||
serverId: "srv_b",
|
||||
endpoint: "b:6767",
|
||||
label: "My B",
|
||||
});
|
||||
try {
|
||||
if (directFirst) await addDirectB();
|
||||
await store.upsertDirectConnection({
|
||||
serverId: "srv_c",
|
||||
endpoint: "c:6767",
|
||||
label: "My C",
|
||||
});
|
||||
await store.upsertDirectConnection({ serverId: "srv_a", endpoint: "a:6767" });
|
||||
await waitForHostOnline(store, "srv_a");
|
||||
directory.resolve({
|
||||
self: { serverId: "srv_a", label: "A" },
|
||||
hosts: ["b", "c"].map((target) => ({
|
||||
registrationId: `reg_${target}`,
|
||||
serverId: `srv_${target}`,
|
||||
label: `Fleet ${target.toUpperCase()}`,
|
||||
source: { id: "user", key: target },
|
||||
metadata: {},
|
||||
enabled: true,
|
||||
status: "online" as const,
|
||||
targetDescription: `${target}:6767`,
|
||||
lastOnlineAt: null,
|
||||
nextRetryAt: null,
|
||||
error: null,
|
||||
})),
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(store.getHosts()).toContainEqual(
|
||||
expect.objectContaining({
|
||||
serverId: "srv_b",
|
||||
connections: expect.arrayContaining([
|
||||
{ id: "via:srv_a", type: "routed", originServerId: "srv_a" },
|
||||
]),
|
||||
}),
|
||||
),
|
||||
);
|
||||
await waitForHostOnline(store, "srv_b");
|
||||
if (!directFirst) await addDirectB();
|
||||
const targets = store.getHosts().filter((host) => host.serverId === "srv_b");
|
||||
expect(targets).toHaveLength(1);
|
||||
expect(targets[0]?.connections).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ id: "via:srv_a", type: "routed", originServerId: "srv_a" },
|
||||
{ id: "direct:b:6767", type: "directTcp", endpoint: "b:6767", useTls: false },
|
||||
]),
|
||||
);
|
||||
expect(targets[0]?.connections).toHaveLength(2);
|
||||
expect(targets[0]?.label).toBe(directFirst ? "My B" : "Fleet B");
|
||||
expect(store.getSnapshot("srv_b")?.connectionStatus).toBe("online");
|
||||
expect(store.getHosts()).toHaveLength(3);
|
||||
const targetC = store.getHosts().find((host) => host.serverId === "srv_c");
|
||||
expect(targetC).toMatchObject({
|
||||
serverId: "srv_c",
|
||||
label: "My C",
|
||||
connections: [
|
||||
{ id: "direct:c:6767", type: "directTcp", endpoint: "c:6767", useTls: false },
|
||||
{ id: "via:srv_a", type: "routed", originServerId: "srv_a" },
|
||||
],
|
||||
});
|
||||
await waitForHostOnline(store, "srv_c");
|
||||
expect(store.getSnapshot("srv_c")?.connectionStatus).toBe("online");
|
||||
} finally {
|
||||
store.syncHosts([]);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["active", "inactive", "background"] as const)(
|
||||
"keeps reconnect enabled through inactive/background and resumes immediately (mounted %s)",
|
||||
async (currentState) => {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { createHostChannelTransport } from "@getpaseo/client/internal/hosts";
|
||||
import type { OwnedSubscription } from "@getpaseo/client";
|
||||
import type { HostDirectory } from "@getpaseo/protocol/hosts";
|
||||
import { watchdog } from "@/watchdog";
|
||||
import { useSyncExternalStore, useMemo } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import equal from "fast-deep-equal/es6";
|
||||
@@ -86,7 +84,7 @@ import {
|
||||
type ViewedTimelineOwnerPorts,
|
||||
} from "@/timeline/viewed-timeline-sync";
|
||||
import { projectIconCache } from "@/projects/icon-cache";
|
||||
import { nativePerformanceTrace } from "@/performance/native-trace";
|
||||
import { createPerformanceTrace } from "@/performance/native-trace";
|
||||
import { revokePushNotifications } from "@/push-notifications";
|
||||
import { createAppWebSocketFactory } from "./websocket-factory";
|
||||
|
||||
@@ -116,7 +114,6 @@ export type ConnectionLinkImport =
|
||||
| { status: "cancelled" };
|
||||
|
||||
export type ActiveConnection =
|
||||
| { type: "routed"; endpoint: string; display: string }
|
||||
| { type: "directTcp"; endpoint: string; display: string }
|
||||
| { type: "directSocket"; endpoint: string; display: "socket" }
|
||||
| { type: "directPipe"; endpoint: string; display: "pipe" }
|
||||
@@ -238,12 +235,6 @@ const ADAPTIVE_SWITCH_CONSECUTIVE_PROBES = 3;
|
||||
const CONFIGURED_OVERRIDE_BOOTSTRAP_RETRY_MS = 1_000;
|
||||
|
||||
function toActiveConnection(connection: HostConnection): ActiveConnection {
|
||||
if (connection.type === "routed")
|
||||
return {
|
||||
type: "routed",
|
||||
endpoint: connection.originServerId,
|
||||
display: `via ${connection.originServerId}`,
|
||||
};
|
||||
if (connection.type === "directSocket") {
|
||||
return {
|
||||
type: "directSocket",
|
||||
@@ -528,9 +519,7 @@ function probeIntervalForConnection(
|
||||
return PROBE_MAX_BACKOFF_MS;
|
||||
}
|
||||
|
||||
function createDefaultDeps(
|
||||
resolveOrigin: (serverId: string) => DaemonClient | null = () => null,
|
||||
): HostRuntimeControllerDeps {
|
||||
function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
const browserHostAvailable =
|
||||
typeof getDesktopHost()?.browser?.executeAutomationCommand === "function";
|
||||
const browserAutomationCapabilities = browserHostAvailable
|
||||
@@ -556,20 +545,11 @@ function createDefaultDeps(
|
||||
appVersion: resolveAppVersion() ?? undefined,
|
||||
runtimeGeneration,
|
||||
capabilities: appCapabilities,
|
||||
trace: nativePerformanceTrace,
|
||||
trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }),
|
||||
providerSnapshots: "wire",
|
||||
...(host.password ? { password: host.password } : {}),
|
||||
localCredential: () => readDesktopManagedLocalCredential(connection),
|
||||
} satisfies Omit<DaemonClientConfig, "url">;
|
||||
if (connection.type === "routed") {
|
||||
const origin = resolveOrigin(connection.originServerId);
|
||||
if (!origin) throw new Error(`Origin host ${connection.originServerId} is unavailable`);
|
||||
return new DaemonClient({
|
||||
...base,
|
||||
url: "ws://routed-host/ws",
|
||||
transportFactory: createHostChannelTransport(origin, host.serverId, clientId, null),
|
||||
});
|
||||
}
|
||||
if (connection.type === "directSocket" || connection.type === "directPipe") {
|
||||
return new DaemonClient({
|
||||
...base,
|
||||
@@ -618,40 +598,15 @@ function createDefaultDeps(
|
||||
},
|
||||
});
|
||||
},
|
||||
connectToDaemon: async ({ host, connection, timeoutMs }) => {
|
||||
if (connection.type === "routed") {
|
||||
const origin = resolveOrigin(connection.originServerId);
|
||||
if (!origin?.isConnected)
|
||||
throw new Error(`Origin host ${connection.originServerId} is unavailable`);
|
||||
const client = new DaemonClient({
|
||||
url: "ws://routed-host/ws",
|
||||
clientId: crypto.randomUUID(),
|
||||
transportFactory: createHostChannelTransport(
|
||||
origin,
|
||||
host.serverId,
|
||||
crypto.randomUUID(),
|
||||
null,
|
||||
),
|
||||
reconnect: { enabled: false },
|
||||
appVersion: resolveAppVersion() ?? undefined,
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
return { client, serverId: host.serverId, hostname: null };
|
||||
} catch (error) {
|
||||
await client.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return connectToDaemon(connection, {
|
||||
connectToDaemon: ({ host, connection, timeoutMs }) =>
|
||||
connectToDaemon(connection, {
|
||||
...(host.serverId ? { serverId: host.serverId } : {}),
|
||||
...(host.password ? { password: host.password } : {}),
|
||||
localCredential: () => readDesktopManagedLocalCredential(connection),
|
||||
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
||||
capabilities: appCapabilities,
|
||||
trace: nativePerformanceTrace,
|
||||
});
|
||||
},
|
||||
trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }),
|
||||
}),
|
||||
getClientId: () => getOrCreateClientId(),
|
||||
mountClientHandlers: ({ client, host }) => {
|
||||
const unmountServerData = mountServerDataPushRouter({
|
||||
@@ -1205,12 +1160,18 @@ export class HostRuntimeController {
|
||||
});
|
||||
}
|
||||
|
||||
private logConnectionTransition(_input: {
|
||||
private logConnectionTransition(input: {
|
||||
from: HostRuntimeConnectionMachineState["tag"];
|
||||
to: HostRuntimeConnectionMachineState["tag"];
|
||||
event: HostRuntimeConnectionMachineEvent;
|
||||
}): void {
|
||||
// Intentionally empty - logging removed.
|
||||
watchdog?.record("host.transition", {
|
||||
host: this.host.serverId,
|
||||
from: input.from,
|
||||
to: input.to,
|
||||
event: input.event.type,
|
||||
epoch: String(this.connectionEpoch),
|
||||
});
|
||||
}
|
||||
|
||||
private trackConnectionFirstSeen(): void {
|
||||
@@ -1534,11 +1495,6 @@ interface AgentDirectoryRefreshInput {
|
||||
|
||||
export class HostRuntimeStore {
|
||||
private controllers = new Map<string, HostRuntimeController>();
|
||||
private hostObservations = new Map<
|
||||
string,
|
||||
{ client: DaemonClient; observation: OwnedSubscription<HostDirectory>; unsubscribe: () => void }
|
||||
>();
|
||||
private fleetDirectoryDemand = new Map<string, () => void>();
|
||||
private serverListeners = new Map<string, Set<() => void>>();
|
||||
private globalListeners = new Set<() => void>();
|
||||
private hostListListeners = new Set<() => void>();
|
||||
@@ -1569,7 +1525,7 @@ export class HostRuntimeStore {
|
||||
replicaRowStore?: ReplicaRowStore;
|
||||
revokePushNotifications?: typeof revokePushNotifications;
|
||||
}) {
|
||||
this.deps = input?.deps ?? createDefaultDeps((serverId) => this.getClient(serverId));
|
||||
this.deps = input?.deps ?? createDefaultDeps();
|
||||
this.storage = input?.storage ?? AsyncStorage;
|
||||
this.replicaCache = new ReplicaCache(input?.replicaRowStore ?? createReplicaRowStore());
|
||||
this.revokePushNotifications = input?.revokePushNotifications ?? revokePushNotifications;
|
||||
@@ -2309,11 +2265,6 @@ export class HostRuntimeStore {
|
||||
if (nextIds.has(serverId)) {
|
||||
continue;
|
||||
}
|
||||
this.hostObservations.get(serverId)?.unsubscribe();
|
||||
void this.hostObservations.get(serverId)?.observation.release();
|
||||
this.hostObservations.delete(serverId);
|
||||
this.fleetDirectoryDemand.get(serverId)?.();
|
||||
this.fleetDirectoryDemand.delete(serverId);
|
||||
this.controllers.delete(serverId);
|
||||
this.lastConnectionStatusByServer.delete(serverId);
|
||||
this.connectionStatusStartedAtByServer.delete(serverId);
|
||||
@@ -2370,7 +2321,6 @@ export class HostRuntimeStore {
|
||||
const snapshot = controller.getSnapshot();
|
||||
this.syncSessionReplica(snapshot.serverId, snapshot);
|
||||
this.syncDirectoryConnection(snapshot.serverId);
|
||||
this.syncHostDiscovery(snapshot.serverId);
|
||||
this.emit(snapshot.serverId);
|
||||
});
|
||||
void controller
|
||||
@@ -2389,56 +2339,6 @@ export class HostRuntimeStore {
|
||||
}
|
||||
}
|
||||
|
||||
private syncHostDiscovery(serverId: string): void {
|
||||
const client = this.getClient(serverId);
|
||||
const previous = this.hostObservations.get(serverId);
|
||||
if (previous?.client === client) return;
|
||||
previous?.unsubscribe();
|
||||
void previous?.observation.release();
|
||||
this.hostObservations.delete(serverId);
|
||||
const host = this.hosts.find((candidate) => candidate.serverId === serverId);
|
||||
if (
|
||||
!client?.isConnected ||
|
||||
!client.getLastServerInfoMessage()?.features?.connectedHosts ||
|
||||
host?.connections.some((connection) => connection.type === "routed")
|
||||
)
|
||||
return;
|
||||
const observation = client.observeHosts();
|
||||
const apply = (directory: HostDirectory) => {
|
||||
if (this.hostObservations.get(serverId)?.client !== client) return;
|
||||
for (const target of directory.hosts) {
|
||||
if (!target.serverId || target.serverId === serverId) continue;
|
||||
const next = upsertHostConnectionInProfiles({
|
||||
profiles: this.hosts,
|
||||
serverId: target.serverId,
|
||||
label: target.label ?? undefined,
|
||||
connection: {
|
||||
id: `via:${serverId}`,
|
||||
type: "routed",
|
||||
originServerId: serverId,
|
||||
},
|
||||
});
|
||||
if (next !== this.hosts) {
|
||||
this.setHostsAndSync(next);
|
||||
void this.persistHosts();
|
||||
}
|
||||
if (!this.fleetDirectoryDemand.has(target.serverId))
|
||||
this.fleetDirectoryDemand.set(
|
||||
target.serverId,
|
||||
this.acquireDirectoryDemand(target.serverId),
|
||||
);
|
||||
}
|
||||
};
|
||||
const unsubscribe = observation.subscribe({
|
||||
snapshot: apply,
|
||||
update: (message) => {
|
||||
if (message.type === "hosts.changed") apply(message.payload.directory);
|
||||
},
|
||||
});
|
||||
this.hostObservations.set(serverId, { client, observation, unsubscribe });
|
||||
void observation.ready.then(apply).catch(() => undefined);
|
||||
}
|
||||
|
||||
private syncSessionReplica(serverId: string, snapshot: HostRuntimeSnapshot): void {
|
||||
if (!snapshot.client) {
|
||||
return;
|
||||
|
||||
@@ -1331,10 +1331,11 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
const installedPlugins = useInstalledPlugins();
|
||||
const detailTitle = ((): string | undefined => {
|
||||
if (view.kind === "plugin") {
|
||||
const screen = installedPlugins
|
||||
.find((plugin) => plugin.serverId === view.serverId && plugin.id === view.pluginId)
|
||||
?.settingsScreens.find((candidate) => candidate.id === view.screenId);
|
||||
return `${view.pluginId} · ${screen?.title ?? t("settings.title")}`;
|
||||
const plugin = installedPlugins.find(
|
||||
(candidate) => candidate.serverId === view.serverId && candidate.id === view.pluginId,
|
||||
);
|
||||
const screen = plugin?.settingsScreens.find((candidate) => candidate.id === view.screenId);
|
||||
return `${view.pluginId} · ${plugin && screen ? plugin.i18n.resolve(screen.title, plugin.locale) : t("settings.title")}`;
|
||||
}
|
||||
if (view.kind === "host") {
|
||||
const item = HOST_SECTION_ITEMS.find((s) => s.id === view.section);
|
||||
|
||||
@@ -106,7 +106,6 @@ const removeProfileIcon = <ThemedTrash2 size={ICON_SIZE.sm} uniProps={destructiv
|
||||
const addProfileIcon = <ThemedPlus size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
|
||||
|
||||
function formatHostConnectionLabel(connection: HostConnection, t: TFunction): string {
|
||||
if (connection.type === "routed") return `Via ${connection.originServerId}`;
|
||||
if (connection.type === "relay") {
|
||||
return `${t("settings.host.badges.relay")} (${connection.relayEndpoint})`;
|
||||
}
|
||||
|
||||
@@ -13,29 +13,43 @@ export function createValidatedPersistStorage<State>(
|
||||
});
|
||||
|
||||
const accepted = new Map<string, StorageValue<State>>();
|
||||
const reading = new Set<string>();
|
||||
|
||||
async function read(name: string): Promise<StorageValue<State> | null> {
|
||||
const raw = await backingStorage.getItem(name);
|
||||
if (raw === null) return null;
|
||||
|
||||
let decoded: unknown;
|
||||
try {
|
||||
decoded = JSON.parse(raw);
|
||||
} catch {
|
||||
await backingStorage.removeItem(name);
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = envelopeSchema.safeParse(decoded);
|
||||
if (!result.success) {
|
||||
await backingStorage.removeItem(name);
|
||||
return null;
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
return {
|
||||
getItem: async (name) => {
|
||||
accepted.delete(name);
|
||||
const raw = await backingStorage.getItem(name);
|
||||
if (raw === null) return null;
|
||||
|
||||
let decoded: unknown;
|
||||
reading.add(name);
|
||||
try {
|
||||
decoded = JSON.parse(raw);
|
||||
} catch {
|
||||
await backingStorage.removeItem(name);
|
||||
return null;
|
||||
return await read(name);
|
||||
} finally {
|
||||
reading.delete(name);
|
||||
}
|
||||
|
||||
const result = envelopeSchema.safeParse(decoded);
|
||||
if (!result.success) {
|
||||
await backingStorage.removeItem(name);
|
||||
return null;
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
setItem: async (name, value) => {
|
||||
// A store that changes before hydration settles writes the defaults it started with.
|
||||
// Hydration then merges the stored state in memory without writing it back, so that
|
||||
// write would replace the stored state until the next change.
|
||||
if (reading.has(name)) return;
|
||||
const previous = accepted.get(name);
|
||||
if (
|
||||
options.skipUnchanged &&
|
||||
|
||||
@@ -52,3 +52,39 @@ it("skips unchanged reconciliations and immediately saves real layout changes",
|
||||
store.getState().layoutByWorkspace[workspaceKey],
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the saved layout when the shell publishes its layout mode before hydration", async () => {
|
||||
const values = new Map<string, string>();
|
||||
const storage: StateStorage = {
|
||||
getItem: (name) => values.get(name) ?? null,
|
||||
setItem: (name, value) => {
|
||||
values.set(name, value);
|
||||
},
|
||||
removeItem: (name) => {
|
||||
values.delete(name);
|
||||
},
|
||||
};
|
||||
const workspaceKey = "server:workspace";
|
||||
const saved = createWorkspaceLayoutStore(undefined, storage);
|
||||
await saved.persist.rehydrate();
|
||||
saved.getState().openTab({
|
||||
workspaceKey,
|
||||
target: { kind: "file", path: "notes.md" },
|
||||
intent: "reveal",
|
||||
});
|
||||
|
||||
// A page load: the shell publishes its mode from the first commit, before the read settles.
|
||||
const loaded = createWorkspaceLayoutStore(undefined, storage);
|
||||
loaded.getState().setLayoutMode("single-pane");
|
||||
await new Promise<void>((resolve) => loaded.persist.onFinishHydration(() => resolve()));
|
||||
expect(loaded.getState().layoutByWorkspace[workspaceKey]).toEqual(
|
||||
saved.getState().layoutByWorkspace[workspaceKey],
|
||||
);
|
||||
|
||||
// Leaving the page before any other layout change must not lose the tabs.
|
||||
const reloaded = createWorkspaceLayoutStore(undefined, storage);
|
||||
await reloaded.persist.rehydrate();
|
||||
expect(reloaded.getState().layoutByWorkspace[workspaceKey]).toEqual(
|
||||
saved.getState().layoutByWorkspace[workspaceKey],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { parentAddressLabels } from "@getpaseo/protocol/agent-labels";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildWorkspaceTabSnapshot,
|
||||
@@ -11,7 +10,6 @@ import { selectSubagentsForParent } from "@/subagents/select";
|
||||
import { findPaneById, findPaneContainingTab } from "./workspace-layout-actions";
|
||||
import { useWorkspaceLayoutStore } from "./workspace-layout-store";
|
||||
import { useSessionStore, type Agent } from "./session-store";
|
||||
import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => {
|
||||
const storage = new Map<string, string>();
|
||||
@@ -75,11 +73,7 @@ const AGENT_DEFAULTS: Agent = {
|
||||
};
|
||||
|
||||
function makeAgent(input: Partial<Agent> & Pick<Agent, "id">): Agent {
|
||||
const agent = { ...AGENT_DEFAULTS, ...input };
|
||||
return {
|
||||
...normalizeAgentSnapshot(projectAgentSnapshot(agent), SERVER_ID),
|
||||
projectPlacement: agent.projectPlacement,
|
||||
};
|
||||
return { ...AGENT_DEFAULTS, ...input };
|
||||
}
|
||||
|
||||
function initializeAgents(agents: Agent[]): void {
|
||||
@@ -148,7 +142,7 @@ describe("workspace subagents integration", () => {
|
||||
|
||||
const child = makeAgent({
|
||||
id: "child-agent",
|
||||
labels: parentAddressLabels({ serverId: SERVER_ID, agentId: "parent-agent" }),
|
||||
parentAgentId: "parent-agent",
|
||||
title: "Child agent",
|
||||
});
|
||||
const parent = makeAgent({
|
||||
@@ -192,7 +186,7 @@ describe("workspace subagents integration", () => {
|
||||
});
|
||||
const child = makeAgent({
|
||||
id: "child-agent",
|
||||
labels: parentAddressLabels({ serverId: SERVER_ID, agentId: "parent-agent" }),
|
||||
parentAgentId: "parent-agent",
|
||||
title: "Child agent",
|
||||
});
|
||||
|
||||
@@ -211,7 +205,7 @@ describe("workspace subagents integration", () => {
|
||||
).map((row) => row.id),
|
||||
).toEqual(["child-agent"]);
|
||||
|
||||
appendAgent(makeAgent({ ...child, labels: {} }));
|
||||
appendAgent({ ...child, parentAgentId: null, labels: {} });
|
||||
reconcileWorkspaceTabs(workspaceKey!, deriveVisibilityFromSession());
|
||||
|
||||
expect(getWorkspaceAgentTabIds(workspaceKey!)).toEqual([
|
||||
@@ -244,7 +238,7 @@ describe("workspace subagents integration", () => {
|
||||
});
|
||||
const child = makeAgent({
|
||||
id: "child-agent",
|
||||
labels: parentAddressLabels({ serverId: SERVER_ID, agentId: parent.id }),
|
||||
parentAgentId: parent.id,
|
||||
title: "Cross-workspace child",
|
||||
});
|
||||
|
||||
@@ -277,7 +271,7 @@ describe("workspace subagents integration", () => {
|
||||
});
|
||||
const child = makeAgent({
|
||||
id: "child-agent",
|
||||
labels: parentAddressLabels({ serverId: SERVER_ID, agentId: parent.id }),
|
||||
parentAgentId: parent.id,
|
||||
title: "Child agent",
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { PaseoSubagentRow, ProviderSubagentRow } from "./select";
|
||||
function paseo(id: string, status: PaseoSubagentRow["status"] = "idle"): PaseoSubagentRow {
|
||||
return {
|
||||
kind: "paseo",
|
||||
address: { serverId: "server-1", agentId: id },
|
||||
id,
|
||||
provider: "codex",
|
||||
title: id,
|
||||
@@ -77,8 +76,8 @@ describe("createArchiveFinishedSubagents", () => {
|
||||
[provider("native"), paseo("first"), paseo("second", "error"), paseo("resumed")],
|
||||
{
|
||||
parentAgentId: "parent",
|
||||
getManagedSubagent: ({ agentId: id }) => current.get(id),
|
||||
archiveManagedSubagent: ({ agentId: id }) => {
|
||||
getManagedSubagent: (id) => current.get(id),
|
||||
archiveManagedSubagent: (id) => {
|
||||
events.push(`archive:${id}`);
|
||||
return id === "first" ? first.promise : second.promise;
|
||||
},
|
||||
@@ -131,8 +130,8 @@ describe("createArchiveFinishedSubagents", () => {
|
||||
const calls: string[] = [];
|
||||
const archive = createArchiveFinishedSubagents([paseo("first"), paseo("second")], {
|
||||
parentAgentId: "parent",
|
||||
getManagedSubagent: ({ agentId: id }) => current.get(id),
|
||||
archiveManagedSubagent: ({ agentId: id }) => {
|
||||
getManagedSubagent: (id) => current.get(id),
|
||||
archiveManagedSubagent: (id) => {
|
||||
calls.push(id);
|
||||
const response = responses.shift();
|
||||
if (!response) throw new Error("Unexpected archive");
|
||||
@@ -205,8 +204,8 @@ describe("createArchiveFinishedSubagents", () => {
|
||||
]);
|
||||
const archive = createArchiveFinishedSubagents([paseo("first"), paseo("second")], {
|
||||
parentAgentId: "parent",
|
||||
getManagedSubagent: ({ agentId: id }) => current.get(id),
|
||||
archiveManagedSubagent: async ({ agentId: id }) => {
|
||||
getManagedSubagent: (id) => current.get(id),
|
||||
archiveManagedSubagent: async (id) => {
|
||||
throw id === "first" ? firstError : secondError;
|
||||
},
|
||||
dismissProviderSubagents: () => undefined,
|
||||
@@ -231,7 +230,7 @@ describe("createArchiveFinishedSubagents", () => {
|
||||
const current = new Map([["failed", managed("failed")]]);
|
||||
const archive = createArchiveFinishedSubagents([paseo("failed")], {
|
||||
parentAgentId: "parent",
|
||||
getManagedSubagent: ({ agentId: id }) => current.get(id),
|
||||
getManagedSubagent: (id) => current.get(id),
|
||||
archiveManagedSubagent: async () => {
|
||||
current.set("failed", managed("failed", "running"));
|
||||
throw new Error("archive failed");
|
||||
@@ -257,7 +256,7 @@ describe("createArchiveFinishedSubagents", () => {
|
||||
const current = new Map([["failed", managed("failed")]]);
|
||||
const archive = createArchiveFinishedSubagents([paseo("failed")], {
|
||||
parentAgentId: "parent",
|
||||
getManagedSubagent: ({ agentId: id }) => current.get(id),
|
||||
getManagedSubagent: (id) => current.get(id),
|
||||
archiveManagedSubagent: async () => {
|
||||
current.set("failed", managed("failed", "initializing"));
|
||||
throw new Error("archive failed");
|
||||
@@ -298,7 +297,7 @@ describe("createArchiveFinishedSubagents", () => {
|
||||
const current = new Map([["failed", managed("failed")]]);
|
||||
const archive = createArchiveFinishedSubagents([paseo("failed")], {
|
||||
parentAgentId: "parent",
|
||||
getManagedSubagent: ({ agentId: id }) => current.get(id),
|
||||
getManagedSubagent: (id) => current.get(id),
|
||||
archiveManagedSubagent: async () => {
|
||||
updateSnapshot(current);
|
||||
throw error;
|
||||
@@ -326,8 +325,8 @@ describe("createArchiveFinishedSubagents", () => {
|
||||
const calls: string[] = [];
|
||||
const archive = createArchiveFinishedSubagents([paseo("first"), paseo("second")], {
|
||||
parentAgentId: "parent",
|
||||
getManagedSubagent: ({ agentId: id }) => current.get(id),
|
||||
archiveManagedSubagent: async ({ agentId: id }) => {
|
||||
getManagedSubagent: (id) => current.get(id),
|
||||
archiveManagedSubagent: async (id) => {
|
||||
calls.push(id);
|
||||
if (calls.length === 1) throw firstError;
|
||||
if (calls.length === 2) {
|
||||
@@ -385,8 +384,8 @@ describe("createArchiveFinishedSubagents", () => {
|
||||
const archived: string[] = [];
|
||||
const archive = createArchiveFinishedSubagents(rows, {
|
||||
parentAgentId: "parent",
|
||||
getManagedSubagent: ({ agentId: id }) => current.get(id),
|
||||
archiveManagedSubagent: async ({ agentId: id }) => {
|
||||
getManagedSubagent: (id) => current.get(id),
|
||||
archiveManagedSubagent: async (id) => {
|
||||
archived.push(id);
|
||||
current.set("resumed", managed("resumed", "running"));
|
||||
},
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { AgentAddress } from "@getpaseo/protocol/addresses";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import type { SubagentRow } from "./select";
|
||||
|
||||
@@ -23,8 +22,8 @@ export type ManagedSubagentSnapshot = Pick<Agent, "id" | "status" | "parentAgent
|
||||
|
||||
export interface ArchiveFinishedSubagentsDeps {
|
||||
parentAgentId: string;
|
||||
getManagedSubagent: (address: AgentAddress) => ManagedSubagentSnapshot | undefined;
|
||||
archiveManagedSubagent: (address: AgentAddress) => Promise<void>;
|
||||
getManagedSubagent: (id: string) => ManagedSubagentSnapshot | undefined;
|
||||
archiveManagedSubagent: (id: string) => Promise<void>;
|
||||
dismissProviderSubagents: (ids: string[]) => void;
|
||||
}
|
||||
|
||||
@@ -56,12 +55,12 @@ function eligibleSignature(rows: readonly SubagentRow[]): string {
|
||||
return identitySignature(rowIdentities(rows));
|
||||
}
|
||||
|
||||
function managedRowIdentity(address: AgentAddress): string {
|
||||
return `paseo:${address.serverId}:${address.agentId}`;
|
||||
function managedRowIdentity(id: string): string {
|
||||
return `paseo:${id}`;
|
||||
}
|
||||
|
||||
function rowIdentity(row: SubagentRow): string {
|
||||
return row.kind === "paseo" ? managedRowIdentity(row.address) : `provider:${row.id}`;
|
||||
return row.kind === "paseo" ? managedRowIdentity(row.id) : `provider:${row.id}`;
|
||||
}
|
||||
|
||||
function rowIdentities(rows: readonly SubagentRow[]): Set<string> {
|
||||
@@ -170,7 +169,7 @@ async function runArchiveFinished(
|
||||
reportProgress: (completedCount: number) => void,
|
||||
): Promise<{ outcome: ArchiveFinishedOutcome; retryableFailureIds: Set<string> }> {
|
||||
const providerIds = rows.filter((row) => row.kind === "provider").map((row) => row.id);
|
||||
const paseoAddresses = rows.filter((row) => row.kind === "paseo").map((row) => row.address);
|
||||
const paseoIds = rows.filter((row) => row.kind === "paseo").map((row) => row.id);
|
||||
let completedCount = 0;
|
||||
const outcome = emptyOutcome();
|
||||
const retryableFailureIds = new Set<string>();
|
||||
@@ -182,16 +181,15 @@ async function runArchiveFinished(
|
||||
reportProgress(completedCount);
|
||||
}
|
||||
|
||||
for (const address of paseoAddresses) {
|
||||
const id = address.agentId;
|
||||
if (canArchiveManagedSubagent(deps.getManagedSubagent(address), deps.parentAgentId)) {
|
||||
for (const id of paseoIds) {
|
||||
if (canArchiveManagedSubagent(deps.getManagedSubagent(id), deps.parentAgentId)) {
|
||||
try {
|
||||
await deps.archiveManagedSubagent(address);
|
||||
await deps.archiveManagedSubagent(id);
|
||||
outcome.archivedPaseoIds.push(id);
|
||||
} catch (error) {
|
||||
outcome.failures.push({ id, error });
|
||||
if (canArchiveManagedSubagent(deps.getManagedSubagent(address), deps.parentAgentId)) {
|
||||
retryableFailureIds.add(managedRowIdentity(address));
|
||||
if (canArchiveManagedSubagent(deps.getManagedSubagent(id), deps.parentAgentId)) {
|
||||
retryableFailureIds.add(managedRowIdentity(id));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export type { SubagentRow } from "./select";
|
||||
export { selectSubagentsForParent, useSubagentsForParent } from "./select";
|
||||
export { useArchiveSubagent } from "./use-archive-subagent";
|
||||
export { useDetachSubagent } from "./use-detach-subagent";
|
||||
export { useArchiveSubagent, type UseArchiveSubagentInput } from "./use-archive-subagent";
|
||||
export { useDetachSubagent, type UseDetachSubagentInput } from "./use-detach-subagent";
|
||||
export { resolveCloseAgentTabPolicy, type CloseAgentTabPolicy } from "./close-tab-policy";
|
||||
export { isWorkspaceRootAgent } from "./workspace-root-policy";
|
||||
export {
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { parentAddressLabels } from "@getpaseo/protocol/agent-labels";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { useSubagentOpensInSheet } from "./opens-in-sheet";
|
||||
@@ -79,15 +77,7 @@ if (!workspaceKey) throw new Error("workspace key");
|
||||
|
||||
function seedParentWithChild(): SubagentRow {
|
||||
const parent: Agent = { ...AGENT_DEFAULTS, id: "parent", title: "Parent" };
|
||||
const child = normalizeAgentSnapshot(
|
||||
projectAgentSnapshot({
|
||||
...AGENT_DEFAULTS,
|
||||
id: "child",
|
||||
title: "Child",
|
||||
labels: parentAddressLabels({ serverId: SERVER_ID, agentId: parent.id }),
|
||||
}),
|
||||
SERVER_ID,
|
||||
);
|
||||
const child: Agent = { ...AGENT_DEFAULTS, id: "child", title: "Child", parentAgentId: "parent" };
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
||||
useSessionStore.getState().setAgents(
|
||||
SERVER_ID,
|
||||
|
||||
@@ -53,14 +53,7 @@ const AGENT_DEFAULTS: Agent = {
|
||||
};
|
||||
|
||||
function makeAgent(input: Partial<Agent> & Pick<Agent, "id">): Agent {
|
||||
return {
|
||||
...AGENT_DEFAULTS,
|
||||
...input,
|
||||
labels: {
|
||||
...(input.parentAgentId ? { "paseo.parent-agent-id": input.parentAgentId } : {}),
|
||||
...input.labels,
|
||||
},
|
||||
};
|
||||
return { ...AGENT_DEFAULTS, ...input };
|
||||
}
|
||||
|
||||
function setAgents(agents: Agent[]): void {
|
||||
@@ -80,41 +73,6 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("selectSubagentsForParent", () => {
|
||||
it("keeps equal child IDs on different hosts qualified to the complete parent address", () => {
|
||||
const parentId = "shared-parent";
|
||||
const childId = "shared-child";
|
||||
const local = makeAgent({
|
||||
id: childId,
|
||||
parentAgentId: parentId,
|
||||
labels: { "paseo.parent-agent-id": parentId, "paseo.parent-server-id": SERVER_ID },
|
||||
});
|
||||
const remote = makeAgent({
|
||||
id: childId,
|
||||
serverId: "server-2",
|
||||
parentAgentId: null,
|
||||
labels: { "paseo.parent-agent-id": parentId, "paseo.parent-server-id": SERVER_ID },
|
||||
});
|
||||
setAgents([local]);
|
||||
useSessionStore.getState().initializeSession("server-2", null as unknown as DaemonClient);
|
||||
useSessionStore.getState().setAgents("server-2", new Map([[childId, remote]]));
|
||||
try {
|
||||
const rows = selectSubagentsForParent(
|
||||
useSessionStore.getState(),
|
||||
{ serverId: SERVER_ID, parentAgentId: parentId },
|
||||
EMPTY_PENDING_ARCHIVE_IDS,
|
||||
);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ address: { serverId: SERVER_ID, agentId: childId } }),
|
||||
expect.objectContaining({ address: { serverId: "server-2", agentId: childId } }),
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
useSessionStore.getState().clearSession("server-2");
|
||||
}
|
||||
});
|
||||
|
||||
it("hides cached provider children when the host does not support them", () => {
|
||||
useProviderSubagentStore.getState().applyUpdate(SERVER_ID, {
|
||||
kind: "upsert",
|
||||
@@ -357,7 +315,6 @@ describe("selectSubagentsForParent", () => {
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
kind: "paseo",
|
||||
address: { serverId: SERVER_ID, agentId: "child" },
|
||||
id: "child",
|
||||
provider: "claude",
|
||||
title: "Review child",
|
||||
@@ -370,7 +327,6 @@ describe("selectSubagentsForParent", () => {
|
||||
},
|
||||
]);
|
||||
expect(Object.keys(rows[0] ?? {}).sort()).toEqual([
|
||||
"address",
|
||||
"createdAt",
|
||||
"description",
|
||||
"id",
|
||||
@@ -415,7 +371,7 @@ describe("selectSubagentsForParent", () => {
|
||||
setAgents([
|
||||
makeAgent({ id: "parent-a" }),
|
||||
makeAgent({ id: "parent-b" }),
|
||||
{ ...child, parentAgentId: "parent-b", labels: { "paseo.parent-agent-id": "parent-b" } },
|
||||
{ ...child, parentAgentId: "parent-b" },
|
||||
]);
|
||||
|
||||
expect(
|
||||
@@ -453,7 +409,7 @@ describe("selectSubagentsForParent", () => {
|
||||
serverId: SERVER_ID,
|
||||
parentAgentId: "parent",
|
||||
},
|
||||
new Set([`${SERVER_ID}:child-b`]),
|
||||
new Set(["child-b"]),
|
||||
);
|
||||
|
||||
expect(rows.map((row) => row.id)).toEqual(["child-a"]);
|
||||
@@ -468,7 +424,7 @@ describe("selectSubagentsForParent", () => {
|
||||
serverId: SERVER_ID,
|
||||
parentAgentId: "parent",
|
||||
},
|
||||
new Set([`${SERVER_ID}:child`]),
|
||||
new Set(["child"]),
|
||||
);
|
||||
|
||||
expect(rows).toEqual([]);
|
||||
@@ -585,7 +541,7 @@ describe("createSubagentsForParentSelector", () => {
|
||||
|
||||
const rows = createSubagentsForParentSelector(
|
||||
params,
|
||||
new Set([`${SERVER_ID}:child-b`]),
|
||||
new Set(["child-b"]),
|
||||
)(useSessionStore.getState());
|
||||
|
||||
expect(rows.map((row) => row.id)).toEqual(["child-a"]);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { AgentAddress } from "@getpaseo/protocol/addresses";
|
||||
import { getParentAddressFromLabels } from "@getpaseo/protocol/agent-labels";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { usePendingArchiveAgentKeys } from "@/hooks/use-archive-agent";
|
||||
import { usePendingArchiveAgentIds } from "@/hooks/use-archive-agent";
|
||||
import equal from "fast-deep-equal";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
@@ -10,7 +8,6 @@ import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messa
|
||||
|
||||
export interface PaseoSubagentRow {
|
||||
kind: "paseo";
|
||||
address: AgentAddress;
|
||||
id: Agent["id"];
|
||||
provider: Agent["provider"];
|
||||
title: Agent["title"];
|
||||
@@ -58,7 +55,6 @@ const EMPTY_PROVIDER_SUBAGENT_ROWS: ProviderSubagentRow[] = [];
|
||||
function toSubagentRow(agent: Agent): SubagentRow {
|
||||
return {
|
||||
kind: "paseo",
|
||||
address: { serverId: agent.serverId, agentId: agent.id },
|
||||
id: agent.id,
|
||||
provider: agent.provider,
|
||||
title: agent.title,
|
||||
@@ -76,19 +72,21 @@ export function selectSubagentsForParent(
|
||||
params: SelectSubagentsParams,
|
||||
pendingArchiveIds: ReadonlySet<string>,
|
||||
): SubagentRow[] {
|
||||
const agents = state.sessions[params.serverId]?.agents;
|
||||
if (!agents || agents.size === 0) {
|
||||
return EMPTY_SUBAGENT_ROWS;
|
||||
}
|
||||
|
||||
const rows: SubagentRow[] = [];
|
||||
for (const [serverId, session] of Object.entries(state.sessions)) {
|
||||
for (const agent of session.agents.values()) {
|
||||
const parent = getParentAddressFromLabels(agent.labels, serverId);
|
||||
if (
|
||||
agent.archivedAt ||
|
||||
pendingArchiveIds.has(`${serverId}:${agent.id}`) ||
|
||||
parent?.serverId !== params.serverId ||
|
||||
parent.agentId !== params.parentAgentId
|
||||
)
|
||||
continue;
|
||||
rows.push(toSubagentRow(agent));
|
||||
for (const agent of agents.values()) {
|
||||
if (
|
||||
agent.archivedAt ||
|
||||
pendingArchiveIds.has(agent.id) ||
|
||||
agent.parentAgentId !== params.parentAgentId
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
rows.push(toSubagentRow(agent));
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
@@ -106,21 +104,12 @@ export function createSubagentsForParentSelector(
|
||||
params: SelectSubagentsParams,
|
||||
pendingArchiveIds: ReadonlySet<string>,
|
||||
): (state: SessionStoreSnapshot) => SubagentRow[] {
|
||||
let previousAgents: Array<[string, Map<string, Agent>]> = [];
|
||||
let previousAgents: Map<string, Agent> | undefined;
|
||||
let previousRows: SubagentRow[] | null = null;
|
||||
|
||||
return (state) => {
|
||||
const agents = Object.entries(state.sessions).map(
|
||||
([serverId, session]) => [serverId, session.agents] as [string, Map<string, Agent>],
|
||||
);
|
||||
if (
|
||||
previousRows &&
|
||||
agents.length === previousAgents.length &&
|
||||
agents.every(
|
||||
([serverId, map], index) =>
|
||||
previousAgents[index]?.[0] === serverId && previousAgents[index]?.[1] === map,
|
||||
)
|
||||
) {
|
||||
const agents = state.sessions[params.serverId]?.agents;
|
||||
if (previousRows && agents === previousAgents) {
|
||||
return previousRows;
|
||||
}
|
||||
|
||||
@@ -170,7 +159,7 @@ export function selectProviderSubagentsForParent(
|
||||
|
||||
export function useSubagentsForParent(params: SelectSubagentsParams): SubagentRow[] {
|
||||
const { serverId, parentAgentId } = params;
|
||||
const pendingArchiveIds = usePendingArchiveAgentKeys();
|
||||
const pendingArchiveIds = usePendingArchiveAgentIds(serverId);
|
||||
const selectPaseoRows = useMemo(
|
||||
() => createSubagentsForParentSelector({ serverId, parentAgentId }, pendingArchiveIds),
|
||||
[serverId, parentAgentId, pendingArchiveIds],
|
||||
|
||||
@@ -13,7 +13,6 @@ function row(
|
||||
): PaseoSubagentRow {
|
||||
return {
|
||||
kind: "paseo",
|
||||
address: overrides.address ?? { serverId: "server-1", agentId: overrides.id },
|
||||
id: overrides.id,
|
||||
provider: overrides.provider ?? "codex",
|
||||
title: overrides.title ?? `Agent ${overrides.id}`,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { AgentAddress } from "@getpaseo/protocol/addresses";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useCallback, useMemo, type ReactElement, type ReactNode } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -37,17 +35,17 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
export interface SubagentsTrackProps {
|
||||
serverId: string;
|
||||
rows: SubagentRow[];
|
||||
onOpenSubagent: (address: AgentAddress) => void;
|
||||
onOpenSubagent: (id: string) => void;
|
||||
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
|
||||
/**
|
||||
* Whether opening this row shows it in a sheet. A sheet stacks over the list and uncovers it on
|
||||
* close, so the list stays open; a tab replaces the list's pane, so choosing it ends the list.
|
||||
*/
|
||||
opensInSheet: (row: SubagentRow) => boolean;
|
||||
onArchiveSubagent: (address: AgentAddress) => void;
|
||||
onArchiveSubagent: (id: string) => void;
|
||||
onArchiveFinished?: () => void;
|
||||
archiveFinishedStatus?: ArchiveFinishedStatus;
|
||||
onDetachSubagent?: (address: AgentAddress) => void;
|
||||
onDetachSubagent?: (id: string) => void;
|
||||
}
|
||||
|
||||
const IDLE_ARCHIVE_FINISHED_STATUS: ArchiveFinishedStatus = { kind: "idle" };
|
||||
@@ -107,7 +105,7 @@ export function SubagentsTrack({
|
||||
) : null}
|
||||
{rows.map((row) => (
|
||||
<SubagentsTrackRow
|
||||
key={row.kind === "paseo" ? `${row.address.serverId}:${row.id}` : row.id}
|
||||
key={row.id}
|
||||
row={row}
|
||||
serverId={serverId}
|
||||
onOpenSubagent={onOpenSubagent}
|
||||
@@ -182,11 +180,11 @@ function ArchiveFinishedRow({
|
||||
interface SubagentsTrackRowProps {
|
||||
serverId: string;
|
||||
row: SubagentRow;
|
||||
onOpenSubagent: (address: AgentAddress) => void;
|
||||
onOpenSubagent: (id: string) => void;
|
||||
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
|
||||
opensInSheet: boolean;
|
||||
onArchiveSubagent: (address: AgentAddress) => void;
|
||||
onDetachSubagent?: (address: AgentAddress) => void;
|
||||
onArchiveSubagent: (id: string) => void;
|
||||
onDetachSubagent?: (id: string) => void;
|
||||
}
|
||||
|
||||
function SubagentsTrackRow({
|
||||
@@ -200,24 +198,22 @@ function SubagentsTrackRow({
|
||||
}: SubagentsTrackRowProps): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const rowServerId = row.kind === "paseo" ? row.address.serverId : serverId;
|
||||
const hostLabel = useHosts().find((host) => host.serverId === rowServerId)?.label ?? rowServerId;
|
||||
const presentation = useRowPresentation(row, rowServerId);
|
||||
const presentation = useRowPresentation(row, serverId);
|
||||
const displayLabel =
|
||||
presentation.titleState === "loading" ? t("common.states.loading") : presentation.label;
|
||||
const handlePress = useCallback(() => {
|
||||
if (row.kind === "provider") {
|
||||
onOpenProviderSubagent(row.parentAgentId, row.id);
|
||||
} else {
|
||||
onOpenSubagent(row.address);
|
||||
onOpenSubagent(row.id);
|
||||
}
|
||||
}, [onOpenProviderSubagent, onOpenSubagent, row]);
|
||||
const handleArchivePress = useCallback(() => {
|
||||
if (row.kind === "paseo") onArchiveSubagent(row.address);
|
||||
}, [onArchiveSubagent, row]);
|
||||
onArchiveSubagent(row.id);
|
||||
}, [onArchiveSubagent, row.id]);
|
||||
const handleDetachPress = useCallback(() => {
|
||||
if (row.kind === "paseo") onDetachSubagent?.(row.address);
|
||||
}, [onDetachSubagent, row]);
|
||||
onDetachSubagent?.(row.id);
|
||||
}, [onDetachSubagent, row.id]);
|
||||
const actionsAlwaysVisible = isNative || isCompact;
|
||||
|
||||
const renderRow = useCallback(
|
||||
@@ -229,10 +225,7 @@ function SubagentsTrackRow({
|
||||
{displayLabel}
|
||||
</Text>
|
||||
{row.kind === "paseo" ? (
|
||||
<>
|
||||
<SubagentFacts rowId={row.id} text={rowServerId !== serverId ? hostLabel : ""} />
|
||||
<PaseoSubagentFacts serverId={rowServerId} agentId={row.id} />
|
||||
</>
|
||||
<PaseoSubagentFacts serverId={serverId} agentId={row.id} />
|
||||
) : (
|
||||
<SubagentFacts rowId={row.id} text={presentation.subtitle} />
|
||||
)}
|
||||
@@ -260,8 +253,6 @@ function SubagentsTrackRow({
|
||||
row.kind,
|
||||
row.id,
|
||||
serverId,
|
||||
rowServerId,
|
||||
hostLabel,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getParentAddressFromLabels } from "@getpaseo/protocol/agent-labels";
|
||||
import { useEffect, useMemo, useSyncExternalStore } from "react";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
@@ -33,16 +32,8 @@ export function useArchiveFinishedSubagents({
|
||||
() =>
|
||||
createArchiveFinishedSubagents([], {
|
||||
parentAgentId,
|
||||
getManagedSubagent: (address) => {
|
||||
const agent = useSessionStore
|
||||
.getState()
|
||||
.sessions[address.serverId]?.agents.get(address.agentId);
|
||||
if (!agent) return undefined;
|
||||
const parent = getParentAddressFromLabels(agent.labels, address.serverId);
|
||||
if (parent?.serverId !== serverId || parent.agentId !== parentAgentId) return undefined;
|
||||
return { ...agent, parentAgentId: parent.agentId };
|
||||
},
|
||||
archiveManagedSubagent: archiveAgent,
|
||||
getManagedSubagent: (id) => useSessionStore.getState().sessions[serverId]?.agents.get(id),
|
||||
archiveManagedSubagent: (id) => archiveAgent({ serverId, agentId: id }),
|
||||
dismissProviderSubagents: (ids) => {
|
||||
useProviderSubagentStore.getState().hideFromTrack(serverId, parentAgentId, ids);
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user